Files
freshrss-read-more/docs/superpowers/plans/2026-11-06-read-more.md
T
2026-11-06 18:48:10 +01:00

14 KiB
Raw Blame History

Read More — plan d'implémentation

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Extension FreshRSS qui replie les articles du mode lecture à une hauteur fixe avec un bouton « Lire plus », sans casser le marquage « lu » au scroll.

Architecture: Extension user sans base de données. extension.php injecte un JS et un CSS et passe la hauteur configurée au JS via le hook js_vars (context.extensions.read_more). Le JS replie les .flux .text trop hauts par max-height (jamais display:none). L'article reste plus haut que 0, donc onScroll() de FreshRSS continue de le marquer lu. Le dépôt est directement le dossier de l'extension.

Tech Stack: PHP 8.2+ (API Minz de FreshRSS 1.30.4), JS vanilla (pas de build), CSS. Tests : node:test + jsdom (unitaires DOM), Playwright + Docker Compose (freshrss/freshrss:1.30.4) pour le bout-en-bout.

Spec: docs/superpowers/specs/2026-11-06-read-more-design.md

Global Constraints

  • Cible : FreshRSS 1.30.4. L'image Docker de test est épinglée sur freshrss/freshrss:1.30.4.
  • Mode lecture uniquement : le JS ne fait rien si context.current_view !== 'reader'.
  • Ne jamais rendre un .flux de hauteur 0. Repli = max-height + overflow: hidden sur .text.
  • Hauteur repliée par défaut 250 px, réglable, bornée 100–2000 px (valeur hors bornes ramenée à la borne).
  • Seuil : repli seulement si hauteur du contenu ≥ 1,5 × hauteur repliée.
  • Le bouton ne modifie jamais l'état lu/non lu. Clic : preventDefault() + stopPropagation().
  • Après dépliage : reste déplié, pas de bouton « Réduire ».
  • Libellés : FR « Lire plus », EN « Read more ».
  • Aucune dépendance runtime. node_modules, package.json et tests/ ne servent qu'au développement.
  • Noms fixés : metadata.json name = "Read More", entrypoint = "ReadMore", classe ReadMoreExtension, clé de config utilisateur collapsed_height.

Review Focus

  1. Image chargée après coup : un article d'abord trop court pour être replié et qui grandit quand ses images arrivent doit être replié. Test : reevaluates short entry after image load (Task 2).
  2. freshrss:load-more émis plusieurs fois : pas de bouton en double, pas de re-repli d'un article déplié. Tests : is idempotent et does not re-collapse expanded entry (Task 2).
  3. Valeur de config absurde (vide, 5, 99999, texte) : bornée, pas d'erreur. Test e2e config height is clamped (Task 3).
  4. Extension active hors mode lecture (vue normale) : aucun bouton, aucun repli. Test e2e normal view untouched (Task 3).
  5. Article déplié puis dépassé : marqué lu une fois son bas dépassé. Test e2e expanded entry marked read after scrolling past (Task 3).

Task 1: Squelette de l'extension (PHP, config, i18n, README)

Files:

  • Create: metadata.json, extension.php, configure.phtml, i18n/fr/ext.php, i18n/en/ext.php, README.md, LICENSE, .gitignore
  • Create (vides pour l'instant, remplis en Task 2) : static/read-more.js, static/read-more.css

Interfaces:

  • Produces (pour le JS, Task 2) : window.context.extensions.read_more = { collapsed_height: int, label: string }.

  • Produces : clé i18n ext.read_more.button, ext.read_more.height.label, ext.read_more.height.help.

  • Step 1: metadata.json {"name": "Read More", "author": "vincentb99", "description": "Replie les articles du mode lecture avec un bouton « Lire plus »", "version": "0.1.0", "entrypoint": "ReadMore", "type": "user"}

  • Step 2: extension.php : final class ReadMoreExtension extends Minz_Extension, sur le modèle de xExtension-ReadingTime (dépôt FreshRSS/Extensions).

    • public const DEFAULT_HEIGHT = 250; MIN_HEIGHT = 100; MAX_HEIGHT = 2000;
    • init(): void : registerTranslates(). Si !FreshRSS_Context::hasUserConf(), arrêter là. Sinon Minz_View::appendStyle($this->getFileUrl('read-more.css')), Minz_View::appendScript($this->getFileUrl('read-more.js')), registerHook(Minz_HookType::JsVars, $this->jsVars(...)).
    • public function getCollapsedHeight(): int : getUserConfigurationInt('collapsed_height') ?? DEFAULT_HEIGHT, passé par clampHeight.
    • public static function clampHeight(int $h): int : max(MIN, min(MAX, $h)).
    • public function jsVars(array $vars): array : ajoute $vars['read_more'] = ['collapsed_height' => getCollapsedHeight(), 'label' => _t('ext.read_more.button')].
    • handleConfigureAction(): void : registerTranslates(). En POST, setUserConfigurationValue('collapsed_height', self::clampHeight(Minz_Request::paramInt('collapsed_height') ?: self::DEFAULT_HEIGHT)).
  • Step 3: configure.phtml : même structure que celle de ReadingTime (form POST vers _url('extension','configure','e',urlencode($this->getName())), champ _csrf, form-group, boutons gen.action.submit / gen.action.cancel). Un champ <input type="number" name="collapsed_height" min="100" max="2000" step="10"> avec pour valeur getCollapsedHeight().

  • Step 4: i18n : i18n/fr/ext.php et i18n/en/ext.php retournent ['read_more' => ['button' => …, 'height' => ['label' => …, 'help' => …]]]. FR : « Lire plus », « Hauteur repliée (px) », « Hauteur visible d'un article avant le bouton « Lire plus » (100 à 2000). » EN : "Read more", "Collapsed height (px)", "Visible height of an article before the "Read more" button (100–2000)."

  • Step 5: README.md (FR) : rôle de l'extension, prérequis (FreshRSS ≥ 1.30.4, option « marquer comme lu au défilement » activée), installation Docker (git clone dans /var/www/FreshRSS/extensions/, activation dans Configuration → Extensions), mise à jour (git pull), réglage, lancement des tests. LICENSE : MIT, copyright vincentb99. .gitignore : node_modules/, test-results/, playwright-report/.

  • Step 6: Lint PHP Run: docker run --rm -v "$PWD":/app -w /app php:8.2-cli sh -c 'for f in extension.php configure.phtml i18n/*/ext.php; do php -l "$f" || exit 1; done' Expected : No syntax errors detected pour chaque fichier.

  • Step 7: Commit : git add -A && git commit -m "feat: squelette de l'extension Read More (config, i18n)"


Task 2: Logique de repli (JS + CSS) et tests unitaires

Files:

  • Create: package.json ("private": true, "type": "commonjs", devDeps jsdom, @playwright/test, scripts "test:unit": "node --test tests/unit/", "test:e2e": "playwright test")
  • Modify: static/read-more.js, static/read-more.css
  • Test: tests/unit/read-more.test.js

Interfaces:

  • Consumes : window.context.extensions.read_more (Task 1), window.context.current_view.

  • Produces : static/read-more.js est un IIFE qui expose window.ReadMore et, si module existe, module.exports, avec :

    • THRESHOLD_RATIO = 1.5
    • shouldCollapse(contentHeight: number, collapsedHeight: number): boolean
    • processEntries(root: ParentNode, opts: { collapsedHeight: number, label: string, measure?: (el: Element) => number }): void. measure vaut par défaut el => el.scrollHeight.
    • init(win: Window): void : vérifie la vue, lit la config, lance processEntries(document), écoute freshrss:load-more sur document.body et les load d'images en capture sur document. S'exécute au chargement si window.context.extensions existe, sinon sur freshrss:globalContextLoaded.
  • Conventions DOM (utilisées aussi par Task 3) :

    • sur .flux : data-read-more = "collapsed" | "short" | "expanded"
    • sur .text replié : classe read-more-collapsed, style inline --read-more-height: <n>px
    • bouton : <button type="button" class="btn read-more-btn">label</button> inséré juste après .text
  • Step 1: Écrire les tests dans tests/unit/read-more.test.js (jsdom, fixture : deux .flux contenant article.flux_content > div.content > div.text, measure injecté par une map élément → hauteur) :

    • shouldCollapse thresholds : (374, 250) → false, (375, 250) → true, (1000, 250) → true
    • collapses tall entry : hauteur 1000, h=250 → .text a read-more-collapsed et la propriété --read-more-height vaut 250px. Un seul .read-more-btn, de texte Lire plus, frère suivant de .text. data-read-more="collapsed".
    • skips short entry : hauteur 300 → pas de classe, pas de bouton, data-read-more="short"
    • is idempotent : processEntries appelé deux fois → toujours un seul bouton par article
    • click expands : clic → classe retirée, bouton retiré, data-read-more="expanded", event.defaultPrevented === true. Un listener click sur le .flux parent n'est pas appelé.
    • does not re-collapse expanded entry : clic, puis processEntries à nouveau → toujours déplié, pas de bouton
    • does not touch read state : un .flux.not_read reste not_read après repli et clic
    • reevaluates short entry after image load : article short, sa hauteur passe à 1000, puis dispatch new Event('load') sur un <img> de son .text (après init) → replié
    • init ignores non-reader view : context.current_view = 'normal' → aucun bouton
    • init handles load-more : current_view='reader', init, puis ajout d'un nouveau .flux haut et dispatch de freshrss:load-more sur body → nouveau bouton
  • Step 2: Vérifier l'échec : npm install && npm run test:unit → FAIL (fonctions absentes).

  • Step 3: Implémenter static/read-more.js selon les interfaces ci-dessus. Ne traiter que les .flux sans data-read-more, ou ceux en short lors d'une réévaluation. Pour init en jsdom, laisser measure injectable via win.ReadMore._measure ou un paramètre optionnel, au choix de l'implémenteur, pourvu que les tests l'utilisent.

  • Step 4: static/read-more.css

    • .flux .text.read-more-collapsed { max-height: var(--read-more-height, 250px); overflow: hidden; position: relative; }
    • ::after en bas : dégradé de transparent vers la couleur de fond du thème (var(--frss-background-color, #fff)), hauteur 4em, pointer-events: none
    • .read-more-btn { display: block; margin: 0.5em auto 1em; }
  • Step 5: Tests verts : npm run test:unit → tous PASS.

  • Step 6: Commit : git add -A && git commit -m "feat: repli des articles en mode lecture avec bouton Lire plus"


Task 3: Tests bout-en-bout sur FreshRSS 1.30.4

Files:

  • Create: tests/e2e/docker-compose.yml, tests/e2e/fixtures/feed.xml, tests/e2e/fixtures/subscriptions.opml, tests/e2e/setup.sh, tests/e2e/read-more.spec.js, playwright.config.js

Interfaces:

  • Consumes : conventions DOM de Task 2, nom "Read More" et clé collapsed_height de Task 1.

  • Step 1: Environnement

    • docker-compose.yml : service freshrss (freshrss/freshrss:1.30.4, port 8080:80, volume ../..:/var/www/FreshRSS/extensions/freshrss-read-more:ro, FRESHRSS_INSTALL: --default-user test --auth-type none --db-type sqlite --base-url http://localhost:8080, FRESHRSS_USER: --user test --password testtest1). Service feeds (nginx:alpine) qui sert fixtures/ sur le réseau compose.
    • feed.xml : RSS avec 8 items. Les items 1 à 6 sont longs (≥ 15 paragraphes), le 7 est court (1 paragraphe) et le 8 est long.
    • subscriptions.opml : un abonnement http://feeds/feed.xml.
    • setup.sh : docker compose up -d --wait, puis dans le conteneur (--user www-data) : cli/import-for-user.php --user test --filename …opml, cli/actualize-user.php --user test. Enfin un php -r qui modifie data/users/test/config.php (include, puis var_export) : extensions_enabled['Read More']=true, mark_when['scroll']=true, view_mode='reader'. Ces clés sont des tableaux, que reconfigure-user.php refuse en 1.30.4. Le script se termine par docker compose down -v s'il est appelé avec --down.
  • Step 2: Écrire tests/e2e/read-more.spec.js (viewport 390×844, baseURL http://localhost:8080, page /i/?a=reader&state=2, c'est-à-dire les non lus). Avant chaque test, remettre tout en non lu et collapsed_height à 250 via php -r dans le conteneur :

    • long entries collapsed : chaque article long a data-read-more="collapsed" et un .read-more-btn. La hauteur de son .text est ≤ 250.
    • short entry not collapsed : l'item 7 est en data-read-more="short", sans bouton
    • collapsed entry marked read after scrolling past : scroller jusqu'à ce que le bas du 1er .flux dépasse le haut de l'écran, attendre 1 s (throttle de 500 ms) → le 1er .flux n'a plus not_read. Après rechargement de la page des non lus, l'article n'y figure plus.
    • read more expands without changing read state or scroll : clic sur le bouton du 1er article → .text sans max-height, l'article est toujours not_read, et window.scrollY varie de moins de 5 px
    • expanded entry marked read after scrolling past : déplier le 2e, scroller au-delà de son bas → lu
    • normal view untouched : /i/?a=normal → 0 .read-more-btn
    • config height is clamped : sur la page de config de l'extension, soumettre 5 → le champ affiche 100. Soumettre 99999 → 2000. En mode lecture avec 400, la hauteur de .text replié vaut ≤ 400.
  • Step 3: Lancer : bash tests/e2e/setup.sh && npx playwright install chromium && npm run test:e2e → tous PASS. En cas d'échec sur le marquage lu, vérifier d'abord que mark_when.scroll est bien actif (cli/reconfigure-user.php --user test --list) avant de soupçonner l'extension.

  • Step 4: Commit : git add -A && git commit -m "test: e2e sur FreshRSS 1.30.4 (repli, marquage lu au scroll)"


Task 4: Vérification manuelle et publication

  • Step 1 : avec l'environnement de Task 3, ouvrir http://localhost:8080 dans le navigateur en mode mobile. Vérifier visuellement le dégradé et le bouton dans les thèmes clair et sombre (Configuration → Affichage).
  • Step 2 : bash tests/e2e/setup.sh --down.
  • Step 3 : demander confirmation à l'utilisateur avant git push -u origin main (dépôt externe).
  • Step 4 : installation en production (par l'utilisateur, suivant le README) : git clone dans le volume extensions, activation, réglage, test sur smartphone.