-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMarkdownToNotebook.wl
More file actions
5943 lines (5532 loc) · 306 KB
/
Copy pathMarkdownToNotebook.wl
File metadata and controls
5943 lines (5532 loc) · 306 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
(* MarkdownToNotebook - convert a literate-markdown document into a Wolfram
notebook, choosing the layout from a template.
The source can be a local file path, an http(s) URL, or a raw markdown
string. The Template frontmatter key selects a registry entry:
FunctionResource - fill the slots of the official Function Repository
definition template (its stylesheet and docked Deploy/Submit toolbar
are preserved), so the .nb is publishable as-is.
Symbol, Guide - fill the DocumentationTools authoring templates.
Default - map headings and code directly to standard notebook styles.
Frontmatter drives metadata; the Definition section is the code; example
sections become evaluated + cached Input/Output cells.
Deliberately plain top-level definitions (no BeginPackage): the converter
Gets this file into a freshly generated private context so converting a
document cannot clobber the live definition doing the converting. *)
Needs["GeneralUtilities`"]
(* The LaTeX math pipeline wants the published Wolfram/Parser paclet - its
LaTeXMathParse handles \frac / \mathbb / scripts / sized delimiters that
the ImportString[..., "TeX"] fallback (wolframParserTeX) loses.
The dependency is enforced on USE (ensureParser[], called by every
MarkdownToNotebook invocation), not at load. A deployed ResourceFunction's
body is loaded once in whatever session first dereferences it (the build /
deploy kernel), so the check + Needs must run when the function is actually
called, in the caller's kernel - the same reason $convertDepth is read at
call time below. Only the vendored-submodule path is resolved here, while
$InputFileName still points at this file. *)
(* the vendored submodule, resolved against THIS file's location so it is found
from any working directory - the old cwd-relative check silently degraded to
ImportString when MTN ran from elsewhere (issue #25) *)
$parserDir = FileNameJoin[{
Replace[DirectoryName[$InputFileName], "" :> Directory[]], "examples", "Paclet", "WolframParser"}]
$parserReady = False
(* Make Wolfram`Parser` available, once per session. Prefer the vendored submodule
(PacletDirectoryLoad makes the paclet manager serve the highest version, so a
stale installed copy can't shadow it); else install the published paclet from
the Paclet Repository (PacletInstall is a no-op when it is already installed).
Best-effort: if no parser is reachable the math path falls back to ImportString
(see wolframParserTeX, which gates on the symbol existing at call time rather
than on this succeeding). *)
ensureParser[] := If[! TrueQ[$parserReady],
If[ DirectoryQ[$parserDir],
PacletDirectoryLoad[$parserDir],
Quiet @ PacletInstall["Wolfram/Parser"]
];
Quiet @ Check[Needs["Wolfram`Parser`"], Null];
$parserReady = True
]
mdSep = "\n(*--cell--*)\n"
(* === frontmatter === *)
stripQuotes[s_String] := StringReplace[StringTrim[s], {
StartOfString ~~ "\"" ~~ v___ ~~ "\"" ~~ EndOfString :> v,
StartOfString ~~ "'" ~~ v___ ~~ "'" ~~ EndOfString :> v
}]
(* split a bracketed list body on commas that are not inside double quotes, so a
quoted item (e.g. a citation) keeps its internal commas *)
splitListItems[s_String] := Block[{acc = {}, cur = "", inQ = False},
Do[
Which[
c === "\"", inQ = ! inQ; cur = cur <> c,
c === "," && ! inQ, AppendTo[acc, cur]; cur = "",
True, cur = cur <> c
],
{c, Characters[s]}
];
Append[acc, cur]
]
parseFmValue[s_String] := Block[{t = StringTrim[s]},
Which[
StringMatchQ[t, "[" ~~ ___ ~~ "]"],
stripQuotes /@ Select[StringTrim /@ splitListItems[StringTake[t, {2, -2}]], # =!= "" &]
,
MemberQ[{"true", "false"}, ToLowerCase[t]],
ToLowerCase[t] === "true"
,
True,
stripQuotes[t]
]
]
yamlLine[line_String] := Block[{parts = StringSplit[line, ":", 2]},
StringTrim[First[parts]] -> parseFmValue[Last[parts]]
]
(* A frontmatter list written in flow style, key: ["a", "b", ...], may WRAP across
several physical lines (long Links: / Authors: lists routinely do). The line-oriented
parse below would then read only the opening line - an unterminated "[...", which fails
the [___] list test and collapses to a truncated scalar - and treat each continuation
line as its own key:value (URLs contain ":", so they parse as garbage keys). Fold those
continuations back first: accumulate lines while an unquoted "[" is still open and break
a logical line only once the flow list closes. Brackets INSIDE "..." are literal (markdown
link labels are full of them), so quoted spans are skipped when tracking depth. *)
foldFrontmatterLines[lines_List] := Block[{out = {}, buf = "", depth = 0, inQ = False},
Scan[
Function[line,
Do[
Which[
c === "\"", inQ = ! inQ,
! inQ && c === "[", depth++,
! inQ && c === "]", depth--
],
{c, Characters[line]}
];
buf = If[buf === "", line, buf <> " " <> line];
If[depth <= 0, AppendTo[out, buf]; buf = ""; depth = 0]
],
lines
];
If[buf =!= "", AppendTo[out, buf]];
out
]
parseYamlish[lines_List] := Association @ Map[yamlLine, Select[foldFrontmatterLines[lines], StringContainsQ[#, ":"] &]]
extractFrontmatter[text_String] := Block[{lines, close},
lines = StringSplit[text, "\n"];
If[ lines === {} || StringTrim[First[lines]] =!= "---",
Return[{<||>, text}]
];
close = SelectFirst[Range[2, Length[lines]], StringTrim[lines[[#]]] === "---" &, 0];
If[ close === 0,
Return[{<||>, text}]
];
{parseYamlish[lines[[2 ;; close - 1]]], StringRiffle[lines[[close + 1 ;;]], "\n"]}
]
(* === block parsing === *)
fenceQ[line_String] := StringMatchQ[StringTrim[line], "```" ~~ ___]
fenceLen[line_String] := StringLength @ FirstCase[
StringCases[StringTrim[line], StartOfString ~~ f : ("`" ..) :> f], _String, ""]
headingQ[line_String] := StringMatchQ[line, ("#" ..) ~~ " " ~~ ___]
headingBlock[line_String] := Block[{hashes},
hashes = First @ StringCases[StringTrim[line], StartOfString ~~ h : ("#" ..) :> h];
<|
"Type" -> "Heading",
"Level" -> StringLength[hashes],
"Text" -> StringTrim @ StringReplace[StringTrim[line], StartOfString ~~ ("#" ..) ~~ " " -> ""]
|>
]
parseOptionValue[s_String] := Which[
MemberQ[{"true", "yes"}, ToLowerCase[StringTrim[s]]], True,
MemberQ[{"false", "no"}, ToLowerCase[StringTrim[s]]], False,
True, stripQuotes[s]
]
cellOptionLine[line_String] := Block[{parts = StringSplit[StringReplace[StringTrim[line], StartOfString ~~ "#|" -> ""], ":", 2]},
StringTrim[First[parts]] -> parseOptionValue[Last[parts]]
]
parseCellOptions[lines_List] := Association @ Map[cellOptionLine, Select[lines, StringContainsQ[#, ":"] &]]
(* a top-level "#| key: value" line - written inline, or recovered by
stripComments from a "<!-- #| ... -->" metadata comment - is a cell directive
that binds to the NEXT block (see attachDirectives). Inside a fenced code
block #| lines are the code cell's own options and are handled by codeBlock. *)
directiveLineQ[line_String] := StringStartsQ[StringTrim[line], "#|"]
codeBlock[info_String, bodyLines_List] := Block[{optLines, codeLines},
optLines = TakeWhile[bodyLines, StringMatchQ[StringTrim[#], "#|" ~~ ___] &];
codeLines = Drop[bodyLines, Length[optLines]];
<|
"Type" -> "Code",
"Lang" -> First[StringSplit[ToLowerCase @ StringDelete[info, {"{", "}"}]], ""],
"Options" -> parseCellOptions[optLines],
"Code" -> StringRiffle[codeLines, "\n"]
|>
]
(* fenceSplit[lines, openLen]: gather lines until a CLOSE fence whose own
backtick run is at least openLen long. A shorter fence inside a longer one
is content, not a closer (CommonMark semantics). Iterative so we don't
trip $IterationLimit on a very long inlined-file cell (a `#| file:`
include of, say, 5000 lines). *)
fenceSplit[lines_List, openLen_Integer] := Module[{n = Length[lines], i = 1},
While[i <= n && ! (fenceQ[lines[[i]]] && fenceLen[lines[[i]]] >= openLen), i++];
{Take[lines, i - 1], If[i > n, {}, Drop[lines, i]]}
]
(* Pandoc-style fenced divs ":::". An opening line "::: kind" (kind is any
non-empty token, e.g. "solved-example", "theorem", "proof", "exercise",
"solution") starts a div; the matching "::: " closes it. Divs nest.
Used by the Chapter template to scaffold the multi-cell book constructs
(SolvedExample, Theorem/Proof, Exercise/Solution) that have no direct
markdown analogue. *)
divOpenQ[line_String] := Block[{t = StringTrim[line]},
StringStartsQ[t, ":::"] && StringTrim[StringDrop[t, 3]] =!= ""
]
divCloseQ[line_String] := StringTrim[line] === ":::"
divKind[line_String] := StringTrim[StringDrop[StringTrim[line], 3]]
(* gather lines until the matching ::: closer; respects nested divs *)
divSplit[lines_List] := Block[{depth = 1, acc = {}, rest = lines},
While[depth > 0 && rest =!= {},
Which[
divOpenQ[First[rest]],
depth++; AppendTo[acc, First[rest]]; rest = Rest[rest],
divCloseQ[First[rest]],
depth--;
If[depth > 0, AppendTo[acc, First[rest]]];
rest = Rest[rest],
True,
AppendTo[acc, First[rest]]; rest = Rest[rest]
]
];
{acc, rest}
]
paraSplit[{}, collected_] := {Reverse[collected], {}}
paraSplit[lines_List, collected_] := Block[{line = First[lines]},
If[ StringTrim[line] === "" || fenceQ[line] || headingQ[line] || listItemQ[line] ||
orderedItemQ[line] || blockquoteQ[line] || mathBlockOpenQ[line] ||
divOpenQ[line] || divCloseQ[line],
{Reverse[collected], lines},
paraSplit[Rest[lines], Prepend[collected, line]]
]
]
(* markdown list items: "- ", "* " or "+ " (the marker is stripped). Tested by
explicit character checks, not a string pattern: a bare "*" in a
StringExpression is the wildcard metacharacter, not a literal asterisk. *)
listItemQ[line_String] := Block[{t = StringTrim[line]},
StringLength[t] >= 2 && MemberQ[{"-", "*", "+"}, StringTake[t, 1]] && StringTake[t, {2}] === " "
]
listText[line_String] := taskCheckbox @ StringTrim @ StringDrop[StringTrim[line], 2]
(* leading-whitespace width of a list line, tabs counted as 4 columns. Used to
recover the nesting depth of an indented sub-bullet, which listItemQ/listText
otherwise discard by trimming. *)
listLineIndent[line_String] := Module[
{m = StringCases[line, StartOfString ~~ w : (" " | "\t") .. :> w, 1]},
If[m === {}, 0, StringLength[StringReplace[First[m], "\t" -> " "]]]
]
(* map each item's raw indent width to a 0-based nesting depth. A stack of the
indent columns seen along the current branch turns *any* indent increase into
exactly one deeper level (CommonMark nests by alignment, not by a fixed step),
so both 2- and 4-space sub-bullets nest, and an outdent pops back to the
matching ancestor level. *)
listDepths[indents_List] := Module[{stack = {}, out = {}},
Do[
While[stack =!= {} && Last[stack] > ind, stack = Most[stack]];
If[stack === {} || Last[stack] < ind, AppendTo[stack, ind]];
AppendTo[out, Length[stack] - 1],
{ind, indents}
];
out
]
(* a GitHub task-list item "[ ] ..." / "[x] ..." -> a ballot-box glyph before the
text (unchecked U+2610, checked U+2611), so the checkbox renders instead of a
literal "[ ]". *)
taskCheckbox[t_String] := Which[
StringStartsQ[t, "[ ] "], "\:2610 " <> StringDrop[t, 4],
StringStartsQ[t, "[x] " | "[X] "], "\:2611 " <> StringDrop[t, 4],
True, t
]
(* ordered list items: "1. ", "2) ", ... (a run of digits, then "." or ")", then a
space). The marker is dropped; the List block is tagged "Ordered" so it renders
numbered. *)
orderedItemQ[line_String] := StringMatchQ[StringTrim[line], DigitCharacter .. ~~ ("." | ")") ~~ " " ~~ ___]
orderedText[line_String] := StringTrim @ StringReplace[StringTrim[line], StartOfString ~~ DigitCharacter .. ~~ ("." | ")") ~~ " " -> ""]
orderedSplit[{}, collected_] := {Reverse[collected], {}}
orderedSplit[lines_List, collected_] := With[{line = First[lines]},
Which[
orderedItemQ[line],
orderedSplit[Rest[lines], Prepend[collected, orderedText[line]]],
collected =!= {} && listContinuationQ[line],
orderedSplit[Rest[lines],
Prepend[Rest[collected], First[collected] <> " " <> StringTrim[line]]],
True,
{Reverse[collected], lines}
]
]
(* a "$$ ... $$" line (or a "$$"-fenced block across multiple lines) is display math.
Detected separately from inline "$math$" so it can become a centered DisplayFormula
block rather than be mis-parsed as broken inline math on either side. *)
mathBlockOpenQ[line_String] := StringStartsQ[StringTrim[line], "$$"]
mathBlockClosedQ[line_String] := Block[{t = StringTrim[line]}, StringLength[t] >= 4 && StringStartsQ[t, "$$"] && StringEndsQ[t, "$$"]]
mathBlockGather[lines_List] := Block[{first = StringTrim @ First[lines], rest = Rest[lines], idx},
If[ mathBlockClosedQ[First[lines]],
{StringTrim @ StringTake[first, {3, -3}], rest},
idx = FirstPosition[rest, l_String /; StringEndsQ[StringTrim[l], "$$"], {0}, {1}, Heads -> False];
If[ idx === {0},
(* unterminated: take everything after the opening "$$" as content *)
{StringTrim @ StringDrop[first, 2], rest},
With[{n = First[idx]},
{StringTrim @ StringRiffle[Join[
{StringDrop[first, 2]},
rest[[1 ;; n - 1]],
{StringDrop[StringTrim[rest[[n]]], -2]}
], "\n"], rest[[n + 1 ;;]]}
]
]
]
]
(* blockquote lines start with ">". Consecutive lines are gathered and the marker
("> " or ">") stripped; the joined text becomes a "Quote" block. *)
blockquoteQ[line_String] := StringStartsQ[StringTrim[line], ">"]
quoteText[line_String] := StringReplace[StringTrim[line], StartOfString ~~ ">" ~~ (" " | "") -> ""]
quoteSplit[{}, collected_] := {Reverse[collected], {}}
quoteSplit[lines_List, collected_] := If[ blockquoteQ[First[lines]],
quoteSplit[Rest[lines], Prepend[collected, quoteText[First[lines]]]],
{Reverse[collected], lines}
]
(* a thematic break - a line of only "-", "_" or "*" (3+) between blank lines -
is an explicit example separator (an ExampleDelimiter). Frontmatter "---" is
already stripped, and "|---|" table rules contain "|", so neither matches.
Tested by explicit character checks: in a string pattern a bare "*" is the
wildcard metacharacter, so StringMatchQ[t, "*"..] would match anything. *)
separatorQ[line_String] := Block[{chars = Characters[StringTrim[line]]},
Length[chars] >= 3 && MemberQ[{"-", "_", "*"}, First[chars, ""]] && Length[DeleteDuplicates[chars]] === 1
]
(* a continuation line under a list item: any non-empty, non-marker-starting
line that does not itself open a new block. Standard markdown indents the
continuation under the bullet's text column (2-4 spaces); we accept any
leading whitespace and fall back to a plain non-list line, so wrapped
prose under a bullet folds into the same item instead of breaking the list
into "one item + a paragraph + another item + a paragraph + ..." (which is
what the user sees as "6 bullets instead of 3"). *)
listContinuationQ[line_String] := StringLength[line] > 0 &&
StringStartsQ[line, " " | "\t"] && StringTrim[line] =!= "" &&
! listItemQ[line] && ! orderedItemQ[line] && ! headingQ[line] &&
! fenceQ[line] && ! blockquoteQ[line] && ! mathBlockOpenQ[line]
(* collect a markdown list, folding indented continuation lines into the
current item (joined with a single space, the way a CommonMark renderer
would). `collected` is built in reverse - the first element is the current
item being extended, so prepending a new item makes that the new "current". *)
listSplit[{}, collected_] := {Reverse[collected], {}}
listSplit[lines_List, collected_] := With[{line = First[lines]},
Which[
listItemQ[line],
listSplit[Rest[lines], Prepend[collected, {listLineIndent[line], listText[line]}]],
collected =!= {} && listContinuationQ[line],
listSplit[Rest[lines],
Prepend[Rest[collected],
MapAt[# <> " " <> StringTrim[line] &, First[collected], 2]]],
True,
{Reverse[collected], lines}
]
]
(* Turn the {indent, text} pairs listSplit collected into a List block, keeping
Items as bare strings (every downstream reader still expects that) and adding
a parallel Depths list so nesting-aware renderers can pick Item/Subitem/... . *)
listBlock[pairs_List] := <|
"Type" -> "List",
"Items" -> pairs[[All, 2]],
"Depths" -> listDepths[pairs[[All, 1]]]
|>
(* GitHub-flavored tables: a "| a | b |" row whose next line is a "|---|---|"
separator. Cells are split on "|" with the outer pipes trimmed. *)
tableRowLineQ[line_String] := StringContainsQ[line, "|"] && StringTrim[line] =!= "" && ! fenceQ[line] && ! headingQ[line]
tableSepQ[line_String] := StringContainsQ[line, "-"] && StringContainsQ[line, "|"] &&
StringMatchQ[StringTrim[line], ("|" | ":" | "-" | " ") ..]
(* GitHub-flavored Markdown lets a cell contain a literal `|` by
escaping it as `\|` - the backslash protects the pipe from being
read as a cell delimiter. We split on UNescaped `|`s by temporarily
swapping `\|` for a U+0001 sentinel character (never appears in
normal Markdown), splitting on `|`, then swapping the sentinel back
to a literal `|` in each cell. *)
splitTableRow[line_String] := Block[{sentinel = FromCharacterCode[1]},
Map[
StringReplace[#, sentinel -> "|"] &,
StringTrim /@ StringSplit[
StringReplace[StringTrim[StringTrim[line], "|"], "\\|" -> sentinel],
"|"
]
]
]
tableSplit[{}, collected_] := {Reverse[collected], {}}
tableSplit[lines_List, collected_] := If[ tableRowLineQ[First[lines]],
tableSplit[Rest[lines], Prepend[collected, First[lines]]],
{Reverse[collected], lines}
]
(* markdown image on its own line:  or . The
optional title is read as an effect keyword - "papertear" applies the front
end's Paper Tear background to the inlined image's cell. *)
imageLineQ[line_String] := StringMatchQ[StringTrim[line], ""]
parseImageTarget[rest_String] := Block[{
m = StringCases[StringTrim[rest],
StartOfString ~~ p : Shortest[Except["\""] ..] ~~ "\"" ~~ t : Shortest[___] ~~ "\"" ~~ EndOfString :> {StringTrim[p], t}, 1]
},
If[m === {}, {StringTrim[rest], ""}, First[m]]
]
imageBlock[line_String] := First @ StringCases[StringTrim[line],
"![" ~~ alt : Shortest[Except["]"] ...] ~~ "](" ~~ rest : Shortest[Except[")"] ..] ~~ ")" :>
With[{pt = parseImageTarget[rest]}, <|"Type" -> "Image", "Alt" -> alt, "Path" -> First[pt], "Effect" -> Last[pt]|>]]
blockLoop[{}, acc_] := Reverse[acc]
blockLoop[lines_List, acc_] := Block[{line = First[lines], rest = Rest[lines], split},
Which[
StringTrim[line] === "",
blockLoop[rest, acc]
,
directiveLineQ[line],
blockLoop[rest, Prepend[acc, <|"Type" -> "Directive", "Options" -> parseCellOptions[{line}]|>]]
,
fenceQ[line],
split = fenceSplit[rest, fenceLen[line]];
blockLoop[Last[split], Prepend[acc, codeBlock[StringReplace[StringTrim[line], StartOfString ~~ ("`" ..) -> ""], First[split]]]]
,
divOpenQ[line],
split = divSplit[rest];
blockLoop[Last[split], Prepend[acc,
<|"Type" -> "Div",
"Kind" -> divKind[line],
"Blocks" -> parseBlocks[StringRiffle[First[split], "\n"]]|>
]]
,
headingQ[line],
blockLoop[rest, Prepend[acc, headingBlock[line]]]
,
imageLineQ[line],
blockLoop[rest, Prepend[acc, imageBlock[line]]]
,
separatorQ[line],
blockLoop[rest, Prepend[acc, <|"Type" -> "Separator"|>]]
,
blockquoteQ[line],
split = quoteSplit[lines, {}];
blockLoop[Last[split], Prepend[acc, <|"Type" -> "Quote", "Text" -> StringRiffle[First[split], " "]|>]]
,
mathBlockOpenQ[line],
split = mathBlockGather[lines];
blockLoop[Last[split], Prepend[acc, <|"Type" -> "MathBlock", "Text" -> First[split]|>]]
,
tableRowLineQ[line] && rest =!= {} && tableSepQ[First[rest]],
split = tableSplit[lines, {}];
With[{rows = splitTableRow /@ First[split]},
blockLoop[Last[split], Prepend[acc, <|"Type" -> "Table", "Header" -> First[rows], "Rows" -> Drop[rows, 2]|>]]
]
,
listItemQ[line],
split = listSplit[lines, {}];
blockLoop[Last[split], Prepend[acc, listBlock[First[split]]]]
,
orderedItemQ[line],
split = orderedSplit[lines, {}];
blockLoop[Last[split], Prepend[acc, <|"Type" -> "List", "Ordered" -> True, "Items" -> First[split]|>]]
,
True,
split = paraSplit[lines, {}];
blockLoop[Last[split], Prepend[acc, <|"Type" -> "Prose", "Text" -> StringRiffle[First[split], " "]|>]]
]
]
(* Resolve "Directive" blocks (stand-alone "#| key: value" lines) into the block
stream. A directive run that carries cell CONTENT ("file" or "boxes" - e.g. a
saved Output cell) becomes its OWN code block; a content-free run (just style /
tags) is a MODIFIER folded into the "Options" of the block that follows it,
overriding same-named options. A trailing modifier-only run is dropped. *)
contentDirectiveQ[opts_] := KeyExistsQ[opts, "file"] || KeyExistsQ[opts, "boxes"]
attachDirectives[blocks_List] := Module[{out = {}, pend = <||>, flush},
flush[] := (
If[contentDirectiveQ[pend],
AppendTo[out, <|"Type" -> "Code", "Lang" -> "wl", "Options" -> pend, "Code" -> ""|>]];
pend = <||>
);
Do[
If[ b["Type"] === "Directive",
(* a key that repeats inside a content-bearing run starts a NEW
standalone cell (consecutive Output runs are separated only by a
blank line, which blockLoop drops) - flush the current one first *)
If[ contentDirectiveQ[pend] && AnyTrue[Keys[b["Options"]], KeyExistsQ[pend, #] &],
flush[]];
pend = Join[pend, b["Options"]],
If[ contentDirectiveQ[pend],
flush[]; AppendTo[out, b],
(* a modifier run (annotation / style / tags) attaches to the next
real content block; pass OVER a delimiter that carries no cell
metadata so a standalone "#| annotation:" placed just before a
"---" still lands on a cell instead of being dropped *)
If[ pend =!= <||> && b["Type"] === "Separator",
AppendTo[out, b],
AppendTo[out, If[pend === <||>, b,
Append[b, "Options" -> Join[Lookup[b, "Options", <||>], pend]]]];
pend = <||>
]
]
],
{b, blocks}
];
flush[];
out
]
(* blockLoop and its sibling splitters (fenceSplit, paraSplit, listSplit, ...)
recurse once per source line, and Wolfram does not tail-call optimize -
the default $RecursionLimit of 1024 trips on any document longer than
roughly a thousand lines. Lift the limit to scale with the document
(8x the line count, capped, with a 10000 floor so short docs are
unaffected) so a real-world tutorial of tens of thousands of lines
parses without aborting. Rewriting the parser as an iterative
While-loop would be cleaner long-term; this is the minimal patch
that keeps the parser useful on big inputs. *)
parseBlocks[body_String] := Block[
{lines = StringSplit[body, "\n"], $RecursionLimit},
$RecursionLimit = Max[10000, 8 * Length[lines], Replace[$RecursionLimit, Except[_Integer] -> 0]];
attachDirectives[blockLoop[lines, {}]]
]
(* drop HTML/markdown comments (e.g. "<!-- => 21. -->" output annotations).
Only strips from prose segments - content inside fenced code blocks (```)
is left untouched, so a string literal in WL source that happens to
contain "<!-- ... -->" survives a walker-twin -> rebuild round trip
instead of being corrupted by the global regex. *)
stripComments[s_String] := Module[{lines = StringSplit[s, "\n", All], out = {}, inFence = False, openLen = 0, buf = {}},
flushProse[] := If[buf =!= {},
AppendTo[out, StringReplace[StringRiffle[buf, "\n"],
"<!--" ~~ inner : Shortest[___] ~~ "-->" :>
(* a multi-line "<!-- #| annotation: ... -->" is hard-wrapped prose;
join the wrapped lines into one so the line-based block parser
keeps the whole directive value, not just its first line *)
If[StringStartsQ[StringTrim[inner], "#|"],
StringReplace[StringTrim[inner], "\n" ~~ WhitespaceCharacter ... -> " "],
""]]];
buf = {}
];
Do[
Which[
! inFence && fenceQ[line], (* open *)
flushProse[]; inFence = True; openLen = fenceLen[line]; AppendTo[out, line],
inFence && fenceQ[line] && fenceLen[line] >= openLen, (* close *)
AppendTo[out, line]; inFence = False; openLen = 0,
inFence, (* inside fence - preserve verbatim *)
AppendTo[out, line],
True, (* prose line *)
AppendTo[buf, line]
],
{line, lines}
];
flushProse[];
StringRiffle[out, "\n"]
]
litParse[text_String] := Block[{fm = extractFrontmatter[text]},
<|"Metadata" -> First[fm], "Blocks" -> parseBlocks[stripComments[Last[fm]]]|>
]
(* === source resolution ===
The entry accepts a local file path, an http(s) URL, or a raw markdown
string. Resolution yields the text, a base for "#| file:" includes, a name
for the default output, and whether the source is a local file. *)
urlQ[s_String] := StringMatchQ[s, ("http://" | "https://") ~~ ___]
joinSource[base_String, path_String] := If[ urlQ[base],
StringTrim[base, "/"] <> "/" <> path,
FileNameJoin[{base, path}]
]
resolveSource[input_String] := Which[
urlQ[input],
<|"Text" -> Import[input, "Text"], "Base" -> StringReplace[input, RegularExpression["/[^/]*$"] -> ""], "Name" -> FileBaseName @ Last @ StringSplit[input, "/"], "Local" -> False, "Id" -> input|>
,
FileExistsQ[input],
<|"Text" -> Import[input, "Text"], "Base" -> DirectoryName[input], "Name" -> FileBaseName[input], "Local" -> True, "Id" -> input|>
,
True,
<|"Text" -> input, "Base" -> Directory[], "Name" -> "Notebook", "Local" -> False, "Id" -> input|>
]
(* a code cell carrying a "file" option inlines that file's contents as its
body, resolved (file or URL) relative to the document; a markdown image block
imports its image (file or URL) relative to the document. Both resolutions
happen here because the document base is known. *)
resolveBlock[b_Association, base_String] := Which[
(* a ".wxf" include is raw box data (a saved Output cell's BoxData), not text:
import the boxes and mark the cell boxes-literal so it renders unevaluated
through the box path (the "#| style: Output" directive then makes it an
Output cell). This is the large-output counterpart of inline "#| boxes". *)
b["Type"] === "Code" && KeyExistsQ[b["Options"], "file"] &&
StringEndsQ[ToLowerCase[b["Options"]["file"]], ".wxf"],
Join[b, <|"Boxes" -> Quiet @ Import[joinSource[base, b["Options"]["file"]], "WXF"],
"Options" -> Append[b["Options"], "boxes" -> True]|>],
b["Type"] === "Code" && KeyExistsQ[b["Options"], "file"],
Append[b, "Code" -> Import[joinSource[base, b["Options"]["file"]], "Text"]],
b["Type"] === "Image",
Append[b, "Image" -> Quiet @ Import[joinSource[base, b["Path"]]]],
True, b
]
resolveIncludes[blocks_List, base_String] := Map[resolveBlock[#, base] &, blocks]
(* === sections === *)
(* Canonicalise a heading title to its section-key form. The doc-template's
ExampleSection cells ship with "&" in titles ("Properties & Relations",
"Scope & Additional Elements"); some hand-authored .md files use the
word "and" instead. Normalise both to the same canonical key so a doc
that round-trips through NotebookToMarkdown (which recovers the
template's literal "&" title) is re-recognised on the next forward
build. *)
sectionKey[text_String] := StringReplace[ToLowerCase[text], " & " -> " and "]
sectionsFrom[blocks_List] := Block[{step, init},
init = <|"key" -> "", "acc" -> <||>, "pend" -> <||>|>;
step[state_, b_] := Which[
b["Type"] === "Heading" && b["Level"] <= 2,
(* A "#| " directive on a section heading ("#| annotation:" before
"## Details", say) has no header cell of its own on a structured page -
the heading maps to a template slot, not a visible cell. Carry the
heading's style / tags / annotation to the section's FIRST content
block, where the block builders' applyBlockMeta lands it on the first
cell the section produces (e.g. the first Notes cell). Directives that
only make sense on a code cell (eval / file / ...) are not carried. *)
With[{k = sectionKey[b["Text"]]},
<|"key" -> k, "acc" -> Append[state["acc"], k -> {}],
"pend" -> KeyTake[Lookup[b, "Options", <||>], {"style", "tags", "annotation"}]|>]
,
state["key"] === "",
state
,
True,
(* stash the heading's directives on the first block under a private
"headingMeta" key; applyBlockMeta lands them on that block's FIRST
cell only, so a "## Details" annotation is one note, not one per item *)
<|"key" -> state["key"],
"acc" -> MapAt[Append[#, If[state["pend"] === <||>, b,
Append[b, "Options" -> Append[Lookup[b, "Options", <||>], "headingMeta" -> state["pend"]]]]] &,
state["acc"], Key[state["key"]]],
"pend" -> <||>|>
];
Fold[step, init, blocks]["acc"]
]
(* "#| boxes: true" reads the cell content as a literal box expression
(parsed by ToExpression, not evaluated) and splices it directly into
BoxData - the author writes raw RowBox / GridBox / TemplateBox /
TagBox / TooltipBox and the converter renders those boxes unchanged.
A boxes cell is non-executable by definition, so executableQ excludes
it the same way "#| eval: false" is excluded - the non-executable
rendering path then dispatches in nonExecutableCell to emit a boxed
Input cell instead of a Program-styled plain-text cell. *)
(* "#| boxes: true" takes the fence body as the box expression; "#| boxes: <expr>"
carries the expression in the option value itself (used by the comment carrier,
which has no fence body - e.g. a small Output cell inlined in a metadata comment). *)
boxesLiteralQ[b_] := With[{v = Lookup[b["Options"], "boxes", False]}, TrueQ[v] || StringQ[v]]
executableQ[b_] := b["Type"] === "Code" && MemberQ[{"wl", "wolfram", "mathematica"}, b["Lang"]] && TrueQ[Lookup[b["Options"], "eval", True]] && ! boxesLiteralQ[b]
(* parse the cell text as a Wolfram expression with no evaluation and
return it as-is for use as box data; on a parse failure fall back to
the raw string so the author sees the source rather than nothing. *)
cellLiteralBoxes[code_String] := With[{e = Quiet @ ToExpression[code, InputForm, HoldComplete]},
Replace[e, {HoldComplete[expr_] :> expr, _ :> code}]
]
(* non-executable code-block rendering: an Input cell whose BoxData is the
literal parsed box expression for `#| boxes: true`; a non-evaluated
Wolfram-language fence keeps the Input style (issue #17 - the spec in
docs/formatting.md reads "keep the input cell" for #| eval: false);
genuinely foreign-language fences (bash, python, ...) render as a
plain-text Program cell. *)
(* box data for a boxes-literal cell: imported ".wxf" boxes when present (large
output), else the "#| boxes: <expr>" option value, else the fence body. *)
cellBoxesOf[b_] := With[{v = Lookup[b["Options"], "boxes", False]},
Which[
KeyExistsQ[b, "Boxes"], b["Boxes"],
StringQ[v], cellLiteralBoxes[v],
True, cellLiteralBoxes[b["Code"]]
]]
nonExecutableCell[b_] := applyCellMeta[
Which[
boxesLiteralQ[b],
Cell[BoxData[cellBoxesOf[b]], "Input"],
MemberQ[{"wl", "wolfram", "mathematica"}, b["Lang"]],
Cell[BoxData[inputBoxes[b["Code"]]], "Input"],
True,
Cell[b["Code"], "Program"]
],
Lookup[b, "Options", <||>]
]
sectionCells[sections_, key_] := Cases[Lookup[sections, key, {}], b_ /; executableQ[b]]
sectionCode[sections_, key_] := StringRiffle[#["Code"] & /@ sectionCells[sections, key], "\n\n"]
(* Concatenate the prose paragraphs of a section, preserving any inline
markdown (backticks / bold / italic / links / math) - downstream renderers
route the result through inlineTextData so they handle the markup; the
legacy backtick-stripping done here used to defend a plain-string cell
path that no longer exists. *)
sectionText[sections_, key_] := StringRiffle[
Cases[Lookup[sections, key, {}], b_ /; b["Type"] === "Prose" :> b["Text"]],
" "
]
(* === notebook evaluation with a cumulative-hash cache ===
All executable cells are evaluated in document order, threading state, so a
cell's cache key depends on every cell before it. The "EvaluateSeparator"
MarkdownToNotebook option picks the granularity of isolation between
cells:
Automatic - reset at "---" thematic breaks and at every heading,
at any level (the default - each (sub)section runs
in a clean context). A reset clears only symbols
INTRODUCED AFTER the document's "## Definition" section
has run; the definition's symbols (the FunctionResource
function, helper bindings) persist across every later
section. The protected baseline is snapshotted at the
first reset that follows the definition section, so an
example that introduces a temp local helper is wiped
between sections but the resource's function is not.
All - reset before every executable cell, so each cell runs
in a fresh context with its hash depending only on
its own code (maximum isolation, minimum cache reuse).
The same definition-preservation rule applies.
None - no reset; the whole notebook shares one eval context
and the cumulative-hash chain spans every cell.
Supported but not recommended: per-example helpers
leak across every later section, so a stray binding in
## Basic Examples may collide with names in ## Scope.
Put binding that legitimately needs to cross sections
in the document's ## Definition section, and let
Automatic preserve only it.
The input list interleaves executable cells, "Separator" blocks, and
"Heading" blocks; the returned hash list has one entry per executable
cell (boundaries contribute a reset, not a hash). *)
resetBoundaryQ[mode_, item_] := Switch[mode,
None, False,
All, MatchQ[item["Type"], "Separator" | "Heading"] || executableQ[item],
_, MatchQ[item["Type"], "Separator" | "Heading"]
]
cumulativeHashes[mode_, items_List] := Block[{acc = "", hashes = {}},
Scan[
item |-> (
If[resetBoundaryQ[mode, item], acc = ""];
If[executableQ[item],
acc = acc <> mdSep <> item["Code"]; AppendTo[hashes, Hash[acc]]
]
),
items
];
hashes
]
(* the light/dark mode example renderings are pinned to. Default light (the
deployed notebook is light); the markdown-out twin sets it to "Dark". Guard the
initialization with ValueQ so re-loading this file (the Definition cell inlines
and evaluates the whole .wl during conversion) does not clobber an override. *)
If[! ValueQ[$lightDark], $lightDark = "Light"]
(* conversion nesting depth. A self-referential example (a doc whose example calls
MarkdownToNotebook on a document, e.g. the function on its own GitHub source)
would otherwise re-evaluate that document's examples, and so on without end. So
only the top-level conversion evaluates examples; a nested one builds the
notebook with its example cells left unevaluated (input only). *)
If[! ValueQ[$convertDepth], $convertDepth = 0]
(* the notebook a result stands for (a Notebook expression, or the open notebook of
a NotebookObject), pinned to the current mode. The Resource templates
(FunctionResource / Example / Data) come from DefinitionNotebookClient with
LightDark -> "Light" already baked in; appending a second LightDark would
leave both in the option sequence and the front end would honour the first
(Light) one, so strip any existing LightDark before setting ours. *)
resultNotebook[res_] := Block[{nb = If[MatchQ[res, _NotebookObject], NotebookGet[res], res]},
nb /. Notebook[c_, o___] :> Notebook[c, Sequence @@ DeleteCases[{o}, LightDark -> _], LightDark -> $lightDark]
]
(* output for an evaluated cell. A whole notebook has no faithful inline form -
inlining its cells breaks the surrounding layout (a Title/Section renders
document-wide; a CellFrame is just a per-cell option, it does not bound a group).
So a produced notebook is shown as a *rendered thumbnail*: a NotebookObject (the
notebook itself, opened with NotebookPut, as published WFR functions return) is
rasterized and closed, the WFR-canonical display; a bare Notebook *expression*
opts into the same rasterization with "#| screenshot: true" (otherwise it is
shown as its literal expression boxes). *)
(* keep a rasterized cell image under the resource Check's "large cell area"
threshold (~500k pixels). Cap both the long dimension and the area to keep
the cell from tripping LargeCellBounds while still showing the content
readably. *)
$rasterMaxLongDim = 1200
$rasterMaxArea = 480000
capRaster[img_] := If[ ! ImageQ[img], img,
Block[{w, h, longDim, area, scale},
{w, h} = ImageDimensions[img];
longDim = Max[w, h];
area = w h;
scale = Min[1, $rasterMaxLongDim / longDim, Sqrt[$rasterMaxArea / area]];
If[scale < 1, ImageResize[img, Round[scale {w, h}]], img]
]
]
outputBoxes[res_, opts_] := Which[
res === Null, Null,
MatchQ[res, _NotebookObject],
(* a whole-notebook thumbnail can be enormous; rasterize at a lower
resolution and cap the height so the cell does not trip the analysis
"huge raster" / "large screen area" checks, while still showing the
entire notebook. *)
With[{img = Quiet @ Rasterize[resultNotebook[res], ImageResolution -> 96]},
Quiet @ NotebookClose[res];
ToBoxes @ capRaster[img]],
TrueQ[Lookup[opts, "screenshot", False]] && MatchQ[res, _Notebook],
ToBoxes @ capRaster @ Quiet @ Rasterize[resultNotebook[res], ImageResolution -> 144],
True, ToBoxes[res]
]
(* === message capture (box level) ===
captureCellRun installs an Internal`HandlerBlock["Message"] handler rather than
redirecting $Messages to a text file, so a message is captured as the same box
structure a notebook would show. handleMsg reformats each shown message from its
template + args: the name is a "MessageName"-styled token and each "`n`" slot is
the nth arg boxified - a string inserts verbatim, a RawBoxes inlines its boxes
(so ResourceFunctionMessage's styled name renders as real boxes instead of
dumping literal "RawBoxes[StyleBox[...]]"), anything else goes through ToBoxes.
The General::tag template is the fallback, matching the kernel. *)
msgArgBox[s_String] := s
msgArgBox[RawBoxes[b_]] := b
msgArgBox[e_] := ToBoxes[e]
SetAttributes[messageNameString, HoldFirst]
messageNameString[s_Symbol] := SymbolName[Unevaluated[s]]
messageNameString[o_] := ToString[Unevaluated[o], InputForm]
slotFill[tmpl_String, args_List] := Replace[
StringSplit[tmpl, "`" ~~ ds : (DigitCharacter ..) ~~ "`" :> msgSlot[FromDigits[ds]]],
msgSlot[i_] :> If[1 <= i <= Length[args], msgArgBox[args[[i]]], ""], {1}]
messageBoxData[name_String, tmpl_, args_List] := RowBox[Flatten[{
StyleBox[name, "MessageName"], ": ",
If[StringQ[tmpl], slotFill[tmpl, args], Riffle[msgArgBox /@ args, " "]]}, 1]]
(* Quiet the body: building the box (ToBoxes of an arg, the template lookup) must
not itself fire a message, or the still-active handler would re-enter and loop.
A message fired here is Quiet'd -> reported to the handler as shown=False ->
falls to the no-op below, so there is no recursion. *)
handleMsg[Hold[Message[MessageName[sym_, tag_String], margs___], True]] := Quiet @
AppendTo[msgs, messageBoxData[
messageNameString[Unevaluated[sym]] <> "::" <> tag,
With[{t = MessageName[sym, tag]}, If[StringQ[t], t, MessageName[General, tag]]],
{margs}]]
handleMsg[_] := Null
(* legacy: an OLD cache entry stored messages as plain text, where a styled
RawBoxes name dumped as literal "RawBoxes[StyleBox[RowBox[{name, ::, tag}],
MessageName]]" - recover "name::tag" so a cached page still reads cleanly. *)
cleanMessageText[s_String] := StringReplace[s,
"RawBoxes[StyleBox[RowBox[{" ~~ name : Shortest[__] ~~ ", ::, " ~~
tag : Shortest[__] ~~ "}], MessageName]]" :> StringTrim[name] <> "::" <> StringTrim[tag]]
(* a captured message -> a notebook "Message" cell: box data (the new box-level
capture) wraps directly; a legacy text string is cleaned and wrapped. *)
messageCell[s_String] := Cell[cleanMessageText[s], "Message", "MSG"]
messageCell[box_] := Cell[BoxData[box], "Message", "MSG"]
(* Captured Print / Echo output -> a notebook "Print" cell.
Print is intercepted by Block-shadowing the symbol (rebinding $Output
does NOT re-route Print under `wl -f`: it writes straight to stdout via
a hardcoded channel; only a local Block-binding on the Protected symbol
intercepts). Each arg is coerced individually: a string splices as a
literal box leaf (matching notebook Print's "unquoted strings"
semantics), any other expression goes through ToBoxes - so
Print[graphic] / Print[image] / Print[dataset] / Print[typeset] all
render correctly in the resulting cell instead of collapsing to
"-Graphics-" the way a `ToString` round-trip would. *)
printArgBoxes[s_String] := s
printArgBoxes[expr_] := ToBoxes[expr]
printCellFromArgs[args_List] := With[{bs = printArgBoxes /@ args},
Cell[BoxData[If[Length[bs] === 1, First[bs], RowBox[bs]]], "Print"]
]
(* Backwards-compat: old cache entries stored Print output as plain text
chunks. Wrap any string entries in a Print cell on the way out. *)
asPrintCell[s_String] := Cell[s, "Print"]
asPrintCell[c_Cell] := c
asPrintCell[other_] := Cell[ToString[other, InputForm], "Print"]
(* box -> plain text, for the markdown twin's blockquote of a captured message
(a StyleBox keeps only its content; a RowBox concatenates its parts). *)
msgBoxText[s_String] := s
msgBoxText[RowBox[xs_List]] := StringJoin[msgBoxText /@ xs]
msgBoxText[(StyleBox | TagBox | InterpretationBox | FrameBox)[b_, ___]] := msgBoxText[b]
msgBoxText[b_] := ToString[b, InputForm]
(* Plain-text rendering of captured messages / prints for the markdown twin.
A box-level message renders via msgBoxText; a legacy text string is cleaned.
Print cells may be rich - a string-only Print becomes a blockquote, anything
richer rasterises to a screenshot image. *)
messageMd[s_String] := "> " <> StringReplace[StringTrim[cleanMessageText[s]], "\n" -> "\n> "]
messageMd[box_] := With[{t = StringTrim[msgBoxText[box]]},
If[t === "", "", "> " <> StringReplace[t, "\n" -> "\n> "]]]
(* Extract a textual form from a captured Print cell, or Missing if it
carries rich content (a GraphicsBox, a Dataset, a TemplateBox, ...).
Used by the twin renderer to decide between a blockquote and a
rasterised image. *)
printTextForm[s_String] := s
printTextForm[Cell[s_String, "Print", ___]] := s
printTextForm[Cell[BoxData[s_String], "Print", ___]] := s
printTextForm[Cell[BoxData[RowBox[lst_List]], "Print", ___]] /;
AllTrue[lst, StringQ] := StringJoin[lst]
printTextForm[_] := Missing["Rich"]
(* Capture EVERYTHING a cell's evaluation would produce in a real notebook,
without relying on the FE-driven NotebookEvaluate (which hangs in headless
`wl` since there is no FE->kernel link to drive it). Three complementary
mechanisms cover the four observable channels:
- Messages: redirect $Messages to a private write stream. Captures every
fired message including the OptionValue::optnf / General::newsym noise
Wolfram's framework fires per Plot (mostly suppressed by $Off / Stop /
Internal`InheritedBlock in Charting). Redirecting the print stream is
the *one* mechanism that respects all of those - whatever the kernel
decides to actually print, we capture verbatim.
- Print / Echo: Block-shadow the symbols. In script mode ($Output is
{OutputStream[stdout]}, no FE link), rebinding $Output does NOT
re-route Print - it writes straight to stdout via a hardcoded channel.
A local Block-binding on the Protected symbol works inside the
dynamic scope without touching the global definition.
- Intermediate Output values: parse the cell source into top-level
statements (Hold[s1, s2, ..., sN]) and evaluate each in order. A
statement whose held form is CompoundExpression[..., Null] (a
`;`-terminated source line) suppresses its Output cell - matching
notebook semantics. Plain expressions emit one Output per statement,
so a cell with `1+1\n2+2\n3+3` round-trips with three Output cells,
not just the last value Get returned previously.
- CellPrint: Block-shadow to capture cells injected by EchoFunction,
ResourceObject scrapers, or hand-written CellPrint calls. The captured
cells render in-line right after the Print/Echo cells.
Returns <|"outs" -> {boxes-or-Missing per non-suppressed statement},
"msgs" -> {message text, ...},
"prints" -> {print text, ...},
"cells" -> {injected cell, ...}|>.
Failed Get / parse returns "outs" -> {Missing[]} so callers downstream
still see the failure as a no-output cell rather than a crash. *)
captureCellRun[code_String, opts_Association : <||>] := Block[{stmts, outs, cellsExtra, msgs, prints},
stmts = Quiet @ ToExpression[code, InputForm, Hold];
If[Head[stmts] =!= Hold, stmts = Hold[code]]; (* parse failure: re-run for the message *)
outs = {};
cellsExtra = {};
prints = {};
msgs = {};
(* $Messages -> {} suppresses the kernel's default text output; messages are
captured at the box level by the Internal`HandlerBlock below (handleMsg
AppendTo's the Block-local msgs via dynamic scope). *)
Block[{$Messages = {}, Print, Echo, CellPrint},
(* Print accumulates a real Cell built from the args, not a text
chunk - so Print[graphics], Print[image], Print[dataset], or
Print["count: ", n] each render correctly downstream. *)
Print[args___] := (AppendTo[prints, printCellFromArgs[{args}]]; Null);
Echo[e_] := (Print[">> ", e]; e);
Echo[e_, label_] := (Print[">> ", label, " ", e]; e);
Echo[e_, label_, f_] := (Print[">> ", label, " ", f[e]]; e);
CellPrint[c_Cell] := (AppendTo[cellsExtra, c]; Null);
CellPrint[cs_List] := (cellsExtra = Join[cellsExtra, Cases[cs, _Cell]]; Null);
(* Walk statements: each held position is extracted with Extract,
which preserves the Hold wrapper so we can inspect its surface
form before releasing it. A CompoundExpression[..., Null] tail
is the source's "no output" mark - skip emission for that. The
HandlerBlock captures every message fired inside this walk. *)
Internal`HandlerBlock[{"Message", handleMsg},
Do[
With[{heldStmt = Extract[stmts, i, Hold]},
If[ MatchQ[heldStmt, Hold[CompoundExpression[___, Null]]],
(* suppressed - evaluate for side effects, no Output *)
ReleaseHold[heldStmt],
(* Quiet Syntax:: while rendering the result: typesetting a
TraditionalForm[HoldForm[...]] display label (e.g. a Manipulate Item
showing a vector/fraction) reparses its ASCII form and fires a spurious
Syntax::sntx, spamming the build though the notebook is correct (issue
#59). The statement itself already parsed at the top-level ToExpression,
so a syntax message here is never a real parse error. *)
AppendTo[outs, Quiet[outputBoxes[ReleaseHold[heldStmt], opts],
{Syntax::sntx, Syntax::sntxi, Syntax::sntxb, Syntax::tsntxi}]]
]
],