Export Web/SCORM : corrige les URLs absolues cassées en local (file://)
Build and deploy / test-python (push) Successful in 13m15s
Build and deploy / test-js (push) Successful in 1m4s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Bug signalé : icône disparue et texte suivant du dialogue absent après
export. render_icone.py (et son miroir JS, forgeRenderIcone) bakaient
une URL "/static/icons/..." en dur, correcte en ligne (racine du
serveur Flask) mais bloquée par CORS une fois ouverte en local
(file:///static/...). Même souci pour les fichiers envoyés par le
créateur (/game/<slug>/uploads/..., jamais dans static/, jamais copiés
par le paquet). Ajoute _relativize_absolute_urls (réécriture globale
post-rendu du HTML) + copie du dossier uploads/, et relativise le port
JS de l'icône pour toute régénération après action en mémoire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 09:31:44 +02:00
co-authored by Claude Sonnet 5
parent f3b72d73c9
commit 7bc4c89eaa
4 changed files with 66 additions and 2 deletions
+32
View File
@@ -44,6 +44,36 @@ def _copy_static_assets(staging_dir, slug):
shutil.copy2(src, dst) shutil.copy2(src, dst)
def _copy_uploads(staging_dir, slug):
"""Fichiers envoyés par le créateur (widget Image/Vidéo, "Envoyer un
fichier" — voir routes/uploads/upload_file.py) : servis EN LIGNE par
une route Flask (/game/<slug>/uploads/<fichier>, jamais static/), donc
jamais copiés par _copy_static_assets ci-dessus. Absents s'il n'y en a
aucun (os.path.isdir garde-fou)."""
src = os.path.join(db.game_dir(slug), "uploads")
if os.path.isdir(src):
shutil.copytree(src, os.path.join(staging_dir, "uploads"))
def _relativize_absolute_urls(html, slug):
"""screens/rendering (render_icone.py) et screens/labels (sprite_
library.py/animal_sprite_library.py) construisent certaines URLs en
DUR avec un préfixe absolu "/static/..." ou "/game/<slug>/uploads/..."
(corrects EN LIGNE, où ils partent de la racine du serveur Flask) —
déjà bakées dans le HTML/JSON rendu à ce stade, impossible à
distinguer d'un bête texte sans réécrire chaque fonction de rendu une
à une. Un remplacement global est plus sûr : ces deux préfixes précis
(le second inclut le slug du jeu) n'ont aucune raison d'apparaître
ailleurs que dans une URL réellement générée par le moteur. Sans ce
correctif, un index.html ouvert en local (file://) échouait à charger
icônes/sprites/fichiers envoyés (CORS bloque une requête absolue vers
file:///static/... — "la requête CORS n'utilise pas HTTP")."""
return (
html.replace(f"/game/{slug}/uploads/", "uploads/")
.replace("/static/", "static/")
)
def _list_package_files(staging_dir): def _list_package_files(staging_dir):
"""Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir """Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir
(hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS (hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS
@@ -81,11 +111,13 @@ def build_scorm_zip(slug):
staging_dir = tempfile.mkdtemp(prefix="forge_scorm_") staging_dir = tempfile.mkdtemp(prefix="forge_scorm_")
try: try:
_copy_static_assets(staging_dir, slug) _copy_static_assets(staging_dir, slug)
_copy_uploads(staging_dir, slug)
html = render_template( html = render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
offline_mode=True, asset_url=lambda filename: "static/" + filename, offline_mode=True, asset_url=lambda filename: "static/" + filename,
) )
html = _relativize_absolute_urls(html, slug)
with open(os.path.join(staging_dir, "index.html"), "w", encoding="utf-8") as f: with open(os.path.join(staging_dir, "index.html"), "w", encoding="utf-8") as f:
f.write(html) f.write(html)
@@ -55,7 +55,7 @@ test('icône — repli sur "star" si _icon_slug absent', () => {
const html = forgeRenderElementHtml(el, baseGameData(), {}, {}); const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
// le style entier passe par forgeHtmlEscape (comme html.escape() côté // le style entier passe par forgeHtmlEscape (comme html.escape() côté
// serveur) : l'apostrophe de url('...') devient &#x27;. // serveur) : l'apostrophe de url('...') devient &#x27;.
assert.ok(html.includes("--icon-url:url(&#x27;/static/icons/star.svg&#x27;)")); assert.ok(html.includes("--icon-url:url(&#x27;static/icons/star.svg&#x27;)"));
}); });
test('onglets — un panneau actif, les autres masqués', () => { test('onglets — un panneau actif, les autres masqués', () => {
@@ -73,7 +73,13 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
var existingClass = attrs.class || ""; var existingClass = attrs.class || "";
delete attrs.class; delete attrs.class;
attrs.class = (existingClass + " icon-svg").trim(); attrs.class = (existingClass + " icon-svg").trim();
var iconUrl = "/static/icons/" + iconSlug + ".svg"; // Chemin RELATIF (jamais "/static/...") : ce fichier ne s'exécute que
// dans le paquet exporté (window.FORGE_OFFLINE, voir render-element.js),
// ouvert en local (file://) sans racine de serveur — un chemin absolu y
// déclenche un blocage CORS ("la requête CORS n'utilise pas HTTP"),
// exactement le bug qui affectait aussi le rendu initial côté serveur
// (voir _relativize_absolute_urls dans publish/build_scorm_package.py).
var iconUrl = "static/icons/" + iconSlug + ".svg";
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');"; var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
return "<i " + forgeAttrString(attrs, style) + "></i>"; return "<i " + forgeAttrString(attrs, style) + "></i>";
} }
+26
View File
@@ -50,6 +50,32 @@ def test_export_scorm_index_html_is_offline_and_self_contained(client, game):
assert "let gameData = " in html assert "let gameData = " in html
def test_export_scorm_icon_widget_uses_a_relative_url(client, game):
"""Régression signalée par un utilisateur : render_icone.py construit
son URL en dur avec un préfixe "/static/..." (correct EN LIGNE, où il
part de la racine du serveur Flask) — jamais réécrit par le refactor
asset_url de templates/play.html (celui-ci ne touche que les <script>/
<link> du template, pas le rendered_html déjà bake dans le payload).
Sans _relativize_absolute_urls (build_scorm_package.py), un index.html
ouvert en local (file://) déclenchait un blocage CORS sur cette icône
("la requête CORS n'utilise pas HTTP") — voir aussi le miroir JS
(forgeRenderIcone, static/js/play/offline/render-special-widgets.js),
qui régénère ce même widget après une action, et devait être corrigé
séparément."""
import re
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "icone"}, follow_redirects=False)
assert resp.status_code == 302, resp.data
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "static/icons/star.svg" in html
assert "/static/icons/star.svg" not in html
def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game): def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game):
resp = client.post(f"/game/{game}/export-scorm") resp = client.post(f"/game/{game}/export-scorm")
import io import io