Commit526e651bRecorded20 Feb 2026Repositorysigil-org

feat: Add sigil-org Org Mode parser

Message

Parses Org Mode documents into SXML, supporting headlines with TODO state and tags, document metadata, property drawers, source/quote/example blocks, inline markup (bold, italic, code, verbatim, underline, strikethrough), links, ordered/unordered lists with checkboxes, timestamps, horizontal rules, and comments.

29 tests covering all element types.

Changed
 package.sgl       |  15 ++++
 src/sigil/org.sgl | 725 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/test-org.sgl | 314 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 1054 insertions(+)
Diff
package.sgladded
@@ -0,0 +1,15 @@
+1
;;; sigil-org - Org Mode parser
+2
;;;
+3
;;; Parses Org Mode text into SXML for use with (sigil sxml).
+4
+5
(package
+6
name: "sigil-org"
+7
version: "0.5.0"
+8
description: "Org Mode parser producing SXML output"
+9
url: "https://codeberg.org/sigil/sigil"
+10
license: "BSD-3-Clause"
+11
authors: (list "David Wilson <[email protected]>")
+12
+13
dependencies: (list
+14
(from-workspace name: "sigil-stdlib")
+15
(from-workspace name: "sigil-peg")))
src/sigil/org.sgladded
@@ -0,0 +1,725 @@
+1
;;; (sigil org) - Org Mode Parser
+2
;;;
+3
;;; Parses Org Mode text into an SXML document tree. Targets the elements
+4
;;; commonly used in System Crafters content.
+5
;;;
+6
;;; ## Basic Usage
+7
;;;
+8
;;; ```scheme
+9
;;; (import (sigil org))
+10
;;;
+11
;;; (org->sxml "* Hello\n\nSome text.")
+12
;;; ; => (org-document
+13
;;; ; (headline (@ (level 1)) "Hello"
+14
;;; ; (section (paragraph "Some text."))))
+15
;;; ```
+16
;;;
+17
;;; ## Supported Elements
+18
;;;
+19
;;; | Element | Syntax |
+20
;;; |------------------|-------------------------------------------|
+21
;;; | Metadata | `#+TITLE: value`, `#+AUTHOR: value` |
+22
;;; | Headlines | `* Level 1`, `** Level 2`, with tags |
+23
;;; | Property drawers | `:PROPERTIES:` ... `:END:` |
+24
;;; | Source blocks | `#+BEGIN_SRC lang` ... `#+END_SRC` |
+25
;;; | Other blocks | `#+BEGIN_QUOTE` ... `#+END_QUOTE` |
+26
;;; | Text markup | `*bold*`, `/italic/`, `~code~`, `=verb=` |
+27
;;; | Links | `[[url][desc]]`, `[[url]]` |
+28
;;; | Lists | `- item`, `1. item`, checkboxes |
+29
;;; | Horizontal rules | `-----` (5+ dashes on a line) |
+30
;;; | Comments | Lines starting with `#` (not `#+`) |
+31
;;; | Timestamps | `<2026-02-20 Fri>`, `[2026-02-20 Fri]` |
+32
+33
(define-library (sigil org)
+34
(import (sigil string)
+35
(sigil peg)
+36
(sigil io))
+37
(export
+38
org->sxml
+39
org-file->sxml)
+40
+41
(begin
+42
+43
;; ============================================================
+44
;; String Utilities
+45
;; ============================================================
+46
+47
(define (string->lines str)
+48
(let ((len (string-length str)))
+49
(let loop ((start 0) (i 0) (lines '()))
+50
(cond
+51
((>= i len)
+52
(reverse (if (> i start)
+53
(cons (substring str start i) lines)
+54
lines)))
+55
((char=? (string-ref str i) #\newline)
+56
(loop (+ i 1) (+ i 1)
+57
(cons (substring str start i) lines)))
+58
(else
+59
(loop start (+ i 1) lines))))))
+60
+61
(define (string-drop str n)
+62
(if (>= n (string-length str))
+63
""
+64
(substring str n (string-length str))))
+65
+66
(define (count-leading str ch)
+67
(let ((len (string-length str)))
+68
(let loop ((i 0))
+69
(if (and (< i len) (char=? (string-ref str i) ch))
+70
(loop (+ i 1))
+71
i))))
+72
+73
(define (string-ci-starts-with? str prefix)
+74
(let ((slen (string-length str))
+75
(plen (string-length prefix)))
+76
(and (>= slen plen)
+77
(equal? (string-downcase (substring str 0 plen))
+78
(string-downcase prefix)))))
+79
+80
;; ============================================================
+81
;; Line Classification
+82
;; ============================================================
+83
+84
(define (blank-line? line)
+85
(string-blank? line))
+86
+87
(define (headline? line)
+88
(and (> (string-length line) 0)
+89
(char=? (string-ref line 0) #\*)
+90
(let ((stars (count-leading line #\*)))
+91
(and (< stars (string-length line))
+92
(char=? (string-ref line stars) #\space)))))
+93
+94
(define (metadata-line? line)
+95
(and (string-starts-with? line "#+")
+96
(not (string-ci-starts-with? line "#+BEGIN_"))
+97
(not (string-ci-starts-with? line "#+END_"))))
+98
+99
(define (comment-line? line)
+100
(and (> (string-length line) 0)
+101
(char=? (string-ref line 0) #\#)
+102
(or (= (string-length line) 1)
+103
(not (char=? (string-ref line 1) #\+)))))
+104
+105
(define (horizontal-rule? line)
+106
(and (>= (string-length (string-trim line)) 5)
+107
(let ((trimmed (string-trim line)))
+108
(let loop ((i 0))
+109
(if (>= i (string-length trimmed))
+110
#t
+111
(if (char=? (string-ref trimmed i) #\-)
+112
(loop (+ i 1))
+113
#f))))))
+114
+115
(define (block-begin? line)
+116
(string-ci-starts-with? (string-trim line) "#+BEGIN_"))
+117
+118
(define (block-end? line)
+119
(string-ci-starts-with? (string-trim line) "#+END_"))
+120
+121
(define (property-drawer-start? line)
+122
(equal? (string-trim (string-downcase line)) ":properties:"))
+123
+124
(define (property-drawer-end? line)
+125
(equal? (string-trim (string-downcase line)) ":end:"))
+126
+127
(define (unordered-list-item? line)
+128
(let ((trimmed (string-trim-start line)))
+129
(and (>= (string-length trimmed) 2)
+130
(or (char=? (string-ref trimmed 0) #\-)
+131
(char=? (string-ref trimmed 0) #\+))
+132
(char=? (string-ref trimmed 1) #\space))))
+133
+134
(define (ordered-list-item? line)
+135
(let ((trimmed (string-trim-start line)))
+136
(and (>= (string-length trimmed) 3)
+137
(char-numeric? (string-ref trimmed 0))
+138
(let loop ((i 1))
+139
(cond
+140
((>= i (string-length trimmed)) #f)
+141
((char-numeric? (string-ref trimmed i)) (loop (+ i 1)))
+142
((and (char=? (string-ref trimmed i) #\.)
+143
(< (+ i 1) (string-length trimmed))
+144
(char=? (string-ref trimmed (+ i 1)) #\space))
+145
#t)
+146
(else #f))))))
+147
+148
(define (char-numeric? c)
+149
(and (char>=? c #\0) (char<=? c #\9)))
+150
+151
;; ============================================================
+152
;; Metadata Parsing
+153
;; ============================================================
+154
+155
(define (parse-metadata-line line)
+156
(let* ((content (string-drop line 2))
+157
(colon (string-index content (lambda (c) (char=? c #\:)))))
+158
(if colon
+159
(let ((key (string-downcase (string-trim (substring content 0 colon))))
+160
(value (string-trim (string-drop content (+ colon 1)))))
+161
(cons (string->symbol key) value))
+162
#f)))
+163
+164
;; ============================================================
+165
;; Headline Parsing
+166
;; ============================================================
+167
+168
(define (parse-headline line)
+169
(let* ((stars (count-leading line #\*))
+170
(rest (string-trim (string-drop line (+ stars 1))))
+171
(attrs (list (list 'level stars))))
+172
;; Parse optional TODO keyword
+173
(let-values (((todo rest2) (parse-todo rest)))
+174
(let ((attrs2 (if todo (cons (list 'todo todo) attrs) attrs)))
+175
;; Parse optional priority
+176
(let-values (((priority rest3) (parse-priority rest2)))
+177
(let ((attrs3 (if priority (cons (list 'priority priority) attrs2) attrs2)))
+178
;; Parse optional tags at end
+179
(let-values (((tags title) (parse-tags rest3)))
+180
(let ((attrs4 (if (null? tags) attrs3
+181
(cons (list 'tags (string-join tags " ")) attrs3))))
+182
(values (string-trim title) (reverse attrs4))))))))))
+183
+184
(define (parse-todo text)
+185
(cond
+186
((string-starts-with? text "TODO ")
+187
(values "TODO" (string-drop text 5)))
+188
((string-starts-with? text "DONE ")
+189
(values "DONE" (string-drop text 5)))
+190
((string-starts-with? text "WAIT ")
+191
(values "WAIT" (string-drop text 5)))
+192
((string-starts-with? text "NEXT ")
+193
(values "NEXT" (string-drop text 5)))
+194
(else (values #f text))))
+195
+196
(define (parse-priority text)
+197
(if (and (>= (string-length text) 4)
+198
(string-starts-with? text "[#")
+199
(char=? (string-ref text 3) #\])
+200
(char-alphabetic? (string-ref text 2)))
+201
(values (list->string (list (string-ref text 2)))
+202
(string-trim (string-drop text 4)))
+203
(values #f text)))
+204
+205
(define (parse-tags text)
+206
(let ((len (string-length text)))
+207
(if (and (> len 1)
+208
(char=? (string-ref text (- len 1)) #\:))
+209
;; Find the start of the tag section (last sequence of :tag1:tag2:)
+210
(let loop ((i (- len 2)))
+211
(cond
+212
((< i 0) (values '() text))
+213
((char=? (string-ref text i) #\:)
+214
;; Check if preceded by a space or at start
+215
(if (or (= i 0) (char=? (string-ref text (- i 1)) #\space))
+216
(let* ((tag-str (substring text (+ i 1) (- len 1)))
+217
(tags (string-split tag-str ":"))
+218
(title (string-trim (substring text 0 i))))
+219
(values tags title))
+220
(loop (- i 1))))
+221
((or (char-alphabetic? (string-ref text i))
+222
(char-numeric? (string-ref text i))
+223
(char=? (string-ref text i) #\_)
+224
(char=? (string-ref text i) #\@))
+225
(loop (- i 1)))
+226
(else (values '() text))))
+227
(values '() text))))
+228
+229
;; ============================================================
+230
;; Property Drawer Parsing
+231
;; ============================================================
+232
+233
(define (parse-property-line line)
+234
(let ((trimmed (string-trim line)))
+235
(if (and (> (string-length trimmed) 1)
+236
(char=? (string-ref trimmed 0) #\:))
+237
(let ((colon2 (string-index (string-drop trimmed 1)
+238
(lambda (c) (char=? c #\:)))))
+239
(if colon2
+240
(let ((key (substring trimmed 1 (+ colon2 1)))
+241
(value (string-trim (string-drop trimmed (+ colon2 3)))))
+242
(list 'property key value))
+243
#f))
+244
#f)))
+245
+246
;; ============================================================
+247
;; Block Parsing
+248
;; ============================================================
+249
+250
(define (parse-block-header line)
+251
(let* ((trimmed (string-trim line))
+252
(prefix-end (string-index trimmed (lambda (c) (char=? c #\_))))
+253
(after-underscore (string-drop trimmed (+ prefix-end 1)))
+254
(space-pos (string-index after-underscore
+255
(lambda (c) (char=? c #\space)))))
+256
(if space-pos
+257
(let ((block-type (string-downcase (substring after-underscore 0 space-pos)))
+258
(args (string-trim (string-drop after-underscore (+ space-pos 1)))))
+259
(values block-type args))
+260
(values (string-downcase after-underscore) ""))))
+261
+262
;; ============================================================
+263
;; Inline Markup Parsing
+264
;; ============================================================
+265
+266
;; Parse inline elements from text: *bold*, /italic/, ~code~,
+267
;; =verbatim=, _underline_, +strikethrough+, links, timestamps
+268
(define (parse-inline text)
+269
(let ((len (string-length text)))
+270
(let loop ((i 0) (start 0) (result '()))
+271
(if (>= i len)
+272
(reverse (if (> i start)
+273
(cons (substring text start i) result)
+274
result))
+275
(let ((c (string-ref text i)))
+276
(cond
+277
;; Link: [[target][desc]] or [[target]]
+278
((and (char=? c #\[)
+279
(< (+ i 1) len)
+280
(char=? (string-ref text (+ i 1)) #\[))
+281
(let ((before (if (> i start)
+282
(cons (substring text start i) result)
+283
result)))
+284
(let link-loop ((j (+ i 2)))
+285
(cond
+286
((>= j (- len 1))
+287
(loop (+ i 1) start result))
+288
((and (char=? (string-ref text j) #\])
+289
(char=? (string-ref text (+ j 1)) #\]))
+290
;; Found ]], parse link content
+291
(let* ((inner (substring text (+ i 2) j))
+292
(bracket-pos (string-index inner
+293
(lambda (c) (char=? c #\])))))
+294
(if (and bracket-pos
+295
(< (+ bracket-pos 1) (string-length inner))
+296
(char=? (string-ref inner (+ bracket-pos 1)) #\[))
+297
;; [[target][desc]]
+298
(let ((target (substring inner 0 bracket-pos))
+299
(desc (substring inner (+ bracket-pos 2)
+300
(string-length inner))))
+301
(loop (+ j 2) (+ j 2)
+302
(cons (list 'a (list '@ (list 'href target)) desc)
+303
before)))
+304
;; [[target]]
+305
(loop (+ j 2) (+ j 2)
+306
(cons (list 'a (list '@ (list 'href inner)) inner)
+307
before)))))
+308
(else (link-loop (+ j 1)))))))
+309
+310
;; Active timestamp: <2026-02-20 Fri>
+311
((and (char=? c #\<)
+312
(< (+ i 10) len)
+313
(char-numeric? (string-ref text (+ i 1))))
+314
(let ((before (if (> i start)
+315
(cons (substring text start i) result)
+316
result)))
+317
(let ts-loop ((j (+ i 1)))
+318
(cond
+319
((>= j len)
+320
(loop (+ i 1) start result))
+321
((char=? (string-ref text j) #\>)
+322
(loop (+ j 1) (+ j 1)
+323
(cons (list 'timestamp
+324
(list '@ (list 'type "active"))
+325
(substring text (+ i 1) j))
+326
before)))
+327
(else (ts-loop (+ j 1)))))))
+328
+329
;; Inactive timestamp: [2026-02-20 Fri]
+330
((and (char=? c #\[)
+331
(< (+ i 10) len)
+332
(char-numeric? (string-ref text (+ i 1))))
+333
(let ((before (if (> i start)
+334
(cons (substring text start i) result)
+335
result)))
+336
(let ts-loop ((j (+ i 1)))
+337
(cond
+338
((>= j len)
+339
(loop (+ i 1) start result))
+340
((char=? (string-ref text j) #\])
+341
(loop (+ j 1) (+ j 1)
+342
(cons (list 'timestamp
+343
(list '@ (list 'type "inactive"))
+344
(substring text (+ i 1) j))
+345
before)))
+346
(else (ts-loop (+ j 1)))))))
+347
+348
;; Markup: *bold* /italic/ ~code~ =verbatim= _underline_ +strike+
+349
((and (markup-char? c)
+350
(can-open-markup? text i len))
+351
(let ((before (if (> i start)
+352
(cons (substring text start i) result)
+353
result)))
+354
(let ((close-pos (find-closing-markup text (+ i 1) len c)))
+355
(if close-pos
+356
(let* ((inner-text (substring text (+ i 1) close-pos))
+357
(tag (markup-tag c))
+358
(content (if (or (eq? tag 'code) (eq? tag 'verbatim))
+359
(list inner-text)
+360
(parse-inline inner-text))))
+361
(loop (+ close-pos 1) (+ close-pos 1)
+362
(cons (cons tag content) before)))
+363
;; No closing marker, treat as literal
+364
(loop (+ i 1) start result)))))
+365
+366
;; Regular character
+367
(else
+368
(loop (+ i 1) start result))))))))
+369
+370
(define (markup-char? c)
+371
(or (char=? c #\*) (char=? c #\/) (char=? c #\~)
+372
(char=? c #\=) (char=? c #\_) (char=? c #\+)))
+373
+374
(define (markup-tag c)
+375
(cond
+376
((char=? c #\*) 'bold)
+377
((char=? c #\/) 'italic)
+378
((char=? c #\~) 'code)
+379
((char=? c #\=) 'verbatim)
+380
((char=? c #\_) 'underline)
+381
((char=? c #\+) 'strike)
+382
(else 'span)))
+383
+384
;; Org markup can only open when preceded by start-of-string, space, or open paren
+385
(define (can-open-markup? text i len)
+386
(and (< (+ i 1) len)
+387
(not (char=? (string-ref text (+ i 1)) #\space))
+388
(or (= i 0)
+389
(char=? (string-ref text (- i 1)) #\space)
+390
(char=? (string-ref text (- i 1)) #\()
+391
(char=? (string-ref text (- i 1)) #\newline))))
+392
+393
;; Find closing markup character (must be followed by end, space, or punct)
+394
(define (find-closing-markup text start len marker)
+395
(let loop ((j start))
+396
(cond
+397
((>= j len) #f)
+398
((char=? (string-ref text j) #\newline) #f)
+399
((and (char=? (string-ref text j) marker)
+400
(not (char=? (string-ref text (- j 1)) #\space))
+401
(or (= (+ j 1) len)
+402
(char=? (string-ref text (+ j 1)) #\space)
+403
(char=? (string-ref text (+ j 1)) #\.)
+404
(char=? (string-ref text (+ j 1)) #\,)
+405
(char=? (string-ref text (+ j 1)) #\;)
+406
(char=? (string-ref text (+ j 1)) #\:)
+407
(char=? (string-ref text (+ j 1)) #\!)
+408
(char=? (string-ref text (+ j 1)) #\?)
+409
(char=? (string-ref text (+ j 1)) #\))
+410
(char=? (string-ref text (+ j 1)) #\])
+411
(char=? (string-ref text (+ j 1)) #\newline)))
+412
j)
+413
(else (loop (+ j 1))))))
+414
+415
;; ============================================================
+416
;; List Parsing
+417
;; ============================================================
+418
+419
(define (parse-list-item-text line)
+420
(let ((trimmed (string-trim-start line)))
+421
(cond
+422
;; Unordered: - item or + item
+423
((and (>= (string-length trimmed) 2)
+424
(or (char=? (string-ref trimmed 0) #\-)
+425
(char=? (string-ref trimmed 0) #\+))
+426
(char=? (string-ref trimmed 1) #\space))
+427
(string-drop trimmed 2))
+428
;; Ordered: 1. item
+429
(else
+430
(let loop ((i 0))
+431
(cond
+432
((>= i (string-length trimmed)) trimmed)
+433
((char-numeric? (string-ref trimmed i)) (loop (+ i 1)))
+434
((and (char=? (string-ref trimmed i) #\.)
+435
(< (+ i 1) (string-length trimmed))
+436
(char=? (string-ref trimmed (+ i 1)) #\space))
+437
(string-drop trimmed (+ i 2)))
+438
(else trimmed)))))))
+439
+440
;; Parse checkbox from item text: [ ] or [x] or [X]
+441
(define (parse-checkbox text)
+442
(cond
+443
((string-starts-with? text "[ ] ")
+444
(values #f (string-drop text 4)))
+445
((or (string-starts-with? text "[x] ")
+446
(string-starts-with? text "[X] "))
+447
(values #t (string-drop text 4)))
+448
(else (values 'none text))))
+449
+450
;; ============================================================
+451
;; Block-Level Document Parser
+452
;; ============================================================
+453
+454
;;; Parse Org Mode text into an SXML document.
+455
;;;
+456
;;; Returns an `org-document` SXML element.
+457
;;;
+458
;;; ```scheme
+459
;;; (org->sxml "#+TITLE: Hello\n\n* Section 1\n\nSome text.")
+460
;;; ; => (org-document (@ (title "Hello"))
+461
;;; ; (headline (@ (level 1)) "Section 1"
+462
;;; ; (section (paragraph "Some text."))))
+463
;;; ```
+464
(define (org->sxml text)
+465
(let* ((lines (string->lines text))
+466
(result (parse-document lines)))
+467
result))
+468
+469
;;; Parse an Org Mode file into an SXML document.
+470
;;;
+471
;;; ```scheme
+472
;;; (org-file->sxml "notes.org")
+473
;;; ```
+474
(define (org-file->sxml filename)
+475
(let ((port (open-input-file filename)))
+476
(let loop ((chars '()))
+477
(let ((c (read-char port)))
+478
(if (eof-object? c)
+479
(begin
+480
(close-input-port port)
+481
(org->sxml (list->string (reverse chars))))
+482
(loop (cons c chars)))))))
+483
+484
;; Parse a full document: metadata, then top-level elements
+485
(define (parse-document lines)
+486
(let-values (((metadata rest) (parse-metadata lines)))
+487
(let ((body (parse-elements rest 0)))
+488
(if (null? metadata)
+489
(cons 'org-document body)
+490
(cons 'org-document
+491
(cons (cons '@ (map (lambda (pair)
+492
(list (car pair) (cdr pair)))
+493
metadata))
+494
body))))))
+495
+496
;; Parse metadata lines at the start
+497
(define (parse-metadata lines)
+498
(let loop ((lines lines) (metadata '()))
+499
(cond

Showing the first 500 of 726 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.

test/test-org.sgladded
@@ -0,0 +1,314 @@
+1
(import (sigil test)
+2
(sigil org)
+3
(sigil sxml)
+4
(sigil string))
+5
+6
;; ============================================================
+7
;; Metadata
+8
;; ============================================================
+9
+10
(test-group "metadata"
+11
(test "title metadata"
+12
(let ((doc (org->sxml "#+TITLE: My Document\n\nContent")))
+13
(assert-equal 'org-document (sxml-tag doc))
+14
(assert-equal "My Document" (sxml-attr-ref doc 'title))))
+15
+16
(test "multiple metadata"
+17
(let ((doc (org->sxml "#+TITLE: Test\n#+AUTHOR: Alice\n\nContent")))
+18
(assert-equal "Test" (sxml-attr-ref doc 'title))
+19
(assert-equal "Alice" (sxml-attr-ref doc 'author))))
+20
+21
(test "no metadata"
+22
(let ((doc (org->sxml "* Heading\n\nText")))
+23
(assert-equal 'org-document (sxml-tag doc))
+24
(assert-equal '() (sxml-attributes doc)))))
+25
+26
;; ============================================================
+27
;; Headlines
+28
;; ============================================================
+29
+30
(test-group "headlines"
+31
(test "simple headline"
+32
(let* ((doc (org->sxml "* Hello"))
+33
(hl (car (sxml-content doc))))
+34
(assert-equal 'headline (sxml-tag hl))
+35
(assert-equal 1 (sxml-attr-ref hl 'level))))
+36
+37
(test "nested headlines"
+38
(let* ((doc (org->sxml "* Top\n** Sub"))
+39
(top (car (sxml-content doc))))
+40
(assert-equal 'headline (sxml-tag top))
+41
(assert-equal 1 (sxml-attr-ref top 'level))
+42
;; Sub-headline should be nested
+43
(let ((children (filter (lambda (x)
+44
(and (pair? x) (eq? (car x) 'headline)))
+45
(cdr top))))
+46
(assert-true (> (length children) 0)))))
+47
+48
(test "headline with TODO"
+49
(let* ((doc (org->sxml "* TODO Fix the bug"))
+50
(hl (car (sxml-content doc))))
+51
(assert-equal "TODO" (sxml-attr-ref hl 'todo))))
+52
+53
(test "headline with tags"
+54
(let* ((doc (org->sxml "* My heading :tag1:tag2:"))
+55
(hl (car (sxml-content doc))))
+56
(assert-equal "tag1 tag2" (sxml-attr-ref hl 'tags))))
+57
+58
(test "headline with priority"
+59
(let* ((doc (org->sxml "* [#A] Important task"))
+60
(hl (car (sxml-content doc))))
+61
(assert-equal "A" (sxml-attr-ref hl 'priority)))))
+62
+63
;; ============================================================
+64
;; Property Drawers
+65
;; ============================================================
+66
+67
(test-group "property drawers"
+68
(test "property drawer"
+69
(let* ((doc (org->sxml "* Heading\n:PROPERTIES:\n:CUSTOM_ID: my-id\n:END:\n\nText"))
+70
(hl (car (sxml-content doc)))
+71
(drawer (let find ((elems (cdr hl)))
+72
(cond
+73
((null? elems) #f)
+74
((and (pair? (car elems))
+75
(eq? (caar elems) 'property-drawer))
+76
(car elems))
+77
(else (find (cdr elems)))))))
+78
(assert-true (pair? drawer)))))
+79
+80
;; ============================================================
+81
;; Source Blocks
+82
;; ============================================================
+83
+84
(test-group "source blocks"
+85
(test "source block with language"
+86
(let* ((doc (org->sxml "#+BEGIN_SRC scheme\n(define x 42)\n#+END_SRC"))
+87
(block (car (sxml-content doc))))
+88
(assert-equal 'src-block (sxml-tag block))
+89
(assert-equal "scheme" (sxml-attr-ref block 'language))))
+90
+91
(test "source block preserves content"
+92
(let* ((doc (org->sxml "#+BEGIN_SRC python\nprint(\"hello\")\nprint(\"world\")\n#+END_SRC"))
+93
(block (car (sxml-content doc)))
+94
(text (car (sxml-content block))))
+95
(assert-true (string-contains? text "hello"))
+96
(assert-true (string-contains? text "world")))))
+97
+98
;; ============================================================
+99
;; Other Blocks
+100
;; ============================================================
+101
+102
(test-group "other blocks"
+103
(test "quote block"
+104
(let* ((doc (org->sxml "#+BEGIN_QUOTE\nSome quote\n#+END_QUOTE"))
+105
(block (car (sxml-content doc))))
+106
(assert-equal 'quote-block (sxml-tag block))))
+107
+108
(test "example block"
+109
(let* ((doc (org->sxml "#+BEGIN_EXAMPLE\nSome example\n#+END_EXAMPLE"))
+110
(block (car (sxml-content doc))))
+111
(assert-equal 'example-block (sxml-tag block)))))
+112
+113
;; ============================================================
+114
;; Inline Markup
+115
;; ============================================================
+116
+117
(test-group "inline markup"
+118
(test "bold text"
+119
(let* ((doc (org->sxml "This is *bold* text"))
+120
(para (car (sxml-content doc)))
+121
(has-bold (let find ((elems (cdr para)))
+122
(cond
+123
((null? elems) #f)
+124
((and (pair? (car elems))
+125
(eq? (caar elems) 'bold))
+126
#t)
+127
(else (find (cdr elems)))))))
+128
(assert-true has-bold)))
+129
+130
(test "italic text"
+131
(let* ((doc (org->sxml "This is /italic/ text"))
+132
(para (car (sxml-content doc)))
+133
(has-italic (let find ((elems (cdr para)))
+134
(cond
+135
((null? elems) #f)
+136
((and (pair? (car elems))
+137
(eq? (caar elems) 'italic))
+138
#t)
+139
(else (find (cdr elems)))))))
+140
(assert-true has-italic)))
+141
+142
(test "code text"
+143
(let* ((doc (org->sxml "Use ~my-function~ here"))
+144
(para (car (sxml-content doc)))
+145
(has-code (let find ((elems (cdr para)))
+146
(cond
+147
((null? elems) #f)
+148
((and (pair? (car elems))
+149
(eq? (caar elems) 'code))
+150
#t)
+151
(else (find (cdr elems)))))))
+152
(assert-true has-code)))
+153
+154
(test "verbatim text"
+155
(let* ((doc (org->sxml "The value is =42= here"))
+156
(para (car (sxml-content doc)))
+157
(has-verb (let find ((elems (cdr para)))
+158
(cond
+159
((null? elems) #f)
+160
((and (pair? (car elems))
+161
(eq? (caar elems) 'verbatim))
+162
#t)
+163
(else (find (cdr elems)))))))
+164
(assert-true has-verb))))
+165
+166
;; ============================================================
+167
;; Links
+168
;; ============================================================
+169
+170
(test-group "links"
+171
(test "link with description"
+172
(let* ((doc (org->sxml "Visit [[https://example.com][Example]]"))
+173
(para (car (sxml-content doc)))
+174
(link (let find ((elems (cdr para)))
+175
(cond
+176
((null? elems) #f)
+177
((and (pair? (car elems))
+178
(eq? (caar elems) 'a))
+179
(car elems))
+180
(else (find (cdr elems)))))))
+181
(assert-true (pair? link))
+182
(assert-equal "https://example.com" (sxml-attr-ref link 'href))))
+183
+184
(test "link without description"
+185
(let* ((doc (org->sxml "See [[https://example.com]]"))
+186
(para (car (sxml-content doc)))
+187
(link (let find ((elems (cdr para)))
+188
(cond
+189
((null? elems) #f)
+190
((and (pair? (car elems))
+191
(eq? (caar elems) 'a))
+192
(car elems))
+193
(else (find (cdr elems)))))))
+194
(assert-true (pair? link)))))
+195
+196
;; ============================================================
+197
;; Lists
+198
;; ============================================================
+199
+200
(test-group "lists"
+201
(test "unordered list"
+202
(let* ((doc (org->sxml "- item one\n- item two\n- item three"))
+203
(list-elem (car (sxml-content doc))))
+204
(assert-equal 'unordered-list (sxml-tag list-elem))))
+205
+206
(test "ordered list"
+207
(let* ((doc (org->sxml "1. first\n2. second\n3. third"))
+208
(list-elem (car (sxml-content doc))))
+209
(assert-equal 'ordered-list (sxml-tag list-elem))))
+210
+211
(test "list with checkbox"
+212
(let* ((doc (org->sxml "- [ ] unchecked\n- [x] checked"))
+213
(list-elem (car (sxml-content doc)))
+214
(items (sxml-content list-elem)))
+215
(assert-equal 2 (length items)))))
+216
+217
;; ============================================================
+218
;; Paragraphs and Rules
+219
;; ============================================================
+220
+221
(test-group "paragraphs"
+222
(test "simple paragraph"
+223
(let* ((doc (org->sxml "Hello world"))
+224
(para (car (sxml-content doc))))
+225
(assert-equal 'paragraph (sxml-tag para))))
+226
+227
(test "multiple paragraphs"
+228
(let* ((doc (org->sxml "First paragraph.\n\nSecond paragraph."))
+229
(content (sxml-content doc)))
+230
(assert-equal 2 (length content)))))
+231
+232
(test-group "horizontal rules"
+233
(test "horizontal rule"
+234
(let* ((doc (org->sxml "Above\n\n-----\n\nBelow"))
+235
(content (sxml-content doc))
+236
(has-hr (let find ((elems content))
+237
(cond
+238
((null? elems) #f)
+239
((and (pair? (car elems))
+240
(eq? (caar elems) 'hr))
+241
#t)
+242
(else (find (cdr elems)))))))
+243
(assert-true has-hr))))
+244
+245
;; ============================================================
+246
;; Timestamps
+247
;; ============================================================
+248
+249
(test-group "timestamps"
+250
(test "active timestamp"
+251
(let* ((doc (org->sxml "Scheduled for <2026-02-20 Fri>"))
+252
(para (car (sxml-content doc)))
+253
(has-ts (let find ((elems (cdr para)))
+254
(cond
+255
((null? elems) #f)
+256
((and (pair? (car elems))
+257
(eq? (caar elems) 'timestamp))
+258
#t)
+259
(else (find (cdr elems)))))))
+260
(assert-true has-ts)))
+261
+262
(test "inactive timestamp"
+263
(let* ((doc (org->sxml "Created [2026-02-20 Fri]"))
+264
(para (car (sxml-content doc)))
+265
(ts (let find ((elems (cdr para)))
+266
(cond
+267
((null? elems) #f)
+268
((and (pair? (car elems))
+269
(eq? (caar elems) 'timestamp))
+270
(car elems))
+271
(else (find (cdr elems)))))))
+272
(assert-true (pair? ts))
+273
(assert-equal "inactive" (sxml-attr-ref ts 'type)))))
+274
+275
;; ============================================================
+276
;; Comments
+277
;; ============================================================
+278
+279
(test-group "comments"
+280
(test "comment lines are skipped"
+281
(let* ((doc (org->sxml "# This is a comment\nVisible text"))
+282
(content (sxml-content doc)))
+283
;; Should only have the paragraph, not the comment
+284
(assert-equal 1 (length content))
+285
(assert-equal 'paragraph (sxml-tag (car content))))))
+286
+287
;; ============================================================
+288
;; Integration
+289
;; ============================================================
+290
+291
(test-group "integration"
+292
(test "full document"
+293
(let ((doc (org->sxml
+294
(string-append
+295
"#+TITLE: My Document\n"
+296
"#+AUTHOR: Test User\n"
+297
"\n"
+298
"* Introduction\n"
+299
"\n"
+300
"This is a *bold* introduction with a [[https://example.com][link]].\n"
+301
"\n"
+302
"** Details\n"
+303
"\n"
+304
"- Item one\n"
+305
"- Item two\n"
+306
"\n"
+307
"#+BEGIN_SRC scheme\n"
+308
"(define x 42)\n"
+309
"#+END_SRC\n"))))
+310
(assert-equal 'org-document (sxml-tag doc))
+311
(assert-equal "My Document" (sxml-attr-ref doc 'title))
+312
(assert-equal "Test User" (sxml-attr-ref doc 'author)))))
+313
+314
(run-tests)