diff --git a/CLAUDE.md b/CLAUDE.md index 3f0fa2532..5eec4cdf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,11 +282,16 @@ safe_rmrf() { ### כללים - **אין להריץ** קוד בטופ-לבל בזמן build (importים חייבים להיות בטוחים) - RTD נחשב נכשל על אזהרות (`fail_on_warning: true`) – שמור 0 warnings +- **כל עמוד חדש חייב להירשם ב-`docs/index.rst`** (ה-master document) בתוך `toctree` מתאים. + עמוד שלא רשום מייצר אזהרת `document isn't included in any toctree` – ומכיוון ש-RTD נכשל + על אזהרות, זה **מפיל את הבילד**. אם עמוד לא אמור להתפרסם – הוסף אותו ל-`exclude_patterns` + ב-`docs/conf.py` במקום להשאיר אותו "יתום" - השתמש ב-`:noindex:` בעמודי סקירה חופפים: api, database, handlers, services, configuration ### הגדרות - `autodoc_mock_imports`: cairosvg, aiohttp, textstat, langdetect, pytest, search_engine, code_processor, integrations -- `docs/examples.rst` מוחרג עד שהעמוד יתווסף ל-toctree (ואז הסר מה-exclude) +- `exclude_patterns` (ב-`docs/conf.py`) מרכז את העמודים שאינם ב-toctree בכוונה (למשל כפילויות + `.md`/`.rst`). לפני שמוסיפים החרגה – ודא שהעמוד באמת לא אמור להיות בתוכן העניינים --- diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index 3419fa785..f113b9bdc 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -4,6 +4,11 @@ .. note:: בכל פעם שמוסיפים או משנים משתני סביבה בקוד/Infra **חייבים** לעדכן עמוד זה (רפרנס משתני הסביבה) וכן לציין זאת ב-PR. בכך אנו מבטיחים שהמידע הופך ל-Single Source of Truth גם למפתחים וגם לאנשי DevOps. +.. seealso:: + :doc:`webapp/config-inspector` — כלי האדמין שמציג את המשתנים בזמן ריצה. שם מתועדים + גם הכללים להוספת משתנה: איך קובעים לאיזה שירות הוא שייך, איך נמנעים מסטטוס + ``Modified`` שגוי, ומה צריך לתעד כשמשתנה משרת כמה שירותים. + טבלה מרכזית ------------ diff --git a/docs/index.rst b/docs/index.rst index 6d4f68f27..e898aeb48 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -126,6 +126,7 @@ Code Keeper Bot - תיעוד API webapp/caching webapp/advanced-caching webapp/cache-inspector + webapp/config-inspector webapp/static-checklist webapp/commands-catalog webapp/code-execution diff --git a/docs/webapp/config-inspector.rst b/docs/webapp/config-inspector.rst new file mode 100644 index 000000000..4f12445ea --- /dev/null +++ b/docs/webapp/config-inspector.rst @@ -0,0 +1,261 @@ +.. _webapp-config-inspector: + +Config Inspector (סקירת משתני סביבה) +===================================== + +מה זה Config Inspector? +------------------------ + +**Config Inspector** הוא כלי אדמין שמציג תמונת מצב של הקונפיגורציה: אילו משתני סביבה +מוגדרים, מה הערך הפעיל שלהם, מה ברירת המחדל בקוד, והאם הערך שונה מברירת המחדל. + +הכלי נותן מענה לשאלות שקשה לענות עליהן מול Render Dashboard: + +- האם המשתנה הזה בכלל מוגדר, או שאנחנו רצים על ברירת מחדל? +- מה ברירת המחדל שבקוד, ובמה הערך שברנדר שונה ממנה? +- לאיזה שירות שייך המשתנה, ואיפה צריך להגדיר אותו? +- אילו משתנים הכרחיים חסרים? + +איך נכנסים? +----------- + +**דרך ה-UI:** דף **Settings** ← קטגוריית **כלי אדמין** ← **Config Inspector** + +**ישירות:** + +.. code-block:: text + + GET /admin/config-inspector + +.. note:: + הדף זמין **רק לאדמינים** (``@admin_required`` ב-``webapp/app.py``). ערך מוצג ממוסך + (``********``) **רק אם הוא מסווג כרגיש** — לפי שם המשתנה או לפי סימון מפורש + ``sensitive=True``; כל שאר הערכים מוצגים כמות שהם. הסיווג מפורט ב- + :ref:`config-inspector-sensitive`, וחשוב לקרוא אותו לפני הוספת משתנה שהוא סוד. + +.. _config-inspector-two-pages: + +שני עמודים — ולמה +------------------ + +הדף מחולק לשני טאבים, וההפרדה ביניהם אינה קוסמטית אלא נובעת ממגבלה אמיתית. + +.. _config-inspector-page1: + +עמוד 1: שירות ה-Webapp (עם Status וערך פעיל) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +העמוד הראשון מציג **רק משתנים ששייכים (גם) לשירות ה-Webapp**, ורק הם מקבלים עמודות +``Status`` ו-``Active Value``. + +**הסיבה:** ה-Config Inspector הוא קוד שרץ **בתוך תהליך ה-Webapp**. כשהוא קורא +``os.environ`` הוא רואה אך ורק את משתני הסביבה של אותו תהליך. הבוט, שרת ה-MCP וה-webserver +הם **שירותי Render נפרדים**, כל אחד עם ENV משלו — ותהליך ה-Webapp פשוט אינו יכול לראות +אותם. + +לכן, אילו היינו מציגים ``Status`` למשתנה של הבוט, הוא היה מוצג כ-*Default* או *Missing* +גם כשהוא מוגדר מצוין בשירות הבוט. **מידע שגוי גרוע מהיעדר מידע** — ולכן העמוד הראשון +מסונן, והסינון נאכף בקוד: + +.. code-block:: python + + # services/config_inspector_service.py — get_config_overview + for definition in self.CONFIG_DEFINITIONS.values(): + # עמוד ראשי: רק משתנים ששייכים (גם) לשירות ה-webapp + if "webapp" not in definition.services: + continue + +בראש העמוד מוצגים כרטיסי סיכום (סה"כ / שונו מדיפולט / הוגדרו בסביבה / חסרים / ברירת מחדל), +שורת סינון (קטגוריה + סטטוס), פירוט לפי קטגוריות, וטבלת המשתנים המלאה. כפתור +**"העתק הכל"** מייצא את השורות המוצגות בפורמט ``KEY=VALUE`` (שורות ``.env``). + +עמוד 2: שירותים אחרים — Bot / MCP / Webserver +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +העמוד השני מציג את כל המשתנים שיש להם שירות **שאינו** webapp — כולל משתנים משותפים +שמופיעים גם בעמוד הראשון. + +מה מוצג: ``Key`` / ``שירות`` / ``Default Value`` / ``תיאור``. + +מה **לא** מוצג, ולמה: **אין כאן ``Status`` ואין ``Active Value``** — מאותה סיבה בדיוק +שתוארה למעלה. הערכים חיים בתהליכים אחרים ואינם נגישים מכאן. לערכים בפועל יש לבדוק +ב-Render Dashboard של השירות הרלוונטי. + +- שורה של משתנה שמוגדר גם בוובאפ מסומנת בתגית **"גם Webapp"** — הערך שלה מופיע בעמוד הראשון. +- שורת הסינון בעמוד זה היא לפי **קטגוריה** ולפי **שירות** (ולא לפי סטטוס — אין כאן סטטוס). +- "העתק הכל" בעמוד זה מעתיק ``KEY=<ברירת מחדל>`` ומציין זאת מפורשות, כדי שלא ייווצר רושם + שאלו הערכים החיים. + +.. _config-inspector-status: + +ארבעת הסטטוסים — ואיך נמנעים מ-"Modified" שגוי +------------------------------------------------ + +.. list-table:: + :header-rows: 1 + :widths: 15 45 40 + + * - סטטוס + - מתי מתקבל + - משמעות + * - ``Default`` + - אין ערך בסביבה ויש ברירת מחדל בקוד — או שהערך בסביבה **זהה** לברירת המחדל + - רצים על ברירת המחדל. אין מה לעשות + * - ``Set`` + - יש ערך בסביבה ו**אין ברירת מחדל בקוד** + - המשתנה הוגדר (למשל ברנדר). זו **אינה** סטייה — אין דיפולט שממנו אפשר לסטות + * - ``Modified`` + - יש ערך בסביבה, יש ברירת מחדל, והם **שונים** + - סטייה אמיתית מברירת המחדל — כאן צריך להסתכל + * - ``Missing`` + - אין ערך בסביבה, אין ברירת מחדל, והמשתנה מסומן ``required=True`` + - משתנה הכרחי חסר. מוצג גם כאזהרה בראש הדף + +הלוגיקה ממומשת ב-``ConfigService.determine_status``. + +.. warning:: + **הכלל השורשי:** ה-``default`` שרשום ב-``ConfigDefinition`` חייב להיות **זהה תו-בתו** + לברירת המחדל האמיתית בקוד. אחרת מתקבל סטטוס ``Modified`` שקרי — משתנה שמוגדר נכון + ייראה כאילו מישהו שינה אותו, ואי אפשר יהיה לסמוך על העמודה הזו. + +שתי הטעויות שמייצרות ``Modified`` שגוי: + +**1. דיפולט משוער במקום זה שבקוד.** אם הקוד עושה ``os.getenv("X", "60")`` אבל בהגדרה +נרשם ``default="30"`` — משתנה שמוגדר ל-60 ברנדר יוצג כ-``Modified``, למרות שהוא זהה +לברירת המחדל בפועל. + +**2. המצאת דיפולט למשתנה שאין לו דיפולט בקוד.** אם בקוד יש ``os.getenv("TOKEN")`` בלי +ערך שני, אין ברירת מחדל — וההגדרה צריכה להשאיר ``default`` ריק. אז הסטטוס יהיה ``Set`` +(נכון), ולא ``Modified`` (מטעה). + +**הבדיקה לפני הוספה** — לאתר את ברירת המחדל האמיתית ולהעתיק אותה כמות שהיא: + +.. code-block:: bash + + rg -n -w 'MY_VAR' -t py -g '!tests/**' + +``-w`` מחפש את שם המשתנה כמילה שלמה, ולכן תופס גם ``os.getenv("MY_VAR")``, גם +``os.getenv('MY_VAR')`` וגם ``config.MY_VAR``, בלי להיתפס ל-``MY_VAR_OTHER``. + +.. _config-inspector-services: + +לאיזה שירות שייך המשתנה? — לבדוק, לא לנחש +-------------------------------------------- + +השדה ``services`` בהגדרה קובע באיזה עמוד המשתנה יופיע. שיוך שגוי מייצר רעש: משתנה +של הוובאפ בלבד שמסומן כשייך לכולם מופיע בעמוד 2 כאילו צריך להגדיר אותו בארבעה מקומות. + +.. important:: + **אל תסיקו את השיוך משם המשתנה.** שם שנשמע גלובלי (``PUSH_*``, ``UPTIME_*``, + ``MAINTENANCE_*``) לא אומר שהמשתנה נצרך בכל השירותים. בסבב ניקוי אחד הוסרו 68 שיוכים + שגויים שנקבעו לפי תחושה. + +**הנוהל (שלושה צעדים):** + +1. **למצוא איפה המשתנה נצרך בפועל** — חיפוש אחד תופס גם ``os.getenv`` (בכל סוג גרשיים) + וגם גישה דרך אובייקט הקונפיג (``config.MY_VAR``): + + .. code-block:: bash + + rg -n -w 'MY_VAR' -t py -g '!tests/**' + +2. **לזהות לאיזה שירות שייך הקובץ שנמצא** — לפי נקודות הכניסה: + + .. list-table:: + :header-rows: 1 + :widths: 40 25 35 + + * - קובץ / תיקייה + - שירות + - נקודת כניסה + * - ``main.py``, ``handlers/``, ``*_handler.py`` + - ``bot`` + - ``main.py`` + * - ``webapp/`` + - ``webapp`` + - ``webapp/app.py`` + * - ``mcp_server/`` + - ``mcp`` + - ``mcp_server/app.py`` + * - ``services/webserver.py`` + - ``webserver`` + - ``services/webserver.py`` + * - ``scripts/`` + - ``scripts`` + - סקריפטים ידניים/CI + +3. **קובץ משותף — לפי שרשרת ה-imports.** קובץ כמו ``database/``, ``services/`` או + ``utils.py`` אינו שייך לשירות מסוים בפני עצמו: הוא שייך לשירות רק אם הוא **נטען** + בשרשרת ה-imports של אותה נקודת כניסה. משתנה שנצרך רק ב-``webapp/app.py`` **אינו** + שייך ל-bot/mcp/webserver, נקודה. + +.. tip:: + ``PORT`` הוא חריג מוצדק: הוא לא בהכרח מופיע בקוד של השירות, אבל כל שירות web צריך + אותו בפקודת ההרצה. חריגים כאלה — לתעד בהערה ליד ההגדרה. + +.. _config-inspector-multi-service: + +משתנה שמשרת כמה שירותים — לתעד בכל המקומות +-------------------------------------------- + +משתנה יכול להיות מוגדר בכמה שירותי Render במקביל (למשל ``MONGODB_URL``). במקרה כזה +מתעדים אותו **בכל המקומות הרלוונטיים, כל אחד במקום שלו** — ולא בוחרים "בית" אחד: + +1. **``services`` בהגדרה** — כל השירותים, לא רק העיקרי: + + .. code-block:: python + + "MONGODB_URL": ConfigDefinition( + key="MONGODB_URL", + services=("webapp", "bot", "mcp", "webserver"), + ... + ) + +2. **``docs/environment-variables.rst``** — עמודת **"רכיב"** בטבלה המרכזית חייבת לשקף + את **אותה** רשימת שירותים. השורה הזו היא הרפרנס לאנשי DevOps. + +3. **תיאור תואם בשני המקומות** — ה-``description`` שב-``ConfigDefinition`` (מה שמוצג + בטבלת ה-Config Inspector) וההסבר שב-``environment-variables.rst`` צריכים לומר את אותו + דבר. שני התיאורים נקראים זה לצד זה כשמדבגים תקלת קונפיגורציה, ותיאורים סותרים גרועים + מהיעדר תיאור. + +.. _config-inspector-sensitive: + +מיסוך ערכים רגישים +------------------- + +ערך ממוסך (``********``) בשני מקרים: + +- **לפי שם המשתנה** — ``SENSITIVE_PATTERNS`` (``TOKEN``, ``KEY``, ``PASSWORD``, ``SECRET``, + ``URI``, ``CREDENTIALS``, ``AUTH``, ``PRIVATE``, ``CERT``, ``DSN``, ``CONNECTION_STRING``). +- **לפי סימון מפורש** — ``sensitive=True`` בהגדרה. + +.. note:: + ``URL`` הוסר מהרשימה **בכוונה**: כתובת ציבורית (``WEBAPP_URL``, ``MCP_SERVER_URL``) + אינה סוד, ומיסוכה הפך את הדף לחסר תועלת. כתובת שמכילה credentials — למשל + ``MONGODB_URL`` בפורמט ``scheme://user:pass@host`` — מסומנת ``sensitive=True`` + מפורשות, ובנוסף קיים זיהוי אוטומטי של תבנית ה-credentials בתוך URL. + + המסקנה המעשית: **משתנה חדש שהוא סוד ושמו אינו מכיל אחת מהמילים ברשימה — סמנו + ``sensitive=True`` ידנית.** + +צ'קליסט: הוספת משתנה סביבה חדש +-------------------------------- + +1. **לאתר את הצריכה בפועל** — ``grep`` על שם המשתנה (:ref:`config-inspector-services`). +2. **להעתיק את ברירת המחדל מהקוד תו-בתו** — ואם אין דיפולט, להשאיר ריק כדי לקבל ``Set`` + ולא ``Modified`` (:ref:`config-inspector-status`). +3. **לקבוע ``services``** לפי הצריכה שנמצאה — כל השירותים שצורכים, ורק הם. +4. **``sensitive=True``** אם זה סוד ששמו לא נתפס אוטומטית (:ref:`config-inspector-sensitive`). +5. **לעדכן את ``docs/environment-variables.rst``** — שורה בטבלה המרכזית עם עמודת "רכיב" + תואמת ותיאור זהה. זו **חובה** לפי ההנחיה שבראש אותו עמוד. +6. **לוודא בדף** שהמשתנה מופיע בעמוד הנכון ושהסטטוס הגיוני (משתנה שלא נגעתם בו ברנדר + אמור להיות ``Default``, לא ``Modified``). + +טסטים: ``tests/test_config_inspector_service.py``. + +ראו גם +------- + +- :doc:`../environment-variables` — הרפרנס המלא של משתני הסביבה +- :doc:`cache-inspector` — כלי אדמין מקביל ל-Redis diff --git a/tests/md-anchors.test.js b/tests/md-anchors.test.js new file mode 100644 index 000000000..cd3efaf73 --- /dev/null +++ b/tests/md-anchors.test.js @@ -0,0 +1,204 @@ +/** + * טסטים ל-webapp/static/js/md-anchors.js — עוגני HTML מפורשים במסמכי Markdown. + * + * הרצה: node tests/md-anchors.test.js + * (בריפו אין כרגע runner מוגדר ל-JS, ולכן הקובץ עצמאי ומחזיר קוד יציאה 1 בכישלון.) + * + * הקובץ הנבדק נטען כמו בדפדפן — script קלאסי שמגדיר window.MdAnchors — כי package.json + * מוגדר "type": "module" ולכן require() רגיל לא היה עובד עליו. + */ +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODULE_PATH = path.join(__dirname, '..', 'webapp', 'static', 'js', 'md-anchors.js'); + +function loadMdAnchors() { + const code = fs.readFileSync(MODULE_PATH, 'utf8'); + const sandbox = { window: {} }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox.window.MdAnchors; +} + +const { extractExplicitAnchors, sanitizeAnchorId } = loadMdAnchors(); + +let passed = 0; +let failed = 0; + +function check(name, condition, actual) { + if (condition) { + passed += 1; + console.log(' ✅', name); + } else { + failed += 1; + console.log(' ❌', name, actual !== undefined ? '\n actual: ' + JSON.stringify(actual) : ''); + } +} + +console.log('עוגן בתוך שורת הכותרת'); +{ + const r = extractExplicitAnchors('## 1. תקשורת '); + check('התגית הוסרה מטקסט הכותרת', r.source === '## 1. תקשורת', r.source); + check('המזהה נאסף לכותרת הראשונה', + JSON.stringify(r.anchors) === '[{"headingIndex":0,"ids":["communication"]}]', r.anchors); +} + +console.log('עוגן בשורה נפרדת לפני הכותרת (דפוס GitHub)'); +{ + const r = extractExplicitAnchors('\n## מבוא'); + check('שורת העוגן הוסרה', r.source === '## מבוא', r.source); + check('העוגן שויך לכותרת שאחריו', r.anchors[0] && r.anchors[0].ids[0] === 'intro', r.anchors); +} + +console.log('בלוק קוד — אין לגעת בתוכן'); +{ + const src = '```html\n## לא כותרת \n```'; + const r = extractExplicitAnchors(src); + check('המקור לא שונה', r.source === src, r.source); + check('לא נאספו עוגנים', r.anchors.length === 0, r.anchors); +} + +console.log('מסמך ללא עוגנים'); +{ + const src = '# כותרת\n\nטקסט רגיל עם לא-עוגן.'; + const r = extractExplicitAnchors(src); + check('המקור לא שונה', r.source === src, r.source); + check('לא נאספו עוגנים', r.anchors.length === 0, r.anchors); +} + +console.log('כמה עוגנים לאותה כותרת, כולל גרש בודד'); +{ + const r = extractExplicitAnchors("## API "); + check('שני המזהים נאספו לפי הסדר', + JSON.stringify(r.anchors[0].ids) === '["api","rest"]', r.anchors); + check('הכותרת נוקתה', r.source === '## API', r.source); +} + +console.log('אינדוקס כותרות'); +{ + const r = extractExplicitAnchors('# A\n## B \n### C'); + check('העוגן שויך לכותרת השנייה (index=1)', r.anchors[0].headingIndex === 1, r.anchors); +} + +console.log('כותרות Setext — חייבות להיספר כדי שהאינדקס יתאים ל-h1..h6 בפועל'); +{ + // markdown-it מייצר כאן h1 (Setext) ואז h2 (ATX) — העוגן שייך לכותרת index=1 + const r = extractExplicitAnchors('מבוא\n====\n\nטקסט.\n\n## תקשורת '); + check('העוגן שויך לכותרת השנייה', r.anchors[0] && r.anchors[0].headingIndex === 1, r.anchors); + check('הכותרת נוקתה מהתגית', r.source.includes('## תקשורת') && !r.source.includes('\n-------\n\nטקסט.'); + check('עוגן בכותרת Setext נאסף', r.anchors[0] && r.anchors[0].ids[0] === 'comm', r.anchors); + check('index=0 לכותרת הראשונה', r.anchors[0].headingIndex === 0, r.anchors); + check('שורת הטקסט נוקתה', r.source.split('\n')[0] === 'תקשורת', r.source); +} +{ + // עוגן בשורה נפרדת לפני כותרת Setext + const r = extractExplicitAnchors('\nמבוא\n===='); + check('שויך לכותרת Setext', r.anchors[0] && r.anchors[0].ids[0] === 'intro', r.anchors); + check('שורת העוגן הוסרה', r.source === 'מבוא\n====', r.source); +} +{ + // ערבוב: Setext, ATX, Setext — שלוש כותרות, העוגן על השלישית + const r = extractExplicitAnchors('A\n===\n\n## B\n\nC \n---'); + check('שלוש כותרות — העוגן על index=2', r.anchors[0].headingIndex === 2, r.anchors); +} +{ + // setext=false (כש-lheading מנוטרל) — לא סופרים כותרות Setext + const r = extractExplicitAnchors('מבוא\n====\n\n## תקשורת ', { setext: false }); + check('בלי Setext האינדקס הוא 0', r.anchors[0].headingIndex === 0, r.anchors); +} + +console.log('Setext — הימנעות מזיהוי שגוי'); +{ + // "---" אחרי שורה ריקה הוא קו מפריד (hr), לא כותרת + const r = extractExplicitAnchors('טקסט.\n\n---\n\n## כותרת '); + check('hr לא נספר ככותרת', r.anchors[0].headingIndex === 0, r.anchors); +} +{ + // שורת הפרדה של טבלה אינה קו תחתון של Setext + const r = extractExplicitAnchors('| a | b |\n|---|---|\n| 1 | 2 |\n\n## כותרת '); + check('טבלה לא נספרת ככותרת', r.anchors[0].headingIndex === 0, r.anchors); +} +{ + // פריט רשימה שאחריו --- אינו כותרת Setext + const r = extractExplicitAnchors('- פריט\n---\n\n## כותרת '); + check('פריט רשימה לא נספר ככותרת', r.anchors[0].headingIndex === 0, r.anchors); +} + +console.log('סניטציה של מזהה'); +{ + check('סולמית מובילה מוסרת', sanitizeAnchorId('#intro') === 'intro'); + check('רווחים וגרשיים מוסרים', sanitizeAnchorId(' my id ') === 'myid'); + check('גרשיים מסולסלים מוסרים', sanitizeAnchorId('“x”') === 'x'); + check('ערך ריק מוחזר כמחרוזת ריקה', sanitizeAnchorId(null) === ''); +} + +// ---- applyExplicitAnchors: נבדק מול DOM מינימלי מדומה (אין jsdom בריפו) ---- + +function makeFakeDom(headingCount) { + const byId = new Map(); + const doc = { + getElementById: (id) => byId.get(id) || null, + createElement: () => ({ + className: '', + _id: '', + attrs: {}, + get id() { return this._id; }, + set id(v) { this._id = v; if (v) byId.set(v, this); }, + setAttribute(k, v) { this.attrs[k] = v; }, + }), + }; + const inserted = []; // [{beforeIndex, id}] + const headings = []; + for (let i = 0; i < headingCount; i++) { + const h = { + index: i, + ownerDocument: doc, + parentNode: { + insertBefore: (node, ref) => { inserted.push({ beforeIndex: ref.index, id: node.id }); }, + }, + }; + headings.push(h); + } + const container = { querySelectorAll: () => headings }; + return { container, inserted, doc }; +} + +console.log('applyExplicitAnchors — הוספת יעדי עוגן ל-DOM'); +{ + const { applyExplicitAnchors } = loadMdAnchors(); + const { container, inserted } = makeFakeDom(3); + const count = applyExplicitAnchors(container, [ + { headingIndex: 1, ids: ['communication'] }, + { headingIndex: 2, ids: ['api', 'rest'] }, + ]); + check('הוחלו שלושה עוגנים', count === 3, count); + check('העוגן הוכנס לפני הכותרת הנכונה', + inserted[0].beforeIndex === 1 && inserted[0].id === 'communication', inserted); + check('שני עוגנים לאותה כותרת', inserted.length === 3 && inserted[2].id === 'rest', inserted); +} + +console.log('applyExplicitAnchors — לא דורס מזהה תפוס'); +{ + const { applyExplicitAnchors } = loadMdAnchors(); + const { container, inserted, doc } = makeFakeDom(2); + doc.getElementById = (id) => (id === 'taken' ? {} : null); + const count = applyExplicitAnchors(container, [{ headingIndex: 0, ids: ['taken'] }]); + check('מזהה קיים לא נדרס', count === 0 && inserted.length === 0, inserted); +} + +console.log('applyExplicitAnchors — קלט ריק לא מפיל'); +{ + const { applyExplicitAnchors } = loadMdAnchors(); + check('null container', applyExplicitAnchors(null, [{ headingIndex: 0, ids: ['x'] }]) === 0); + check('רשימה ריקה', applyExplicitAnchors(makeFakeDom(1).container, []) === 0); +} + +console.log('\nסה"כ: ' + passed + ' עברו, ' + failed + ' נכשלו'); +process.exit(failed === 0 ? 0 : 1); diff --git a/webapp/static/js/live-preview.js b/webapp/static/js/live-preview.js index 7076f69b2..4efcb5bf5 100644 --- a/webapp/static/js/live-preview.js +++ b/webapp/static/js/live-preview.js @@ -437,7 +437,8 @@ } } - async function enhance(root) { + async function enhance(root, anchors) { + applyExplicitAnchors(root, anchors); highlightBlocks(root); enhanceTaskLists(root); lazyLoadImages(root); @@ -445,6 +446,44 @@ await renderMermaid(root); } + // החלת עוגני ה-HTML שנשלפו מהמקור (`## סעיף `). ה-renderer רץ עם + // html:false ולכן התגית לא שורדת — ראו webapp/static/js/md-anchors.js. + // העוגנים מועברים במפורש מהקורא (ולא דרך מצב מודולרי), כדי ששני רינדורים + // עוקבים לא ידרסו זה את העוגנים של זה. + function applyExplicitAnchors(root, anchors) { + if (!root || !anchors || !anchors.length) { + return; + } + try { + if (typeof window !== 'undefined' && window.MdAnchors) { + window.MdAnchors.applyExplicitAnchors(root, anchors); + } + } catch (_) { + // best-effort — כשל בהחלת עוגנים לא אמור לשבור את התצוגה המקדימה + } + } + + // רינדור + שליפת העוגנים המפורשים. משמש גם את render() (שמחזיר HTML בלבד, + // לשמירת תאימות עם קוראים קיימים) וגם את renderWithAnchors(). + function renderMarkdownSource(text) { + const md = ensureRenderer(); + if (!md) { + throw new Error('markdown_renderer_missing'); + } + let source = text || ''; + let anchors = []; + try { + if (typeof window !== 'undefined' && window.MdAnchors) { + const extracted = window.MdAnchors.extractExplicitAnchors(source); + source = extracted.source; + anchors = extracted.anchors; + } + } catch (_) { + // אם השליפה נכשלה — ממשיכים עם המקור המקורי + } + return { html: md.render(source), anchors: anchors }; + } + return { isSupported() { return !!ensureRenderer(); @@ -455,12 +494,14 @@ isMarkdownFile(value) { return isMarkdownExtension(value); }, + // מחזיר HTML בלבד (חתימה קיימת). הכותרות מנוקות מתגיות העוגן, אך כדי גם + // *להחיל* את העוגנים יש להשתמש ב-renderWithAnchors ולהעביר אותם ל-enhance. async render(text) { - const md = ensureRenderer(); - if (!md) { - throw new Error('markdown_renderer_missing'); - } - return md.render(text || ''); + return renderMarkdownSource(text).html; + }, + // מחזיר { html, anchors } — העוגנים מועברים לאחר מכן ל-enhance(root, anchors) + async renderWithAnchors(text) { + return renderMarkdownSource(text); }, enhance, }; @@ -695,13 +736,14 @@ this.state.inflightHash = payloadHash; this.setStatus(STATUS.LOADING); try { - const html = await MarkdownLiveRenderer.render(content); + const rendered = await MarkdownLiveRenderer.renderWithAnchors(content); + const html = rendered.html; this.setMarkdownContext(true); this.previewCanvas.innerHTML = html || '
אין תוכן להצגה.
'; if (this.styleEl) { this.styleEl.textContent = ''; } - await MarkdownLiveRenderer.enhance(this.previewCanvas); + await MarkdownLiveRenderer.enhance(this.previewCanvas, rendered.anchors); this.applyPreviewTheme('markdown'); const duration = typeof performance !== 'undefined' && typeof performance.now === 'function' ? Math.max(1, Math.round(performance.now() - started)) diff --git a/webapp/static/js/md-anchors.js b/webapp/static/js/md-anchors.js new file mode 100644 index 000000000..003a78676 --- /dev/null +++ b/webapp/static/js/md-anchors.js @@ -0,0 +1,247 @@ +/** + * md-anchors.js — תמיכה בעוגני HTML מפורשים בתוך מסמכי Markdown. + * + * הבעיה שזה פותר: מסמכים רבים (בעיקר כאלה שנכתבו ל-GitHub) מגדירים עוגן לכותרת כך: + * + * ## 1. תקשורת + * + * ואז מפנים אליו מתוכן העניינים: `[תקשורת](#communication)`. + * ה-renderer שלנו רץ עם `html: false` (הגנת XSS על תוכן שמשתמשים מעלים), ולכן התגית + * לא הופכת לאלמנט — היא נמחקת כתגית ומוצגת כטקסט זבל בתוך הכותרת, וגם מזהמת את ה-slug + * האוטומטי. התוצאה: הקישור לא מוביל לשום מקום. + * + * הפתרון כאן שומר על `html: false`: + * 1. לפני הרינדור — שולפים את העוגנים משורות הכותרות ומנקים אותם מהטקסט. + * 2. אחרי הרינדור — מוסיפים אלמנט יעד בלתי-נראה לפני כל כותרת רלוונטית. + * + * הערה: אנחנו לא דורסים את ה-id של הכותרת עצמה — מזהי הכותרות משמשים לסימניות, + * ודריסה הייתה שוברת סימניות קיימות. + */ +(function (root) { + 'use strict'; + + // / / — גם עם רווחים ותכונות בסדר הפוך + var ANCHOR_RE = /]*?(?:id|name)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))[^>]*>\s*<\/a\s*>/gi; + + // שורת כותרת ATX: עד 3 רווחי הזחה, 1-6 סולמיות, ואז רווח + var HEADING_RE = /^\s{0,3}#{1,6}\s/; + + // גדר בלוק קוד: ``` או ~~~ (עם שפה אופציונלית) + var FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/; + + // קו תחתון של כותרת Setext: שורה שכולה "=" (h1) או "-" (h2) + var SETEXT_UNDERLINE_RE = /^\s{0,3}(=+|-+)\s*$/; + + /** + * האם השורה יכולה לשמש כטקסט של כותרת Setext (כלומר פסקה רגילה)? + * הבידוק שמרני בכוונה: כל דבר שהוא בלוק אחר (רשימה, ציטוט, טבלה, קוד מוזח) + * לא יוצר כותרת גם כשאחריו "---" — שם ה-"---" הוא קו מפריד (hr), לא כותרת. + */ + function canBeSetextText(line) { + if (!line || !line.trim()) return false; // שורה ריקה + if (SETEXT_UNDERLINE_RE.test(line)) return false; // קו תחתון בעצמה + if (HEADING_RE.test(line)) return false; // כותרת ATX + if (FENCE_RE.test(line)) return false; // גדר קוד + if (/^\s{0,3}>/.test(line)) return false; // ציטוט + if (/^\s{0,3}([-*+]|\d{1,9}[.)])\s/.test(line)) return false; // פריט רשימה + if (/^\s{4,}\S/.test(line)) return false; // בלוק קוד מוזח + if (/^\s{0,3}\|/.test(line)) return false; // שורת טבלה + return true; + } + + /** + * ניקוי מזהה עוגן. ה-id מוצב דרך element.id (DOM property) ולא דרך innerHTML, + * ולכן אין וקטור הזרקה — הניקוי כאן נועד למנוע מזהים מוזרים/שבורים בלבד. + */ + function sanitizeAnchorId(rawId) { + if (!rawId) return ''; + var id = String(rawId).trim(); + // גרשיים מסולסלים נוצרים כש-typographer פעיל; מסירים גם אותם + id = id.replace(/[\s<>"'“”‘’]/g, ''); + // "#intro" → "intro" (מסמכים לפעמים כוללים סולמית מיותרת) + id = id.replace(/^#+/, ''); + return id; + } + + /** + * עיבוד מקדים של מקור ה-Markdown: שליפת עוגנים מפורשים והסרתם מהטקסט. + * + * @param {string} source מקור ה-Markdown הגולמי + * @param {{setext?: boolean}} [options] setext=false אם ה-renderer מנטרל כותרות Setext + * (md.disable('lheading')). ברירת מחדל true — כמו ההתנהגות הרגילה של markdown-it. + * @returns {{source: string, anchors: Array<{headingIndex: number, ids: string[]}>}} + * source — המקור אחרי ניקוי תגיות העוגן + * anchors — לכל כותרת (לפי סדר הופעתה, 0-based) רשימת המזהים שהוגדרו לה. + * חשוב: הספירה חייבת לכלול גם כותרות Setext, אחרת האינדקס לא יתאים לאלמנטי + * ה-h1..h6 בפועל והעוגן יוחל על הכותרת הלא נכונה. + */ + function extractExplicitAnchors(source, options) { + var text = (source == null) ? '' : String(source); + var allowSetext = !(options && options.setext === false); + if (!text || text.indexOf('= 0) { + pushAnchors(headingIndex, pendingIds); + } + + return { source: outLines.join('\n'), anchors: anchors }; + } + + /** + * החלת העוגנים על ה-DOM אחרי הרינדור: לפני כל כותרת רלוונטית מוסיפים אלמנט יעד ריק. + * + * @param {Element} container אלמנט שמכיל את ה-HTML המרונדר + * @param {Array} anchors הפלט של extractExplicitAnchors + * @returns {number} כמה עוגנים הוחלו בפועל + */ + function applyExplicitAnchors(container, anchors) { + if (!container || !anchors || !anchors.length) return 0; + var headings; + try { + headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6'); + } catch (_) { + return 0; + } + var applied = 0; + for (var i = 0; i < anchors.length; i++) { + var entry = anchors[i]; + var heading = headings[entry.headingIndex]; + if (!heading) continue; + for (var j = 0; j < entry.ids.length; j++) { + var id = entry.ids[j]; + if (!id) continue; + // אם המזהה כבר תפוס (למשל ע"י כותרת אחרת) — לא דורסים + var existing = null; + try { + existing = (heading.ownerDocument || document).getElementById(id); + } catch (_) { + existing = null; + } + if (existing) continue; + var target = (heading.ownerDocument || document).createElement('span'); + target.className = 'md-explicit-anchor'; + target.id = id; // DOM property — אין הזרקת HTML + target.setAttribute('aria-hidden', 'true'); + if (heading.parentNode) { + heading.parentNode.insertBefore(target, heading); + applied += 1; + } + } + } + return applied; + } + + /** + * עיבוד מקדים + רינדור + החלת עוגנים, בקריאה אחת. + * מיועד לשימוש בצרכנים (md_preview / live-preview) כדי לא לשכפל את הסדר. + * + * @param {string} source מקור Markdown + * @param {function(string): string} renderFn פונקציית רינדור (md.render) + * @param {Element} container היעד ב-DOM (ה-HTML יוזרק אליו) + */ + function renderWithAnchors(source, renderFn, container, options) { + var extracted = extractExplicitAnchors(source, options); + var html = renderFn(extracted.source || ''); + if (container) { + container.innerHTML = html; + applyExplicitAnchors(container, extracted.anchors); + } + return html; + } + + var api = { + extractExplicitAnchors: extractExplicitAnchors, + applyExplicitAnchors: applyExplicitAnchors, + renderWithAnchors: renderWithAnchors, + sanitizeAnchorId: sanitizeAnchorId, + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; // לבדיקות מ-Node + } + if (root) { + root.MdAnchors = api; // שימוש בדפדפן + } +})(typeof window !== 'undefined' ? window : null); diff --git a/webapp/static/js/repo-browser.js b/webapp/static/js/repo-browser.js index 87efb6971..c2497f810 100644 --- a/webapp/static/js/repo-browser.js +++ b/webapp/static/js/repo-browser.js @@ -248,13 +248,14 @@ async function renderMarkdownPreview(content) { } if (typeof MarkdownLiveRenderer !== 'undefined' && MarkdownLiveRenderer.isSupported()) { - // רינדור ה-Markdown ל-HTML - const html = await MarkdownLiveRenderer.render(content); - previewContent.innerHTML = html; + // רינדור ה-Markdown ל-HTML (כולל שליפת עוגני HTML מפורשים מהכותרות — + // ה-renderer רץ עם html:false, ראו webapp/static/js/md-anchors.js) + const rendered = await MarkdownLiveRenderer.renderWithAnchors(content); + previewContent.innerHTML = rendered.html; - // שיפורים: syntax highlighting, math, mermaid + // שיפורים: syntax highlighting, math, mermaid + החלת העוגנים המפורשים try { - await MarkdownLiveRenderer.enhance(previewContent); + await MarkdownLiveRenderer.enhance(previewContent, rendered.anchors); } catch (err) { console.warn('Markdown enhancements failed', err); } diff --git a/webapp/templates/edit_file.html b/webapp/templates/edit_file.html index 2ae10a3f8..53ebf3e51 100644 --- a/webapp/templates/edit_file.html +++ b/webapp/templates/edit_file.html @@ -180,6 +180,7 @@