inkmark 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57a396edb9b57902c126f56331f56132cae3c00a13208b8ae850d645a19be87d
4
- data.tar.gz: ff500020da2be1f902c497d56961328b264a49eb0a32027e42364b348fe2db83
3
+ metadata.gz: 4a3feea6c75cbbc48daa868ce5aa140f1929df7b6b109ee83e6ed67c4b290351
4
+ data.tar.gz: ddddb3918c21fe25cf33f2b58370475c9e5f4a1a0b2963b32de904d9d096cf1f
5
5
  SHA512:
6
- metadata.gz: 4b71c748877beac92c040e23e597c1290dc9b66843ff4f2afa1aa389acf8d4e94093c84c059ef77d1410f1bcb95c49fdcf3ce18b8373ac388e3ee3388e90379f
7
- data.tar.gz: 1ded4e94b1baa611c54597c371557ec1b438a61a2d7187118db67600c40492b4dc28d88d2878f24ce0eb4ca08774ad50e2a87c62ecda8222c726ed5f203ecbd7
6
+ metadata.gz: 0a02d6c1b1f9067787cf880fbf044b59bc05220bc075d99332af7e0cac416f2bdcf2545b935d7d309db45411d67f01ab4f0614ed52f0ddc1f28bb261ddff892c
7
+ data.tar.gz: 242ecf84608b2de0fdd37e883506d3fbd0ec7cb1dc5184bbb707e1648a99e0ca4d3834ee48d74b21d69d90cf5c3922fb1c36d48754b521c858c20e00c3af9e17
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [0.3.1] - 2026-10-24
2
+
3
+ - Re-release of 0.3.0, which was yanked.
4
+ - Breaking: Markdown output (`to_markdown`, `chunks_by_heading`, `chunks_by_size`, `truncate_markdown`) uses pulldown-cmark-to-cmark 23; escaping and formatting differ from 0.2.
5
+ - Fix raw HTML leaking into Markdown output with `raw_html: false` when a line held more than one tag.
6
+ - Fix `chunks_by_size` and `truncate_markdown` (`at: :block`) joining blocks without a blank line, which merged adjacent paragraphs.
7
+ - Fix Markdown output crashing on an image nested in another image's alt text with `images: { lazy: true }`, `allowed_hosts`, or `allowed_schemes`.
8
+ - `chunks_by_heading` starts sections only at top-level headings; a heading inside a block quote or list item no longer splits it.
9
+ - Raise `Inkmark::Error` on Markdown serialization failures instead of crashing the process.
10
+
11
+ ## [0.3.0] - 2026-10-24 [YANKED]
12
+
13
+ - Yanked: a RubyGems issue interrupted publishing, so only one platform gem was released. Its changes shipped in 0.3.1.
14
+
15
+ ## [0.2.2] - 2026-10-24
16
+
17
+ - Updated dependencies, including pulldown-cmark 0.13.5 (upstream security fixes) and magnus 0.9.3.
18
+
1
19
  ## [0.2.1] - 2026-09-25
2
20
 
3
21
  - Updated dependencies, including magnus 0.9.
data/Cargo.lock CHANGED
@@ -109,9 +109,9 @@ dependencies = [
109
109
 
110
110
  [[package]]
111
111
  name = "crc32fast"
112
- version = "1.5.2"
112
+ version = "1.5.3"
113
113
  source = "registry+https://github.com/rust-lang/crates.io-index"
114
- checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
114
+ checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
115
115
  dependencies = [
116
116
  "cfg-if",
117
117
  ]
@@ -130,29 +130,29 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
130
130
  dependencies = [
131
131
  "proc-macro2",
132
132
  "quote",
133
- "syn 3.0.6",
133
+ "syn 3.0.9",
134
134
  ]
135
135
 
136
136
  [[package]]
137
137
  name = "either"
138
- version = "1.18.0"
138
+ version = "1.19.0"
139
139
  source = "registry+https://github.com/rust-lang/crates.io-index"
140
- checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
140
+ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
141
141
 
142
142
  [[package]]
143
143
  name = "emojis"
144
- version = "0.9.0"
144
+ version = "0.9.1"
145
145
  source = "registry+https://github.com/rust-lang/crates.io-index"
146
- checksum = "0b1514ced566c94991ed9563258ecf61e311fd773bf0a3f20606830386bf66e3"
146
+ checksum = "589871e639330783bc20ad89d32ac1b049e02c273d96c907ca27478c8bc674a2"
147
147
  dependencies = [
148
148
  "phf",
149
149
  ]
150
150
 
151
151
  [[package]]
152
152
  name = "equivalent"
153
- version = "1.0.2"
153
+ version = "1.0.3"
154
154
  source = "registry+https://github.com/rust-lang/crates.io-index"
155
- checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
155
+ checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
156
156
 
157
157
  [[package]]
158
158
  name = "fancy-regex"
@@ -229,9 +229,9 @@ dependencies = [
229
229
 
230
230
  [[package]]
231
231
  name = "icu_collections"
232
- version = "2.3.0"
232
+ version = "2.4.0"
233
233
  source = "registry+https://github.com/rust-lang/crates.io-index"
234
- checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
234
+ checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
235
235
  dependencies = [
236
236
  "displaydoc",
237
237
  "potential_utf",
@@ -243,9 +243,9 @@ dependencies = [
243
243
 
244
244
  [[package]]
245
245
  name = "icu_locale_core"
246
- version = "2.3.0"
246
+ version = "2.4.0"
247
247
  source = "registry+https://github.com/rust-lang/crates.io-index"
248
- checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
248
+ checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
249
249
  dependencies = [
250
250
  "displaydoc",
251
251
  "litemap",
@@ -256,9 +256,9 @@ dependencies = [
256
256
 
257
257
  [[package]]
258
258
  name = "icu_normalizer"
259
- version = "2.3.0"
259
+ version = "2.4.0"
260
260
  source = "registry+https://github.com/rust-lang/crates.io-index"
261
- checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
261
+ checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
262
262
  dependencies = [
263
263
  "icu_collections",
264
264
  "icu_normalizer_data",
@@ -270,15 +270,15 @@ dependencies = [
270
270
 
271
271
  [[package]]
272
272
  name = "icu_normalizer_data"
273
- version = "2.3.0"
273
+ version = "2.4.0"
274
274
  source = "registry+https://github.com/rust-lang/crates.io-index"
275
- checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
275
+ checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
276
276
 
277
277
  [[package]]
278
278
  name = "icu_properties"
279
- version = "2.3.0"
279
+ version = "2.4.0"
280
280
  source = "registry+https://github.com/rust-lang/crates.io-index"
281
- checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
281
+ checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
282
282
  dependencies = [
283
283
  "displaydoc",
284
284
  "icu_collections",
@@ -291,15 +291,15 @@ dependencies = [
291
291
 
292
292
  [[package]]
293
293
  name = "icu_properties_data"
294
- version = "2.3.0"
294
+ version = "2.4.0"
295
295
  source = "registry+https://github.com/rust-lang/crates.io-index"
296
- checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
296
+ checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
297
297
 
298
298
  [[package]]
299
299
  name = "icu_provider"
300
- version = "2.3.1"
300
+ version = "2.4.0"
301
301
  source = "registry+https://github.com/rust-lang/crates.io-index"
302
- checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
302
+ checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
303
303
  dependencies = [
304
304
  "displaydoc",
305
305
  "icu_locale_core",
@@ -333,7 +333,7 @@ dependencies = [
333
333
 
334
334
  [[package]]
335
335
  name = "inkmark"
336
- version = "0.2.1"
336
+ version = "0.3.1"
337
337
  dependencies = [
338
338
  "deunicode",
339
339
  "emojis",
@@ -363,15 +363,15 @@ dependencies = [
363
363
 
364
364
  [[package]]
365
365
  name = "lazy_static"
366
- version = "1.5.0"
366
+ version = "1.5.1"
367
367
  source = "registry+https://github.com/rust-lang/crates.io-index"
368
- checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
368
+ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
369
369
 
370
370
  [[package]]
371
371
  name = "libc"
372
- version = "0.2.189"
372
+ version = "0.2.191"
373
373
  source = "registry+https://github.com/rust-lang/crates.io-index"
374
- checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
374
+ checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
375
375
 
376
376
  [[package]]
377
377
  name = "libloading"
@@ -406,9 +406,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
406
406
 
407
407
  [[package]]
408
408
  name = "magnus"
409
- version = "0.9.0"
409
+ version = "0.9.3"
410
410
  source = "registry+https://github.com/rust-lang/crates.io-index"
411
- checksum = "379d50b6eea4ca84074ccd3e551eb9b07f7865194d5b3198325317e9ef78e5e9"
411
+ checksum = "40ef424404d166cbb71c8b8d19c9c589c748c4547c1afa1928c5504299f6c713"
412
412
  dependencies = [
413
413
  "magnus-macros",
414
414
  "rb-sys",
@@ -418,9 +418,9 @@ dependencies = [
418
418
 
419
419
  [[package]]
420
420
  name = "magnus-macros"
421
- version = "0.9.0"
421
+ version = "0.9.1"
422
422
  source = "registry+https://github.com/rust-lang/crates.io-index"
423
- checksum = "4ca70566af98184bb92d4d9853daaf533c1a3418b9570d0547ea44970035f3fe"
423
+ checksum = "9dafde2eeb66f090e0f326112323eb8324b12cb32b5d1210e541d3df6b0ce77e"
424
424
  dependencies = [
425
425
  "proc-macro2",
426
426
  "quote",
@@ -509,9 +509,9 @@ dependencies = [
509
509
 
510
510
  [[package]]
511
511
  name = "pulldown-cmark"
512
- version = "0.13.4"
512
+ version = "0.13.5"
513
513
  source = "registry+https://github.com/rust-lang/crates.io-index"
514
- checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
514
+ checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb"
515
515
  dependencies = [
516
516
  "bitflags",
517
517
  "memchr",
@@ -527,11 +527,12 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
527
527
 
528
528
  [[package]]
529
529
  name = "pulldown-cmark-to-cmark"
530
- version = "22.0.1"
530
+ version = "23.0.0"
531
531
  source = "registry+https://github.com/rust-lang/crates.io-index"
532
- checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60"
532
+ checksum = "e9e14cd4ec725b2227ba76d4772169af814438bcf48f6d9b806d1308f40a7413"
533
533
  dependencies = [
534
534
  "pulldown-cmark",
535
+ "unicase",
535
536
  ]
536
537
 
537
538
  [[package]]
@@ -670,7 +671,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
670
671
  dependencies = [
671
672
  "proc-macro2",
672
673
  "quote",
673
- "syn 3.0.6",
674
+ "syn 3.0.9",
674
675
  ]
675
676
 
676
677
  [[package]]
@@ -699,9 +700,9 @@ checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
699
700
 
700
701
  [[package]]
701
702
  name = "smallvec"
702
- version = "1.16.2"
703
+ version = "1.16.4"
703
704
  source = "registry+https://github.com/rust-lang/crates.io-index"
704
- checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
705
+ checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
705
706
 
706
707
  [[package]]
707
708
  name = "stable_deref_trait"
@@ -722,9 +723,9 @@ dependencies = [
722
723
 
723
724
  [[package]]
724
725
  name = "syn"
725
- version = "3.0.6"
726
+ version = "3.0.9"
726
727
  source = "registry+https://github.com/rust-lang/crates.io-index"
727
- checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
728
+ checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
728
729
  dependencies = [
729
730
  "proc-macro2",
730
731
  "quote",
@@ -739,7 +740,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
739
740
  dependencies = [
740
741
  "proc-macro2",
741
742
  "quote",
742
- "syn 3.0.6",
743
+ "syn 3.0.9",
743
744
  ]
744
745
 
745
746
  [[package]]
@@ -777,14 +778,14 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
777
778
  dependencies = [
778
779
  "proc-macro2",
779
780
  "quote",
780
- "syn 3.0.6",
781
+ "syn 3.0.9",
781
782
  ]
782
783
 
783
784
  [[package]]
784
785
  name = "tinystr"
785
- version = "0.8.4"
786
+ version = "0.8.5"
786
787
  source = "registry+https://github.com/rust-lang/crates.io-index"
787
- checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
788
+ checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
788
789
  dependencies = [
789
790
  "displaydoc",
790
791
  "zerovec",
@@ -792,15 +793,15 @@ dependencies = [
792
793
 
793
794
  [[package]]
794
795
  name = "unicase"
795
- version = "2.9.0"
796
+ version = "2.10.0"
796
797
  source = "registry+https://github.com/rust-lang/crates.io-index"
797
- checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
798
+ checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
798
799
 
799
800
  [[package]]
800
801
  name = "unicode-ident"
801
- version = "1.0.26"
802
+ version = "1.0.27"
802
803
  source = "registry+https://github.com/rust-lang/crates.io-index"
803
- checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
804
+ checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
804
805
 
805
806
  [[package]]
806
807
  name = "unicode-segmentation"
@@ -877,9 +878,9 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
877
878
 
878
879
  [[package]]
879
880
  name = "yoke"
880
- version = "0.8.3"
881
+ version = "0.8.4"
881
882
  source = "registry+https://github.com/rust-lang/crates.io-index"
882
- checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
883
+ checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
883
884
  dependencies = [
884
885
  "stable_deref_trait",
885
886
  "yoke-derive",
@@ -888,13 +889,13 @@ dependencies = [
888
889
 
889
890
  [[package]]
890
891
  name = "yoke-derive"
891
- version = "0.8.3"
892
+ version = "0.8.4"
892
893
  source = "registry+https://github.com/rust-lang/crates.io-index"
893
- checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
894
+ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
894
895
  dependencies = [
895
896
  "proc-macro2",
896
897
  "quote",
897
- "syn 3.0.6",
898
+ "syn 3.0.9",
898
899
  "synstructure",
899
900
  ]
900
901
 
@@ -915,7 +916,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
915
916
  dependencies = [
916
917
  "proc-macro2",
917
918
  "quote",
918
- "syn 3.0.6",
919
+ "syn 3.0.9",
919
920
  "synstructure",
920
921
  ]
921
922
 
@@ -932,9 +933,9 @@ dependencies = [
932
933
 
933
934
  [[package]]
934
935
  name = "zerovec"
935
- version = "0.11.8"
936
+ version = "0.11.9"
936
937
  source = "registry+https://github.com/rust-lang/crates.io-index"
937
- checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
938
+ checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
938
939
  dependencies = [
939
940
  "yoke",
940
941
  "zerofrom",
@@ -943,13 +944,13 @@ dependencies = [
943
944
 
944
945
  [[package]]
945
946
  name = "zerovec-derive"
946
- version = "0.11.6"
947
+ version = "0.11.7"
947
948
  source = "registry+https://github.com/rust-lang/crates.io-index"
948
- checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
949
+ checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
949
950
  dependencies = [
950
951
  "proc-macro2",
951
952
  "quote",
952
- "syn 3.0.6",
953
+ "syn 3.0.9",
953
954
  ]
954
955
 
955
956
  [[package]]
data/README.md CHANGED
@@ -206,6 +206,9 @@ Inkmark.to_html("<script>alert(1)</script>")
206
206
  # => "<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>\n"
207
207
  ```
208
208
 
209
+ Markdown output escapes it too; see
210
+ [Raw HTML in the source](#raw-html-in-the-source).
211
+
209
212
  Enable pass-through with `raw_html: true`; _only do this for trusted
210
213
  input_:
211
214
 
@@ -403,6 +406,12 @@ nested `###` subsections, which also appear as their own entries. Content
403
406
  before the first heading (if any) becomes a preamble entry with
404
407
  `heading: nil` and `level: 0`.
405
408
 
409
+ Only **top-level** headings start a section. A heading inside a block
410
+ quote or list item (such as a `## Tip` in a `> [!NOTE]` callout) stays in
411
+ the content of the section around it, so the quote or list is never split
412
+ across sections. It still counts toward slug deduplication, so every
413
+ section's `:id` matches the anchor `headings: { ids: true }` generates.
414
+
406
415
  `:breadcrumb` carries the ancestor heading texts from root to immediate
407
416
  parent. Root-level sections and the preamble have an empty array. Skipped
408
417
  levels are omitted, so an `###` directly under an `#` has `breadcrumb:
@@ -740,6 +749,17 @@ Inkmark.to_markdown(source, options: {
740
749
  })
741
750
  ```
742
751
 
752
+ ### Raw HTML in the source
753
+
754
+ With `raw_html: false` (the default), raw HTML from the source comes out of
755
+ `to_markdown`, `chunks_by_heading`, `chunks_by_size`, and `truncate_markdown`
756
+ escaped, so the Markdown stays inert even if a later stage renders it with raw
757
+ HTML allowed. HTML produced by the HTML-emitting filters above is the
758
+ exception: it is embedded as described.
759
+
760
+ If the filtered document cannot be written back as Markdown, these methods
761
+ raise `Inkmark::Error`.
762
+
743
763
  ## Event handlers
744
764
 
745
765
  Register handlers with `#on` to inspect or transform document elements as they
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "inkmark"
3
- version = "0.2.1"
3
+ version = "0.3.1"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -22,7 +22,7 @@ syntect = { version = "5.2", default-features = false, features = ["parsing", "h
22
22
  linkify = "0.11"
23
23
  globset = "0.4"
24
24
  url = "2"
25
- pulldown-cmark-to-cmark = "22"
25
+ pulldown-cmark-to-cmark = "23"
26
26
 
27
27
  [build-dependencies]
28
28
  rb-sys-env = "0.2.2"
@@ -14,6 +14,12 @@
14
14
  //! emoji/autolink rewrites happen *inside* a heading's text events,
15
15
  //! but the bracketing tags stay in place—so post-filter heading-
16
16
  //! position scanning is coherent.
17
+ //!
18
+ //! Only top-level headings start a section. A heading inside a block
19
+ //! quote, list item, or other container stays in the content of the
20
+ //! section around it, so a container is never split across sections.
21
+ //! Nested headings still consume slugs, keeping section ids aligned
22
+ //! with the ids `heading_ids` / `toc` emit.
17
23
 
18
24
  use magnus::{Error, RArray, RHash, Ruby};
19
25
  use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
@@ -21,6 +27,7 @@ use unicode_segmentation::UnicodeSegmentation;
21
27
 
22
28
  use crate::document::{apply_filters, content_events};
23
29
  use crate::heading::{self, SlugDeduplicator};
30
+ use crate::markdown;
24
31
  use crate::options::build_options;
25
32
  use crate::toc;
26
33
  use crate::truncate::{self, TruncateParams};
@@ -57,8 +64,13 @@ pub fn native_chunks_by_heading(
57
64
  let content_opts = ContentOptions {
58
65
  with_counts: flags.statistics,
59
66
  truncate_params: truncate_params.as_ref(),
67
+ md_opts: markdown::serializer_options(cm_opts, &flags),
60
68
  };
61
- let preamble_end = boundaries.first().map(|b| b.start).unwrap_or(events.len());
69
+ let preamble_end = boundaries
70
+ .iter()
71
+ .find(|b| b.top_level)
72
+ .map(|b| b.start)
73
+ .unwrap_or(events.len());
62
74
  if preamble_end > 0 {
63
75
  let preamble_events = &events[0..preamble_end];
64
76
  if !is_empty_content(preamble_events) {
@@ -78,23 +90,29 @@ pub fn native_chunks_by_heading(
78
90
  let mut dedup = SlugDeduplicator::new();
79
91
  let mut ancestors: Vec<(u8, String)> = Vec::new();
80
92
  for (i, boundary) in boundaries.iter().enumerate() {
93
+ let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
94
+ let id = heading_id(&mut dedup, &heading_text);
95
+ if !boundary.top_level {
96
+ // Stays in the surrounding section's content; only its slug
97
+ // counts, so later ids match `heading_ids`.
98
+ continue;
99
+ }
81
100
  let section_end = find_section_end(&boundaries, i, events.len());
82
101
  let level = toc::level_to_u8(boundary.level);
83
102
  while ancestors.last().is_some_and(|(l, _)| *l >= level) {
84
103
  ancestors.pop();
85
104
  }
86
105
  let breadcrumb: Vec<&str> = ancestors.iter().map(|(_, t)| t.as_str()).collect();
87
- let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
88
106
  // Content = events after End(Heading) up to the next section or
89
- // end of document. Re-serialized through cmark_write.
107
+ // end of document. Re-serialized through `markdown::render`.
90
108
  let content_events = &events[(boundary.end + 1)..section_end];
91
109
  let hash = build_section_hash(
92
110
  ruby,
93
111
  boundary.level,
94
112
  &heading_text,
113
+ &id,
95
114
  &breadcrumb,
96
115
  content_events,
97
- &mut dedup,
98
116
  &content_opts,
99
117
  )?;
100
118
  result.push(hash)?;
@@ -110,6 +128,9 @@ struct HeadingBoundary {
110
128
  start: usize,
111
129
  end: usize,
112
130
  level: HeadingLevel,
131
+ /// Not inside a block quote, list item, or other container. Only
132
+ /// top-level headings start sections.
133
+ top_level: bool,
113
134
  }
114
135
 
115
136
  /// How each entry's `content` is rendered: optionally truncated, and
@@ -117,14 +138,20 @@ struct HeadingBoundary {
117
138
  struct ContentOptions<'a> {
118
139
  with_counts: bool,
119
140
  truncate_params: Option<&'a TruncateParams>,
141
+ /// Serializer options, from `markdown::serializer_options`.
142
+ md_opts: pulldown_cmark::Options,
120
143
  }
121
144
 
122
145
  fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
123
146
  let mut boundaries = Vec::new();
147
+ // Containers open around the cursor. Headings are leaf blocks, so
148
+ // none open at a Start(Heading) means it is not inside anything.
149
+ let mut open_containers = 0usize;
124
150
  let mut i = 0;
125
151
  while i < events.len() {
126
152
  if let Event::Start(Tag::Heading { level, .. }) = &events[i] {
127
153
  let lvl = *level;
154
+ let top_level = open_containers == 0;
128
155
  // CommonMark disallows headings inside headings, but carry a
129
156
  // depth counter so we stay correct if pulldown-cmark ever
130
157
  // permits them.
@@ -147,23 +174,29 @@ fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
147
174
  start: i,
148
175
  end: j,
149
176
  level: lvl,
177
+ top_level,
150
178
  });
151
179
  i = j + 1;
152
180
  } else {
181
+ match &events[i] {
182
+ Event::Start(_) => open_containers += 1,
183
+ Event::End(_) => open_containers = open_containers.saturating_sub(1),
184
+ _ => {}
185
+ }
153
186
  i += 1;
154
187
  }
155
188
  }
156
189
  boundaries
157
190
  }
158
191
 
159
- /// Section i ends at the first subsequent heading with level <=
160
- /// current. Headings with a strictly greater level are subsections:
192
+ /// Section i ends at the first subsequent top-level heading with level
193
+ /// <= current. Headings with a strictly greater level are subsections:
161
194
  /// they belong to the current section's content too.
162
195
  fn find_section_end(boundaries: &[HeadingBoundary], i: usize, events_len: usize) -> usize {
163
196
  let current = toc::level_to_u8(boundaries[i].level);
164
197
  boundaries[(i + 1)..]
165
198
  .iter()
166
- .find(|b| toc::level_to_u8(b.level) <= current)
199
+ .find(|b| b.top_level && toc::level_to_u8(b.level) <= current)
167
200
  .map(|b| b.start)
168
201
  .unwrap_or(events_len)
169
202
  }
@@ -181,10 +214,7 @@ fn build_preamble_hash(
181
214
  // with proper sections so callers can treat every entry alike.
182
215
  hash.aset(ruby.to_symbol("breadcrumb"), ruby.ary_new_capa(0))?;
183
216
 
184
- let content = match content_opts.truncate_params {
185
- Some(params) => truncate::truncate_events(events, params),
186
- None => render_markdown(events),
187
- };
217
+ let content = render_content(ruby, events, content_opts)?;
188
218
  if content_opts.with_counts {
189
219
  let (chars, words) = count_post_truncate(events, content_opts.truncate_params, &content);
190
220
  hash.aset(ruby.to_symbol("character_count"), chars)?;
@@ -198,21 +228,11 @@ fn build_section_hash(
198
228
  ruby: &Ruby,
199
229
  level: HeadingLevel,
200
230
  heading_text: &str,
231
+ id: &str,
201
232
  breadcrumb: &[&str],
202
233
  content_events: &[Event<'_>],
203
- dedup: &mut SlugDeduplicator,
204
234
  content_opts: &ContentOptions<'_>,
205
235
  ) -> Result<RHash, Error> {
206
- // Slug is the deduplicated slugify of the (filter-applied) heading
207
- // text, matching the ids `heading_ids` / `toc` would emit for the
208
- // same document.
209
- let base = heading::slugify(heading_text);
210
- let id = if base.is_empty() {
211
- String::new()
212
- } else {
213
- dedup.deduplicate(base)
214
- };
215
-
216
236
  let hash = ruby.hash_new();
217
237
  hash.aset(ruby.to_symbol("heading"), heading_text)?;
218
238
  hash.aset(ruby.to_symbol("level"), toc::level_to_u8(level))?;
@@ -227,10 +247,7 @@ fn build_section_hash(
227
247
  }
228
248
  hash.aset(ruby.to_symbol("breadcrumb"), breadcrumb_arr)?;
229
249
 
230
- let content = match content_opts.truncate_params {
231
- Some(params) => truncate::truncate_events(content_events, params),
232
- None => render_markdown(content_events),
233
- };
250
+ let content = render_content(ruby, content_events, content_opts)?;
234
251
  if content_opts.with_counts {
235
252
  let (chars, words) =
236
253
  count_post_truncate(content_events, content_opts.truncate_params, &content);
@@ -289,11 +306,24 @@ fn count_text(events: &[Event<'_>]) -> (usize, usize) {
289
306
  (chars, words)
290
307
  }
291
308
 
292
- fn render_markdown(events: &[Event<'_>]) -> String {
293
- let mut buf = String::new();
294
- pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf)
295
- .expect("markdown serialization failed");
296
- buf
309
+ /// Deduplicated slug of the (filter-applied) heading text, matching the
310
+ /// ids `heading_ids` / `toc` would emit for the same document. Empty when
311
+ /// the text has nothing to slugify.
312
+ fn heading_id(dedup: &mut SlugDeduplicator, heading_text: &str) -> String {
313
+ dedup.deduplicate(heading::slugify(heading_text))
314
+ }
315
+
316
+ /// Serialize a section's events to Markdown, truncated when requested.
317
+ fn render_content(
318
+ ruby: &Ruby,
319
+ events: &[Event<'_>],
320
+ content_opts: &ContentOptions<'_>,
321
+ ) -> Result<String, Error> {
322
+ match content_opts.truncate_params {
323
+ Some(params) => truncate::truncate_events(events, params, content_opts.md_opts),
324
+ None => markdown::render(events, content_opts.md_opts),
325
+ }
326
+ .map_err(|e| markdown::to_ruby_error(ruby, e))
297
327
  }
298
328
 
299
329
  fn collect_inline_text(events: &[Event<'_>]) -> String {
@@ -324,3 +354,33 @@ fn is_empty_content(events: &[Event<'_>]) -> bool {
324
354
  )
325
355
  })
326
356
  }
357
+
358
+ #[cfg(test)]
359
+ mod tests {
360
+ use super::*;
361
+ use pulldown_cmark::Options;
362
+
363
+ fn events(source: &str) -> Vec<Event<'_>> {
364
+ Parser::new_ext(source, Options::empty()).collect()
365
+ }
366
+
367
+ #[test]
368
+ fn headings_inside_containers_are_not_top_level() {
369
+ let ev = events("# Top\n\n> ## Quoted\n\n- # Listed\n\n## Sub\n");
370
+ let top_level: Vec<bool> = find_heading_boundaries(&ev)
371
+ .iter()
372
+ .map(|b| b.top_level)
373
+ .collect();
374
+ assert_eq!(top_level, vec![true, false, false, true]);
375
+ }
376
+
377
+ #[test]
378
+ fn section_spanning_a_nested_heading_stays_balanced() {
379
+ let ev = events("# Top\n\n> ## Inner\n> text\n\nafter\n");
380
+ let boundaries = find_heading_boundaries(&ev);
381
+ let end = find_section_end(&boundaries, 0, ev.len());
382
+ let content = &ev[(boundaries[0].end + 1)..end];
383
+ let md = markdown::render(content, Options::empty()).expect("balanced slice");
384
+ assert!(md.contains("Inner") && md.contains("after"), "{md:?}");
385
+ }
386
+ }