diff --git a/CHANGES.md b/CHANGES.md
index b880b99f..3c12285f 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -24,6 +24,9 @@ This file describes changes in the AutoDoc package.
defined (via `@BeginChunk`/`@BeginCode`)
- Allow using Markdown-style headings `#`/`##`/`###` as aliases for
`@Chapter`/`@Section`/`@Subsection` in `.autodoc` files and doc comments
+ - Support fenced code blocks using triple backticks or tildes in
+ Markdown-like text; `@listing`, `@example`, and `@log` info strings
+ select the corresponding GAPDoc element
2025.12.19
- Don't replace empty lines in `@BeginCode` blocks by `
`
diff --git a/doc/Comments.xml b/doc/Comments.xml
index de08fec1..9cf58bfd 100644
--- a/doc/Comments.xml
+++ b/doc/Comments.xml
@@ -761,6 +761,47 @@ can be important.
This produces the following output:
Call function foobar() at the start.
+
+
+
+ Fenced code blocks
+
+ One can insert verbatim code blocks by placing the code between lines
+ containing at least three backticks or at least three tildes. The opening
+ fence may optionally be followed by an info string. The values
+ @listing, @example, and @log select the corresponding
+ GAPDoc element; any other value is currently ignored. If nothing is specified
+ the default is to generate <Listing>. Example:
+
+ [ 1 .. 3 ] ^ 2;
+#! [ 1, 4, 9 ]
+#! ~~~
+#! ```@log
+#! #I some log message
+#! ```
+]]>
+
+ This produces the following output:
+
+ [ 1 .. 3 ] ^ 2;
+[ 1, 4, 9 ]
+]]>
+
+
diff --git a/gap/Markdown.gi b/gap/Markdown.gi
index c274eec5..4fc57fa5 100644
--- a/gap/Markdown.gi
+++ b/gap/Markdown.gi
@@ -21,7 +21,67 @@ InstallGlobalFunction( CONVERT_LIST_OF_STRINGS_IN_MARKDOWN_TO_GAPDOC_XML,
local i, current_list, current_string, max_line_length,
current_position, already_in_list, command_list_with_translation, beginning,
commands, position_of_command, insert, beginning_whitespaces, temp, string_list_temp, skipped,
- already_inserted_paragraph, in_list, in_item;
+ already_inserted_paragraph, in_list, in_item, converted_string_list,
+ fence_char, fence_length, trimmed_line, code_block, info_string,
+ fence_element;
+
+ converted_string_list := [ ];
+ i := 1;
+ while i <= Length( string_list ) do
+ trimmed_line := StripBeginEnd( string_list[ i ], " \t\r\n" );
+ if Length( trimmed_line ) >= 3 and
+ ( ForAll( trimmed_line{ [ 1 .. 3 ] }, c -> c = '`' ) or
+ ForAll( trimmed_line{ [ 1 .. 3 ] }, c -> c = '~' ) ) then
+ fence_char := trimmed_line[ 1 ];
+ fence_length := 1;
+ while fence_length < Length( trimmed_line ) and
+ trimmed_line[ fence_length + 1 ] = fence_char do
+ fence_length := fence_length + 1;
+ od;
+ if fence_length >= 3 then
+ info_string := NormalizedWhitespace(
+ trimmed_line{ [ fence_length + 1 .. Length( trimmed_line ) ] }
+ );
+ fence_element := "Listing";
+ if info_string = "@example" then
+ fence_element := "Example";
+ elif info_string = "@log" then
+ fence_element := "Log";
+ elif info_string = "@listing" then
+ fence_element := "Listing";
+ fi;
+ Add( converted_string_list,
+ Concatenation( "<", fence_element, ">= fence_length and
+ ForAll( trimmed_line{ [ 1 .. fence_length ] }, c -> c = fence_char ) and
+ ForAll( trimmed_line{ [ fence_length + 1 .. Length( trimmed_line ) ] },
+ c -> c in " \t\r\n" ) then
+ code_block := true;
+ break;
+ fi;
+ Add( converted_string_list,
+ ReplacedString( string_list[ i ], "]]>", "]]]]>" ) );
+ i := i + 1;
+ od;
+ if code_block = true then
+ Add( converted_string_list,
+ Concatenation( "]]>", fence_element, ">" ) );
+ i := i + 1;
+ continue;
+ fi;
+ Add( converted_string_list,
+ Concatenation( "]]>", fence_element, ">" ) );
+ break;
+ fi;
+ fi;
+ Add( converted_string_list, string_list[ i ] );
+ i := i + 1;
+ od;
+ string_list := converted_string_list;
## Check for paragraphs by turning an empty string into
diff --git a/gap/Parser.gi b/gap/Parser.gi
index f22f27ec..641f3126 100644
--- a/gap/Parser.gi
+++ b/gap/Parser.gi
@@ -214,7 +214,10 @@ InstallGlobalFunction( AutoDoc_Parser_ReadFiles,
current_line_unedited,
ReadLineWithLineCount, Normalized_ReadLine, line_number, ErrorWithPos, create_title_item_function,
current_line_positition_for_filter, read_session_example, DeclarationDelimiterPosition,
- ReadInstallMethodFilterString, ReadInstallMethodArguments;
+ ReadInstallMethodFilterString, ReadInstallMethodArguments,
+ markdown_fence, preserve_shebang_in_fenced_code,
+ AUTODOC_MarkdownFenceFromLine, AUTODOC_IsMatchingMarkdownFence,
+ current_line_fence, current_line_is_fence_delimiter;
groupnumber := 0;
level_scope := 0;
autodoc_read_line := false;
@@ -240,6 +243,44 @@ InstallGlobalFunction( AutoDoc_Parser_ReadFiles,
list := Concatenation(arg, [ ",\n", "at ", filename, ":", line_number]);
CallFuncList(Error, list);
end;
+ AUTODOC_MarkdownFenceFromLine := function( line )
+ local comment_pos, trimmed_line, fence_char, fence_length;
+ if plain_text_mode then
+ trimmed_line := StripBeginEnd( Chomp( line ), " \t\r\n" );
+ else
+ comment_pos := AUTODOC_PositionPrefixShebang( line );
+ if comment_pos = fail then
+ return fail;
+ fi;
+ trimmed_line := StripBeginEnd(
+ Chomp( line{ [ comment_pos + 2 .. Length( line ) ] } ),
+ " \t\r\n"
+ );
+ fi;
+ if Length( trimmed_line ) < 3 or
+ not ( trimmed_line[ 1 ] in "`~" ) or
+ not ForAll( trimmed_line{ [ 1 .. 3 ] }, c -> c = trimmed_line[ 1 ] ) then
+ return fail;
+ fi;
+ fence_char := trimmed_line[ 1 ];
+ fence_length := 1;
+ while fence_length < Length( trimmed_line ) and
+ trimmed_line[ fence_length + 1 ] = fence_char do
+ fence_length := fence_length + 1;
+ od;
+ return rec(
+ char := fence_char,
+ length := fence_length,
+ remainder := trimmed_line{ [ fence_length + 1 .. Length( trimmed_line ) ] }
+ );
+ end;
+ AUTODOC_IsMatchingMarkdownFence := function( fence, current_line_fence )
+ return current_line_fence <> fail and
+ fence <> fail and
+ current_line_fence.char = fence.char and
+ current_line_fence.length >= fence.length and
+ ForAll( current_line_fence.remainder, c -> c in " \t\r\n" );
+ end;
DeclarationDelimiterPosition := function( line )
return Minimum( [ PositionSublist( line, "," ), PositionSublist( line, ");" ) ] );
end;
@@ -339,6 +380,7 @@ InstallGlobalFunction( AutoDoc_Parser_ReadFiles,
context_stack := [ ];
Unbind( current_item );
plain_text_mode := false;
+ markdown_fence := fail;
end;
Scan_for_Declaration_part := function()
local declare_position, current_type, filter_string, has_filters,
@@ -831,7 +873,7 @@ InstallGlobalFunction( AutoDoc_Parser_ReadFiles,
return;
fi;
comment_pos := AUTODOC_PositionPrefixShebang( current_line_unedited );
- if comment_pos <> fail then
+ if comment_pos <> fail and not preserve_shebang_in_fenced_code then
current_line_unedited := current_line_unedited{[ comment_pos + 2 .. Length( current_line_unedited ) ]};
fi;
Add( current_item, current_line_unedited );
@@ -938,13 +980,39 @@ InstallGlobalFunction( AutoDoc_Parser_ReadFiles,
fi;
current_line_unedited := ShallowCopy( current_line );
NormalizeWhitespace( current_line );
+ current_line_fence := AUTODOC_MarkdownFenceFromLine( current_line_unedited );
+ current_line_is_fence_delimiter := false;
+ if current_line_fence <> fail then
+ if markdown_fence = fail then
+ current_line_is_fence_delimiter := true;
+ else
+ current_line_is_fence_delimiter :=
+ AUTODOC_IsMatchingMarkdownFence( markdown_fence, current_line_fence );
+ fi;
+ fi;
+ preserve_shebang_in_fenced_code :=
+ markdown_fence <> fail and
+ AUTODOC_PositionPrefixShebang( current_line_unedited ) <> fail and
+ not current_line_is_fence_delimiter;
current_command := Scan_for_AutoDoc_Part( current_line, plain_text_mode );
+ if current_line_is_fence_delimiter then
+ current_command[ 1 ] := "STRING";
+ elif markdown_fence <> fail and current_command[ 1 ] <> false then
+ current_command[ 1 ] := "STRING";
+ fi;
if current_command[ 1 ] <> false then
autodoc_read_line := true;
if not IsBound( command_function_record.(current_command[ 1 ]) ) then
ErrorWithPos("unknown AutoDoc command ", current_command[ 1 ]);
fi;
command_function_record.(current_command[ 1 ])();
+ if current_line_is_fence_delimiter then
+ if markdown_fence = fail then
+ markdown_fence := current_line_fence;
+ else
+ markdown_fence := fail;
+ fi;
+ fi;
continue;
fi;
current_line := current_command[ 2 ];
diff --git a/tst/misc.tst b/tst/misc.tst
index bfd3d71e..92eb0d47 100644
--- a/tst/misc.tst
+++ b/tst/misc.tst
@@ -120,6 +120,88 @@ gap> item!.tester_names;
gap> item!.arguments;
"x,y"
+#
+# fenced code blocks in Markdown-like text
+#
+gap> CONVERT_LIST_OF_STRINGS_IN_MARKDOWN_TO_GAPDOC_XML([
+> "Before",
+> "```gap",
+> "if x = 2 then",
+> " Print(\"ok\\n\");",
+> "fi;",
+> "```",
+> "After"
+> ]) = [
+> "Before",
+> " "if x = 2 then",
+> " Print(\"ok\\n\");",
+> "fi;",
+> "]]>",
+> "After"
+> ];
+true
+gap> CONVERT_LIST_OF_STRINGS_IN_MARKDOWN_TO_GAPDOC_XML([
+> "~~~",
+> "gap> [[2]]>[[1]];",
+> "~~~"
+> ]) = [
+> " "gap> [[2]]]]>[[1]];",
+> "]]>"
+> ];
+true
+gap> CONVERT_LIST_OF_STRINGS_IN_MARKDOWN_TO_GAPDOC_XML([
+> "```@example",
+> "gap> 1 + 1;",
+> "2",
+> "```"
+> ]) = [
+> " "gap> 1 + 1;",
+> "2",
+> "]]>"
+> ];
+true
+gap> CONVERT_LIST_OF_STRINGS_IN_MARKDOWN_TO_GAPDOC_XML([
+> "```@log",
+> "#I some log message",
+> "```"
+> ]) = [
+> " "#I some log message",
+> "]]>"
+> ];
+true
+
+#
+# fenced code blocks preserve literal #! lines
+#
+gap> tmpdir := Filename(DirectoryTemporary(), "autodoc-fenced-comments-test");;
+gap> if IsDirectoryPath(tmpdir) then RemoveDirectoryRecursively(tmpdir); fi;
+gap> AUTODOC_CreateDirIfMissing(tmpdir);
+true
+gap> tmpdir_obj := Directory(tmpdir);;
+gap> file1 := Filename(tmpdir_obj, "fenced-comments.gd");;
+gap> stream := OutputTextFile(file1, false);;
+gap> AppendTo(stream, "#! @Chapter FenceChapter\n");;
+gap> AppendTo(stream, "#! @Section FenceSection\n");;
+gap> AppendTo(stream, "#! ```@listing\n");;
+gap> AppendTo(stream, "#! @Section ThisMustStayLiteral\n");;
+gap> AppendTo(stream, "#! #! AlsoLiteral\n");;
+gap> AppendTo(stream, "#! ```\n");;
+gap> CloseStream(stream);
+gap> tree5 := DocumentationTree();;
+gap> AutoDoc_Parser_ReadFiles([file1], tree5, rec());
+gap> WriteDocumentation(tree5, tmpdir_obj, 0);
+gap> xml := StringFile(Filename(tmpdir_obj, "_Chapter_FenceChapter.xml"));;
+gap> PositionSublist(xml, "") <> fail;
+true
+gap> PositionSublist(xml, "Section_ThisMustStayLiteral") = fail;
+true
+gap> RemoveDirectoryRecursively(tmpdir);
+true
+
#
# warn about defined-but-never-inserted chunks
#