Files
Forge-Engine/static/js/play/offline/render-special-widgets.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

168 lines
8.8 KiB
JavaScript

// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — étape 4 : port des widgets à "special_render" les plus
// simples (screens/rendering/render_checkbox_or_radio.py, render_select.py,
// render_fieldset.py, render_table.py, render_icone.py, render_onglets.py).
// Jauge/Superposition/Personnage/Répéteur (logique/données plus lourde)
// vivent dans des fichiers dédiés (render-jauge.js, render-repeater.js).
// Dépend de render-element.js (forgeVisibleAttrs/forgeStyleString/
// forgeAttrString/forgeApplyCtx/forgeHtmlEscape/forgeAutoId/
// forgeParentFlexDirection/forgeRenderChildren), chargé avant ce fichier.
function forgeRenderCheckboxOrRadio(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const inputType = el.widget === 'case_a_cocher' ? 'checkbox' : 'radio';
const attrs = forgeVisibleAttrs(el, meta, ctx);
attrs.type = inputType;
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; align-items:center; gap:8px;`;
const content = forgeApplyCtx(el.content || '', ctx);
return `<label ${forgeAttrString({ class: inputType }, style)}><input ${forgeAttrString(attrs, '')}> ${forgeHtmlEscape(content)}</label>`;
}
function forgeRenderSelect(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const outerAttrs = forgeVisibleAttrs(el, meta, ctx);
const bulmaClasses = outerAttrs.class || '';
const selectAttrs = {}; const
wrapperAttrs = {};
Object.keys(outerAttrs).forEach((k) => {
if (k === 'id' || k === 'data-element-id' || k === 'data-anim-target') wrapperAttrs[k] = outerAttrs[k];
else if (k !== 'class') selectAttrs[k] = outerAttrs[k];
});
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex;`;
const content = forgeApplyCtx(el.content || '', ctx);
const options = content.split('\n').map((o) => o.trim()).filter(Boolean);
const optsHtml = options.map((o) => {
const esc = forgeHtmlEscape(o);
return `<option value="${esc}">${esc}</option>`;
}).join('');
const selectHtml = `<select ${forgeAttrString(selectAttrs, '')}>${optsHtml}</select>`;
const selectWrapClass = forgeHtmlEscape((`select is-fullwidth ${bulmaClasses}`).trim());
return `<div ${forgeAttrString(wrapperAttrs, style)}><div class="${selectWrapClass}">${selectHtml}</div></div>`;
}
function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const attrs = forgeVisibleAttrs(el, meta, ctx);
const style = forgeStyleString(el, parentFlexDirection, el.widget);
const content = forgeApplyCtx(el.content || '', ctx) || 'Groupe de champs';
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
return `<fieldset ${forgeAttrString(attrs, style)}><legend>${forgeHtmlEscape(content)}</legend>${childHtml}</fieldset>`;
}
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const attrs = forgeVisibleAttrs(el, meta, ctx);
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
const content = forgeApplyCtx(el.content || '', ctx);
const style = forgeStyleString(el, parentFlexDirection, el.widget);
const rows = content.split('\n').filter((r) => r.trim());
const htmlRows = rows.map((r, i) => {
const cells = r.split(';');
const cellTag = (headerRow && i === 0) ? 'th' : 'td';
const cellsHtml = cells.map((c) => `<${cellTag}>${forgeHtmlEscape(c.trim())}</${cellTag}>`).join('');
return `<tr>${cellsHtml}</tr>`;
}).join('');
return `<table ${forgeAttrString(attrs, style)}>${htmlRows}</table>`;
}
function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
// Contrairement au serveur, ne revalide pas _icon_slug contre le
// catalogue curaté (screens/widgets/fontawesome_icons.py) : une valeur
// invalide ne peut être enregistrée que depuis l'éditeur, qui la valide
// déjà à la sauvegarde — un jeu EXPORTÉ n'en produit jamais.
const rawAttrs = el.attributes || {};
const iconSlug = rawAttrs._icon_slug || 'star';
const attrs = forgeVisibleAttrs(el, meta, ctx);
const existingClass = attrs.class || '';
delete attrs.class;
attrs.class = (`${existingClass} icon-svg`).trim();
// data: URI pré-calculée à l'export (voir gameData.icon_data_uris,
// publish/build_scorm_package.py::_build_icon_data_uris) — jamais un
// chemin de fichier, même relatif : Firefox traite chaque ressource
// file:// comme une origine opaque distincte, et mask-image y déclenche
// un fetch en mode CORS quel que soit le chemin ("Blocage d'une requête
// multiorigine... la requête CORS n'utilise pas HTTP", signalé par un
// utilisateur MALGRÉ un chemin relatif par ailleurs correct). Repli sur
// un chemin relatif si jamais l'icône n'a pas été pré-encodée (ne
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
// "icone", posés directement ou nichés dans un élément de jeu
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`);
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} --icon-url:url('${iconUrl}');`;
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
// description dédié dans l'éditeur) est traitée comme purement
// décorative — jamais annoncée par un lecteur d'écran, comme le nom du
// slug FontAwesome ("star") n'aurait aucun sens lu à voix haute.
if (!('aria-hidden' in attrs)) attrs['aria-hidden'] = 'true';
return `<i ${forgeAttrString(attrs, style)}></i>`;
}
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const tabs = (childrenMap && childrenMap[el.id]) || [];
const attrs = forgeVisibleAttrs(el, meta, ctx);
const tabsExtraClasses = attrs.class || '';
delete attrs.class;
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:column;`;
const groupId = forgeAutoId(el);
if (!tabs.length) {
return `<div ${forgeAttrString(attrs, style)}><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>`;
}
const flexDir = forgeParentFlexDirection(el);
const barItems = []; const
panels = [];
tabs.forEach((tab, i) => {
const label = tab.name || (`Onglet ${i + 1}`);
const active = i === 0;
const tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
const targetId = `tabpanel-${tab.id}`;
barItems.push(
`<li class="${active ? 'is-active' : ''}" data-tab-btn="${groupId}" data-tab-target="${targetId
}" onclick="forgeShowTab(this)"><a>${forgeHtmlEscape(String(label))}</a></li>`,
);
const hiddenStyle = active ? '' : ' style="display:none;"';
panels.push(`<div class="forgeTabPanel" id="${targetId}" data-tab-group="${groupId}"${hiddenStyle}>${tabHtml}</div>`);
});
const tabsClass = forgeHtmlEscape((`tabs ${tabsExtraClasses}`).trim());
return (
`<div ${forgeAttrString(attrs, style)}>`
+ `<div class="${tabsClass}"><ul>${barItems.join('')}</ul></div>`
+ `<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">${panels.join('')}</div>`
+ '</div>'
);
}
// Port de render_personnage.py — lit directement gameData.personnage_animations
// (déjà résolu une fois par full_game_payload.py, à l'export) au lieu de
// re-résoudre la bibliothèque de sprites Forge/l'URL personnalisée : ce
// travail est fait UNE fois, à la génération du paquet, jamais à chaque
// rendu côté client.
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const attrs = forgeVisibleAttrs(el, meta, ctx);
attrs['data-personnage'] = '1';
const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
const idleFrame = frames && frames.length ? frames[0] : '';
if (idleFrame) attrs.src = idleFrame;
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
// décoratif sur le widget "image" générique.
if (!('alt' in attrs)) attrs.alt = '';
const style = forgeStyleString(el, parentFlexDirection, el.widget);
return `<img ${forgeAttrString(attrs, style)}>`;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeRenderCheckboxOrRadio,
forgeRenderSelect,
forgeRenderFieldset,
forgeRenderTable,
forgeRenderIcone,
forgeRenderOnglets,
forgeRenderPersonnage,
};
const renderMod = require('./render-element.js');
['forgeVisibleAttrs', 'forgeStyleString', 'forgeAttrString', 'forgeApplyCtx', 'forgeHtmlEscape',
'forgeAutoId', 'forgeParentFlexDirection', 'forgeRenderChildren', 'forgeRenderElementHtml'].forEach((name) => {
global[name] = global[name] || renderMod[name];
});
}