typsphinx 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,1583 @@
1
+ """
2
+ Typst translator for docutils nodes.
3
+
4
+ This module implements the TypstTranslator class, which translates docutils
5
+ nodes to Typst markup.
6
+ """
7
+
8
+ import re
9
+ from typing import Any, Optional
10
+
11
+ from docutils import nodes
12
+ from sphinx import addnodes
13
+ from sphinx.util import logging
14
+ from sphinx.util.docutils import SphinxTranslator
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class TypstTranslator(SphinxTranslator):
20
+ """
21
+ Translator class that converts docutils nodes to Typst markup.
22
+
23
+ This translator visits nodes in the document tree and generates
24
+ corresponding Typst markup.
25
+ """
26
+
27
+ def __init__(self, document: nodes.document, builder: Any) -> None:
28
+ """
29
+ Initialize the translator.
30
+
31
+ Args:
32
+ document: The docutils document to translate
33
+ builder: The Sphinx builder instance
34
+ """
35
+ super().__init__(document, builder)
36
+ self.builder = builder
37
+ self.body = []
38
+
39
+ # State management variables
40
+ self.section_level = 0
41
+ self.in_figure = False
42
+ self.in_table = False
43
+ self.in_caption = False
44
+ self.list_stack = [] # Track list nesting: 'bullet' or 'enumerated'
45
+
46
+ # Figure-specific state
47
+ self.figure_content = []
48
+ self.figure_caption = ""
49
+
50
+ # Code block container state (Issue #20)
51
+ self.in_captioned_code_block = False
52
+ self.code_block_caption = ""
53
+ self.code_block_label = ""
54
+
55
+ def astext(self) -> str:
56
+ """
57
+ Return the translated text as a string.
58
+
59
+ Returns:
60
+ The translated Typst markup
61
+ """
62
+ return "".join(self.body)
63
+
64
+ def add_text(self, text: str) -> None:
65
+ """
66
+ Add text to the output body or table cell content.
67
+
68
+ Args:
69
+ text: The text to add
70
+ """
71
+ if (
72
+ hasattr(self, "in_table")
73
+ and self.in_table
74
+ and hasattr(self, "table_cell_content")
75
+ ):
76
+ self.table_cell_content.append(text)
77
+ else:
78
+ self.body.append(text)
79
+
80
+ def visit_document(self, node: nodes.document) -> None:
81
+ """
82
+ Visit a document node.
83
+
84
+ Args:
85
+ node: The document node
86
+ """
87
+ # Document root doesn't need special markup
88
+ pass
89
+
90
+ def depart_document(self, node: nodes.document) -> None:
91
+ """
92
+ Depart a document node.
93
+
94
+ Args:
95
+ node: The document node
96
+ """
97
+ # Document root doesn't need closing
98
+ pass
99
+
100
+ def visit_section(self, node: nodes.section) -> None:
101
+ """
102
+ Visit a section node.
103
+
104
+ Args:
105
+ node: The section node
106
+ """
107
+ # Increment section level
108
+ self.section_level += 1
109
+
110
+ def depart_section(self, node: nodes.section) -> None:
111
+ """
112
+ Depart a section node.
113
+
114
+ Args:
115
+ node: The section node
116
+ """
117
+ # Decrement section level
118
+ self.section_level -= 1
119
+ # Add a newline after sections
120
+ self.add_text("\n")
121
+
122
+ def visit_title(self, node: nodes.title) -> None:
123
+ """
124
+ Visit a title node.
125
+
126
+ Args:
127
+ node: The title node
128
+ """
129
+ # Typst heading syntax: = Title, == Title, === Title, etc.
130
+ # Use section_level to determine heading level
131
+ heading_prefix = "=" * self.section_level
132
+ self.add_text(f"{heading_prefix} ")
133
+
134
+ def depart_title(self, node: nodes.title) -> None:
135
+ """
136
+ Depart a title node.
137
+
138
+ Args:
139
+ node: The title node
140
+ """
141
+ self.add_text("\n\n")
142
+
143
+ def visit_subtitle(self, node: nodes.subtitle) -> None:
144
+ """
145
+ Visit a subtitle node.
146
+
147
+ Args:
148
+ node: The subtitle node
149
+ """
150
+ # Typst subtitle syntax: use emphasized text for subtitle
151
+ self.add_text("_")
152
+
153
+ def depart_subtitle(self, node: nodes.subtitle) -> None:
154
+ """
155
+ Depart a subtitle node.
156
+
157
+ Args:
158
+ node: The subtitle node
159
+ """
160
+ self.add_text("_\n\n")
161
+
162
+ def visit_compound(self, node: nodes.compound) -> None:
163
+ """
164
+ Visit a compound node.
165
+
166
+ Compound nodes are containers that group related content.
167
+ They are often used to wrap toctree directives.
168
+
169
+ Args:
170
+ node: The compound node
171
+ """
172
+ # Compound nodes are just containers, process their children
173
+ pass
174
+
175
+ def depart_compound(self, node: nodes.compound) -> None:
176
+ """
177
+ Depart a compound node.
178
+
179
+ Args:
180
+ node: The compound node
181
+ """
182
+ pass
183
+
184
+ def visit_container(self, node: nodes.container) -> None:
185
+ """
186
+ Visit a container node.
187
+
188
+ Handle Sphinx-generated containers, particularly literal-block-wrapper
189
+ for captioned code blocks (Issue #20).
190
+
191
+ Args:
192
+ node: The container node
193
+ """
194
+ # Check if this is a literal-block-wrapper (captioned code block)
195
+ if "literal-block-wrapper" in node.get("classes", []):
196
+ self.in_captioned_code_block = True
197
+ # Caption and literal_block children will be processed separately
198
+ # We need to extract caption text first
199
+ for child in node.children:
200
+ if isinstance(child, nodes.caption):
201
+ self.code_block_caption = child.astext()
202
+ elif isinstance(child, nodes.literal_block):
203
+ # Extract label from :name: option
204
+ if child.get("names"):
205
+ self.code_block_label = child.get("names")[0]
206
+ # Other container types: just process children
207
+ pass
208
+
209
+ def depart_container(self, node: nodes.container) -> None:
210
+ """
211
+ Depart a container node.
212
+
213
+ Args:
214
+ node: The container node
215
+ """
216
+ # Reset state after literal-block-wrapper
217
+ if "literal-block-wrapper" in node.get("classes", []):
218
+ self.in_captioned_code_block = False
219
+ self.code_block_caption = ""
220
+ self.code_block_label = ""
221
+
222
+ def visit_paragraph(self, node: nodes.paragraph) -> None:
223
+ """
224
+ Visit a paragraph node.
225
+
226
+ Args:
227
+ node: The paragraph node
228
+ """
229
+ # Paragraphs don't need special markup in Typst
230
+ pass
231
+
232
+ def depart_paragraph(self, node: nodes.paragraph) -> None:
233
+ """
234
+ Depart a paragraph node.
235
+
236
+ Args:
237
+ node: The paragraph node
238
+ """
239
+ # Add double newline after paragraphs
240
+ self.add_text("\n\n")
241
+
242
+ def visit_comment(self, node: nodes.comment) -> None:
243
+ """
244
+ Visit a comment node.
245
+
246
+ Comments are skipped entirely in Typst output as they are meant
247
+ for source-level documentation only.
248
+
249
+ Args:
250
+ node: The comment node
251
+
252
+ Raises:
253
+ nodes.SkipNode: Always raised to skip the comment
254
+ """
255
+ raise nodes.SkipNode
256
+
257
+ def depart_comment(self, node: nodes.comment) -> None:
258
+ """
259
+ Depart a comment node.
260
+
261
+ Args:
262
+ node: The comment node
263
+
264
+ Note:
265
+ This method is not called when SkipNode is raised in visit_comment.
266
+ """
267
+ pass
268
+
269
+ def visit_raw(self, node: nodes.raw) -> None:
270
+ """
271
+ Visit a raw node.
272
+
273
+ Pass through content if format is 'typst', otherwise skip.
274
+
275
+ Args:
276
+ node: The raw node
277
+
278
+ Raises:
279
+ nodes.SkipNode: When format is not 'typst'
280
+ """
281
+ format_name = node.get("format", "").lower()
282
+
283
+ if format_name == "typst":
284
+ # Output the raw Typst content directly
285
+ content = node.astext()
286
+ if content: # Only add non-empty content
287
+ self.add_text(content)
288
+ self.add_text("\n\n")
289
+ raise nodes.SkipNode
290
+ else:
291
+ # Skip content for other formats
292
+ logger.debug(f"Skipping raw node with format: {format_name}")
293
+ raise nodes.SkipNode
294
+
295
+ def depart_raw(self, node: nodes.raw) -> None:
296
+ """
297
+ Depart a raw node.
298
+
299
+ Args:
300
+ node: The raw node
301
+
302
+ Note:
303
+ This method is not called when SkipNode is raised in visit_raw.
304
+ """
305
+ pass
306
+
307
+ def visit_Text(self, node: nodes.Text) -> None:
308
+ """
309
+ Visit a text node.
310
+
311
+ Args:
312
+ node: The text node
313
+ """
314
+ # Add the text content
315
+ self.add_text(node.astext())
316
+
317
+ def depart_Text(self, node: nodes.Text) -> None:
318
+ """
319
+ Depart a text node.
320
+
321
+ Args:
322
+ node: The text node
323
+ """
324
+ # Text nodes don't need closing
325
+ pass
326
+
327
+ def visit_emphasis(self, node: nodes.emphasis) -> None:
328
+ """
329
+ Visit an emphasis (italic) node.
330
+
331
+ Args:
332
+ node: The emphasis node
333
+ """
334
+ # Typst italic syntax: _text_
335
+ self.add_text("_")
336
+
337
+ def depart_emphasis(self, node: nodes.emphasis) -> None:
338
+ """
339
+ Depart an emphasis (italic) node.
340
+
341
+ Args:
342
+ node: The emphasis node
343
+ """
344
+ self.add_text("_")
345
+
346
+ def visit_strong(self, node: nodes.strong) -> None:
347
+ """
348
+ Visit a strong (bold) node.
349
+
350
+ Args:
351
+ node: The strong node
352
+ """
353
+ # Typst bold syntax: *text*
354
+ self.add_text("*")
355
+
356
+ def depart_strong(self, node: nodes.strong) -> None:
357
+ """
358
+ Depart a strong (bold) node.
359
+
360
+ Args:
361
+ node: The strong node
362
+ """
363
+ self.add_text("*")
364
+
365
+ def visit_literal(self, node: nodes.literal) -> None:
366
+ """
367
+ Visit a literal (inline code) node.
368
+
369
+ Args:
370
+ node: The literal node
371
+ """
372
+ # Typst inline code syntax: `code`
373
+ self.add_text("`")
374
+
375
+ def depart_literal(self, node: nodes.literal) -> None:
376
+ """
377
+ Depart a literal (inline code) node.
378
+
379
+ Args:
380
+ node: The literal node
381
+ """
382
+ self.add_text("`")
383
+
384
+ def visit_subscript(self, node: nodes.subscript) -> None:
385
+ """
386
+ Visit a subscript node.
387
+
388
+ Args:
389
+ node: The subscript node
390
+ """
391
+ # Typst subscript syntax: #sub[text]
392
+ self.add_text("#sub[")
393
+
394
+ def depart_subscript(self, node: nodes.subscript) -> None:
395
+ """
396
+ Depart a subscript node.
397
+
398
+ Args:
399
+ node: The subscript node
400
+ """
401
+ self.add_text("]")
402
+
403
+ def visit_superscript(self, node: nodes.superscript) -> None:
404
+ """
405
+ Visit a superscript node.
406
+
407
+ Args:
408
+ node: The superscript node
409
+ """
410
+ # Typst superscript syntax: #super[text]
411
+ self.add_text("#super[")
412
+
413
+ def depart_superscript(self, node: nodes.superscript) -> None:
414
+ """
415
+ Depart a superscript node.
416
+
417
+ Args:
418
+ node: The superscript node
419
+ """
420
+ self.add_text("]")
421
+
422
+ def visit_bullet_list(self, node: nodes.bullet_list) -> None:
423
+ """
424
+ Visit a bullet list node.
425
+
426
+ Args:
427
+ node: The bullet list node
428
+ """
429
+ self.list_stack.append("bullet")
430
+
431
+ def depart_bullet_list(self, node: nodes.bullet_list) -> None:
432
+ """
433
+ Depart a bullet list node.
434
+
435
+ Args:
436
+ node: The bullet list node
437
+ """
438
+ self.list_stack.pop()
439
+ self.add_text("\n")
440
+
441
+ def visit_enumerated_list(self, node: nodes.enumerated_list) -> None:
442
+ """
443
+ Visit an enumerated (numbered) list node.
444
+
445
+ Args:
446
+ node: The enumerated list node
447
+ """
448
+ self.list_stack.append("enumerated")
449
+
450
+ def depart_enumerated_list(self, node: nodes.enumerated_list) -> None:
451
+ """
452
+ Depart an enumerated (numbered) list node.
453
+
454
+ Args:
455
+ node: The enumerated list node
456
+ """
457
+ self.list_stack.pop()
458
+ self.add_text("\n")
459
+
460
+ def visit_list_item(self, node: nodes.list_item) -> None:
461
+ """
462
+ Visit a list item node.
463
+
464
+ Args:
465
+ node: The list item node
466
+ """
467
+ # Calculate indentation based on nesting level
468
+ indent = " " * (len(self.list_stack) - 1)
469
+
470
+ # Determine list marker based on list type
471
+ if self.list_stack and self.list_stack[-1] == "bullet":
472
+ self.add_text(f"{indent}- ")
473
+ elif self.list_stack and self.list_stack[-1] == "enumerated":
474
+ self.add_text(f"{indent}+ ")
475
+
476
+ def depart_list_item(self, node: nodes.list_item) -> None:
477
+ """
478
+ Depart a list item node.
479
+
480
+ Args:
481
+ node: The list item node
482
+ """
483
+ self.add_text("\n")
484
+
485
+ def visit_literal_block(self, node: nodes.literal_block) -> None:
486
+ """
487
+ Visit a literal block (code block) node.
488
+
489
+ Implements Task 4.2.2: codly forced usage with #codly-range() for highlighted lines
490
+ Design 3.5: All code blocks use codly, with #codly-range() for highlights
491
+ Requirements 7.3, 7.4: Support line numbers and highlighted lines
492
+ Issue #20: Support :linenos:, :caption:, and :name: options
493
+ Issue #31: Support :lineno-start: and :dedent: options
494
+
495
+ Args:
496
+ node: The literal block node
497
+ """
498
+ # Issue #20: Handle captioned code blocks
499
+ # If we're in a captioned code block (literal-block-wrapper container),
500
+ # wrap the code block in a #figure()
501
+ if self.in_captioned_code_block and self.code_block_caption:
502
+ # Escape special characters in caption
503
+ escaped_caption = self.code_block_caption
504
+ # Start figure with caption (will add closing bracket in depart)
505
+ self.add_text(f"#figure(caption: [{escaped_caption}])[\n")
506
+
507
+ # Check for :linenos: option (Issue #20)
508
+ # If linenos is not set or False, disable line numbers in codly
509
+ linenos = node.get("linenos", False)
510
+ if not linenos:
511
+ self.add_text("#codly(number-format: none)\n")
512
+
513
+ # Extract highlight_args if present (Task 4.2.2)
514
+ highlight_args = node.get("highlight_args", {})
515
+ hl_lines = highlight_args.get("hl_lines", [])
516
+
517
+ # Issue #31: Support :lineno-start: option
518
+ # Sphinx stores lineno-start in highlight_args['linenostart']
519
+ lineno_start = highlight_args.get("linenostart")
520
+ if linenos and lineno_start is not None:
521
+ self.add_text(f"#codly(start: {lineno_start})\n")
522
+
523
+ # Generate #codly-range() if highlight lines are specified
524
+ if hl_lines:
525
+ # Convert list of line numbers to Typst array format
526
+ # Example: [2, 3] -> #codly-range(highlight: (2, 3))
527
+ # Example: [2, 4, 5, 6] -> #codly-range(highlight: (2, 4, 5, 6))
528
+ highlight_str = ", ".join(str(line) for line in hl_lines)
529
+ self.add_text(f"#codly-range(highlight: ({highlight_str}))\n")
530
+
531
+ # Typst code block syntax: ```language\ncode\n```
532
+ # Extract language if specified
533
+ language = node.get("language", "")
534
+ if language:
535
+ self.add_text(f"```{language}\n")
536
+ else:
537
+ self.add_text("```\n")
538
+
539
+ def depart_literal_block(self, node: nodes.literal_block) -> None:
540
+ """
541
+ Depart a literal block (code block) node.
542
+
543
+ Issue #20: Handle closing figure bracket and labels.
544
+
545
+ Args:
546
+ node: The literal block node
547
+ """
548
+ # Close code block
549
+ self.add_text("\n```\n")
550
+
551
+ # Issue #20: Close figure wrapper if we're in a captioned code block
552
+ if self.in_captioned_code_block and self.code_block_caption:
553
+ # Close the figure's trailing content block with ]
554
+ self.add_text("]")
555
+ # Add label if present
556
+ if self.code_block_label:
557
+ self.add_text(f" <{self.code_block_label}>")
558
+ self.add_text("\n\n")
559
+ elif node.get("names"):
560
+ # Handle :name: option without :caption: - just add label after code block
561
+ label = node.get("names")[0]
562
+ self.add_text(f" <{label}>\n\n")
563
+ else:
564
+ # Normal code block - just add spacing
565
+ self.add_text("\n")
566
+
567
+ def visit_definition_list(self, node: nodes.definition_list) -> None:
568
+ """
569
+ Visit a definition list node.
570
+
571
+ Args:
572
+ node: The definition list node
573
+ """
574
+ # Definition lists don't need special opening markup in Typst
575
+ pass
576
+
577
+ def depart_definition_list(self, node: nodes.definition_list) -> None:
578
+ """
579
+ Depart a definition list node.
580
+
581
+ Args:
582
+ node: The definition list node
583
+ """
584
+ # Add newline after definition list
585
+ self.add_text("\n")
586
+
587
+ def visit_definition_list_item(self, node: nodes.definition_list_item) -> None:
588
+ """
589
+ Visit a definition list item node.
590
+
591
+ Args:
592
+ node: The definition list item node
593
+ """
594
+ # Definition list items don't need special markup
595
+ pass
596
+
597
+ def depart_definition_list_item(self, node: nodes.definition_list_item) -> None:
598
+ """
599
+ Depart a definition list item node.
600
+
601
+ Args:
602
+ node: The definition list item node
603
+ """
604
+ # Definition list items don't need closing
605
+ pass
606
+
607
+ def visit_term(self, node: nodes.term) -> None:
608
+ """
609
+ Visit a term (definition list term) node.
610
+
611
+ Args:
612
+ node: The term node
613
+ """
614
+ # Typst definition list syntax: / term: definition
615
+ self.add_text("/ ")
616
+
617
+ def depart_term(self, node: nodes.term) -> None:
618
+ """
619
+ Depart a term (definition list term) node.
620
+
621
+ Args:
622
+ node: The term node
623
+ """
624
+ # Add colon after term
625
+ self.add_text(": ")
626
+
627
+ def visit_definition(self, node: nodes.definition) -> None:
628
+ """
629
+ Visit a definition (definition list definition) node.
630
+
631
+ Args:
632
+ node: The definition node
633
+ """
634
+ # Definitions don't need special opening markup
635
+ pass
636
+
637
+ def depart_definition(self, node: nodes.definition) -> None:
638
+ """
639
+ Depart a definition (definition list definition) node.
640
+
641
+ Args:
642
+ node: The definition node
643
+ """
644
+ # Add newline after definition
645
+ self.add_text("\n")
646
+
647
+ def visit_figure(self, node: nodes.figure) -> None:
648
+ """
649
+ Visit a figure node.
650
+
651
+ Args:
652
+ node: The figure node
653
+ """
654
+ self.in_figure = True
655
+ self.figure_content = [] # Store figure content (image)
656
+ self.figure_caption = "" # Store caption text
657
+
658
+ # Start figure with potential label
659
+ self.add_text("#figure(\n")
660
+
661
+ def depart_figure(self, node: nodes.figure) -> None:
662
+ """
663
+ Depart a figure node.
664
+
665
+ Args:
666
+ node: The figure node
667
+ """
668
+ # Close the figure
669
+ if self.figure_caption:
670
+ self.add_text(f",\n caption: [{self.figure_caption}]")
671
+
672
+ # Add label if figure has ids
673
+ if node.get("ids"):
674
+ label = node["ids"][0]
675
+ self.add_text(f"\n) <{label}>\n\n")
676
+ else:
677
+ self.add_text("\n)\n\n")
678
+
679
+ self.in_figure = False
680
+ self.figure_content = []
681
+ self.figure_caption = ""
682
+
683
+ def visit_caption(self, node: nodes.caption) -> None:
684
+ """
685
+ Visit a caption node.
686
+
687
+ Handles captions for both figures and code blocks (Issue #20).
688
+
689
+ Args:
690
+ node: The caption node
691
+ """
692
+ # For captioned code blocks, caption is already extracted in visit_container
693
+ # We should skip output to avoid duplicate caption text
694
+ if self.in_captioned_code_block:
695
+ raise nodes.SkipNode
696
+ # For figures, start collecting caption text
697
+ self.in_caption = True
698
+
699
+ def depart_caption(self, node: nodes.caption) -> None:
700
+ """
701
+ Depart a caption node.
702
+
703
+ Args:
704
+ node: The caption node
705
+ """
706
+ # Store caption text for figures
707
+ if self.in_figure:
708
+ self.figure_caption = node.astext()
709
+ self.in_caption = False
710
+
711
+ def visit_table(self, node: nodes.table) -> None:
712
+ """
713
+ Visit a table node.
714
+
715
+ Args:
716
+ node: The table node
717
+ """
718
+ self.in_table = True
719
+ self.table_cells = [] # Store cells for table generation
720
+ self.table_colcount = 0 # Track number of columns
721
+
722
+ def depart_table(self, node: nodes.table) -> None:
723
+ """
724
+ Depart a table node.
725
+
726
+ Args:
727
+ node: The table node
728
+ """
729
+ # Generate Typst #table() syntax
730
+ if self.table_colcount > 0:
731
+ # Use self.body.append directly to avoid routing to table_cell_content
732
+ self.body.append(f"#table(\n columns: {self.table_colcount},\n")
733
+
734
+ # Add all cells
735
+ for cell in self.table_cells:
736
+ self.body.append(f" [{cell}],\n")
737
+
738
+ self.body.append(")\n\n")
739
+
740
+ self.in_table = False
741
+ self.table_cells = []
742
+ self.table_colcount = 0
743
+
744
+ def visit_tgroup(self, node: nodes.tgroup) -> None:
745
+ """
746
+ Visit a tgroup (table group) node.
747
+
748
+ Args:
749
+ node: The tgroup node
750
+ """
751
+ # Get column count from tgroup
752
+ self.table_colcount = node.get("cols", 0)
753
+
754
+ def depart_tgroup(self, node: nodes.tgroup) -> None:
755
+ """
756
+ Depart a tgroup (table group) node.
757
+
758
+ Args:
759
+ node: The tgroup node
760
+ """
761
+ pass
762
+
763
+ def visit_colspec(self, node: nodes.colspec) -> None:
764
+ """
765
+ Visit a colspec (column specification) node.
766
+
767
+ Args:
768
+ node: The colspec node
769
+ """
770
+ # Column specifications are handled by tgroup
771
+ raise nodes.SkipNode
772
+
773
+ def depart_colspec(self, node: nodes.colspec) -> None:
774
+ """
775
+ Depart a colspec (column specification) node.
776
+
777
+ Args:
778
+ node: The colspec node
779
+ """
780
+ pass
781
+
782
+ def visit_thead(self, node: nodes.thead) -> None:
783
+ """
784
+ Visit a thead (table header) node.
785
+
786
+ Args:
787
+ node: The thead node
788
+ """
789
+ # Header rows are handled the same as body rows in Typst
790
+ pass
791
+
792
+ def depart_thead(self, node: nodes.thead) -> None:
793
+ """
794
+ Depart a thead (table header) node.
795
+
796
+ Args:
797
+ node: The thead node
798
+ """
799
+ pass
800
+
801
+ def visit_tbody(self, node: nodes.tbody) -> None:
802
+ """
803
+ Visit a tbody (table body) node.
804
+
805
+ Args:
806
+ node: The tbody node
807
+ """
808
+ pass
809
+
810
+ def depart_tbody(self, node: nodes.tbody) -> None:
811
+ """
812
+ Depart a tbody (table body) node.
813
+
814
+ Args:
815
+ node: The tbody node
816
+ """
817
+ pass
818
+
819
+ def visit_row(self, node: nodes.row) -> None:
820
+ """
821
+ Visit a row (table row) node.
822
+
823
+ Args:
824
+ node: The row node
825
+ """
826
+ # Rows are processed by collecting entries
827
+ pass
828
+
829
+ def depart_row(self, node: nodes.row) -> None:
830
+ """
831
+ Depart a row (table row) node.
832
+
833
+ Args:
834
+ node: The row node
835
+ """
836
+ pass
837
+
838
+ def visit_entry(self, node: nodes.entry) -> None:
839
+ """
840
+ Visit an entry (table cell) node.
841
+
842
+ Args:
843
+ node: The entry node
844
+ """
845
+ # Start collecting cell content
846
+ self.table_cell_content = []
847
+
848
+ def depart_entry(self, node: nodes.entry) -> None:
849
+ """
850
+ Depart an entry (table cell) node.
851
+
852
+ Args:
853
+ node: The entry node
854
+ """
855
+ # Get cell content and add to table cells
856
+ # Extract text from the accumulated body content since visit_entry
857
+ cell_text = ""
858
+ if hasattr(self, "table_cell_content") and self.table_cell_content:
859
+ cell_text = "".join(self.table_cell_content).strip()
860
+
861
+ if not cell_text:
862
+ # If no content was captured, try to get text from the node
863
+ cell_text = node.astext().strip()
864
+
865
+ self.table_cells.append(cell_text)
866
+ self.table_cell_content = []
867
+
868
+ def visit_block_quote(self, node: nodes.block_quote) -> None:
869
+ """
870
+ Visit a block quote node.
871
+
872
+ Args:
873
+ node: The block quote node
874
+ """
875
+ # Typst block quote syntax: #quote[...]
876
+ # Check if there's an attribution child node
877
+ has_attribution = any(isinstance(child, nodes.attribution) for child in node)
878
+
879
+ if has_attribution:
880
+ # Will add attribution parameter when we encounter the attribution node
881
+ self.add_text("#quote(")
882
+ else:
883
+ self.add_text("#quote[")
884
+
885
+ def depart_block_quote(self, node: nodes.block_quote) -> None:
886
+ """
887
+ Depart a block quote node.
888
+
889
+ Args:
890
+ node: The block quote node
891
+ """
892
+ # Check if there's an attribution child node
893
+ has_attribution = any(isinstance(child, nodes.attribution) for child in node)
894
+
895
+ if has_attribution:
896
+ self.add_text(")\n\n")
897
+ else:
898
+ self.add_text("]\n\n")
899
+
900
+ def visit_attribution(self, node: nodes.attribution) -> None:
901
+ """
902
+ Visit an attribution node (quote attribution).
903
+
904
+ Args:
905
+ node: The attribution node
906
+ """
907
+ # Close the quote content and add attribution parameter
908
+ self.add_text("], attribution: [")
909
+
910
+ def depart_attribution(self, node: nodes.attribution) -> None:
911
+ """
912
+ Depart an attribution node.
913
+
914
+ Args:
915
+ node: The attribution node
916
+ """
917
+ # Close attribution parameter
918
+ self.add_text("]")
919
+
920
+ def visit_image(self, node: nodes.image) -> None:
921
+ """
922
+ Visit an image node.
923
+
924
+ Args:
925
+ node: The image node
926
+ """
927
+ # Typst image syntax: #image("path", width: value)
928
+ uri = node.get("uri", "")
929
+
930
+ # If inside a figure, don't add # prefix (figure will handle it)
931
+ if self.in_figure:
932
+ self.add_text(f' image("{uri}"')
933
+ else:
934
+ self.add_text(f'#image("{uri}"')
935
+
936
+ # Add optional attributes
937
+ if "width" in node:
938
+ width = node["width"]
939
+ self.add_text(f", width: {width}")
940
+
941
+ if "height" in node:
942
+ height = node["height"]
943
+ self.add_text(f", height: {height}")
944
+
945
+ self.add_text(")")
946
+
947
+ def depart_image(self, node: nodes.image) -> None:
948
+ """
949
+ Depart an image node.
950
+
951
+ Args:
952
+ node: The image node
953
+ """
954
+ # If inside a figure, don't add extra newlines (figure will handle spacing)
955
+ if not self.in_figure:
956
+ self.add_text("\n\n")
957
+
958
+ def visit_target(self, node: nodes.target) -> None:
959
+ """
960
+ Visit a target node (label definition).
961
+
962
+ Args:
963
+ node: The target node
964
+ """
965
+ # Generate Typst label if target has ids
966
+ if node.get("ids"):
967
+ label = node["ids"][0]
968
+ self.add_text(f"<{label}> ")
969
+ # Skip processing children as target is typically empty
970
+ raise nodes.SkipNode
971
+
972
+ def depart_target(self, node: nodes.target) -> None:
973
+ """
974
+ Depart a target node.
975
+
976
+ Args:
977
+ node: The target node
978
+ """
979
+ # Target is handled in visit
980
+ pass
981
+
982
+ def visit_pending_xref(self, node: nodes.Node) -> None:
983
+ """
984
+ Visit a pending_xref node (Sphinx cross-reference).
985
+
986
+ Args:
987
+ node: The pending_xref node
988
+ """
989
+ # pending_xref nodes are typically resolved by Sphinx before reaching the writer
990
+ # If we encounter one, it means resolution failed or we're in a special case
991
+ # We handle it by generating a link to the target
992
+
993
+ reftarget = node.get("reftarget", "")
994
+ reftype = node.get("reftype", "")
995
+
996
+ if reftarget:
997
+ # Generate a link to the target
998
+ # Sanitize the target for Typst label format
999
+ label = reftarget.replace(".", "-").replace("_", "-")
1000
+ self.add_text(f"#link(<{label}>)[")
1001
+ # Continue processing children to get the link text
1002
+
1003
+ def depart_pending_xref(self, node: nodes.Node) -> None:
1004
+ """
1005
+ Depart a pending_xref node.
1006
+
1007
+ Args:
1008
+ node: The pending_xref node
1009
+ """
1010
+ reftarget = node.get("reftarget", "")
1011
+ if reftarget:
1012
+ self.add_text("]")
1013
+
1014
+ def _compute_relative_include_path(
1015
+ self, target_docname: str, current_docname: Optional[str]
1016
+ ) -> str:
1017
+ """
1018
+ Compute relative path for toctree #include() directive.
1019
+
1020
+ This method calculates the relative path from the current document
1021
+ to the target document for use in Typst #include() directives.
1022
+ Uses PurePosixPath for OS-independent POSIX path handling.
1023
+
1024
+ Args:
1025
+ target_docname: Target document name (e.g., "chapter1/section1")
1026
+ current_docname: Current document name (e.g., "chapter1/index"), or None
1027
+
1028
+ Returns:
1029
+ Relative path string for #include() (e.g., "section1" or "../chapter2/doc")
1030
+
1031
+ Examples:
1032
+ >>> _compute_relative_include_path("chapter1/section1", "chapter1/index")
1033
+ "section1"
1034
+ >>> _compute_relative_include_path("chapter2/doc", "chapter1/index")
1035
+ "../chapter2/doc"
1036
+ >>> _compute_relative_include_path("chapter1/doc", None)
1037
+ "chapter1/doc"
1038
+
1039
+ Notes:
1040
+ This method implements Issue #5 fix for nested toctree relative paths.
1041
+ It handles three cases:
1042
+ 1. current_docname is None: return absolute path
1043
+ 2. Same directory: use relative_to() directly
1044
+ 3. Cross-directory: calculate via common parent
1045
+
1046
+ Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
1047
+ """
1048
+ from pathlib import PurePosixPath
1049
+
1050
+ logger.debug(
1051
+ f"Computing relative include path: target={target_docname}, "
1052
+ f"current={current_docname}"
1053
+ )
1054
+
1055
+ # Fallback to absolute path if current_docname is None
1056
+ if not current_docname:
1057
+ logger.debug(f"No current document, using absolute path: {target_docname}")
1058
+ return target_docname
1059
+
1060
+ current_path = PurePosixPath(current_docname)
1061
+ target_path = PurePosixPath(target_docname)
1062
+ current_dir = current_path.parent
1063
+
1064
+ logger.debug(
1065
+ f"Path components: current_dir={current_dir}, " f"target_path={target_path}"
1066
+ )
1067
+
1068
+ # Root directory case: use absolute path (backward compatibility)
1069
+ if current_dir == PurePosixPath("."):
1070
+ logger.debug(
1071
+ f"Current document is in root directory, "
1072
+ f"using absolute path: {target_docname}"
1073
+ )
1074
+ return target_docname
1075
+
1076
+ # Try to compute relative path
1077
+ try:
1078
+ rel_path = target_path.relative_to(current_dir)
1079
+ result = str(rel_path)
1080
+ logger.debug(
1081
+ f"Same directory reference: {current_dir} -> {target_path}, "
1082
+ f"result: {result}"
1083
+ )
1084
+ return result
1085
+ except ValueError:
1086
+ # Different directory trees - build path via common parent
1087
+ logger.debug(
1088
+ "Cross-directory reference detected, calculating via common parent"
1089
+ )
1090
+
1091
+ current_parts = current_dir.parts
1092
+ target_parts = target_path.parts
1093
+
1094
+ # Find common parent by comparing path components
1095
+ common_length = 0
1096
+ for i, (c, t) in enumerate(zip(current_parts, target_parts)):
1097
+ if c == t:
1098
+ common_length = i + 1
1099
+ else:
1100
+ break
1101
+
1102
+ logger.debug(
1103
+ f"Common parent depth: {common_length}, "
1104
+ f"current_parts={current_parts}, target_parts={target_parts}"
1105
+ )
1106
+
1107
+ # Build path: "../" from current to common parent
1108
+ up_count = len(current_parts) - common_length
1109
+ up_path = "../" * up_count if up_count > 0 else ""
1110
+
1111
+ # Build path: from common parent to target
1112
+ down_parts = target_parts[common_length:]
1113
+ down_path = "/".join(down_parts) if down_parts else ""
1114
+
1115
+ relative_path: str = up_path + down_path
1116
+
1117
+ logger.debug(
1118
+ f"Cross-directory path calculation: up_count={up_count}, "
1119
+ f"up_path='{up_path}', down_path='{down_path}', "
1120
+ f"result: {relative_path}"
1121
+ )
1122
+
1123
+ return relative_path
1124
+
1125
+ def visit_toctree(self, node: nodes.Node) -> None:
1126
+ """
1127
+ Visit a toctree node (Sphinx table of contents tree).
1128
+
1129
+ Requirement 13: Multi-document integration and toctree processing
1130
+ - Generate #include() for each entry
1131
+ - Apply #set heading(offset: 1) to lower heading levels
1132
+ - Issue #5: Fix relative paths for nested toctrees
1133
+ - Calculate relative paths from current document
1134
+ - Issue #7: Simplify toctree output with single content block
1135
+ - Generate single #[...] block containing all includes
1136
+ - Apply #set heading(offset: 1) once per toctree
1137
+
1138
+ Args:
1139
+ node: The toctree node
1140
+
1141
+ Notes:
1142
+ This method generates Typst #include() directives for each toctree entry
1143
+ within a single content block #[...] to apply heading offset without
1144
+ displaying the block delimiters in the output. This simplifies the
1145
+ generated Typst code and improves readability.
1146
+ """
1147
+ # Get entries from the toctree node
1148
+ entries = node.get("entries", [])
1149
+
1150
+ logger.debug(f"Processing toctree with {len(entries)} entries")
1151
+
1152
+ # If no entries, don't generate anything
1153
+ if not entries:
1154
+ logger.debug("Toctree has no entries, skipping")
1155
+ raise nodes.SkipNode
1156
+
1157
+ # Get current document name for relative path calculation
1158
+ current_docname = getattr(self.builder, "current_docname", None)
1159
+
1160
+ logger.debug(
1161
+ f"Current document for toctree: {current_docname}, "
1162
+ f"entries: {[docname for _, docname in entries]}"
1163
+ )
1164
+
1165
+ # Issue #7: Generate single content block for all includes
1166
+ # Start single content block
1167
+ self.add_text("#[\n")
1168
+ self.add_text(" #set heading(offset: 1)\n")
1169
+
1170
+ # Generate #include() for each entry within the single block
1171
+ # Each included file has its own imports, so block scope is safe
1172
+ for _title, docname in entries:
1173
+ # Compute relative path for #include() (Issue #5 fix)
1174
+ relative_path = self._compute_relative_include_path(
1175
+ docname, current_docname
1176
+ )
1177
+
1178
+ logger.debug(
1179
+ f"Generated #include() for toctree: {docname} -> {relative_path}.typ"
1180
+ )
1181
+
1182
+ # Issue #7: Generate only #include() within the block
1183
+ self.add_text(f' #include("{relative_path}.typ")\n')
1184
+
1185
+ # End single content block
1186
+ self.add_text("]\n\n")
1187
+
1188
+ # Skip processing children as we've handled the toctree entries
1189
+ raise nodes.SkipNode
1190
+
1191
+ def depart_toctree(self, node: nodes.Node) -> None:
1192
+ """
1193
+ Depart a toctree node.
1194
+
1195
+ Args:
1196
+ node: The toctree node
1197
+ """
1198
+ # Toctree is handled in visit
1199
+ pass
1200
+
1201
+ def visit_reference(self, node: nodes.reference) -> None:
1202
+ """
1203
+ Visit a reference node (link).
1204
+
1205
+ Args:
1206
+ node: The reference node
1207
+ """
1208
+ # Get the reference URI
1209
+ refuri = node.get("refuri", "")
1210
+
1211
+ # Check if it's an internal reference (starts with #)
1212
+ if refuri.startswith("#"):
1213
+ # Internal reference to a label
1214
+ label = refuri[1:] # Remove the #
1215
+ self.add_text(f"#link(<{label}>)[")
1216
+ else:
1217
+ # External reference (HTTP/HTTPS URL or relative path)
1218
+ self.add_text(f'#link("{refuri}")[')
1219
+
1220
+ def depart_reference(self, node: nodes.reference) -> None:
1221
+ """
1222
+ Depart a reference node.
1223
+
1224
+ Args:
1225
+ node: The reference node
1226
+ """
1227
+ # Close the link
1228
+ self.add_text("]")
1229
+
1230
+ def unknown_visit(self, node: nodes.Node) -> None:
1231
+ """
1232
+ Handle unknown nodes during visit.
1233
+
1234
+ Args:
1235
+ node: The unknown node
1236
+ """
1237
+ # Log a warning for unknown nodes but don't raise an exception
1238
+ from sphinx.util import logging
1239
+
1240
+ logger = logging.getLogger(__name__)
1241
+ logger.warning(f"unknown node type: {node}")
1242
+
1243
+ def unknown_departure(self, node: nodes.Node) -> None:
1244
+ """
1245
+ Handle unknown nodes during departure.
1246
+
1247
+ Args:
1248
+ node: The unknown node
1249
+ """
1250
+ # Silently ignore unknown departures
1251
+ pass
1252
+
1253
+ def _convert_latex_to_typst(self, latex_content: str) -> str:
1254
+ """
1255
+ Convert LaTeX math syntax to Typst native syntax.
1256
+
1257
+ Implements Task 6.5: Basic LaTeX to Typst conversion
1258
+ Requirement 4.9: Fallback when typst_use_mitex=False
1259
+
1260
+ Args:
1261
+ latex_content: LaTeX math content
1262
+
1263
+ Returns:
1264
+ Typst native math content
1265
+ """
1266
+ # Basic conversion rules for common LaTeX commands
1267
+ result = latex_content
1268
+
1269
+ # Greek letters: \alpha -> alpha, \beta -> beta, etc.
1270
+ greek_letters = [
1271
+ "alpha",
1272
+ "beta",
1273
+ "gamma",
1274
+ "delta",
1275
+ "epsilon",
1276
+ "zeta",
1277
+ "eta",
1278
+ "theta",
1279
+ "iota",
1280
+ "kappa",
1281
+ "lambda",
1282
+ "mu",
1283
+ "nu",
1284
+ "xi",
1285
+ "omicron",
1286
+ "pi",
1287
+ "rho",
1288
+ "sigma",
1289
+ "tau",
1290
+ "upsilon",
1291
+ "phi",
1292
+ "chi",
1293
+ "psi",
1294
+ "omega",
1295
+ "Alpha",
1296
+ "Beta",
1297
+ "Gamma",
1298
+ "Delta",
1299
+ "Epsilon",
1300
+ "Zeta",
1301
+ "Eta",
1302
+ "Theta",
1303
+ "Iota",
1304
+ "Kappa",
1305
+ "Lambda",
1306
+ "Mu",
1307
+ "Nu",
1308
+ "Xi",
1309
+ "Omicron",
1310
+ "Pi",
1311
+ "Rho",
1312
+ "Sigma",
1313
+ "Tau",
1314
+ "Upsilon",
1315
+ "Phi",
1316
+ "Chi",
1317
+ "Psi",
1318
+ "Omega",
1319
+ ]
1320
+ for letter in greek_letters:
1321
+ result = result.replace(f"\\{letter}", letter)
1322
+
1323
+ # Fractions: \frac{a}{b} -> frac(a, b)
1324
+ result = re.sub(r"\\frac\{([^}]+)\}\{([^}]+)\}", r"frac(\1, \2)", result)
1325
+
1326
+ # Sum: \sum_{lower}^{upper} -> sum_(lower)^upper
1327
+ result = re.sub(r"\\sum_\{([^}]+)\}\^\{([^}]+)\}", r"sum_(\1)^(\2)", result)
1328
+ result = re.sub(r"\\sum_\{([^}]+)\}", r"sum_(\1)", result)
1329
+ result = result.replace(r"\sum", "sum")
1330
+
1331
+ # Integral: \int_{lower}^{upper} -> integral_(lower)^upper
1332
+ result = re.sub(
1333
+ r"\\int_\{([^}]+)\}\^\{([^}]+)\}", r"integral_(\1)^(\2)", result
1334
+ )
1335
+ result = re.sub(r"\\int_\{([^}]+)\}", r"integral_(\1)", result)
1336
+ result = result.replace(r"\int", "integral")
1337
+
1338
+ # Product: \prod -> product
1339
+ result = result.replace(r"\prod", "product")
1340
+
1341
+ # Square root: \sqrt{x} -> sqrt(x)
1342
+ result = re.sub(r"\\sqrt\{([^}]+)\}", r"sqrt(\1)", result)
1343
+
1344
+ # Infinity: \infty -> infinity
1345
+ result = result.replace(r"\infty", "infinity")
1346
+
1347
+ # Partial derivative: \partial -> diff (Typst uses diff or ∂)
1348
+ result = result.replace(r"\partial", "diff")
1349
+
1350
+ # Common functions
1351
+ result = result.replace(r"\sin", "sin")
1352
+ result = result.replace(r"\cos", "cos")
1353
+ result = result.replace(r"\tan", "tan")
1354
+ result = result.replace(r"\log", "log")
1355
+ result = result.replace(r"\ln", "ln")
1356
+ result = result.replace(r"\exp", "exp")
1357
+
1358
+ # If there are still backslashes, warn about unconverted syntax
1359
+ if "\\" in result:
1360
+ logger.warning(
1361
+ f"LaTeX math contains commands that may not convert well to Typst: {latex_content}"
1362
+ )
1363
+
1364
+ return result
1365
+
1366
+ def visit_math(self, node: nodes.math) -> None:
1367
+ """
1368
+ Visit an inline math node.
1369
+
1370
+ Implements Task 6.2: LaTeX math conversion (mitex)
1371
+ Implements Task 6.3: Labeled equations
1372
+ Implements Task 6.4: Typst native math support
1373
+ Implements Task 6.5: Math fallback functionality
1374
+ Requirement 4.3: Inline math should use #mi(`...`) format (LaTeX)
1375
+ Requirement 4.9: Fallback when typst_use_mitex=False
1376
+ Requirement 5.2: Inline math should use $...$ format (Typst native)
1377
+ Requirement 4.7: Labeled equations should generate <eq:label> format
1378
+ Design 3.3: Support both mitex and Typst native math
1379
+
1380
+ Args:
1381
+ node: The inline math node
1382
+ """
1383
+ # Extract math content
1384
+ math_content = node.astext()
1385
+
1386
+ # Task 6.4: Check if this is explicitly marked as Typst native
1387
+ is_typst_native = "typst-native" in node.get("classes", [])
1388
+
1389
+ # Task 6.5: Check typst_use_mitex config (default to True)
1390
+ use_mitex = getattr(self.builder.config, "typst_use_mitex", True)
1391
+
1392
+ if is_typst_native or not use_mitex:
1393
+ # Requirement 5.2: Typst native inline math syntax
1394
+ # Task 6.5: Convert LaTeX to Typst if use_mitex=False
1395
+ if not is_typst_native and not use_mitex:
1396
+ # Convert LaTeX syntax to Typst native
1397
+ math_content = self._convert_latex_to_typst(math_content)
1398
+ self.add_text(f"${math_content}$")
1399
+ else:
1400
+ # Requirement 4.3: LaTeX math via mitex
1401
+ self.add_text(f"#mi(`{math_content}`)")
1402
+
1403
+ # Task 6.3: Add label if present
1404
+ if "ids" in node and node["ids"]:
1405
+ label = node["ids"][0]
1406
+ self.add_text(f" <{label}>")
1407
+
1408
+ # Skip children to prevent duplicate output of math content
1409
+ raise nodes.SkipNode
1410
+
1411
+ def depart_math(self, node: nodes.math) -> None:
1412
+ """
1413
+ Depart an inline math node.
1414
+
1415
+ Args:
1416
+ node: The inline math node
1417
+ """
1418
+ # No additional output needed
1419
+ pass
1420
+
1421
+ def visit_math_block(self, node: nodes.math_block) -> None:
1422
+ """
1423
+ Visit a block math node.
1424
+
1425
+ Implements Task 6.2: LaTeX math conversion (mitex)
1426
+ Implements Task 6.3: Labeled equations
1427
+ Implements Task 6.4: Typst native math support
1428
+ Implements Task 6.5: Math fallback functionality
1429
+ Requirement 4.2: Block math should use #mitex(`...`) format (LaTeX)
1430
+ Requirement 4.9: Fallback when typst_use_mitex=False
1431
+ Requirement 5.2: Block math should use $ ... $ format (Typst native)
1432
+ Requirement 4.7: Labeled equations should generate <eq:label> format
1433
+ Design 3.3: Support both mitex and Typst native math
1434
+
1435
+ Args:
1436
+ node: The block math node
1437
+ """
1438
+ # Extract math content
1439
+ math_content = node.astext()
1440
+
1441
+ # Task 6.4: Check if this is explicitly marked as Typst native
1442
+ is_typst_native = "typst-native" in node.get("classes", [])
1443
+
1444
+ # Task 6.5: Check typst_use_mitex config (default to True)
1445
+ use_mitex = getattr(self.builder.config, "typst_use_mitex", True)
1446
+
1447
+ if is_typst_native or not use_mitex:
1448
+ # Requirement 5.2: Typst native block math syntax
1449
+ # Task 6.5: Convert LaTeX to Typst if use_mitex=False
1450
+ if not is_typst_native and not use_mitex:
1451
+ # Convert LaTeX syntax to Typst native
1452
+ math_content = self._convert_latex_to_typst(math_content)
1453
+ self.add_text(f"$ {math_content} $")
1454
+ else:
1455
+ # Requirement 4.2: LaTeX math via mitex
1456
+ self.add_text(f"#mitex(`{math_content}`)")
1457
+
1458
+ # Task 6.3: Add label if present
1459
+ if "ids" in node and node["ids"]:
1460
+ label = node["ids"][0]
1461
+ self.add_text(f" <{label}>")
1462
+
1463
+ self.add_text("\n\n")
1464
+
1465
+ # Skip children to prevent duplicate output of math content
1466
+ raise nodes.SkipNode
1467
+
1468
+ def depart_math_block(self, node: nodes.math_block) -> None:
1469
+ """
1470
+ Depart a block math node.
1471
+
1472
+ Args:
1473
+ node: The block math node
1474
+ """
1475
+ # No additional output needed
1476
+ pass
1477
+
1478
+ # Admonition nodes (Task 3.4)
1479
+ # Requirement 2.8-2.10: Convert Sphinx admonitions to gentle-clues
1480
+
1481
+ def _visit_admonition(
1482
+ self, node: nodes.Node, clue_type: str, custom_title: str = None
1483
+ ) -> None:
1484
+ """
1485
+ Helper method to visit any admonition node.
1486
+
1487
+ Args:
1488
+ node: The admonition node
1489
+ clue_type: The gentle-clues function name (e.g., 'info', 'warning', 'tip')
1490
+ custom_title: Optional custom title for the admonition
1491
+ """
1492
+ # Check if there's a title element in the node
1493
+ title = None
1494
+ for child in node.children:
1495
+ if isinstance(child, nodes.title):
1496
+ title = child.astext()
1497
+ break
1498
+
1499
+ # Use custom title if provided, otherwise check for title element
1500
+ if title:
1501
+ self.add_text(f'#{clue_type}(title: "{title}")[')
1502
+ elif custom_title:
1503
+ self.add_text(f'#{clue_type}(title: "{custom_title}")[')
1504
+ else:
1505
+ self.add_text(f"#{clue_type}[")
1506
+
1507
+ def _depart_admonition(self) -> None:
1508
+ """
1509
+ Helper method to depart any admonition node.
1510
+ """
1511
+ self.add_text("]\n\n")
1512
+
1513
+ def visit_note(self, node: nodes.note) -> None:
1514
+ """Visit a note admonition (converts to #info[])."""
1515
+ self._visit_admonition(node, "info")
1516
+
1517
+ def depart_note(self, node: nodes.note) -> None:
1518
+ """Depart a note admonition."""
1519
+ self._depart_admonition()
1520
+
1521
+ def visit_warning(self, node: nodes.warning) -> None:
1522
+ """Visit a warning admonition (converts to #warning[])."""
1523
+ self._visit_admonition(node, "warning")
1524
+
1525
+ def depart_warning(self, node: nodes.warning) -> None:
1526
+ """Depart a warning admonition."""
1527
+ self._depart_admonition()
1528
+
1529
+ def visit_tip(self, node: nodes.tip) -> None:
1530
+ """Visit a tip admonition (converts to #tip[])."""
1531
+ self._visit_admonition(node, "tip")
1532
+
1533
+ def depart_tip(self, node: nodes.tip) -> None:
1534
+ """Depart a tip admonition."""
1535
+ self._depart_admonition()
1536
+
1537
+ def visit_important(self, node: nodes.important) -> None:
1538
+ """Visit an important admonition (converts to #warning(title: "Important")[])."""
1539
+ self._visit_admonition(node, "warning", custom_title="Important")
1540
+
1541
+ def depart_important(self, node: nodes.important) -> None:
1542
+ """Depart an important admonition."""
1543
+ self._depart_admonition()
1544
+
1545
+ def visit_caution(self, node: nodes.caution) -> None:
1546
+ """Visit a caution admonition (converts to #warning[])."""
1547
+ self._visit_admonition(node, "warning")
1548
+
1549
+ def depart_caution(self, node: nodes.caution) -> None:
1550
+ """Depart a caution admonition."""
1551
+ self._depart_admonition()
1552
+
1553
+ def visit_seealso(self, node: addnodes.seealso) -> None:
1554
+ """Visit a seealso admonition (converts to #info(title: "See Also")[])."""
1555
+ self._visit_admonition(node, "info", custom_title="See Also")
1556
+
1557
+ def depart_seealso(self, node: addnodes.seealso) -> None:
1558
+ """Depart a seealso admonition."""
1559
+ self._depart_admonition()
1560
+
1561
+ # Inline nodes (Task 7.4)
1562
+ # Requirement 3.1: Inline cross-references and links
1563
+
1564
+ def visit_inline(self, node: nodes.inline) -> None:
1565
+ """
1566
+ Visit an inline node.
1567
+
1568
+ Inline nodes are generic containers for inline content.
1569
+ They are often used for cross-references with specific CSS classes.
1570
+
1571
+ Task 7.4: Handle inline nodes, especially those with 'xref' class
1572
+ Requirement 3.1: Cross-references and links
1573
+ """
1574
+ # Inline nodes are transparent containers - we just process their children
1575
+ # The CSS classes (like 'xref', 'doc', 'std-ref') are mainly for HTML/CSS styling
1576
+ # For Typst output, we simply render the text content
1577
+ pass
1578
+
1579
+ def depart_inline(self, node: nodes.inline) -> None:
1580
+ """
1581
+ Depart an inline node.
1582
+ """
1583
+ pass