עקרונות להוספת פיצ’ר לסטיקי-נוטס — מנוע הרינדור והמארקדאון של הפתק
העמוד הזה הוא חלק מ-עקרונות להוספת פיצ’ר לסטיקי-נוטס, ומחזיק את המנוע שבונה את תצוגת הפתק מטקסט המקור, שורה אחר שורה. מה שנשמר במסד ועובר בין השירותים — ב-עמוד השרת, והפתק כווידג’ט בדפדפן — ב-עמוד הממשק.
רינדור בתצוגה: innerHTML אף פעם לא על טקסט לא-מהימן
טקסט של משתמש או של סוכן לעולם אינו נכתב דרך HTML גולמי. כל צומת שנושא תוכן כזה נבנה עם createElement ו-textContent, ותכונות של קישור נכתבות עם setAttribute אחרי אימות סכימה. לכן זריקת <script> מוצגת כטקסט, ואל תכניסו ספריית מארקדאון שפולטת מחרוזת HTML. תבנית סטטית לגמרי, בלי שום קלט משתמש (למשל שלד מודאל התזכורת), מותרת דרך innerHTML — הגבול הוא הקלט, לא ה-API. הכלל המוחלט הוא על טקסט לא-מהימן, לא על השיטה.
אינדקס המשימות: התאמה מלאה בין שרת ללקוח
ספירת המשימות בצד השרת (sticky_notes_tasks) והאינדקס בצד הלקוח חייבים להתקדם זהה — כולל בתוך גדרות קוד. אם השרת סופר שורת - [ ] בתוך גדר והלקוח מדלג עליה, לחיצה על צ’קבוקס אמיתי מסמנת את המשימה הלא-נכונה. זו החלטה מתועדת ומכוסה בטסט, לא צירוף מקרים; שמרו עליה בכל שינוי שנוגע ברינדור המשימות.
טבלאות: בלוק רב-שורתי בתוך מנוע של שורה-לשורה
מנוע הרינדור בנוי על חוזה אחד: כל שורת מקור מייצרת בדיוק אלמנט תצוגה אחד עם data-charOffset משלו. _enterEditFromView מוצא את השורה שנלחצה דרך closest('.sticky-task-line') וקורא ממנה את ההיסט, וכך לחיצה מחזירה לעריכה בשורה הנכונה.
טבלה היא הבלוק הראשון שמקפל כמה שורות מקור לאלמנט אחד, ולכן היא הראשונה שיכולה לשבור את החוזה. הפתרון: ``<tr>`` הוא שורת המקור — הוא נושא את sticky-task-line ואת ההיסט שלו, בדיוק כמו div רגיל. שורת המפריד נצרכת ואינה מייצרת <tr> (אין לה מה להציג), אבל ההיסט מקודם גם עליה — אחרת כל מה שאחרי הטבלה מוסט באורך שורה שלמה.
מכאן גם הכלל לבלוק רב-שורתי הבא: מותר לקפל, בתנאי שכל שורת מקור נשארת אלמנט אחד שנושא את ההיסט שלה. הבדיקה שמגינה על זה נמצאת ב-tests/sticky-notes-target.test.js בשני חלקים בכוונה — היסט של שורה בתוך הבלוק, והיסט של השורה אחריו. הראשון תופס מיפוי שגוי, השני תופס חשבון מצטבר שגוי, ובאג בכל אחד אינו מפיל את השני.
מהו מפריד — תשובה אחת בלבד. הזיהוי נגזר מ-_splitTableRow ולא מרג’קס נפרד. בגרסה הראשונה היה MD_TABLE_ROW_RE שספר מקפים אנכיים גולמיים בעוד המפצל מודע ל-escape, ושני החלקים חלקו על מהו מפריד — פער שהפיל שלושה מקרים בבת אחת: מקף אנכי מוברח, מקף אנכי סוגר בלבד, ופתיחת גדר קוד שיש בה מקף אנכי. כשמוסיפים כאן תחביר, שאלו קודם מי כבר עונה על אותה שאלה.
שתי עמודות לפחות — סטייה מכוונת מ-GFM. לפי התקן grep foo | ואחריו --- היא טבלה חוקית של עמודה אחת. בפתק זו כמעט תמיד פקודה שאחריה קו מפריד, ולכן הזיהוי דורש מקף אנכי פנימי. המחיר הוא שטבלת עמודה אחת אמיתית תרונדר כטקסט, וזה קטן בהרבה מהחלופה.
שורת משימה שנצרכת לטבלה עדיין נספרת. המקף האנכי הסוגר אופציונלי, ולכן - [ ] משימה | עמודה היא גם שורת משימה תקפה וגם כותרת טבלה תקפה. _appendTable מחזירה tasksConsumed, והלולאה מקדמת בו את הסידור — אחרת כל צ’קבוקס שאחרי הטבלה נשלח לשרת עם אינדקס של משימה אחרת. זו אותה החלטה שכבר תועדה עבור שורות משימה בתוך גדר קוד: הספירה מתקדמת בשני הצדדים באותה נקודה, בלי קשר לשאלה אם השורה מוצגת כצ’קבוקס.
רוחב: הטבלה יושבת בעטיפה עם overflow-x ובלי max-width. עם max-width: 100% היא נדחסת לרוחב הפתק והעמודות מתמעכות במקום להיגלל — נמדד בדפדפן, לא הונח.
קינון רשימות: שאלה אחת, מקום אחד, ומקור מדיד
הסעיף על טבלאות מנסח את הכלל ”מהו מפריד — תשובה אחת בלבד“. הקינון הוא המופע השני של אותו כלל, ושם הוא נשך בצורה שונה: - [ ] א היא גם שורת משימה תקפה וגם פריט רשימה תקף. הסיווג ישב פעם בתוך ענף ה-wantMd בלבד, כלומר אחרי שהשורה כבר התפצלה לענף המשימה — ולכן שורות משימה לא הזינו את מחסנית העומק כלל, ופריט שאחרי - [ ] א יצא ברמה 0 במקום 1. לכן הקריאה ל-_classifyLine עלתה לראש הלולאה: שורת מקור נשאלת פעם אחת מהי, לפני שהיא נכנסת לענף כלשהו. מי שיוסיף כאן ענף נוסף — צריך להזין את המחסנית, לא לעקוף אותה.
עמודת התוכן, לא ההזחה. רמה נקבעת לפי העמודה שבה מתחיל הטקסט של הפריט שמעליו, ושורה נשארת בתוכו כל עוד ההזחה שלה מגיעה לשם. ההבדל אינו תיאורטי: תחת כלל של ”כל הזחה = רמה“, - א ואחריו ␣- ב היו יוצאים אב ובן, ובפועל הם אחים.
המקור נמדד, לא נזכר. markdown-it@14.1.0 כבר רץ בריפו ומרנדר את תצוגת ה-Markdown, ולכן הוא הצרכן שהפתק צריך להסכים איתו — אותו טקסט מקונן חייב להיראות אותו הדבר בשני המקומות. שלושת הכללים נלקחו מהקוד שלו: עמודת התוכן והכלל שרווחים מעבר לארבעה נספרים כאחד (lib/rules_block/list.mjs), הסגירה לפי הזחה קטנה מעמודת התוכן (אותו קובץ), וטאב שמתקדם לתחנה שמתחלקת בארבע (lib/rules_block/state_block.mjs). הקצוות — שורה ריקה, שתי שורות ריקות, כותרת, קו מפריד, וכל צירוף של גדר ובלוק מוזחים ולא מוזחים — הורצו דרכו בפועל, ולכן הם עובדה ולא הנחה. הגלגול של התבליט לפי עומק נמדד באותה צורה: getComputedStyle(ul).listStyleType בכרומיום.
גבול בלוק סוגר לפי ההזחה של עצמו — כלל אחד, בלי ענף לכל סוג. _closeListsAbove היא כל התשובה: היא מקבלת הזחה ומוציאה מהמחסנית כל פריט שההזחה כבר אינה בתוכו. שורת רשימה, כותרת, ציטוט, קו מפריד, טבלה, שורת טקסט ושורת גדר פותחת — כולן עוברות דרכה, ולכן בלוק בעמודה 0 סוגר את הרשימה ובלוק שמוזח לתוך הפריט נשאר בתוכו.
הגרסה הראשונה הפרידה ביניהם, ושתי ההתנהגויות יצאו שגויות בכיוונים הפוכים: שורת גדר לא נגעה במחסנית כלל, ולכן גדר בעמודה 0 השאירה את הרשימה פתוחה ורשימה חדשה שמוזחת אחריה הוצגה כתת-רשימה; וכל בלוק אחר הריץ listStack.length = 0, איפוס גורף שהתעלם מההזחה של עצמו, ולכן כותרת מוזחת בתוך פריט סגרה אותו. ריוויוור תפס את החצי הראשון; המדידה מול markdown-it מצאה גם את השני. הכלל האחיד קצר משתי ההתנהגויות שהוא החליף.
הערה
הגדר נכנסת לכלל דרך שורת הפתיחה בלבד, ומבנית ולא בדגל. _closeListsAbove מקבלת רק שורות שמחוץ לגדר, כי הבלוק כולו נצרך כיחידה ב-_appendCodeBlock — שורות התוכן ושורת הסגירה כלל אינן מגיעות ללולאה. שורת הפתיחה קוראת לכלל במפורש מהענף שלה, בדיוק כמו הטבלה.
קודם זה היה מושג בדגלים inFence/isFenceLine ובהחרגות מפורשות. הם נמחקו, וההסבר למה — ומה הם עלו — נמצא בסעיף על בלוק הקוד. ההתנהגות עצמה לא השתנתה: רק הזחת שורת הפתיחה קובעת, וזה מכוסה באותה בדיקה בדיוק.
חשוב
הזחה — יצרן אחד. _lineIndentCols היא התשובה היחידה בקוד לשאלה ”מה ההזחה של השורה הזו“, ו-_classifyLine אינו מחזיר indent בכוונה. שורת גדר לעולם אינה מגיעה ל-_classifyLine, ולכן יצרן שני היה נוצר בדיוק במקום שבו הוא לא נבדק. זה אותו דפוס שכבר נשך כאן בזיהוי מפריד הטבלה: שני חלקים שחולקים על אותה שאלה.
שתי סטיות מכוונות, ולא אחת.
הראשונה — המשך פסקה עצל. ב-markdown-it שורת טקסט או טבלה בעמודה 0 מיד אחרי פריט היא המשך הפסקה שלו, והרשימה ממשיכה. כאן היא סוגרת. הנימוק הוא החוזה של המנוע: כל שורת מקור היא שורת תצוגה נפרדת עם ההיסט שלה, ואין בו מושג של המשך פסקה — ולכן ”המשך“ היה מצב שאי אפשר לייצג. כמו דרישת שתי העמודות בטבלה, זו סטייה שנבחרה ותועדה, לא פער שהתגלה. היא גם אינה עולה ענף: הזחה 0 סוגרת ממילא לפי הכלל האחיד, ולכן הסטייה נופלת מאליה.
השנייה — גדר שנסגרת מוזחת החוצה. אצלנו קובעת שורת הפתיחה בלבד, וב-markdown-it הרשימה כבר הסתיימה בשורת הסגירה. ההתנהגות הנראית מתוארת ב-פתקים דביקים (Sticky Notes); כאן רק הנימוק. הפער אינו במחסנית, ולכן גם לא ייסגר שם. בתקן גדר סוגרת שמוזחת מתחת לעמודת התוכן של המכולה אינה סוגרת את הגדר כלל — היא מסיימת את פריט הרשימה, והתו נקרא כפתיחת גדר חדשה ברמה העליונה שבולעת את כל שאר הפתק לבלוק קוד. אצלנו התאמת הגדרות נאיבית בכוונה: כל שורת גדר מתהפכת, בלי מודעות למכולה.
הערה
הדבר הזה נמדד לפני שהוכרע. סריקה ממצה של כל צירוף של הזחת פותחת × הזחת סוגרת × הזחת הפריט שאחריה השוותה שני מימושים מול markdown-it: הקוד כפי שהוא, ומימוש שבו גם שורת הסגירה מעדכנת את המחסנית. בשישה-עשר צירופים שניהם תואמים, ובעשרים אף אחד מהם אינו תואם — ובאף צירוף המימוש השני אינו מנצח. כלומר סגירת המחסנית בשורת הסגירה מחליפה תשובה שאינה תואמת באחרת שאינה תואמת, ומוסיפה מקרה שבו אותו בלוק גדר מטופל כשני גבולות שונים.
מי שירצה לסגור את הפער הזה באמת — המקום הוא התאמת גדרות מודעת-מכולה, לא _closeListsAbove. זה שינוי התנהגות רחב: הוא קובע מחדש אילו שורות מרונדרות כקוד ליטרלי. שימו לב גם שהתוצאה של markdown-it כאן — בליעת כל שאר הפתק לבלוק קוד — גרועה יותר למשתמש פתק מההתנהגות הנוכחית, ולכן ”להתיישר“ אינו בהכרח שיפור.
ההזחה היא CSS, והחוזה לא נגע. אין קיפול של שורות לתוך <ul>: כל שורת מקור נשארת div.sticky-task-line אחד עם ה-charOffset שלה, ומה שנוסף הוא מחלקת is-depth-N בלבד. המחלקה יושבת על .sticky-task-line ולא על .sticky-md-li, כי זה האלמנט המשותף לשורת רשימה ולשורת צ’קבוקס — כלל אחד מזיח את שתיהן, ואין שני מקומות שיכולים להיסחף. ההזחה נכתבת ב-padding-inline-start: כלל פיזי היה מזיח מהצד הלא נכון בפתק בעברית.
ומה שאסור שיזוז מזה: הסידור. sticky_notes_tasks בשרת סופר כל שורת - [ ], כולל מוזחת, ולכן ההזחה חייבת להישאר תצוגה בלבד. שינוי שיגרום לצד אחד לדלג על שורה מוזחת מחזיר בדיוק את הבאג שהסעיף ”אינדקס המשימות“ מתעד — לחיצה שמסמנת משימה אחרת.
חשוב
מדידה בדפדפן, לא הסקה מה-CSS. ההזחה נמדדה בכרומיום ב-RTL על שלוש רמות, וריצת בקרה על הקוד שלפני התיקון הראתה את כל השורות על אותו קו בדיוק. ההארנס של המדידה הזו לא נשמר בריפו; מה שנשאר הוא שומר טקסטואלי ב-tests/sticky-notes-target.test.js שקורא את קובץ ה-CSS ומוודא שההחלטה לא בוטלה — הטוקן קיים, חמשת הכללים קיימים, כולם מפנים לטוקן, כולם לוגיים, והמחלקה על שורת המקור. השומר שומר על המסקנה של המדידה; הוא אינו מודד מחדש, וכל טענה בו מכוסה במוטציה.
הנימוק שנכתב כאן במקור — ש-Playwright אינו בתלויות הפרויקט — אינו נכון היום: requirements/base.txt נועל אותו, ובדיקות דפדפן יושבות בריפו (tests/*_browser.py). אבל הן מדולגות ב-CI, כי אף workflow אינו מתקין Chromium — ראו ”מה לבדוק, ובמה“ בסעיף על החיפוש בתוך פתק.
קוד בשורה: יחידה אחת, שקודמת לכל מפריד
הבאג. **מילים עם `קוד` בתוך הדגשה** הוצג בפתק כמודגש שבתוכו גרשיים, בזמן שתצוגת המסמך הציגה מודגש שבתוכו קוד. עד אוקטובר 2026 המארקדאון בתוך שורה נקרא ברג’קס אחד, MD_INLINE_RE, שבחר בכל נקודה את מה שמתחיל ראשון — וה-** מתחיל לפני הגרש, ולכן הוא בלע את הקוד כטקסט. מאותו שורש נשברו עוד כמה דברים: קוד בתוך נטוי, קו חוצה, מרקר וקישור; תו בתוך קוד ששבר את העיצוב סביבו (ב-**a `*` b** הכוכבית שבתוך הקוד סגרה נטוי, והשורה יצאה מפורקת); קוד שממשיך מעבר לסוגר של העיצוב (**a `b** c` d), שהמסמך מציג כקוד והפתק הציג כמודגש; ורצף של כמה גרשיים שנסגר בגרש בודד (a ``b` c הוצג עם קוד, והמסמך מציג אותו כטקסט).
ב-markdown-it יש שני שלבים, והקוד שייך לראשון. lib/parser_inline.mjs (markdown-it@14.1.0, הגרסה שבבאנדל של תצוגת המסמך) עובר על השורה פעם אחת ומריץ בכל נקודה את הכללים בסדר קבוע, ו-backticks קודם ל-strikethrough, ל-emphasis ול-link. קטע קוד שנמצא נצרך כיחידה אחת, וכל מה שבתוכו שייך לו. ההדגשות אינן נסגרות בשלב הזה: כל ** רק נרשם כמפריד, והזיווג נעשה אחר כך (lib/rules_inline/balance_pairs.mjs). לכן אין שם שאלה ”מי מתחיל ראשון“ בין קוד להדגשה — הקוד הוא תמיד יחידה, וההדגשה נבנית מסביבו.
הפתק מעתיק את השלב הראשון, ורק אותו:
מהו קוד בשורה — מ-
lib/rules_inline/backticks.mjs: רצף של N גרשיים נסגר ברצף הבא של בדיוק N; רצף בלי סוגר הוא טקסט, והסריקה ממשיכה אחריו; ורווח אחד בכל קצה של התוכן נחתך (MD_CODE_TRIM_RE). רצפי השורה נאספים פעם אחת ב-_inlineScanState, ולכל רצף נשמר הרצף הבא באותו אורך.קוד גובר על כל מפריד. סוגר של עיצוב, וגם
]של תווית קישור, אינם נחפשים בתוך קוד —_firstOutsideCodeמדלג על קטע קוד שלם. כך גם הסוגר של העיצוב יכול להיות בתוך הקוד ולא לסגור דבר, כמו בדוגמה למעלה. התווית של קישור בנויה כך גם ב-markdown-it:parseLinkLabel(lib/helpers/parse_link_label.mjs) מדלג על קוד באותה צורה.התוכן של עיצוב הוא טקסט וקוד.
_parseInlineמחזיר לכל עיצובchildren, ו-_appendInlineSegmentsבונה אותם בתוך האלמנט, עםcreateElementו-textContentבלבד — ראו ”רינדור בתצוגה“ למעלה.
הזיווג נשאר שלנו, והוא נושא נפרד. הסוגר של עיצוב הוא הראשון שמחוץ לקוד, ועיצוב אינו מכיל עיצוב אחר — ולכן ==**בדיקה**== עדיין מציג את הכוכביות בתוך המרקר. מה שפותר את זה הוא השלב השני, balance_pairs, והמעבר אליו משנה הרבה יותר מקוד בשורה: גם את כללי הצמידות של ** לרווח (** a ** אינו מודגש במסמך ומודגש בפתק) וגם את ”כלל השלוש“ של רצפי כוכביות. לכן הוא לא נעשה כאן.
בשורה בלי גרש — אותו פלט בדיוק. העיצובים שאינם קוד נבדקים באותם כללים ובאותו סדר כמו ברג’קס — ** לפני *, ותו המפריד אינו יכול להופיע בתוכן — כולל הכללים של המרקר (_matchInlineMark). נבדק ב-12.10.2026 מול הקוד שלפני השינוי, על כל המחרוזות עד אורך 8 מעל *, =, ~, אות ורווח, ועד אורך 7 בשתי קבוצות עם סוגריים מרובעים ועגולים — אחת יחד עם שלושת המפרידים, ואחת יחד עם נקודתיים, לוכסן, כוכבית, אות, רווח ותחילית https://x שנספרת כתו אחד: אף מחרוזת לא יצאה שונה — לא בפלט, ולא בשאלה אם התצוגה נפתחת (_lineHasInline).
כתובת שאינה http/https אינה קישור (MD_SAFE_LINK) ומוצגת כמו שהוקלדה, אבל הקוד שבתווית שלה מוצג כקוד: [`app.py`](./app.py) מציג את app.py כקוד, בתוך הסוגריים. התווית והיעד עדיין נצרכים יחד כיחידה, ולכן גרש בתוך כתובת אינו פותח קטע קוד — כמו קישור ב-markdown-it, שנצרך כולו לפני שהסריקה מגיעה לגרש שבכתובת.
חשוב
השער נגזר מהפרסר. _lineHasInline שואל את _parseInline אם יצא משהו שאינו טקסט, ואינו סורק בעצמו. סורק שני היה יכול לחלוק עליו — לפתוח תצוגה לשורה שהפרסר מציג כטקסט (a ``b עם רצף בלי סוגר), או להשאיר תיבת עריכה לשורה שיש בה קוד. זה אותו כלל שכבר מנוסח כאן על מפריד הטבלה: שאלה אחת, מקום אחד.
הסריקה לינארית, וזה מחזיק בזכות שני זיכרונות. כל [ מחפש ], וכל ]( מחפש את סוף היעד. בשורה של הרבה פותחים שלא נסגרים, סריקה מחדש מכל אחד מהם היא ריבועית — וגם הרג’קס הקודם היה ריבועי על שורה כזו. _firstOutsideCode זוכר לכל נקודה שסרק את התשובה, ו-_linkDestStop מחשב את סופי היעדים פעם אחת לכל השורה. הבדיקה ”אינליין: הסריקה לינארית בשורה באורך תקרת הפתק“ משווה זמן לזמן ולא לתקרה בשניות, והסף שלה והמדידות שמאחוריו מתועדים ליד INLINE_LINEAR_GROWTH_LIMIT. הילדים של עיצוב נבנים רק אחרי שהעיצוב עבר את כל התנאים, כי ניסיון שנכשל אינו צריך אותם.
איפה הפתק עדיין שונה מהמסמך:
עיצוב בתוך עיצוב, וכללי הצמידות של
**— הזיווג, שלמעלה.\כתו בריחה: לפתק אין בריחה בכלל, ולכן\*מוצג כמו שהוקלד ואינו מבטל את הכוכבית.קישורים: רק
http/https, כתובת חשופה אינה הופכת לקישור (linkify), ותווית עם סוגריים מקוננים אינה נתמכת.ובמקום אחד markdown-it הוא שסוטה, ולא הפתק. אחרי
[שתוויתו לא נסגרה, ובשורה שיש בה גם רצף גרשיים בלי סוגר, markdown-it מציג לפעמים גרשיים במקום קוד — למשל ב-[`foo` and ` xהוא אינו מציג אתfooכקוד. cmark-gfm (דרך החבילהcmarkgfm2025.10.22) מציג שם קוד, וכך גם הפתק. הסיבה היא מטמון ב-backticks.mjs(backticksScannedו-backticks): סריקה של התווית שנכשלה משאירה אותו לא שלם, והסריקה הרגילה סומכת עליו. נבדק ב-12.10.2026: בעותק של הכלל בלי שתי השורות של המטמון ההבדלים נעלמים, חוץ מכמה מקרים של יעד קישור שהפתק מפרש בצורה מפושטת ממילא. לכן ההשוואה הממצה מול markdown-it בבדיקות אינה כוללת סוגריים.
הבדיקות יושבות ב-tests/sticky-notes-target.test.js: הכללים בסעיף ”מארקדאון: קוד בשורה קודם לכל מפריד“, הבנייה ל-DOM בסעיף ”מארקדאון: רינדור מלא ל-DOM“, וההשוואה למסמך בסעיף ”מארקדאון: קוד בשורה מול markdown-it“. הבדיקות בסעיף האחרון מריצות את markdown-it עצמו, עם תוסף המרקר ועם האפשרויות שנקראות מ-md_preview.html: אחת משווה HTML מלא על אוסף קלטים, ואחת משווה את קטעי הקוד בכל השורות הקצרות. כך שדרוג של markdown-it שמשנה את כלל הגרשיים מפיל את ה-CI, ולא מתגלה בפתק.
בלוק קוד: כשצריכה של בלוק שלם מוחקת שלושה סייגים
הגדר רונדרה פעם שורה-שורה בתוך הלולאה, מאחורי שני דגלים — inFence ו-isFenceLine. הם עבדו, אבל הם חייבו שלושה סייגים נפרדים במקומות מרוחקים: חישוב מוקדם של isFenceLine לפני בדיקת הטבלה, ההבחנה החדה בין !inFence ל-!inCode בכלל המחסנית, והחרגת שורת הסגירה. כל אחד מהם היה נכון, לכל אחד מהם הייתה הערה משלו, וכל אחד מהם היה דבר שאפשר לשכוח.
_appendCodeBlock צורכת את הבלוק כיחידה, ומכאן ש**הלולאה לעולם אינה נמצאת בתוך גדר** — שלושת הסייגים נמחקו יחד עם הדגלים. החתימה זהה ל-_appendTable בכוונה: שתי תשובות שונות לאותה שאלה (”כמה שורות בלעתי ומה ההיסט אחריהן“) היו מזמינות אתר קריאה שמטפל באחת ושוכח את השנייה.
חלוקת התפקידים בין שלוש השורות היא כל התכנון: שורת הפתיחה היא הכותרת, ונושאת sticky-task-line ואת ההיסט שלה; שורות התוכן נשארות שורות תצוגה רגילות; ושורת הסגירה נצרכת בלי אלמנט, וזה החריג היחיד — בדיוק זה של שורת המפריד בטבלה ושל שורת ה-::: באלרט.
חשוב
הכותרת אינה קישוט, והיא מה שמונע מבוי סתום. בלי שורת מקור בראש הבלוק, בלוק ריק — גדר ומיד גדר — היה יוצא בלי שום שורה לחיצה, ולא הייתה שום דרך לחזור ממנו לעריכה. זו גם הסיבה שכפתור ההעתקה שיושב בתוכה חייב החרגה ב-_enterEditFromView: בלעדיה כל לחיצה עליו הייתה גם מעתיקה וגם מפילה את התצוגה לעריכה.
שם השפה מוצג, כי הסתרת הגדר היא הסתרה ולא איבוד. הוא נגזר מה-info שאחרי הגדר, במילה הראשונה בלבד — כמו ש-markdown-it גוזר אותו. הוא טקסט שהמשתמש הקליד, ולכן textContent.
מה שהסדר בלולאה אוכף, ומה שהוא לא
בדיקת הגדר קודמת גם לטבלה וגם לאלרט, אבל רק אחת מהשתיים תלויה בכך — וזה נמדד בהיפוך סדר בפועל, לא הוסק:
מול הטבלה הסדר הוא ההגנה.
```sh | xהיא גם פותחת גדר וגם כותרת טבלה סבירה. הזזת בדיקת הטבלה לפני בדיקת הגדר מפילה בדיקה.מול האלרט הסדר אינו משנה דבר. שורת גדר מתחילה בגרשיים ושורת אלרט בנקודתיים — הן זרות זו לזו, והיפוך הסדר אינו מפיל דבר. מה שמשאיר
:::בתוך גדר כקוד ליטרלי הוא הצריכה: השורה נבלעת עם הבלוק ואינה מגיעה ללולאה כלל.
ההערה הראשונה שנכתבה כאן טענה שהסדר מגן על שניהם. המדידה הפריכה את החצי השני, וההערה תוקנה. סייג שנכתב בלי לבדוק אם הוא נדרש הוא חוב, לא הגנה — גם כשההתנהגות שהוא מתאר נכונה.
ההעתקה: מהמקור, לא מה-DOM
כפתור ההעתקה לוכד את שורות הגוף מהמקור בזמן הבנייה, ואינו קורא את ה-DOM בזמן הלחיצה. הסיבה צרה וספציפית: שורה ריקה מרונדרת כרווח בודד, אחרת ה-div בגובה אפס והשורה נעלמת מהתצוגה. קריאה מה-DOM הייתה מחזירה " " במקום "" — כלומר קוד שמודבק עם רווח בכל שורה ריקה. נמדד, ואומת גם בכיוון ההפוך: לחיצה אמיתית בכרומיום, וקריאה חוזרת מהקליפבורד שהחזירה את המקור ביט-זהה.
זה גם המופע הבא של כלל שכבר קיים בפתק: התצוגה היא ראי חד-כיווני של content. המקור הוא תמיד התוכן, ולעולם לא מה שנבנה ממנו.
הערה
מנגנון הקליפבורד עצמו הוא אחד — _copyText — ושני הכפתורים, של הפתק ושל בלוק הקוד, עוברים דרכו. מימוש שני היה נסחף בדיוק בקצה שקשה לבדוק: ההרשאה שנחסמה. ומאותה סיבה _flashCopyResult קיבל יעד מפורש: בלי זה לחיצה על כפתור של בלוק קוד הייתה מהבהבת את כפתור הפתק — חיווי שמופיע במקום הלא נכון גרוע מחיווי שלא מופיע. גם ה-title המקורי נשמר ומשוחזר במקום להיות מוקלד מחדש, שאחרת כשל בהעתקת קוד היה משנה את ההסבר של הכפתור ל“תוכן הפתק“.
האייקון נבנה ב-createElementNS, והצורות זהות לאלה שבתצוגת המסמך. אותו אייקון בשני המסכים, כי זו אותה פעולה על אותו בלוק; אבל שם הוא מחרוזת שנכנסת ל-innerHTML, וכאן הכל צמתים. זה בדיוק מה ש-docs/webapp/language-icons.rst מנסח: מי שממילא בונה DOM אינו צריך innerHTML בכלל.
יישור לימין: אותה שאלה, ולכן אותה פונקציה
בלוק בלי שם שפה שיותר מ-30% מאותיותיו עבריות מוצג מיושר לימין. ההתנהגות עצמה מתועדת ב-פתקים דביקים (Sticky Notes); כאן מה שמסביבה.
”בלוק בלי שם שפה“ כולל גם בלוק שהשם שלו אינו מצהיר על קוד. הרשימה היא PLAIN_LANGUAGE_NAMES ב-utils/rtl-code.js, ושם גם ההסבר לכל שם. markdown ו-md נכנסו אליה אף שיש להם דקדוק הדגשה, כי השאלה כאן אינה ”האם יש הדגשה לשם הזה“ אלא ”האם מי שכתב את הבלוק אמר שזה קוד“. בלוק מארקדאון הוא טקסט לקריאה, ובעברית הוא מתנהג כמו הערה. שני השמות נכנסו יחד כי בריפו הם אותה שפה (isMarkdownLanguage ב-live-preview.js וב-repo-browser.js), ותוספת של אחד בלבד הייתה מיישרת בלוק markdown לימין ובלוק md לשמאל. ההחלטה משותפת לפתקים ולתצוגת המסמכים, כי שתיהן קוראות מאותה רשימה.
הערה
ההרחבה יורשת את שני הכללים שכבר נאכפים על הרשימה, ולא ממציאה כללים משלה. השם נבדק כתחילית עם גבול, ולכן mdx ו-markdowny נשארים שפה ואילו md. ו-markdown, פטורים. וגם mdעברית נשאר שפה, כי הגבול הוא ”אין אחריו אות“ בכל כתב ולא \b, שב-JavaScript מכיר רק אותיות ASCII כתווי מילה. בגרסה הראשונה של ההרחבה הגבול היה \b, ולכן אות עברית צמודה נחשבה סוף שם — וזה חל על כל השמות ברשימה, לא רק על החדשים. ההערה מעל NAME_END ב-utils/rtl-code.js מסבירה את הבחירה, והטסטים מכסים אותה בשתי הצורות. וההשוואה רגישה לרישיות, ולכן Markdown נשאר משמאל — אף ש-isMarkdownLanguage עצמה מנרמלת רישיות. הפער הזה מכוון: נרמול רק כאן היה פותח בדיוק את הפער בין הפתק למסמך שההצלבה בטסטים סוגרת. מי שירצה ש-Markdown יתהפך צריך לשנות את ההשוואה בשתי הצורות יחד.
עמוד המשתמש מונה את השמות, ולכן טסט משווה אותו לקוד. RtlCode.plainLanguageNames חושף עותק קפוא של הרשימה, ו-tests/sticky-notes-target.test.js דורש שהרשימה בעמוד המשתמש תהיה זהה לו. שם שנוסף לקוד ולא לעמוד מפיל את הבדיקה, וזו אותה צורה כמו הבדיקה שכבר קיימת על סוגי האלרט.
אזהרה
``isHebrewMajority`` אינה שואלת על רוב, והשם הזה כבר הטעה פעם אחת. הסף הוא > 0.3, כלומר בלוק שבו 31% מהאותיות עבריות ו-69% לטיניות מתהפך. הניסוח הראשון בשני עמודי התיעוד אמר ”רוב“ — תיאור של השם ולא של ההתנהגות — ונתפס בריוויו. מי שמתעד כאן מתאר את מה שהקוד עושה, לא את מה שהפונקציה נקראת.
הפונקציה המשותפת היא אחת, והיא הקטנה מבין השלוש. utils/rtl-code.js חושף שלוש פונקציות, ורק isHebrewMajority שמישה כאן. applyRtlIfHebrew פותחת ב-block.closest('pre') והפתק אינו מייצר pre בכלל, ולכן היא הייתה מחזירה false תמיד — בשקט. hasExplicitLanguage קוראת class="language-…" שהפתק לעולם אינו כותב; שם השפה בפתק כבר נגזר משורת הגדר, ולכן נוספה לצידה צורה שמקבלת שם גולמי. זה אותו כלל שכבר מנוסח כאן על מפריד הטבלה ועל יצרן ההזחה: לפני שמוסיפים תחביר, שואלים מי כבר עונה על אותה שאלה.
חשוב
הקריאה מ-``window`` היא בדיוק הדרך שהסעיף על הפלטה דחה, וההכרעה כאן הפוכה במודע. ההתנגדות שם (הפלטה חיה פעמיים, ובדיקה מצליבה אותן) אינה ”המקור בפייתון“ אלא שקריאה כזו מחליפה תלות בבדיקה בתלות בסדר טעינת תבניות. ההתנגדות תקפה כאן מילה במילה, והיא מנוהלת ולא מוכחשת: שלוש עריכות תבנית, בדיקת חיווט, ותיקון מטמון. ומה שמצדיק את ההפוך הוא טיב הדבר המשותף — הפלטה היא טבלה, שבדיקה מצליבה מכסה במלואה; זהו אלגוריתם, ובדיקה מצליבה עליו היא דגימה ולא הוכחה. עותק שני היה הימור על כך שהדגימה מכסה בדיוק את מה שיסטה.
שתי מלכודות בבניית הרג’קס מחדש מתוך הרשימה, ושתיהן מכוסות במוטציה. הגבול נשמר לכל איבר — בלעדיו texture נבלע ב-text ומתהפך. וההשוואה נשארת רגישה לרישיות, כמו הרג’קס: toLowerCase בצורה שמקבלת מחרוזת נראה כמו שיפור, והוא בדיוק הפער שהשיתוף בא למנוע — בלוק שהוצהר עליו Text היה מתהפך בפתק ולא במסמך. הרשימה המשותפת מבטיחה שהרשימה זהה; רק בדיקה מבטיחה שההשוואה זהה. הבדיקה מריצה את שתי הצורות על אותם שמות ודורשת תשובה זהה.
סכנה
תיקון ה-``?v=`` אינו ניקיון אלא תנאי. שתיים מהתבניות טענו את utils/rtl-code.js בלי מחרוזת המטמון, ושתיהן מארחות פתקים. מרגע שהקובץ משתנה, דפדפן עם עותק שמור היה מקבל גרסה בלי הפונקציה החדשה. היעדר ה-defer באותן שתיים כן מתועד ככוונה ב-base.html ונשאר; היעדר ה-?v= היה אי-עקביות, כי שתי התבניות האחרות כבר טענו איתה.
אזהרה
``dir=“auto“`` נשקל כאן ואינו יכול לעבוד, והסיבה הראשונה היא CSS ולא התנהגות. התכונה dir פועלת דרך גיליון הסגנון של הדפדפן, וכל כלל של המחבר גובר עליה. .sticky-md-cell אינו מצהיר direction כלל, ולכן תאי הטבלה יכולים להישען עליה; .sticky-md-pre כן מצהיר direction: ltr, ולכן dir על גוף הבלוק פשוט לא היה משפיע. מי שירצה בכל זאת dir נוגע באותו כלל CSS — כלומר אין כאן ”פתרון בלי CSS“.
והסיבה השנייה, על ההתנהגות: dir="auto" מכריע פר אלמנט לפי התו החזק הראשון, ובלוק קוד הוא יחידה אחת שפרושה על אלמנט לכל שורת מקור. בלוק עברי עם שורה אחת באנגלית היה מתיישר לשמאל באמצע. היחס הוא הכלל היחיד שנותן החלטה אחת לבלוק שלם.
ומה שההיפוך אינו עושה: הוא אינו מאחד את הבלוק לפסקת bidi אחת. כל שורה נשארת פסקה בפני עצמה, ולכן בתוך שורה עברית קטע לטיני עדיין מסודר לפי אלגוריתם ה-bidi. זהה למסמכים, ולכן התנהגות ולא רגרסיה.
הכלל ב-CSS הוא הצהרה אחת, ולא שתיים כמו במסמכים. שם text-align: right נחוץ כי כלל הבסיס מצהיר text-align: left; בפתק אין הצהרת text-align על השורה ולא על אף אב, ולכן הערך נשאר start ועוקב אחרי direction מאליו. נמדד בכרומיום: עם השורה ובלעדיה הטקסט נוחת באותו פיקסל בדיוק. שורה שאף מוטציה אינה יכולה להפיל היא שורה שאף בדיקה אינה מכסה — אותה מסקנה שכבר תועדה כאן על overflow-wrap ב-summary, והשומר ב-tests/sticky-notes-target.test.js אוכף גם את ההיעדר הזה. unicode-bidi: embed נשאר ואינו עובר ל-plaintext, שהוא בדיוק ההכרעה הפר-שורתית שהיחס בא להחליף.
חשוב
היעדר ``window.RtlCode`` הוא היעדר יכולת סטטי, והבדיקה עליו אינה הגנתיות. _syncTaskView כולו עטוף ב-try/catch אחד שמוחק את התצוגה בתחילתו וחושף אותה בסופו. חריגה באמצע נבלעת, ולכן TypeError כאן היה משאיר כל פתק שיש בו גדר בלי מארקדאון כלל — בלי שגיאה ובלי לוג. זו אותה החלטה בדיוק שכבר מתועדת ב-_containerSpec מול ADMONITION_TITLES, ובדיקת החיווט היא אותה בדיקה.
הערה
בדיקת החיווט טוענת גם שאין ``defer``, וזה מה שהופך את טענת הסדר לטענה. היא משווה מיקום בקובץ, ומיקום שווה לסדר הרצה רק כששני התגים אינם דחויים. שתי תבניות אחרות בריפו כן טוענות את הקובץ עם defer — וזו הצורה שתועתק בטעות. בלי הסעיף הזה תג דחוי היה עובר בזמן שהוא רץ אחרי המודול.
סכנה
שני תנאים מקדימים בבדיקות, ובלעדיהם הן עוברות מהסיבה הלא נכונה. ראשית, שני הסנדבוקסים של vm חייבים לטעון גם את utils/rtl-code.js; בלי זה window.RtlCode הוא undefined בבדיקה, כל טענה רצה דרך מסלול היעדר-היכולת, וכל בדיקה שמצפה ל-LTR מאשרת את עצמה. נמדד: הסרת הטעינה מפילה חמש בדיקות ומשאירה את השאר ירוקות — כלומר בדיוק מחצית התמונה. שנית, הדמה מממשת .class כחיפוש מחרוזת אחת, ולכן querySelector('.a.b') מחפש מחלקה בשם "a.b" ותמיד מחזיר null; טענה שלילית שכתובה כך עוברת גם כשהקוד מחיל את המחלקה תמיד. כל הטענות שואלות את העוטפת ישירות.
הערה
תיבת העריכה כבר יורשת ``rtl``, וזה לא מצב חדש לתקן. .sticky-note-content אינו מצהיר direction, ולכן הוא מקבל את זה של <html>. כלומר הפער בין התצוגה לעריכה היה קיים קודם ובכיוון ההפוך — תצוגה LTR מול עריכה RTL — והפיצ’ר הזה מצמצם אותו.
בלוקי אלרט: הבלוק הראשון שמכיל שורות אחרות
הסעיף על טבלאות ניסח את הכלל ”מותר לקפל, בתנאי שכל שורת מקור נשארת אלמנט אחד שנושא את ההיסט שלה“. האלרט הוא המופע השני שלו, ובכיוון הפוך: טבלה מקפלת כמה שורות מקור לאלמנט אחד, ואילו אלרט מכיל שורות שממשיכות להיות שורות תצוגה רגילות לכל דבר.
ומכאן נגזר כל המימוש. במקום ענף רינדור שני, משתנה בלולאה אחד: לאן שורה נכתבת. currentParent() מחזירה את התצוגה עצמה, או את גוף האלרט הפנימי ביותר. כל שאר הענפים — כותרת, ציטוט, רשימה, טבלה, גדר, צ’קבוקס — אינם יודעים שהאלרט קיים, וממשיכים לעבוד בדיוק כפי שעבדו.
שתי שורות המרקר מתחלקות ביניהן: שורת הפתיחה היא שורת הכותרת של האלרט, נושאת sticky-task-line ואת ההיסט שלה, ולכן לחיצה עליה מחזירה לעריכה בשורת ::: note עצמה. שורת הסגירה נצרכת בלי אלמנט — אין לה מה להציג — וההיסט מקודם עליה בכל מקרה. זו בדיוק ההחלטה שכבר תועדה עבור שורת המפריד של טבלה, ומאותו נימוק: בלי הקידום, כל מה שאחרי הבלוק מוסט באורך שורה שלמה.
אזהרה
ושורת הסגירה כן נוגעת במחסנית הרשימות — כאן נשברה אנלוגיה, ושווה לדעת איך.
הגרסה הראשונה השאירה את המחסנית ללא נגיעה, בנימוק שזה מה שעושה שורת גדר סוגרת. הנימוק היה שגוי, כי הוא העתיק מסקנה במקום את הראיה שמתחתיה: אצל הגדר זו הוכחה ולא בחירה — בתוך גדר שום דבר אינו נדחף למחסנית, כי התוכן ליטרלי, ולכן לסגירה פשוט אין מה לסגור. בתוך אלרט התוכן עובר את המנוע המלא ו**כן** דוחף, ולכן אותה שורה בדיוק אינה no-op: היא הגבול היחיד שסוגר את מה שנפתח בפנים.
התסמין: - א ואז ␣␣- ב בתוך האלרט, ו-␣␣- ג אחריו יצא בעומק 1 — כבן של פריט שכבר אינו קיים. נמדד מול markdown-it על חמישה-עשר צירופים של רשימה × אלרט: חמישה סטו לפני, ואחד נשאר אחרי — והנותר הוא הסטייה של בלוק הקוד המוזח, שמתועדת למטה.
ההזחה היא של שורת הסגירה עצמה, ולא עומק שנשמר בפתיחה. גם החלופה הזו נמדדה: שמירת listStack.length בשורת הפתיחה ושחזורה בסגירה מתקנת פחות — שלושה מקרים נותרו סוטים מול אחד — כי היא מתעלמת מההזחה, ולכן נשברת בדיוק כשהסגירה מוזחת פנימה או החוצה ביחס לפתיחה. הכלל האחיד, שכבר חל על כותרת, ציטוט, טבלה וגדר פותחת, נכון גם כאן: גבול בלוק סוגר לפי ההזחה של עצמו.
הלקח שרחב מהמקרה: אנלוגיה בין שני בלוקים אינה ראיה. לפני שמעתיקים החלטה מבלוק אחד לאחר, שואלים מה הראיה שמתחתיה ואם היא מתקיימת גם כאן.
חשוב
שורות המרקר לעולם אינן שורות משימה — TASK_LINE_RE דורש - או * בתחילת השורה, והמרקר מתחיל בנקודתיים — ולכן ה-continue שלהן אינו יכול לדלג על קידום הסידור. זה מובטח במבנה, לא בזהירות, וזו הסיבה שהאלרט לא הצריך ספירת tasksConsumed כמו שהטבלה הצריכה. ההבטחה מכוסה בבדיקה שסופרת צ’קבוקסים לפני, בתוך ואחרי אלרט.
התחביר אינו שלנו, ולכן הוא נמדד
markdown-it-container@4.0.0 מרנדר את אותם בלוקים בתצוגת המסמך ובתצוגה החיה, והוא הצרכן שהפתק צריך להסכים איתו — אותו יחס בדיוק שכבר קיים מול markdown-it בקינון הרשימות. הכללים נגזרו מ-node_modules/markdown-it-container/index.mjs והורצו מולו, לא נזכרו: הושוו רצפי פתיחה-וסגירה על עשרות קלטים, כולל שלוש רמות קינון בשני סדרי סגירה.
הכלל שהכי קל לטעות בו הוא סדר הסגירה בקינון: האינטואיציה אומרת ”סוגר את הפנימי“, ובפועל שורת סגירה סוגרת את ה**חיצוני ביותר** שאורך המרקר שלו קטן או שווה לה, ואיתו כל מה שבתוכו. הסיבה נראית רק בקוד המקור של התוסף: כל מכולה סורקת קדימה אחר שורת סגירה משלה, והמכולות שבתוכה נסגרות אוטומטית בסוף ההורה. _closeContainersAt היא כל התשובה, והמוטציה שמחליפה אותה ב“סגור את הפנימי“ מפילה בדיקה. היא אינה יודעת דבר על הסוג, ולכן היא נשארה ללא שינוי גם כשנוסף ::: details — ראו את הסעיף עליו למטה.
וכלל נוסף שנראה שולי ואינו: שורה שאין מעליה אף מכולה שאורכה מתאים אינה סגירה כלל ונשארת תוכן. _closeContainersAt מחזירה בוליאני בדיוק בשביל זה, ולא סוגרת ”מה שאפשר“.
הערה
[A-Za-z]+\b ולא \S+ בזיהוי שם הסוג. הוולידציה של שני הצרכנים היא ^<type>\b\s*(.*)$, ושתי ההתנהגויות שנגזרות ממנה נמדדו: ::: noteX נדחה כי הכמת החמדן בולע את כל האותיות ומקבל סוג שאינו קיים, ו-::: note2 נדחה כי אין גבול מילה בין אות לספרה. בלי ה-\b המקרה השני היה נחתך ל-note והופך לאלרט שהתוסף דוחה — כלומר הפתק היה מציג משהו שהמסמך אינו מציג.
שתי סטיות מכוונות, ושתיהן מאותו שורש
הראשונה — בתוך גדר קוד, ``:::`` הוא טקסט. הסורק של המכולה קורא שורות גולמיות ואינו מודע לגדרות כלל; נמדד, ושם ה-::: שבתוך הגדר באמת סוגר את הבלוק, שובר את הגדר לשניים ומוציא את שאר התוכן החוצה. בפתק זו תוצאה גרועה למשתמש, והיא גם סותרת את העמדה שהפתק כבר נוקט בטבלה שבתוך גדר. הבחירה כאן היא לעקביות פנימית על פני התאמה לתוצאה שאיש אינו רוצה.
השנייה — הזחה אינה פוסלת מרקר. ב-markdown-it שורה שמוזחת ארבעה רווחים היא בלוק קוד מוזח, ולכן ␣␣␣␣::: note אינו פותח מכולה. לפתק אין מושג של בלוק קוד מוזח בכלל, ו-MD_FENCE_RE כבר מזהה גדר בכל הזחה — כלומר הכלל היה חריג יחיד בקובץ שכולו אינו מכיר אותו. הוא חל גם על שורת הסגירה, וגם שם נמדד: markdown-it אינו מקבל סגירה מוזחת בארבעה רווחים ומשאיר את המכולה פתוחה עד סוף המסמך, בעוד הפתק סוגר בה.
שתיהן נובעות מאותו שורש: הפתק אינו מיישם את מודל הבלוקים של markdown-it, אלא מסכים איתו על התוצאה. במקום שבו המודל עצמו הוא ההבדל, ההסכמה נעצרת — כמו שכבר קרה עם ”המשך פסקה עצל“ בקינון הרשימות.
מקור אחד לסוג, וכל הצרכנים קוראים ממנו
admonition-icons.js מחזיק את האייקון ואת התווית בעברית לכל סוג, והצרכנים קוראים משם: תצוגת המסמך, התצוגה החיה, והפתקים. לפני השינוי הזה האייקונים כבר היו משותפים אבל התוויות הוחזקו פעמיים, וכל אחת מהן יכלה לקבל סוג חדש בלי שהשנייה תדע.
המפה היא גם ההגדרה של ”מהו סוג מוכר“ בפתקים: מפתח שאינו בה אינו אלרט, והשורה נשארת טקסט. זה אינו קיצור אלא בדיוק מה שהתוסף עושה על סוג שלא נרשם — נמדד, ::: foo מרונדר כפסקה.
אזהרה
הפתקים קוראים את המפה מ-window, ולכן כל תבנית שטוענת sticky-notes.js חייבת לטעון גם אותה, ולפניה. בעמוד שאינו טוען אותה כל אלרט היה מוצג כטקסט רגיל — בלי שגיאה, בלי לוג, ובלי שום סימן. זה כבר היה המצב בפועל: note_board.html טען את המודול ולא את המפה. הבדיקה שסורקת את כל התבניות ומאמתת גם קיום וגם סדר היא מה שמונע מהמשטח הבא לחזור על זה.
הצבעים: אין כאן פלטה
.admonition, .admonition-title, .admonition-content ו-.admonition-<type> מוגדרות ב-markdown-enhanced.css, שנטען ב-base.html ולכן זמין בכל שלושת המשטחים שמציגים פתקים. לכן אלרט בפתק נראה כמו אלרט בתצוגת המסמך לא במקרה ולא בהעתקה — זה אותו קוד. sticky-notes.css מחזיק רק כיול לפתק הצר: ריפוד, מרווחים וגודל אייקון.
אזהרה
וזה עובד כאן מסיבה צרה שקל לטעות בה, ואסור להכליל ממנה. כללי האלרט שם אינם משתמשים ב-var() כלל — צבע ליטרלי לכל סוג, ורקע שקוף. לכן אין להם מה לרשת מהערכה, וההצלחה כאן אינה ראיה שקובץ משותף מספיק.
הסוג הבא שכן קרא טוקני ערכה — ::: details — נשבר מיד. ראו את סעיף הצבעים שלו למטה, ואת הכלל שנוסח שם: משטח קשיח חייב לספק ערך לכל טוקן שכל רכיב שבתוכו קורא.
חשוב
הכיול חייב לבטל את כלל המובייל, ובשתי תכונות לוגיות ולא בקיצור. markdown-enhanced.css מותח את האלרט אל מחוץ לשוליים במסכים צרים (margin: 0.75rem -0.5rem); בעמוד זה נכון, בפתק זה מוציא אותו אל מחוץ לגבול.
הגרסה הראשונה כתבה margin: .35em 0 ואחריו margin-inline: 0 כ“ביטול מפורש“, ומדידה בכרומיום הראתה שהשורה השנייה חסרת השפעה לחלוטין: הקיצור כבר איפס את הצדדים, והסרתה לא שינתה פיקסל. שורה שאי אפשר להפיל היא שורה שאף בדיקה אינה מכסה — והשומר הטקסטואלי שנכתב עליה היה שומר על כלום.
הפיצול ל-margin-block ול-margin-inline הופך אותה לנושאת משקל, כי המפל פועל לכל תכונה בנפרד גם כשהמקור הוא קיצור: בלי margin-inline הצדדים נופלים חזרה לערך שבכלל המובייל. נמדד בבקרה, פתק ברוחב 260 באזור תצוגה של 390 — 0px עם השורה, -8px בלעדיה, והאלרט חוצה את גבול הפתק. באזור תצוגה של 1024 אין הבדל כלל, ולכן המדידה במסך צר היא היחידה שמסוגלת ליפול.
הערה
השומר הטקסטואלי מסיר את ההערות מה-CSS לפני שהוא בודק. בלי זה הוא קרא את הפרוזה שבתוך הכלל — שמזכירה את שמות התכונות כדי להסביר אותן — ועבר גם כשההצהרה עצמה נמחקה. נתפס במוטציה, לא בעין.
::: details: הסוג היחיד שדורש אלמנט אחר, ולמה זה נעצר בו
כל סוגי המכולה שקדמו לו הם אותו div עם מחלקה אחרת, ולכן כל הקובץ נבנה סביב משתנה אחד בלולאה — לאן שורה נכתבת — ובלי שום ענף רינדור. details הוא הראשון ששובר את זה: הוא דורש <details> ו-<summary> נייטיביים.
נייטיבי, ולא ``div`` עם JS שמראה ומסתיר. הפתק עצמו כבר מתקפל ונפתח, ומנגנון קיפול שני משלנו היה מכונת מצב שנייה שנלחמת בראשונה. הנייטיבי מביא מקלדת, נגישות והדפסה בלי שורה אחת נוספת.
והענף מסתיים שם. _openContainer מחזירה את אותה רשומה בדיוק לשתי הצורות — { markers, content } — ולכן _closeContainersAt, מחסנית הרשימות, חשבון ההיסטים וסידור הצ’קבוקסים אינם יודעים שהסוג קיים. מי שיוסיף סוג נוסף אינו מקבל בזה היתר לענף משלו: ההיתר צר ומוגדר — אלמנט HTML אחר, ולא התנהגות אחרת.
הערה
details אינו ב-ADMONITION_TITLES, ובכוונה: המפה היא ההגדרה של ”מהו סוג אלרט“, והיא מוצלבת בבדיקה מול ADMONITION_ICONS ומול רשימת הסוגים בעמוד המשתמש. תווית ברירת המחדל שלו יושבת לצידה באותו קובץ, DETAILS_DEFAULT_TITLE, וממלאת בדיוק את אותו תפקיד: מקור אחד לשלושת הצרכנים. לפני השינוי הזה המחרוזת הופיעה שלוש פעמים בקוד.
הכרעת הקליק, וההיסט שאינו נוגע בו
לחיצה על ה-``summary`` מקפלת בלבד ואינה נכנסת לעריכה. האנלוגיה הקרובה היא כפתור ההעתקה של בלוק קוד, שגם הוא מוחרג מ-_enterEditFromView — אבל היא אינה מדויקת, ושווה לדעת במה: הכפתור הוא אזור קטן ומוקדש בתוך הבלוק, וכל שאר שורת המקור עדיין מובילה לעריכה. כאן מוותרים על השורה כולה, והיא האלמנט היחיד שמייצג את ::: details כותרת. אין מסלול חלופי לשורה הזו, ומי שרוצה לערוך אותה נכנס לעריכה משורה אחרת בפתק.
סכנה
ההיסט נשאר על שורת הפתיחה גם כשהמאזין משתנה. הפיתוי ברור: הקליק אינו משתמש בהיסט, אז למה שיהיה שם. התשובה היא שההיסט אינו קיים בשביל הקליק אלא בשביל המיפוי בין תצוגה למקור — כל שורת מקור היא אלמנט תצוגה אחד שנושא את ההיסט שלה, וההיסט המצטבר נגזר מהשרשרת הזו. הסרתו מסיטה כל שורה שאחרי הבלוק באורך שורה שלמה, בדיוק כמו הכשל שכבר תועד על שורת הסגירה ועל שורת המפריד של טבלה.
הבדיקה שמסוגלת ליפול על זה סופרת היסטים לפני, בתוך ואחרי הבלוק, גם כשהוא מקופל וגם כשהוא פתוח. שורת הסגירה, כרגיל, נצרכת בלי אלמנט וההיסט מקודם עליה בכל מקרה.
ובמקלדת הפטור רחב יותר מאשר בקישור, ולא באותה נקודה. אצל הקישור רק Enter יוצא לפני ה-preventDefault, ו-Space ממשיך אליו כדי לבלום גלילה. ב-<summary> שני המקשים מקפלים, והקיפול הוא פעולת ברירת המחדל — כלומר preventDefault היה מבטל אותו ומשאיר משתמש מקלדת בלי שום דרך לפתוח את הבלוק, וגם Space אינו גולל שם ממילא. לכן שניהם יוצאים.
ומצב הפתיחה חייב לשרוד רינדור מחדש. <details> נייטיבי מחזיק את מצבו ב-DOM בלבד, והתצוגה נבנית מאפס בכל _syncTaskView — כניסה ויציאה מעריכה, ו-_applyServerContent שרץ אחרי סימון צ’קבוקס. בלי שחזור, בלוק שנפתח נסגר תחת האצבע ברגע שמסמנים משימה שבתוכו, וגם אחרי כל עריכה.
סכנה
שתי טעויות נפרדות היו כאן, ושתיהן התגלו רק במדידה.
הראשונה — מקום הלכידה. היא ישבה מתחת ליציאה המוקדמת של _syncTaskView, ואותה יציאה מוחקת את התצוגה בעצמה. כלומר כניסה לעריכה מחקה את המצב לפני שמישהו קרא אותו. הלכידה חייבת לשבת מעל שתי המחיקות.
השנייה — המפתח, וזו הטעות המעניינת. הגרסה הראשונה השתמשה בהיסט של שורת הפתיחה, בנימוק שזו הזהות שהמנוע כבר מתחזק ושסימון צ’קבוקס אינו מזיז אותה ([ ] ו-[x] באותו אורך). הנימוק נכון — ולא רלוונטי, כי עריכה כן מזיזה אותו: תו אחד שנוסף בתחילת הפתק מזיז את כל ההיסטים באחד. כלומר תיקון מקום הלכידה לבדו מכסה בדיוק את המקרה שבו המשתמש נכנס לעריכה ולא הקליד כלום, ומחטיא את המקרה שבשבילו נכנסים לעריכה.
נמדד בכרומיום על שמונה תרחישים, בשלוש גרסאות: לפני התיקון נפלו שישה, עם מפתח ההיסט נפלו שלושה, ועם סידור הבלוק בפתק אף אחד. הסידור שורד כל שינוי טקסט שאינו מוסיף או מוחק בלוק, ו-querySelectorAll מחזיר סדר מסמך שזהה לסדר הבנייה — גם בקינון, כי בלוק פנימי נוסף לתוך גוף הורה שכבר נוסף.
והלקח שרחב מהמקרה: ההיסט הוא זהות בתוך רינדור אחד. כל מה שצריך לשרוד שינוי טקסט צריך זהות אחרת.
הערה
היציאה המוקדמת משרתת שני מצבים שנראים זהים ואינם. editing אומר ”המשתמש עורך עכשיו“, ושם הזיכרון הוא כל מטרת המנגנון. !hasTask && !wantMd אומר ”לפתק אין מבנה בכלל“, כלומר אין בו בלוקים — וזיכרון ששורד אותו הוא מצב רפאים. נמדד: פתק עם בלוק פתוח, שתוכנו הוחלף בטקסט רגיל ואז בבלוק חדש לגמרי, פתח את החדש. הזיכרון נמחק בענף השני בלבד.
הזיכרון עצמו חי על רשומת הפתק (_getEntry), לצד מצב הביטול, ולא כתכונה על האלמנט — כדי שיהיה מקום אחד לכל מה שהפתק זוכר מעבר לרינדור.
הכותרת: אותה תווית שהמסמך מציג, ולמה דווקא כאן זה קריטי
בשאר הסוגים הכותרת היא תווית על תוכן שגלוי ממילא; אם תיפול, נשארת כרטיסייה עם תוכן. בבלוק מתקפל היא כל מה שקיים על המסך כשהוא סגור, ולכן תווית ריקה היא בלוק שאי אפשר לזהות ואי אפשר לדעת מה בתוכו. זו הסיבה שהיא נקראת מהמקור המשותף ולא נכתבת כמחרוזת רביעית, ושהיעדר הקבוע משאיר את השורה כטקסט במקום לבנות summary ריק.
אזהרה
שני הצרכנים לא הסכימו על חילוץ הכותרת, וזה נמדד לפני הכתיבה. תצוגת המסמך חילצה אותה ב-^details\s+(.*)$ בעוד שכל שאר הסוגים משתמשים ב-^<type>\b\s*(.*)$. ההבדל נראה רק כשאין רווח אחרי שם הסוג: ::: details.x קיבל שם את תווית ברירת המחדל, בעוד ש-::: note.x קיבל את הכותרת .x. הפתק, שקורא את שם הסוג עם גבול מילה, היה מציג .x — כלומר שני הצרכנים היו מציגים דברים שונים לאותו קלט.
התיקון הוא יישור ל-\b\s* בשני הצרכנים, ולא ענף בפתק. הושוו צורות הפרמטרים מול markdown-it-container@4.0.0: עם \s+ שתיים סטו, ואחרי היישור אף אחת לא.
ובאותו עמוד היה גם מסלול שני שאינו מסכים עם עצמו: enhanceMarkdownContainers ב-md_preview.html, שממיר פסקאות שנשארו אחרי הרינדור, זיהה סוג בלי \b — כלומר ::: detailsX היה נפתח שם כבלוק מתקפל בזמן שהתוסף דוחה אותו לגמרי. גם הוא יושר.
הערה
השומר על ”מקור אחד“ חיפש שם משתנה, ולכן פספס עותק שלם. באותו מסלול post-render שרדה מפת תוויות שלישית, מוטמעת בשורה אחת בתוך function defaultTitle. הבדיקה חיפשה DEFAULT_TITLES = { note:, והעותק נקרא m. מה שקבוע בכל עותק אינו שם המשתנה אלא הזוג note ← 'הערה', וזה מה שהשומר בודק היום.
הצבעים והכיול: אותה מלכודת של האלרט, ואותו כלל מובייל
.markdown-details, .markdown-summary ו-.details-content מוגדרות ב-markdown-enhanced.css שנטען ב-base.html, ולכן המבנה והמידות בפתק הם אותו קוד בדיוק כמו בתצוגת המסמך. שתי מלכודות נובעות מזה, ואחת מהן פספסתי.
המלכודת הראשונה היא המידות, והיא באמת אותה שורה של האלרט: .markdown-details, .admonition { margin: 0.75rem -0.5rem; } בכלל המובייל תופסת את שתי המחלקות יחד.
הערה
וההיקש מהאלרט הפיל כאן שורה מתה, בכיוון השני. הגרסה הראשונה הצהירה על ה-summary גם overflow-wrap: anywhere וגם min-width: 0, בהיקש מ-.sticky-md-alert-label. מדידה בכרומיום הראתה ששתיהן חסרות השפעה מוחלטת: overflow-wrap כבר מוצהר על .sticky-note-tasks ויורש, וה-summary אינו פריט flex אלא בלוק בתוך <details>. כותרת של 140 תווים רצופים בפתק ברוחב 260 יצאה באותו גובה בדיוק עם השורות ובלעדיהן. שתיהן ירדו, כי שומר על שורה מתה שומר על כלום — אותו לקח שכבר תועד למעלה על margin-inline, רק שכאן התשובה הייתה למחוק ולא לפצל.
לכן הכיול חוזר על אותה החלטה ומאותו נימוק: margin-block ו-margin-inline בנפרד ולא קיצור margin, כי הקיצור מאפס את הצדדים בעצמו והופך את margin-inline לשורה שאף מוטציה אינה יכולה להפיל. נמדד בכרומיום, פתק ברוחב 260 באזור תצוגה של 390: עם השורה margin-left/margin-right הם 0px והבלוק נצמד לתיבת התוכן של התצוגה; בלעדיה הם -8px והוא חוצה אותה. המדידה נעשית מול תיבת התוכן של .sticky-note-tasks ולא מול הפתק — היחס בין הפתק לתצוגה קבוע ואינו מה שהכלל הזה משנה.
סכנה
והמלכודת השנייה — הצבעים. הגרסה הראשונה של הסעיף הזה כתבה ”ולכן גם כאן אין פלטה חדשה“, וזה היה שגוי.
הכלל הנכון: קובץ CSS משותף אינו מספיק כשהמשטח קשיח ועוקף ערכה. משטח כזה חייב לספק ערך לכל טוקן שכל רכיב שמוצג בתוכו קורא. הפתק הוא נייר בהיר קבוע, ולכן רכיב שקורא טוקן ערכה מקבל ערך שנבחר לרקע אחר לגמרי. זה בדיוק מה שההערה בראש sticky-notes.css כבר קבעה על טקסט, ומה ש-מערכת ערכות הנושא והטוקנים החדשה קובע בסעיף החריגים — והסעיף הזה סתר את שניהם.
מה שנמדד בבקרה, על הקוד לפני התיקון: הטקסט בגוף הבלוק יורש #222 מ-.sticky-note-tasks ונכתב על --details-bg של הערכה. בערכה מיובאת כהה יצא יחס ניגודיות 1.09:1 — טקסט כהה על רקע כהה, בלתי קריא. 1.19 ב-dark, 1.32 ב-high-contrast, 2.03 ב-ocean. אחרי התיקון: 14.85 עד 15.48 בכל שמונה הערכות.
וגם הערכה הבהירה לא היתה תקינה — היא רק נראתה כך. החץ של ה-summary יצא שם 2.09:1, ובערכה מיובאת כהה אחרת 1.69:1 — מתחת לסף 3:1 של WCAG 1.4.11 לרכיבי ממשק. אחרי התיקון 3.83–4.00.
האלרט שרד, אבל לא כי העיקרון עבד: כלליו ב-markdown-enhanced.css אינם משתמשים ב-var() כלל, אלא בצבע ליטרלי לכל סוג. הבלוק המתקפל הוא הרכיב הראשון שמוצג בפתק ו**כן** קורא טוקני ערכה, ולכן הראשון שנשבר. העיקרון לא נבחן שם.
התיקון: sticky-note מצהיר על ששת הטוקנים שהרכיב קורא — --details-bg, --details-border, --details-hover, --summary-bg, --summary-color, --summary-arrow-color. אין !important ואין קרב ספציפיות: הצהרת הערכה יושבת על <html> ושלנו על .sticky-note, כלומר שני אלמנטים שונים, וההכרעה היא ירושה.
חשוב
מה שלא נמדד בסבב הראשון, וזה הלקח שמונע את החזרה. השוליים נמדדו בכרומיום על פתק ברוחב 260 — אבל הצבעים לא נמדדו על משטח הפתק כלל. הם הוסקו מהקובץ המשותף: ”זה אותו קוד, ולכן זה נראה אותו דבר“. ההיסק נכון למבנה ולמידות ושגוי לצבע, כי הצבע תלוי במשטח.
ומדידה שנראית נכונה יכולה עדיין לא להיות מסוגלת ליפול. --summary-bg אינו צבע אלא גרדיאנט בכל שמונת בלוקי הערכה, והכלל הוא background: var(--summary-bg). קיצור background עם ערך תמונה מכניס אותו ל-background-image ו**מאפס את** background-color ל-transparent. כלומר בדיקה שמודדת background-color של ה-summary קוראת transparent גם על הקוד השבור — נמדד בכל שמונה הערכות — וזה נראה בדיוק כמו ”אין קופסה כהה“. הפיקסל באותו רגע היה (27, 43, 52).
לכן צבע נמדד בדגימת פיקסל, לא רק ב-getComputedStyle: צילום של אזור 1×1 במרכז האלמנט, אחרי elementFromPoint שמאשר שנדגם האלמנט הנכון.
הערה
הבדיקה שהיתה כאן אכפה את המסקנה השגויה. היא דרשה ש---details-bg/--details-border לא יופיעו ב-sticky-notes.css, בנימוק ”הצבעים חיים במקום אחד“. כלומר הריפו אכף שאסור לתקן את הבאג. היא הוחלפה בשומר הפוך שנגזר מהרכיב עצמו: הוא סורק את הכללים ב-markdown-enhanced.css, מחלץ כל var(--x) שהם קוראים, ודורש שלכל טוקן צבע יהיה ערך על .sticky-note. טוקן שביעי שיתווסף שם — ע“י מי שאינו חושב על הפתקים בכלל — מפיל אותו ומכריח החלטה.
אזהרה
``–summary-bg`` בפתק שטוח ולא גרדיאנט, וזו החלטה עיצובית ולא המרת ערך. בתצוגת המסמך שורת הכותרת היא רצועה שדוהה לרוחב; בפתק ברוחב ~260 פיקסלים דהייה כזו נקראת כמריחה לא אחידה ולא כרצועה. השומר אוכף את הבחירה, כך שחזרה לגרדיאנט עוברת דרכו.
ומחיר אחד שנמדד ונאמר במפורש: ב-high-contrast החץ ירד מ-19.44:1 ל-4.00:1. הוא עדיין מעל הסף, והפתק ממילא אינו מיישם את הערכה הזו — high-contrast.css אינו מכיל אף כלל ל-.sticky-* — בעוד שהטקסט בגוף הבלוק עלה שם מ-1.32 ל-15.48.
innerHTML: מקום אחד, ובגלל הקלט ולא בגלל ה-API
הסעיף על רינדור בתצוגה קובע שהגבול הוא הקלט ולא ה-API, ושתבנית סטטית לחלוטין מותרת. _appendAlertIcon הוא המימוש היחיד של הפטור הזה בקובץ: מחרוזת SVG קבועה מהמפה המשותפת, שנכנסת ל-span ייעודי.
מה שהופך את זה לבטוח אינו הכוונה אלא המבנה: האייקון והתווית הם שני צמתים נפרדים. התווית — הטקסט היחיד שיכול להגיע מהמשתמש — נכתבת תמיד ב-textContent, ואין מסלול קוד שמחבר בין השניים למחרוזת אחת. בדיקת typeof על ערך המפה אינה קישוט: היא הגלובל היחיד שנכתב כ-HTML, וערך שאינו מחרוזת היה נכתב כ-[object Object].