Skip to content

Commit ca85bb3

Browse files
peopleworksclaude
andcommitted
Emit a heading where a <details> fold was
A seed method's source was wrapped in <details>/<summary>. That fold only opens in a renderer that passes HTML through. Everywhere else -- a Word or PDF export, a plain Markdown viewer, a model reading the file -- the wrapper is literal text, and the fold's label stops being a label and becomes a line of markup. Found by walking the generated Markdown against the Markdig converter in mcpOffice, whose documented behaviour for an HTML block is to emit it as a plain text paragraph. Every other construct the generator writes -- headings, GFM pipe tables, fenced code, lists, bold, inline code -- maps to a real Word equivalent, so this one call site was the whole distance between an extraction and a document somebody can hand over. The trade is honest: the fold is lost on GitHub, so a long seed body makes that section longer to scroll. These files are read far more often than they are scrolled. Guarded by a fence-aware check that no sample project's Markdown, in either language, opens a line with raw HTML outside a code fence. 348 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45tzJFX3NoSrk7svtQeKT
1 parent f83528e commit ca85bb3

4 files changed

Lines changed: 121 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,27 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5454
A key is never read from or written to the configuration file: the endpoint and the model name
5555
are settings, a key is a secret, and that file lives in a home directory that gets copied around.
5656

57+
- **The Markdown we generate is Markdown** ([#28]). A seed method's source was wrapped in a
58+
`<details>` fold. That collapses on GitHub and nowhere else: in a Word or PDF export, in a plain
59+
Markdown viewer, and to a model reading the file, the wrapper is literal text and the fold's
60+
label — "Source code of PopulateStatuses" — stops being a label and becomes a line of markup. It
61+
is now a heading, which survives the trip and takes its place in the document outline. The fold
62+
is lost on GitHub; these files are read far more often than they are scrolled. Found by walking
63+
the output against the Markdig converter in [mcpOffice], whose documented behaviour for an HTML
64+
block is to emit it as plain text — every other construct we write already maps to a real Word
65+
equivalent, so this one call site was the whole distance between an extraction and a document
66+
somebody can hand over.
67+
5768
### Internal
5869

59-
- First tests over `ProjectDiffEngine`, which is why the key above survived. 345 tests.
70+
- First tests over `ProjectDiffEngine`, which is why the key above survived.
71+
72+
- Every sample project's Markdown is now checked, in both languages, for a line that opens raw HTML
73+
outside a code fence. 348 tests.
6074

6175
[#24]: https://github.com/peopleworks/XAFLogicExplainer/issues/24
76+
[#28]: https://github.com/peopleworks/XAFLogicExplainer/issues/28
77+
[mcpOffice]: https://github.com/MBrekhof/mcpOffice
6278

6379
[#21]: https://github.com/peopleworks/XAFLogicExplainer/pull/21
6480
[@MBrekhof]: https://github.com/MBrekhof

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ applications. The agent-facing surface is what is landing now, in the open.
330330
|| Pluggable publishing targets (`IDocumentationSink`) |
331331
|| **MCP server** — 10 tools, live against your source |
332332
|| **Installable Claude Code plugin** with skill and MCP server |
333-
|| **345 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
333+
|| **348 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
334334
|| **DevExpress ground-truth catalog**, generated locally by licensees |
335335

336336
PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the

src/XafLogicExplainer.Core/Generators/MarkdownDocumentationGenerator.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -840,13 +840,16 @@ private DocumentSection GenerateConfigurationSection(ExtractedProject project)
840840

841841
if (!string.IsNullOrEmpty(seed.RawSourceCode))
842842
{
843-
sb.AppendLine("<details>");
844-
sb.AppendLine($"<summary>{_l.SourceCodeOf} {seed.MethodName}</summary>");
843+
// A heading rather than a <details> fold. The fold only opens in a renderer that
844+
// passes HTML through, and everywhere else -- a Word or PDF export, a plain
845+
// Markdown viewer, a model reading the file -- the wrapper is literal text and the
846+
// label stops being a label. This costs the fold on GitHub, which is the trade:
847+
// these files are read far more often than they are scrolled.
848+
sb.AppendLine($"#### {_l.SourceCodeOf} {seed.MethodName}");
845849
sb.AppendLine();
846850
sb.AppendLine("```csharp");
847851
sb.AppendLine(seed.RawSourceCode);
848852
sb.AppendLine("```");
849-
sb.AppendLine("</details>");
850853
sb.AppendLine();
851854
}
852855
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using XafLogicExplainer.Core.Generators;
2+
using XafLogicExplainer.Core.Models;
3+
4+
namespace XafLogicExplainer.Tests;
5+
6+
/// <summary>
7+
/// That the Markdown we generate is Markdown, and not Markdown plus HTML that only a browser opens.
8+
/// </summary>
9+
/// <remarks>
10+
/// The seed section wrapped a method's source in a <c>&lt;details&gt;</c> fold. On GitHub that
11+
/// collapses; anywhere else the wrapper is literal text and the fold's label — "Source code of
12+
/// PopulateStatuses" — stops being a label and becomes a line of markup. "Anywhere else" is most
13+
/// places these files go: a Word or PDF export, a plain Markdown viewer, and a language model, for
14+
/// which the fold is tokens spent on something it cannot open.
15+
/// <para>
16+
/// Found by walking our output against the Markdig-based converter in
17+
/// <see href="https://github.com/MBrekhof/mcpOffice">mcpOffice</see>, whose documented behaviour for
18+
/// an HTML block is to emit it as a plain text paragraph. Every other construct we write — headings,
19+
/// pipe tables, fenced code, lists, bold, inline code — maps to a real Word equivalent, so this one
20+
/// call site was the whole difference between an extraction and a document someone can hand over.
21+
/// </para>
22+
/// </remarks>
23+
public class PortableMarkdownTests
24+
{
25+
private static readonly (string Name, ExtractedProject Project)[] Samples =
26+
[
27+
("Xpo", SampleProjects.Xpo),
28+
("EfCore", SampleProjects.EfCore),
29+
("LegacyEf", SampleProjects.LegacyEf),
30+
("PocoEf", SampleProjects.PocoEf),
31+
("NoOrm", SampleProjects.NoOrm),
32+
("DeepXpo", SampleProjects.DeepXpo),
33+
("AuditedXpo", SampleProjects.AuditedXpo),
34+
("Demo", SampleProjects.Demo),
35+
];
36+
37+
private static string Markdown(ExtractedProject project, string language) =>
38+
string.Join("\n", new MarkdownDocumentationGenerator(language)
39+
.GenerateSections(project)
40+
.Select(section => section.Content))
41+
.Replace("\r", "");
42+
43+
/// <summary>
44+
/// Lines that begin a CommonMark HTML block: outside a fence, a line whose first character is
45+
/// <c>&lt;</c>. Inside a fence the same line is source code and is left alone, which is why this
46+
/// tracks the fence rather than matching the whole document at once.
47+
/// </summary>
48+
private static List<string> RawHtmlLines(string markdown)
49+
{
50+
var offenders = new List<string>();
51+
var insideFence = false;
52+
53+
foreach (var line in markdown.Split('\n'))
54+
{
55+
if (line.TrimStart().StartsWith("```", StringComparison.Ordinal))
56+
{
57+
insideFence = !insideFence;
58+
continue;
59+
}
60+
61+
if (!insideFence && line.TrimStart().StartsWith('<'))
62+
offenders.Add(line.Trim());
63+
}
64+
65+
return offenders;
66+
}
67+
68+
[Theory]
69+
[InlineData("en")]
70+
[InlineData("es")]
71+
public void NoGeneratedPageOpensALineWithRawHtml(string language)
72+
{
73+
foreach (var (name, project) in Samples)
74+
{
75+
var offenders = RawHtmlLines(Markdown(project, language));
76+
77+
Assert.True(offenders.Count == 0,
78+
$"{name} ({language}) emits raw HTML outside a code fence, which renders as literal "
79+
+ $"text everywhere but a browser: {string.Join(" | ", offenders)}");
80+
}
81+
}
82+
83+
[Fact]
84+
public void TheSeedSourceIsIntroducedByAHeadingRatherThanAFold()
85+
{
86+
// A heading survives the trip and keeps its place in the document outline; the fold did
87+
// neither. The fixture that carries this is the XPO sample, whose updater has a body.
88+
var english = Markdown(SampleProjects.Xpo, "en");
89+
90+
Assert.Contains("#### Source code of ", english, StringComparison.Ordinal);
91+
Assert.Contains("#### Codigo fuente de ", Markdown(SampleProjects.Xpo, "es"), StringComparison.Ordinal);
92+
93+
// The label is what was lost: inside <summary> it was markup, and the reader met angle
94+
// brackets where a title belonged.
95+
Assert.DoesNotContain("<summary>", english, StringComparison.Ordinal);
96+
}
97+
}

0 commit comments

Comments
 (0)