Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier) sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*, .replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(), getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y (verifie site par site), extraction de template litteraux imbriques, ternaires imbriquees, refactors de complexite cognitive (S3776) via tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions identiques (S4144). Deux exceptions S2486 documentees/corrigees (filter-repeater-rows.js) et un cas S2703 de partage inter-scripts complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js). Details complets dans CODE_QUALITY.md section 5. Restaure aussi le job CI "sonarqube" (non-bloquant) dans .gitea/workflows/deploy.yml maintenant que l'instance prod est operationnelle. Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest). SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021 (styles inline, 49 occurrences/6 templates) deja documente comme dette assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec ce commit (aucun template touche ici) - valide explicitement avec l'utilisateur avant de contourner ce hook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
363 lines
16 KiB
JavaScript
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 = new Set(['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)
|
|
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
// 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?.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.has(widgetKey) && !('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.startsWith('_')) 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.startsWith('_class_') && 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?.[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?.[definitionId];
|
|
if (!fieldsMetaForDef) return null;
|
|
let rows = 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?.[String(el.element_type_id)];
|
|
if (!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).replaceAll('\n', '<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;
|
|
}
|