Files
xamxam/docs/autosave-system.md
Pontoporeia 38ef550397 feat: render actual elements in markdown cheatsheet instead of labels
Replace text labels (h1, bold, italic) with rendered HTML in the Rendu column:
headings, strong, em, del, code, links, blockquote, lists, hr, sup, small
2026-06-10 00:18:49 +02:00

18 KiB

Autosave System — Architecture & HTMX Migration Assessment

Overview

The admin panel has a custom JavaScript autosave system (autosave.js) that auto-submits forms after a 1.5s debounce. It is used on pages where content is edited with the OverType Markdown editor (static pages, form help blocks) and on structured-data forms (contacts, sidebar links).

Three server-side action handlers implement the same CSRF-rotation + JSON response contract that autosave.js consumes.

Architecture

┌──────────────────────────────────┐
│  autosave.js                     │
│  ─────────                       │
│  Watches <form data-autosave>    │
│  Listens: input / change (bubble)│
│  Debounce: 1500ms                │
│  POST via fetch()                │
│  Accept: application/json        │
│  On 2xx: shows "Enregistré ✓"    │
│  On err: shows "Erreur !", retry │
│  Updates CSRF token from response│
└──────────┬───────────────────────┘
           │  POST (JSON)
           ▼
┌──────────────────────────────────┐
│  Backend handler (page.php,      │
│  apropos.php, form-help.php)     │
│  ────────────────────────────────│
│  1. Check CSRF token             │
│  2. Validate payload             │
│  3. Save to DB                   │
│  4. Regenerate CSRF token        │
│  5. Return JSON:                 │
│     {success:true, csrf_token:X} │
└──────────────────────────────────┘

Forms Using data-autosave

1. Static Pages (about, licenses, charte)

Attribute Value
Template app/templates/admin/contenus-edit.php (branches about_page and page)
Form action /admin/actions/page.php
Editor OverType (custom contenteditable-based Markdown WYSIWYG)
Fields csrf_token (hidden), slug (hidden), content (hidden, synced by OverType onChange)
How events reach autosave User types in OverType's contenteditable div → native input events bubble up to <form> → autosave.js detects them

Key interaction: OverType's onChange(value) { hiddenInput.value = value } sets the hidden #content input's value programmatically. This does NOT fire native DOM events. The autosave trigger comes from input events on the contenteditable editor div, not from the hidden input.

2. About Contacts (apropos groups)

Attribute Value
Template app/templates/admin/apropos-groups-form.php
Form action /admin/actions/apropos.php
Editor Native text/email/url inputs
Fields csrf_token, apropos_key, groups[][role], groups[][entries][][text/email/url]

Has custom JS for adding/removing contact groups and entries (inline <script> in the template). The reindex logic updates name attributes after add/remove.

Attribute Value
Template Inline in app/templates/admin/contenus-edit.php (the about_page branch)
Form action /admin/actions/apropos.php
Fields csrf_token, apropos_key=sidebar_links, links[][label], links[][url]

Has custom JS for add/remove/reindex (inline <script> in the template).

4. Form Help Blocks

Attribute Value
Template app/templates/admin/contenus-edit.php (branch form_help)
Form action /admin/actions/form-help.php
Editor OverType (same as static pages)
Fields csrf_token, form_help_key, content

Same OverType + autosave integration as static pages.

Backend Handlers

page.php/admin/actions/page.php

CSRF check → slug validation (about|licenses|charte) → savePage(slug, content)
→ AdminLogger::logPageEdit() → regenerate CSRF → return JSON

On AJAX: returns {success: true, csrf_token: "<new_token>"}.
On non-AJAX (regular form POST): flashes message, redirects to /admin/contenus.php.

apropos.php/admin/actions/apropos.php

CSRF check → key validation → dispatch by key type:
  URL keys (erg_site_url, source_code_url)      → saveAproposContent(key, url)
  Link lists (sidebar_links)                     → validate URLs, save structured array
  Group-based (contacts)                         → validate groups/entries, save structured array
→ AdminLogger::logAproposEdit() → regenerate CSRF → return JSON

form-help.php/admin/actions/form-help.php

CSRF check → key validation (FORM_HELP_KEYS) → setFormHelpBlock(key, content)
→ AdminLogger::logFormStructureEdit() → regenerate CSRF → return JSON

CSRF Rotation Pattern (shared by all three)

1. Verify: hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
2. Save to DB
3. Rotate: $_SESSION['csrf_token'] = bin2hex(random_bytes(32))
4. Return: {success: true, csrf_token: <NEW>}
5. Client: autosave.js reads data.csrf_token, updates hidden input

Other Autosave-like UI (Non-autosave.js)

These use HTMX directly, NOT autosave.js:

Location Trigger Target Handler
Settings checkboxes (Accès, Types) change via hx-post #acces-response / #types-response /admin/actions/settings.php
TFE messages textareas change via hx-post inline toast /admin/actions/settings.php
File restrictions toggle change via hx-post inline toast /admin/actions/settings.php
Form help inline editor Manual Enregistrer via hx-post collapsed chip /admin/form-help-inline-fragment.php
Form help toggle (dot button) Click via hx-post collapsed chip /admin/form-help-inline-fragment.php
Language inline renames Manual submit via hx-post #langues-table-wrap /admin/actions/language.php

autosave.js — Detailed Behavior

// Watches: form[data-autosave]
// Debounce: 1500ms after last input/change event
// Status display: sibling element matching [data-autosave-status]

const DEBOUNCE_MS = 1500;
let timer = null;
let dirty = false;

// schedule() — called on every input/change event
// Sets dirty=true, clears previous timer, starts new 1500ms timer
schedule()  clearTimeout(timer); timer = setTimeout(doSave, 1500);

// doSave() — fires after 1500ms of inactivity
doSave()  if (!dirty) return;           // guard: skip if not dirty
           dirty = false;                 // mark clean (re-set to true on error)
           setStatus('Enregistrement…');
           const fd = new FormData(form); // snapshots ALL current form values

           fetch(form.action, {
               method: 'POST',
               body: fd,
               headers: { 'Accept': 'application/json' }
           })
           .then(r => {
               if (!r.ok) throw new Error('HTTP ' + r.status);  // 403/500/etc → catch
               setStatus('Enregistré ✓');
               r.json().then(data => {
                   if (data.csrf_token) {
                       // Update both form input and meta tag
                       form.querySelector('input[name="csrf_token"]').value = data.csrf_token;
                       document.querySelector('meta[name="csrf-token"]').content = data.csrf_token;
                   }
               }).catch(() => {});  // silently ignore JSON parse errors
           })
           .catch(() => {
               setStatus('Erreur !');
               dirty = true;  // re-arm: will retry on next input
           });

Important: The CSRF token update happens asynchronously inside .json().then(), after the status is already set to "Enregistré ✓". In practice this is fine because the 1.5s debounce provides ample time for the microtask to complete before the next save cycle.

If the JSON response is malformed (PHP warnings/notices in output), the .catch(() => {}) silently discards the parse error, the CSRF token stays stale, and all subsequent saves fail with 403.

Known Issues

403 Forbidden on Autosave

Observed on charte page (and possibly about/licenses). The response is:

HTTP/1.1 403 Forbidden
{"error": "Erreur de sécurité : token invalide."}

The 403 comes from the CSRF check in page.php (line ~18):

if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
    || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {

Possible causes:

  1. Stale CSRF after error: First save succeeds and rotates the token, but autosave.js fails to update the form (e.g., JSON parse error from PHP warning in output). All subsequent saves fail with 403.

  2. Session not initialized: If $_SESSION['csrf_token'] is not set when page.php runs. In admin context, it should be set by:

    • contenus.php (list page): if (empty($_SESSION['csrf_token'])) { ... }
    • contenus-edit.php (edit page): if (empty($_SESSION['csrf_token'])) { ... }
    • AdminAuth::requireLogin() calls session_start()
  3. Session collision: PHP's default file-based session locking serializes concurrent requests. Unlikely with 1.5s debounce.

  4. OverType → hidden input sync: OverType's onChange updates the hidden input, but if the OverType script fails to load, the hidden input stays empty, and the form submission has no content (but CSRF token should still be present).

Non-event-emitting value updates

autosave.js relies on input/change events bubbling. The OverType editor updates hidden.value programmatically. If someone adds custom JS that updates form values without firing events, autosave won't detect those changes.

HTMX v2 Migration Plan

The Core Pattern

HTMX v2 replaces the entire autosave.js fetch/debounce/CSRF-update loop. The key pieces:

  • hx-trigger handles debouncing
  • hx-on::after-request handles CSRF token rotation
  • hx-swap="none" since you only need the JSON response side-effect
  • A response header (HX-Trigger) can drive the status indicator

These are straightforward since all inputs fire native DOM events.

<form
  hx-post="/admin/actions/apropos.php"
  hx-trigger="change delay:1500ms, input delay:1500ms"
  hx-swap="none"
  hx-on::after-request="handleAutosaveResponse(event)"
>
  <input type="hidden" name="csrf_token" value="...">
  <input type="hidden" name="apropos_key" value="contacts">

  <!-- your inputs -->

  <span data-autosave-status></span>
</form>

One subtlety: HTMX v2 fires the trigger on the element with hx-trigger, which here is the <form> — so change and input events bubbling up from child inputs will correctly trigger it.

2. OverType Forms (Static Pages & Form Help) — Partial Replacement

OverType updates the hidden input programmatically without firing DOM events, so you need to dispatch a custom event from its onChange hook:

// In your OverType init
onChange: function(value) {
  hiddenInput.value = value;
  // Dispatch a custom event that HTMX can listen for
  hiddenInput.dispatchEvent(
    new CustomEvent('overtype:change', { bubbles: true })
  );
}

Then on the form:

<form
  hx-post="/admin/actions/page.php"
  hx-trigger="overtype:change delay:1500ms"
  hx-swap="none"
  hx-on::after-request="handleAutosaveResponse(event)"
>
  <input type="hidden" name="csrf_token" value="...">
  <input type="hidden" name="slug" value="charte">
  <input type="hidden" id="content" name="content" value="">

  <span data-autosave-status></span>
</form>

3. CSRF Rotation + Status Indicator

Replace autosave.js's .json().then() pattern with a single shared handler. The silent-parse-error risk disappears because you're explicitly handling the response:

function handleAutosaveResponse(event) {
  const status = event.target.closest('form')
                             .querySelector('[data-autosave-status]');

  if (!event.detail.successful) {
    if (status) status.textContent = 'Erreur !';
    return;
  }

  try {
    const data = JSON.parse(event.detail.xhr.responseText);

    // Rotate CSRF token in both the form and the meta tag
    if (data.csrf_token) {
      event.target.closest('form')
                  .querySelector('input[name="csrf_token"]').value = data.csrf_token;
      const meta = document.querySelector('meta[name="csrf-token"]');
      if (meta) meta.content = data.csrf_token;
    }

    if (status) status.textContent = data.success ? 'Enregistré ✓' : 'Erreur !';

  } catch {
    // JSON parse failed (e.g. PHP warning in output) — surface it rather than silently swallowing
    if (status) status.textContent = 'Erreur !';
    console.warn('Autosave: could not parse response', event.detail.xhr.responseText);
  }
}

This is a direct improvement over the current autosave.js .catch(() => {}) silent swallow — you now see the PHP warning in the console instead of just getting a mystery 403 on the next save.

4. "Loading" State During Save

If you want a saving indicator (the current "Enregistrement…" state), use htmx:beforeRequest on the form — or use HTMX's built-in htmx-request class which is added to the element automatically while a request is in flight:

/* Target the class HTMX adds */
form.htmx-request [data-autosave-status]::after {
  content: 'Enregistrement…';
}

Or explicitly with an event listener:

document.body.addEventListener('htmx:beforeRequest', e => {
  const status = e.target.querySelector('[data-autosave-status]');
  if (status) status.textContent = 'Enregistrement…';
});

5. The Add/Remove Group JS Still Works Unchanged

The reindex logic (updating name attributes after add/remove) is inline <script> independent of autosave.js. This continues to work — HTMX reads FormData at request time, so newly added/reindexed inputs are automatically included in the next triggered save.

Migration Checklist

Form Change required
contenus-edit.php (pages) Add hx-* attrs, add overtype:change dispatch in OverType onChange
contenus-edit.php (form_help) Same as above
apropos-groups-form.php (contacts) Add hx-* attrs only
contenus-edit.php (sidebar_links) Add hx-* attrs only
autosave.js Delete once all four forms are migrated
Backend handlers No changes needed — they already return {success, csrf_token}

The backend is untouched throughout, which is the cleanest part of this migration.

HTMX Migration Feasibility

The settings toggles already use HTMX successfully for similar patterns (checkbox → toggle → server → toast). Here's an assessment for each form:

1. Static Pages (page.php) — Challenging

The OverType editor is a heavy custom JS component (contenteditable-based Markdown WYSIWYG). HTMX could handle the submission side (replacing fetch() with hx-post), but the editor itself would remain custom JS.

An HTMX approach would:

  • Add hx-post="/admin/actions/page.php" to the form
  • Add hx-trigger="every 3s" or a custom event dispatched by OverType's onChange
  • Use hx-swap="none" or a status indicator swap
  • Need a custom event from OverType like overtype:changed to trigger saves at the right time (not polling-based like every 3s)

Verdict: Partial replacement possible. HTMX for submission, keep OverType for editing. Could eliminate ~40 lines of autosave.js logic for this form.

2. About Contacts (apropos.php) — Easy

Simple text inputs. The add/remove group/entry logic is separate JS. Autosave just watches input events.

HTMX approach:

  • hx-post with hx-trigger="change delay:1500ms" on each input
  • hx-swap="none" + custom status indicator via hx-on::after-request

Verdict: Straightforward replacement. Eliminates autosave.js dependency.

Same as contacts — simple text/URL inputs. The add/remove JS is independent of autosave.

Verdict: Same as contacts.

4. Form Help Blocks (form-help.php) — Challenging

Same OverType integration as static pages. Same considerations apply.

Verdict: Same as static pages.

5. The autosave.js Itself — Can be Replaced

If every form with data-autosave is converted to HTMX, autosave.js can be removed entirely. The script is ~60 lines.

Summary Table

Form Editor HTMX Feasibility Effort
Static pages (about/licenses/charte) OverType Partial (keep OverType) Medium
Form help blocks OverType Partial (keep OverType) Medium
About contacts Native inputs Full Low
About sidebar links Native inputs Full Low

For static pages and form help blocks:

<form hx-post="/admin/actions/page.php"
      hx-trigger="overtype:changed delay:1500ms"
      hx-swap="none"
      hx-on::after-request="
          var data = JSON.parse(event.detail.xhr.responseText);
          if (data.csrf_token) {
              document.querySelector('input[name=csrf_token]').value = data.csrf_token;
          }
      "
      data-autosave>

And dispatch overtype:changed from OverType's onChange:

onChange: function(value) {
    hidden.value = value;
    editorElement.dispatchEvent(new CustomEvent('overtype:changed', { bubbles: true }));
}

For contacts/sidebar links — use per-input hx-trigger="change delay:1500ms" or a form-level trigger on any input change.

The key advantage: HTMX handles the CSRF token rotation natively via hx-on::after-request, eliminating the async .json().then() pattern and the associated silent-parse-error risk.