sigil-markdown 0.9.5: an underscore inside a word is not emphasis
CommonMark treats '' and '*' differently on purpose. An underscore between two word characters is ordinary text, because snakecase is ordinary prose in technical writing. This parser applied the '*' rule to both, so every snake_case identifier written as prose was eaten:
sokol_gfx and sokol_gp -> sokol<em>gfx and sokol</em>gpNothing in the output showed that anything had been removed, which is why it survived: the page reads as if the author wrote it that way.
Measured against the corpus a real consumer renders rather than against invented cases. Over the 1,389 commit messages and 86 READMEs the Azoth estate publishes: 97 of the commit messages and 1 of the READMEs were corrupted. That README is live at /sigil/repos/sigil-graphics/ today. After the fix, 0 READMEs and 2 commit messages still emit emphasis from an underscore, and both are accounted for rather than assumed: attribute((unused)) is CommonMark-conformant strong (the closing run is followed by punctuation), and one base64 string in a quoted example lands in punctuation-flanking territory this simplified rule does not model. Neither is identifier corruption.
The rule: a '_' run may open only when not preceded by a word character and close only when not followed by one. '' KEEPS THE OLD BEHAVIOUR -- foobar*baz is emphasis in CommonMark and remains so here. A negative control test fails if the rule is ever applied to both characters.
emphasis at a word boundary, bold, and foobar_ (which CommonMark emphasises across the inner underscore) are all unchanged and tested.
The new tests take their strings from the real corpus, not from my understanding of the bug: sokolgfx, stbimage, sigilwasmgles3, KILNARTIFACTS, groupid, run_groups. They assert TEXT IDENTITY with the source rather than the absence of an em node, because absence passes for free if the parser drops its input. Sabotage-tested: flipping the new flag back to #f turns 6 of the 9 red, and the 3 that stay green are exactly the boundary and negative-control cases that must not depend on it.
Five of the six bench goldens are byte-identical. inline-edge changed on one construct, underscore, which that corpus deliberately contains; its asterisk neighbours in the same line are untouched. Golden re-blessed.
The boundary test is ASCII-only, like this file's own char-numeric?, so a non-ASCII letter beside an underscore still reads as a boundary. Named in the source rather than left to be discovered.
CHANGELOG.md | 6 ++++++
bench/golden/inline-edge.sxml | 2 +-
package.sgl | 2 +-
src/sigil/markdown.sgl | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++-----------
test/test-markdown.sgl | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 141 insertions(+), 13 deletions(-)CHANGELOG.mdmodified
The format is based on [Keep a Changelog](https://keepachangelog.com/),and this project adheres to [Semantic Versioning](https://semver.org/).## [0.9.5] - 2026-08-02### Fixed- An underscore between two word characters is no longer an emphasis delimiter, per CommonMark. `sokol_gfx and sokol_gp` previously rendered as `sokol<em>gfx and sokol</em>gp`: the underscores were consumed and the words silently joined, with nothing in the output to show that anything had been eaten. Every snake_case identifier written as prose was affected. Measured over the corpus one consumer actually renders, 97 of 1,389 commit messages and 1 of 86 READMEs were corrupted this way. `_emphasis_` at a word boundary, `__bold__`, and `_foo_bar_` (which CommonMark emphasises across the inner underscore) are unchanged. **`*` deliberately keeps the old behaviour** — `foo*bar*baz` is emphasis in CommonMark and remains so here; a negative-control test fails if the rule is ever applied to both characters. The boundary test is ASCII-only, like this parser's own `char-numeric?`, so a non-ASCII letter beside an underscore still reads as a boundary.## [0.9.4] - 2026-07-31### Fixedbench/golden/inline-edge.sxmlmodified
(document (h1 "Inline edge cases") (p "Stray asterisk " (em " in the middle and a lone ! bang and a lone " "_" " underscore. A trailing asterisk word") " and " (em "leading and mid") "dle and un" (em "der") "score words. Bang before bracket " "!" "[" "not an image because no paren] and " (img (@ (src "src.png") (alt "alt"))) " real. Escaped " "*" " star, escaped " "_" " under, escaped " "`" " tick, escaped " "\\" " backslash.") (p "Text with " (code "code with * and _ and [ inside") " should stay literal. Bold " (strong "with " (code "code") " and " (em "should this nest") " inside") " end. Italic " (em "with " (a (@ (href "x")) "a link") " inside") " and " (em "with " (code "code")) " too.") (p "Empty emphasis " (strong) " and " (strong) " and lone " "*" "*" " at start. Link " (a (@ (href "http://example.com/path?a=1&b=2")) "text") " with query. Image " (img (@ (src "path/to/img.png") (alt "alt text"))) " inline.") (p "A run of specials: " (em "_" "`" "[" "!" " " "mixed together. Multiple ") (em "bold") (em " words ") (em "again") (em " and ") "em" (em " then ") "em2" "*" " close together.") (p "Unclosed " "*" (em "bold never closes here. Unclosed ") "italic and " "`" "code both dangle.") (p "Consecutive" "[" "[" "wikilink]]-style double brackets and normal " (a (@ (href "u")) "link") ". Numbers 1234567890 and symbols !@#$%^&()+=~ with no markup.")) No newline at end of file(document (h1 "Inline edge cases") (p "Stray asterisk " (em " in the middle and a lone ! bang and a lone " "_" " underscore. A trailing asterisk word") " and " (em "leading and mid") "dle and un" "_" "der" "_" "score words. Bang before bracket " "!" "[" "not an image because no paren] and " (img (@ (src "src.png") (alt "alt"))) " real. Escaped " "*" " star, escaped " "_" " under, escaped " "`" " tick, escaped " "\\" " backslash.") (p "Text with " (code "code with * and _ and [ inside") " should stay literal. Bold " (strong "with " (code "code") " and " (em "should this nest") " inside") " end. Italic " (em "with " (a (@ (href "x")) "a link") " inside") " and " (em "with " (code "code")) " too.") (p "Empty emphasis " (strong) " and " (strong) " and lone " "*" "*" " at start. Link " (a (@ (href "http://example.com/path?a=1&b=2")) "text") " with query. Image " (img (@ (src "path/to/img.png") (alt "alt text"))) " inline.") (p "A run of specials: " (em "_" "`" "[" "!" " " "mixed together. Multiple ") (em "bold") (em " words ") (em "again") (em " and ") "em" (em " then ") "em2" "*" " close together.") (p "Unclosed " "*" (em "bold never closes here. Unclosed ") "italic and " "`" "code both dangle.") (p "Consecutive" "[" "[" "wikilink]]-style double brackets and normal " (a (@ (href "u")) "link") ". Numbers 1234567890 and symbols !@#$%^&()+=~ with no markup.")) No newline at end of filepackage.sglmodified
(package name: "sigil-markdown" version: "0.9.4" version: "0.9.5" sigil: "^0.17" description: "Markdown and frontmatter parser" url: "https://codeberg.org/sigil/sigil-markdown"src/sigil/markdown.sglmodified
#f)) (else (loop (+ k 1)))))) ;; ---- word-boundary flanking, for '_' only --------------------------- ;; ;; CommonMark treats '_' and '*' differently on purpose: an underscore ;; between two word characters is NOT a delimiter, because snake_case is ;; ordinary prose in technical writing. '*' has no such restriction -- ;; `foo*bar*baz` IS emphasis in CommonMark and stays so here. This is the ;; one place the two characters may legitimately diverge. ;; ;; Without this rule every snake_case identifier in prose was silently ;; mangled, with no error and nothing in the output to show something had ;; been eaten. `sokol_gfx and sokol_gp` rendered as ;; `sokol<em>gfx and sokol</em>gp` -- live and public on azoth.works. ;; Measured over the 1,389 commit messages the Azoth estate actually ;; renders, 97 were corrupted this way; over its 86 READMEs, one was. ;; ;; ASCII-only, deliberately and like this file's own `char-numeric?`. ;; A non-ASCII letter adjacent to an underscore is therefore still read ;; as a boundary. Named rather than silently assumed: CommonMark uses ;; Unicode classes here and this does not. (define (ascii-letter? c) (or (and (char>=? c #\a) (char<=? c #\z)) (and (char>=? c #\A) (char<=? c #\Z)))) (define (word-char? c) (or (ascii-letter? c) (char-numeric? c))) ;; A delimiter run may OPEN only when nothing word-like precedes it... (define (can-open? text i) (or (= i 0) (not (word-char? (string-ref text (- i 1)))))) ;; ...and CLOSE only when nothing word-like follows it. `after` is the ;; index just past the closing run, so the caller accounts for the run's ;; own width rather than this predicate guessing at it. (define (can-close? text after len) (or (>= after len) (not (word-char? (string-ref text after))))) ;; Double-delimiter emphasis: **strong** / __strong__. Requires the ;; doubled opener (caller has already seen the first delimiter char), ;; one or more inner chars, and a doubled closer. Inner is re-parsed. (define (scan-delim-double text i len ch tag) ;; `bounded?` applies the word-boundary rule above to the CLOSER. (define (scan-delim-double text i len ch tag bounded?) (if (and (< (+ i 1) len) (eq? (string-ref text (+ i 1)) ch)) (let loop ((k (+ i 2))) (cond ((>= k len) #f) ((and (eq? (string-ref text k) ch) (< (+ k 1) len) (eq? (string-ref text (+ k 1)) ch)) (eq? (string-ref text (+ k 1)) ch) (or (not bounded?) (can-close? text (+ k 2) len))) (if (> k (+ i 2)) (cons (cons tag (parse-inline (substring text (+ i 2) k))) (+ k 2)) ;; Single-delimiter emphasis: *em* / _em_. Only when the opener is NOT ;; doubled (grammar lookahead (& (seq D (! D)))); one or more inner chars ;; then a closing delimiter. Inner is re-parsed. (define (scan-delim-single text i len ch tag) (define (scan-delim-single text i len ch tag bounded?) (if (or (>= (+ i 1) len) (not (eq? (string-ref text (+ i 1)) ch))) (let loop ((k (+ i 1))) (cond ((>= k len) #f) ((eq? (string-ref text k) ch) ((and (eq? (string-ref text k) ch) (or (not bounded?) (can-close? text (+ k 1) len))) (if (> k (+ i 1)) (cons (cons tag (parse-inline (substring text (+ i 1) k))) (+ k 1)) #f)) ;; Emphasis dispatch for a delimiter char: try bold (double) then italic ;; (single), matching the grammar's ordered choice. (define (scan-emphasis text i len ch) (or (scan-delim-double text i len ch 'strong) (scan-delim-single text i len ch 'em))) ;; (single), matching the grammar's ordered choice. `bounded?` is #t for ;; '_' and #f for '*'. (define (scan-emphasis text i len ch bounded?) (and (or (not bounded?) (can-open? text i)) (or (scan-delim-double text i len ch 'strong bounded?) (scan-delim-single text i len ch 'em bounded?)))) ;; Bracketed construct helper: given the position of the first inner char ;; (just past '[' or '!['), scan a label up to the first ']', require an (loop (+ i 1) (cons "`" acc))))) ;; **bold** / *em* ((eq? c #\*) (let ((r (scan-emphasis text i len #\*))) (let ((r (scan-emphasis text i len #\* #f))) (if r (loop (cdr r) (cons (car r) acc)) (loop (+ i 1) (cons "*" acc))))) ;; __bold__ / _em_ ;; __bold__ / _em_ -- word-bounded, unlike '*' ((eq? c #\_) (let ((r (scan-emphasis text i len #\_))) (let ((r (scan-emphasis text i len #\_ #t))) (if r (loop (cdr r) (cons (car r) acc)) (loop (+ i 1) (cons "_" acc)))))test/test-markdown.sglmodified
(and (pair? x) (eq? (car x) 'img))) result))))));; ============================================================;; Intraword underscores are not emphasis delimiters;; ============================================================;;;; An underscore between two word characters is ordinary text, per;; CommonMark. Before this rule existed, every snake_case identifier written;; as prose was silently eaten: `sokol_gfx and sokol_gp` became;; `sokol<em>gfx and sokol</em>gp`, live on a public site.;;;; THE STRINGS BELOW ARE COPIED FROM THE REAL CORPUS -- Azoth estate READMEs;; and commit messages -- not invented. A case written from the same;; understanding that produced the bug tends to satisfy the buggy rule too;;; these cannot drift from what the estate actually renders.;;;; The assertion is TEXT-IDENTITY, not "no em node present". Absence of a;; symptom passes for free if the parser drops the input entirely; equality;; with the source string does not.(define (inline-text result) (let flatten ((n result) (acc "")) (cond ((string? n) (string-append acc n)) ((and (pair? n) (symbol? (car n))) ;; skip the tag, and an (@ ...) attribute list if present (let ((rest (if (and (pair? (cdr n)) (pair? (cadr n)) (eq? (car (cadr n)) '@)) (cddr n) (cdr n)))) (flatten rest acc))) ((pair? n) (flatten (cdr n) (flatten (car n) acc))) (else acc))))(define (assert-literal source) (assert-equal source (inline-text (parse-inline source))))(test-group "intraword underscores" ;; --- from README.md of sigil-graphics, rendered wrong on azoth.works --- (test "sokol_gfx and sokol_gp survive" (assert-literal "Provides 2D rendering capabilities via sokol_gfx and sokol_gp")) (test "stb_image and stb_truetype survive" (assert-literal "image loading (stb_image), and font rendering (stb_truetype).")) ;; --- from real commit messages in the estate --- (test "sigil_wasm_gles3 survives" (assert-literal "sigil_wasm_gles3 and sigil_wasm_native_start")) (test "KILN_ARTIFACTS survives" (assert-literal "KILN_ARTIFACTS and KILN_DEPS")) (test "group_id, node_id, run_groups survive" (assert-literal "group_id, node_id, plan_form and run_groups")) ;; --- the boundary cases the rule must NOT break --- (test "__init__ is still strong" (let ((result (parse-inline "__init__ method"))) (assert-true (pair? (filter (lambda (x) (and (pair? x) (eq? (car x) 'strong))) result))) (assert-equal "init method" (inline-text result)))) (test "_emphasis_ at a word boundary still works" (let ((result (parse-inline "an _emphasis_ here"))) (assert-true (pair? (filter (lambda (x) (and (pair? x) (eq? (car x) 'em))) result))) (assert-equal "an emphasis here" (inline-text result)))) ;; CommonMark: _foo_bar_ is <em>foo_bar</em>. The inner underscore is ;; intraword and the outer pair is not, so the run closes at the LAST one. (test "_foo_bar_ emphasises across the intraword underscore" (let ((result (parse-inline "_foo_bar_"))) (assert-true (pair? (filter (lambda (x) (and (pair? x) (eq? (car x) 'em))) result))) (assert-equal "foo_bar" (inline-text result)))) ;; The '*' rule is deliberately DIFFERENT and must not change with it. ;; This is the negative control: had the fix been applied to both ;; characters, this test goes red. (test "intraword ASTERISK is still emphasis" (let ((result (parse-inline "foo*bar*baz"))) (assert-true (pair? (filter (lambda (x) (and (pair? x) (eq? (car x) 'em))) result))) (assert-equal "foobarbaz" (inline-text result)))));; ============================================================;; Front matter;; ============================================================