Skip to content

Refactor reusable YAML, markdown, and HTML builders#311

Merged
PrzemyslawKlys merged 8 commits intomainfrom
codex/reusable-yaml-builders
Apr 11, 2026
Merged

Refactor reusable YAML, markdown, and HTML builders#311
PrzemyslawKlys merged 8 commits intomainfrom
codex/reusable-yaml-builders

Conversation

@PrzemyslawKlys
Copy link
Copy Markdown
Member

Summary

  • extract reusable YAML and Markdown builders for release manifests, front matter, docs generation, and discussion/report output
  • standardize API docs and Scriban theme HTML rendering around shared helper patterns instead of large inline string assembly
  • add focused tests for the new builder helpers and keep the existing docs and theme rendering contracts covered

Why

This branch reduces the amount of hand-stitched string output across PowerForge and PowerForge.Web, which makes the rendering code easier to read, safer to reuse, and simpler to extend without copying more append-heavy patterns.

Validation

  • dotnet build .\\PowerForge.Web\\PowerForge.Web.csproj -c Release
  • dotnet test .\\PowerForge.Tests\\PowerForge.Tests.csproj -c Release --filter "FullyQualifiedName~WebApiDocsSocialMetaTests|FullyQualifiedName~WebApiDocsGeneratorPowerShellTests|FullyQualifiedName~WebApiDocsGeneratorSourceAndCssTests|FullyQualifiedName~WebApiDocsGeneratorContractTests|FullyQualifiedName~WebApiDocsGeneratorCrefTests|FullyQualifiedName~WebApiDocsGeneratorUsageTests|FullyQualifiedName~WebApiDocsGeneratorRelatedContentTests|FullyQualifiedName~WebPipelineRunnerApiDocsPreflightTests|FullyQualifiedName~WebApiDocsGeneratorSuiteTests|FullyQualifiedName~WebPipelineRunnerProjectApiDocsTests"
  • dotnet test .\\PowerForge.Tests\\PowerForge.Tests.csproj -c Release --filter "FullyQualifiedName~ScribanPfNavigationHelpersTests|FullyQualifiedName~WebSiteLocalizationFeaturesTests"

@claude
Copy link
Copy Markdown

claude Bot commented Apr 10, 2026

PR Review — Refactor reusable YAML, Markdown, and HTML builders

This is a well-motivated refactor. The extracted builders are clean, the IDisposable indent-scope pattern is consistent, and HTML encoding is applied correctly throughout. A few things worth addressing before merging:


Bugs

Duplicate null check in BuildNavItemHtml (NavTokens.cs)

// Lines added in WebApiDocsGenerator.ApiDocs.NavTokens.cs
if (item is null || string.IsNullOrWhiteSpace(item.Text))
    return string.Empty;

if (string.IsNullOrWhiteSpace(item.Text))   // ← unreachable, always false here
    return string.Empty;

The second guard is redundant — it can never fire. Same pattern appears in the ScribanThemeHelpers.cs hunk for BuildNavItemHtml. Safe to remove.


Inconsistent API between the two Markdown builders

MarkdownDocumentBuilder.Paragraph appends the text and a trailing blank line (two AppendLine calls). MarkdownThreadBuilder.Paragraph only calls AppendLine once — no trailing blank line. Because the two classes expose the same method name, callers reasonably expect the same semantics. Either align the behavior or rename MarkdownThreadBuilder.Paragraph to something that signals the difference (e.g. Line).

Related: MarkdownThreadBuilder.Heading accepts any integer without validating the 1–6 range, whereas MarkdownDocumentBuilder.Heading throws ArgumentOutOfRangeException. A heading level of 0 or 99 would silently produce invalid Markdown.


Minor issues

Unnecessary .ToArray() allocations

string.Concat and string.Join each have IEnumerable<string> overloads, so the intermediate array is wasted work:

// current
return JoinHtmlFragments(items.Select(BuildNavItemHtml).ToArray());
return JoinHtmlFragments(fragments.ToArray());

// simpler
return string.Concat(items.Select(BuildNavItemHtml));
return string.Concat(fragments);

FrontMatter(string, string) allocates a fresh YamlTextWriter per call

Each scalar field in front matter creates a new writer, builds one line, converts to string, and discards the writer. Since FrontMatter is called several times per document, a single shared YamlTextWriter stored on _frontMatter (or just _frontMatter written to directly) would be simpler and avoids repeated allocations.

MarkdownTableBuilder doesn't support center alignment

The ToSeparator method returns "---:" for Right and "---" for everything else, silently treating MarkdownTableAlignment.Center (if it exists in the enum) as left-aligned. If Center is intentionally omitted for now, a comment or default case to make this explicit would help.


Test coverage gaps

New class Tested?
YamlTextWriter
MarkdownDocumentBuilder ✅ (one scenario)
HtmlFragmentBuilder
MarkdownThreadBuilder
WingetManifestWriter

HtmlFragmentBuilder is particularly worth testing — it underpins the HTML output for API docs and Scriban themes, and the Indent()/IDisposable scope logic is the kind of thing that breaks subtly if _indent math is wrong. Even a few cases (basic line output, nested indent/unindent, IsEmpty) would add meaningful protection.


Positive notes

  • The IndentScope.Dispose() guards against double-dispose and clamps to Math.Max(0, …) — no chance of negative indentation.
  • YamlTextWriter.EscapeScalar is now null-safe (the old EscapeYamlScalar in PowerForgeReleaseService would throw on null); the behavior is otherwise identical, so the migration is safe.
  • HTML encoding is consistently applied through Html() / HttpUtility.HtmlEncode — no obvious injection paths in the new code.
  • The new HtmlFragmentBuilder-based overloads in RelatedContent.cs / Suite.cs make the indentation structure explicit and readable compared to the string-literal indent prefixes they replace.

Overall this is a solid cleanup. The duplicate-check bug and the Paragraph API inconsistency are the items I'd fix before merging; the rest are low-priority improvements.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 10, 2026

PR Review: Refactor reusable YAML, markdown, and HTML builders

Overall this is a well-structured refactoring. The new builder classes have clean APIs, the IndentScope/IDisposable pattern in both HtmlFragmentBuilder and YamlTextWriter is idiomatic and correct, and the tests for all new builders are focused and useful. The extraction of WingetManifestWriter from PowerForgeReleaseService is a particularly good cleanup. A few things worth addressing:


Potential bug: WingetManifestWriter.Build — unguarded installers[0] access

The line var installerType = installers[0].InstallerType; throws IndexOutOfRangeException with no diagnostic message if installers is empty. The old code had the same issue, but the refactoring is a good opportunity to add a guard before accessing index 0.


Minor: HtmlFragmentBuilder.Line parameter nullability inconsistency

Line(string text) handles null internally via text ?? string.Empty, but AppendRaw(string? text) correctly declares the parameter nullable. With <Nullable>enable</Nullable> on the project, either make the parameter string? to match the intent, or remove the null-coalescing guard if null should genuinely be disallowed.


Minor: MarkdownDocumentBuilder and MarkdownThreadBuilder diverge on post-heading blank lines

MarkdownDocumentBuilder.Heading appends a blank line after every heading; MarkdownThreadBuilder.Heading does not. This is likely intentional (different rendering targets), but a one-line comment on MarkdownThreadBuilder.Heading explaining the omission would help future readers.


Code quality: MarkdownDocumentBuilder.FrontMatter allocates a YamlTextWriter per key

Each FrontMatter(key, value) call creates a new YamlTextWriter, serialises it to string, then appends to _frontMatter. A single _frontMatterWriter field would avoid the per-call allocation and intermediate ToString() copy, especially when front matter has many keys.


Code quality: attribute list allocation in BuildNavItemHtml

The refactored nav item renderer allocates a List<string> per item to collect optional HTML attributes, then calls string.Join. The original conditional StringBuilder.Append pattern was allocation-free. A simpler alternative is to build each optional attribute as a conditional string and concatenate them directly in the interpolated return value. Not a blocking issue for typical nav sizes, but worth revisiting if this path is hot.


Missing tests: MarkdownTableBuilder

HtmlFragmentBuilder, MarkdownDocumentBuilder/MarkdownThreadBuilder, and YamlTextWriter all have dedicated test files. MarkdownTableBuilder (new in this PR) does not. Given it has non-trivial behaviour — header/separator rendering, alignment formatting, row cell-count validation — a MarkdownTableBuilderTests.cs would complete the pattern established by the other new builders.


Nit: YamlTextWriter.EscapeScalar quotes values containing hyphens

The escape check list includes the hyphen character, which causes any value with a hyphen (e.g. v1.0.0, my-package) to be quoted. A bare hyphen within a plain scalar does not require quoting in YAML. The output is still valid YAML, just unnecessarily verbose for common version strings.


Nit: MarkdownTableAlignment has no Center option

Standard Markdown tables support :---: for center alignment. Only Left and Right are defined. If center is intentionally out of scope, a brief comment would head off a future inconsistent addition.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 21457e90d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +125 to +126
private static string Normalize(string text)
=> (text ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Trim().Replace("\n", Environment.NewLine);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve leading whitespace in fenced code blocks

CodeFence normalizes content with Trim(), which removes leading indentation and leading blank lines from snippets before writing them. MarkdownHelpWriter now sends syntax/examples through this path, so indented examples (for example nested YAML/PowerShell blocks or here-string bodies) get rewritten and may no longer be copy-paste correct. Prefer end-only trimming for fenced code so original code indentation is preserved.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 10, 2026

PR Review: Refactor reusable YAML, markdown, and HTML builders

Overall this is a well-structured refactor. The new builder classes have clear responsibilities, consistent patterns, good argument validation, and focused tests. The dual-signature migration approach (keeping StringBuilder overloads as bridge delegates to new HtmlFragmentBuilder overloads) is clean and avoids a flag-day rewrite. A few things worth addressing before merging:


Issues

MarkdownTableBuilder does not escape pipe characters in cell content

AddRow and ToString emit cells verbatim. A value like a | b in a cell breaks the table:

table.AddRow("x | y", "12");
// produces: | x | y | 12 |  — parsed as 3 columns

If callers are always trusted to pre-escape pipes, that should be stated in a code comment. Otherwise, AddRow should escape |\|.


YamlTextWriter.EscapeScalar over-quotes on -

The - character is in the trigger set, which means any hyphenated value (e.g. my-package, Windows-x64) gets quoted unnecessarily:

EscapeScalar("my-package")  // → "my-package" (quoted)

YAML only requires quoting - when it appears at the start of a line (sequence item indicator). In plain block scalars like key: value, a bare - anywhere is legal. This doesn't produce invalid YAML but does reduce readability of the generated manifests. Consider removing - from the character set.


Minor observations

Duplicated Normalize helper

MarkdownDocumentBuilder and MarkdownThreadBuilder each define an identical private static Normalize method:

private static string Normalize(string text)
    => (text ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Trim().Replace("\n", Environment.NewLine);

Both are internal sealed, so the duplication is contained, but extracting this to a shared internal static class MarkdownNormalizer (or similar) would make it easier to fix edge cases consistently in one place.


HtmlFragmentBuilder has no BlankLine() method

MarkdownDocumentBuilder has BlankLine() but HtmlFragmentBuilder doesn't. The workaround html.Line(string.Empty) works but obscures intent. A BlankLine() convenience method would make the call sites self-documenting and match the style of the markdown builders.


MarkdownDocumentBuilder.FrontMatter allocates a new YamlTextWriter per call

Each FrontMatter(key, value) call instantiates a fresh YamlTextWriter, renders it, and appends the result. For typical front matter with 5–10 keys this is harmless, but a single YamlTextWriter instance held on _frontMatter and written directly would be cleaner and avoid the intermediate string allocation.


MarkdownTableAlignment is missing Center

GFM supports :---: for center-aligned columns. Since the enum is being introduced now, adding Center avoids a later API addition. This is optional if center alignment isn't needed anywhere in the codebase today.


What's working well

  • IndentScope pattern is identical in both HtmlFragmentBuilder and YamlTextWriter — easy to learn once, reusable everywhere.
  • Test coverage hits the key behavioral contracts (indentation, blank tracking, YAML sequence filtering, table alignment, front matter round-trip, error paths).
  • WingetManifestWriter.Build validation test is a good defensive contract test.
  • The HtmlFragmentBuilder.AppendRaw escape hatch is handy but won't accidentally add indentation to raw fragments — clear and predictable.
  • HTML encoding is applied consistently across all attribute and text sites throughout ScribanThemeHelpers.cs.

🤖 Generated with Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0fc8cf9fc8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +994 to +995
html.AppendRaw(System.Web.HttpUtility.HtmlEncode(normalizedCode));
html.Line("</code></pre>");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Insert newline before closing example code tags

When an example code snippet does not already end with a newline (a common case for single-line examples), AppendRaw writes the encoded code and Line("</code></pre>") immediately appends the closing tags on the same line, so the builder’s indentation spaces become part of the rendered code text. This changes copy/paste output by introducing trailing spaces on the final code line, which is a regression from the previous behavior where closing tags were always emitted on their own line.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

PR Review: Refactor reusable YAML, markdown, and HTML builders

Good refactor overall. The extraction of YamlTextWriter, MarkdownDocumentBuilder, MarkdownTableBuilder, MarkdownThreadBuilder, HtmlFragmentBuilder, and WingetManifestWriter into dedicated builder classes significantly improves readability and reuse. The IndentScope/IDisposable pattern is consistent and idiomatic, HTML encoding is applied correctly throughout, and the test suite covers the key contracts of the new builders. A few things worth looking at before merging:


Potential bugs

1. WingetManifestWriter — empty InstallerType silently written

var installerType = installers[0].InstallerType;  // could be null/empty
// ...
writer.WriteScalar("InstallerType", installerType);  // writes an empty scalar

If installers[0].InstallerType is null or empty (the field defaults to "zip" on the model, but callers could override it), the manifest gets InstallerType: "" without any validation error. Consider adding a guard or falling back to "zip" explicitly here so the field always has a valid value.


2. YamlTextWriter.EscapeScalar — over-quotes bare - scalars

return normalized.StartsWith("-", StringComparison.Ordinal)
    || normalized.IndexOfAny(new[] { ':', '#', ... }) >= 0
    || normalized.Contains(' ')
    ? "\"" + ... + "\""
    : normalized;

A bare - only needs quoting when it appears as a block sequence indicator (i.e., - followed by content at the start of a line). As a plain scalar value (e.g., key: -value), it is valid YAML without quotes. The current code quotes any value beginning with -, including legitimate values like -v1.2.3 version strings. Over-quoting is not technically wrong, but it produces noisier output and deviates from the YAML spec. The test fixture already expects "-leading-dash" quoted, so this is baked in — just flagging it in case the output is user-visible and readability matters.


3. MarkdownTableBuilder.EscapeCell — backslash-before-pipe not handled

private static string EscapeCell(string? value)
    => (value ?? string.Empty).Replace("|", "\\|");

A cell value like a\|b becomes a\\|b after this transform, which Markdown parsers interpret as a literal backslash followed by a pipe — not the intended a\|b. The correct order is to escape backslashes first, then pipes:

=> (value ?? string.Empty).Replace("\\", "\\\\").Replace("|", "\\|");

The existing pipe-escape test will still pass; the new edge case is "a\\|b""a\\\\|b" (escaped backslash + escaped pipe).


Design / API observations

4. Divergent blank-line behaviour between MarkdownDocumentBuilder and MarkdownThreadBuilder

MarkdownDocumentBuilder.Heading appends a blank line after every heading; MarkdownThreadBuilder.Heading intentionally does not (the comment explains why). This is reasonable, but since both classes expose similar method names with no shared interface, a future maintainer may call the wrong builder type and get subtly different output. A brief XML doc comment on MarkdownThreadBuilder.Heading noting the intentional difference would help.

5. MarkdownDocumentBuilder.FrontMatter allocates a new YamlTextWriter per call

public void FrontMatter(string key, string value)
{
    var yaml = new YamlTextWriter();
    yaml.WriteScalar(key, value);
    _frontMatter.Append(yaml.ToString());
    _hasFrontMatter = true;
}

Each call allocates a YamlTextWriter and then throws it away. Holding a single YamlTextWriter _frontMatterYaml field and writing directly into it would save allocations (especially when a document has many front-matter keys) and simplify the logic — _frontMatter.Append(yaml.ToString()) becomes _frontMatterYaml.WriteScalar(key, value).


Minor / nitpick

6. List<string> + string.Join for attribute assembly is fine for correctness but adds friction

Several helpers build HTML attributes via new List<string> { ... } then string.Join(" ", attributes). For the expected menu/nav sizes this is fine. However, the pre-existing StringBuilder.Append approach was actually clearer at the call site and did not require a heap-allocated list. The change is internally consistent so this is not a blocker — just worth noting if any of these paths are in a hot render loop.

7. HtmlFragmentBuilder.AppendRaw skips indentation silently

Line() prepends the current indent level; AppendRaw() does not. The name is unambiguous, but a caller that mixes both methods at the same conceptual nesting level will get inconsistent indentation without an obvious error. A short doc comment or an // intentionally raw — no indent applied inline note would help.


What's working well

  • The IndentScope : IDisposable guard pattern is clean and correctly handles double-dispose.
  • HtmlFragmentBuilder.IsEmpty is a nice touch for the bridge methods that conditionally append.
  • WingetManifestWriter's mixed-InstallerType guard throws an early, descriptive InvalidOperationException — good defensive check.
  • Test assertions for ReplaceLineEndings + Environment.NewLine normalisation are portable.
  • The MarkdownTableBuilderTests pipe-escape test and the WingetManifestWriterTests empty-installer test both cover previously raised review concerns — good follow-through.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Code Review: Refactor reusable YAML, markdown, and HTML builders

The overall direction of this PR is excellent — extracting dedicated builder types reduces hand-rolled string assembly, centralises escaping logic, and makes the output contracts testable. The new classes are well-shaped and the test coverage for the core builder behaviours is solid. A few things to address before merging:


Bug: Double-escaping in GitHubHousekeepingReportService

This is the most important issue. MarkdownTableBuilder.AddRow always calls its own internal EscapeCell on every value you pass in, but the callers in GitHubHousekeepingReportService still call their own EscapeCell before passing values in. Any cell containing | or \ will therefore be escaped twice.

Affected call sites (new + lines):

  • BuildMarkdown: EscapeCell(report.Error), EscapeCell(string.Join(...)), EscapeCell(result.Message) passed to BuildFieldTable then AddRow
  • AppendArtifactTable: EscapeCell(item.Name), EscapeCell(item.Reason), EscapeCell(DeleteState(...)) passed to table.AddRow
  • AppendCacheTable: same pattern with EscapeCell(item.Key)
  • AppendRunnerDetails: EscapeCell(step.Title), EscapeCell(step.Message)

Example of the corruption:
A cell value artifact | old would go:

  1. Caller EscapeCell produces artifact \| old
  2. AddRow internal EscapeCell produces artifact \\| old (backslash doubled again)

Fix: Remove the EscapeCell() calls on values passed to AddRow — the table builder owns that responsibility. The local EscapeCell helper in the service can be deleted once those call sites are cleaned up.


Minor: YamlTextWriter.EscapeScalar strips newlines silently

var normalized = (value ?? string.Empty).Replace("\r", string.Empty).Replace("\n", " ").Trim();

Multi-line values are collapsed to a single space-delimited string without any signal to the caller. For the Winget manifest use case this is probably fine, but the behaviour is unexpected — a trailing newline in e.g. a ShortDescription field becomes a trailing space that survives into the YAML value. Consider documenting this as a deliberate contract, or trimming at call sites (already done in several places).


Minor: YamlTextWriter indent step is hardcoded to 2

HtmlFragmentBuilder accepts a configurable indentStep parameter but YamlTextWriter.Indent() always increments by a literal 2. That is the canonical YAML indent and fine for now, but it is an inconsistency worth noting if the two classes are meant to be analogous.


Minor: redundant using System; in MarkdownDocumentBuilderTests.cs

The file has an explicit using System; at the top, but MarkdownTableBuilderTests.cs and MarkdownThreadBuilderTests.cs rely on System being available without declaring it — presumably via a global using. MarkdownDocumentBuilderTests.cs is the odd one out with the redundant explicit import.


Positive highlights

  • MarkdownTableBuilder — alignment-aware separator generation and the escape-on-construction-and-insertion model are clean. The AddRow cell-count validation throws a clear exception rather than silently producing malformed output.
  • HtmlFragmentBuilder — the IndentScope : IDisposable pattern is idiomatic, the double-dispose guard is correctly implemented, and the IsEmpty flag is a nice touch for the bridging code in WebApiDocsGenerator.
  • YamlTextWriter.WriteSequenceItem — the two-overload design (key-value vs. scalar-only) correctly handles both Installers: block mappings and plain sequence items. The nested-case test in YamlTextWriterTests covers this well.
  • MarkdownDocumentBuilder.NormalizeCodeFence — using TrimEnd rather than Trim preserves intentional leading blank lines inside fences while stripping trailing noise. The dedicated test case for this is exactly the right kind of regression guard.
  • WingetManifestWriter.NormalizeInstallerType — defaulting blank/whitespace-only installer types to "zip" is a cleaner contract than relying on the caller to set the property correctly, and the test for this edge case is appreciated.
  • The InternalsVisibleTo additions are correctly scoped to PowerForge.Tests without broadening the internal surface unnecessarily.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a11f5bbcd4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

{
markdown.AppendLine($"| {EscapeCell(item.Name)} | {FormatGiB(item.SizeInBytes)} | {FormatDate(item.CreatedAt)} | {FormatDate(item.UpdatedAt)} | {EscapeCell(item.Reason)} | {EscapeCell(DeleteState(item.DeleteStatusCode, item.DeleteError))} |");
table.AddRow(
EscapeCell(item.Name),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass raw artifact fields into MarkdownTableBuilder

MarkdownTableBuilder.AddRow already escapes markdown table metacharacters, but this loop pre-escapes item.Name, item.Reason, and delete status via EscapeCell(...) before calling AddRow. When a value contains | or \, the output is double-escaped (for example a\\\|b), so rendered housekeeping reports show extra backslashes and differ from prior output. Keep newline normalization if needed, but avoid escaping these fields before passing them to the table builder.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Agent Guide (PSPublishModule / PowerForge.Web + Websites)

Last updated: 2026-03-29

This file is the "start here" context for any agent working on the PowerForge.Web engine and the tracked sibling repos that build on it.

Repos / Local Paths

These are the maintainer's default Windows paths (safe to assume in this workspace):

  • Engine (this repo): C:\Support\GitHub\PSPublishModule

    • Web engine: PowerForge.Web\
    • Web CLI: PowerForge.Web.Cli\
    • Web docs: Docs\PowerForge.Web.*.md
    • PowerShell module: PSPublishModule\ (+ packaging under Module\)
    • Core .NET libs/CLI: PowerForge\, PowerForge.Cli\
  • HtmlForgeX website: C:\Support\GitHub\HtmlForgeX\Website

    • Remote: https://github.com/EvotecIT/HtmlForgeX.git (website lives under Website\)
    • Use this location for all HtmlForgeX website work.
  • IntelligenceX website: C:\Support\GitHub\IntelligenceX\Website

    • Remote: https://github.com/EvotecIT/IntelligenceX.git
  • CodeGlyphX website: C:\Support\GitHub\CodeMatrix\Website

    • Remote: https://github.com/EvotecIT/CodeGlyphX.git
  • OfficeIMO website/docs: C:\Support\GitHub\OfficeIMO\Website

    • Remote: https://github.com/EvotecIT/OfficeIMO.git
    • Track this repo when standardizing website workflows, docs generation, and PowerForge-based housekeeping.
  • DomainDetective repo: C:\Support\GitHub\DomainDetective

    • Remote: https://github.com/EvotecIT/DomainDetective.git
    • Track this repo when standardizing PowerForge-adjacent workflows, module usage, and release/build consistency.
  • TestimoX website/docs: C:\Support\GitHub\TestimoX\Website

    • Remote: https://github.com/EvotecIT/TestimoX.git
    • Track this repo when standardizing PowerForge-based website workflows, docs generation, and quality gates.

Portable Path Discovery (WSL/macOS/Linux)

If you're not on Windows or you don't have C:\Support\GitHub, use this layout heuristic:

  • Common layout: the repos are siblings under one parent folder:
    • <root>/PSPublishModule
    • <root>/HtmlForgeX/Website
    • <root>/IntelligenceX/Website
    • <root>/CodeMatrix/Website
    • <root>/OfficeIMO/Website
    • <root>/DomainDetective
    • <root>/TestimoX/Website

Practical search strategy:

  1. Start at the current repo root (where this AGENTS.md lives).
  2. Check .. (parent) and ../.. (grandparent) for sibling repo folders above.
  3. If you're in WSL, Windows drives usually live under /mnt/c:
    • Example: /mnt/c/Support/GitHub/PSPublishModule

Recommended environment variable (makes site scripts deterministic):

  • Set POWERFORGE_ROOT to the engine repo root (path to PSPublishModule).
    • Website build.ps1 scripts prefer POWERFORGE_ROOT when resolving PowerForge.Web.Cli.

What To Read First (Canonical)

  1. Docs\PowerForge.Web.Roadmap.md (inventory: Have/Partial/Missing + milestones)
  2. Docs\PowerForge.Web.AgentHandoff.md (high-signal handoff + commands)
  3. Docs\PowerForge.Web.QualityGates.md (CI/dev contract, baselines, budgets)
  4. Docs\PowerForge.Web.WebsiteStarter.md (golden path for building new sites without surprises)
  5. Docs\PowerForge.Web.Parity.md (rough parity notes vs DocFX/Hugo/Astro/etc.)

Reference docs (as needed):

  • Docs\PowerForge.Web.ContentSpec.md (content model + navigation)
  • Docs\PowerForge.Web.Theme.md (theme anatomy + shortcodes)
  • Docs\PowerForge.Web.Pipeline.md (pipeline tasks)
  • Docs\PowerForge.Web.ApiDocs.md (API generator)
  • Docs\PSPublishModule.ProjectBuild.md (PowerShell module build/publish pipeline)

Repo Skills (.agents/skills)

This repo ships agent skills under .agents/skills so new contributors/agents don't
need per-user global skill installs.

  • Website scaffolding skill: .agents/skills/powerforge-website-starter
  • Module pipeline skill: .agents/skills/powerforge-module-builder
  • Library/release pipeline skill: .agents/skills/powerforge-library-builder

Working Agreements (Best Practices)

  • Prefer engine fixes over theme hacks when the same issue can recur across sites.
  • Keep PSPublishModule cmdlets thin:
    • parameter binding
    • ShouldProcess / prompting / PowerShell UX
    • output mapping back to PowerShell-facing contract types
  • Move reusable logic into shared services first:
    • PowerForge for host-agnostic logic
    • PowerForge.PowerShell for logic that still needs PowerShell-host/runtime concepts
  • Do not add new business logic to PSPublishModule\Cmdlets\ when the same behavior could be reused by PowerForge.Cli, PowerForge Studio, tests, or another C# host.
  • If a public PSPublishModule result type must stay stable, keep the reusable internal model in PowerForge/PowerForge.PowerShell and map it back in PSPublishModule instead of forcing cmdlet-specific types into shared layers.
  • CI/release should fail on regressions; dev should warn and summarize:
    • Verify: use baselines + failOnNewWarnings:true in CI.
    • Audit: use baselines + failOnNewIssues:true in CI.
  • Prefer stable theme helpers over ad-hoc rendering:
    • Scriban: use pf.nav_links / pf.nav_actions / pf.menu_tree (avoid navigation.menus[0]).
  • Commit frequently. Avoid "big bang" diffs that mix unrelated changes.

Module Layering

When touching the PowerShell module stack, prefer this boundary:

  • PSPublishModule\Cmdlets\
    • PowerShell-only surface area
    • minimal orchestration
    • no reusable build/publish/install rules unless they are truly cmdlet-specific
  • PSPublishModule\Services\
    • cmdlet host adapters or PowerShell-facing compatibility mappers only
  • PowerForge\
    • reusable domain logic, pipelines, models, filesystem/process/network orchestration
  • PowerForge.PowerShell\
    • reusable services that still depend on PowerShell-host concepts, module registration, manifest editing, or other SMA-adjacent behavior

Quick smell test before adding code to a cmdlet:

  1. Could this be called from a test, CLI, Studio app, or another C# host?
  2. Could two cmdlets share it?
  3. Does it manipulate files, versions, dependencies, repositories, GitHub, NuGet, or build plans?

If the answer to any of those is yes, the code probably belongs in PowerForge or PowerForge.PowerShell, not directly in the cmdlet.

Stop extracting when the remaining code is only:

  • PowerShell parameter binding and parameter-set branching
  • ShouldProcess, WhatIf, credential prompts, and PowerShell stream routing
  • host-only rendering such as Host.UI.Write*, Spectre.Console tables/rules, or pipeline-friendly WriteObject behavior
  • compatibility adapters that intentionally map shared models back to stable cmdlet-facing contracts

Preferred pattern for the last 10-20%:

  • extract reusable workflow, validation, planning, summary-shaping, and display-line composition into PowerForge / PowerForge.PowerShell
  • keep the final host-specific rendering in the cmdlet when the rendering technology itself is PowerShell- or Spectre-specific
  • avoid creating fake abstractions just to move AnsiConsole.Write, Host.UI.WriteLine, or WriteObject calls out of cmdlets

Quality Gates (Pattern)

Each website should have:

  • site.json with explicit Features (docs/apiDocs/blog/search/notFound as applicable)
  • pipeline.json with:
    • verify-ci step (modes:["ci"]) with baseline + failOnNewWarnings:true
    • doctor or audit step with baseline + failOnNewIssues:true
    • a simple budget like maxTotalFiles to catch output explosions early
  • .powerforge/verify-baseline.json committed
  • .powerforge/audit-baseline.json committed
  • .gitignore ignoring _site/, _temp/, _reports/ (keep baselines committed)

Commands (Engine)

  • Tests:
    • dotnet test .\PSPublishModule.sln -c Release
  • File size discipline (line limit):
    • node .\Build\linecount.js . 800
    • Optional: check more file types:
      • node .\Build\linecount.js --root . --max 800 --ext .cs,.ps1,.md

Commands (PowerShell Module)

  • Module lives under PSPublishModule\ (plus packaging helpers under Module\).
  • The PowerForge.Web engine is .NET; the PSPublishModule is the PowerShell-facing surface.

Commands (Websites)

From a website repo folder:

  • Full build:
    • .\build.ps1
  • Fast dev loop:
    • .\build.ps1 -Serve -Watch -Dev
  • Run CI-gated steps locally:
    • powerforge-web pipeline --config .\pipeline.json --mode ci

Baselines:

  • Verify baseline:
    • powerforge-web verify --config .\site.json --baseline .\.powerforge\verify-baseline.json --baseline-generate
  • Audit baseline:
    • powerforge-web audit --site-root .\_site --baseline .\.powerforge\audit-baseline.json --baseline-generate

Current State (As Of 2026-02-09)

  • Engine branch: feature/web-engine-contracts contains recent quality-gate and contract hardening work.
  • Website quality gates (baselines/budgets/CI-vs-dev pattern) were merged to the default branches
    of the three site repos. Avoid long-lived "quality-gates" branches; prefer small PR branches
    that get merged and deleted.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

test3 ignore this comment

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

PSPublishModule Changelog

2.0.19 - 2025.06.17

What's Changed

Full Changelog: v2.0.18...v2.0.19

2.0.18 - 2025.06.06

What's new

  • Update to docs and small logic improved for Get-ProjectVersion and Set-ProjectVersion

2.0.17 - 2025.05.28

  • Make sure to error out on PSM1 file missing
  • Added support for .NET 8.0
  • Added Set-ProjectVersion cmdlet to allow for setting version of the project across (csproj/psd1/ps1) files for consistency in multi-file projects
  • Added Get-ProjectVersion cmdlet to allow for getting version of the project across (csproj/psd1/ps1) files for consistency in multi-file projects
Get-ProjectVersion -Path "C:\Support\GitHub\DnsClientX" -ExcludeFolders @('C:\Support\GitHub\DnsClientX\Module\Artefacts')
Set-ProjectVersion -Path "C:\Support\GitHub\DnsClientX" -NewVersion "0.4.0"
Set-ProjectVersion -Path "C:\Support\GitHub\DnsClientX" -VersionType Build

2.0.16 - 2024.10.27

  • Improve error handling

2.0.15 - 2024.10.11

What's Changed

  • Update BuildModule.yml by @SamErde in Update BuildModule.yml #27
  • Improve error handling
  • Improve DLL handling
  • Add new options for binary cmdlet scanning
  • Fixes detecting cmdlets in binary modules for PS 5.1
  • Other small improvements

New Contributors

Full Changelog: v2.0.14...v2.0.15

2.0.14 - 2024.08.14

What's Changed

Full Changelog: v2.0.13...v2.0.14

2.0.13 - 2024.07.13

  • When using New-ConfigurtionManifest and using CmdletsToExport it would not export them in PSM1 file. This is now fixed
  • Moved function to separate file to improve visibility

2.0.12 - 2024.05.06

  • Always publish to Github with PreReleaseTag if it's set
  • Added support for HelpOut module to be used for help generation (platyPS will stay default for now)
  • Added support for NETIgnoreLibraryOnLoad in New-ConfigurationBuild to allow for ignoring libraries on load (for example C/C++ dlls)
  • Added support for NETBinaryModule in New-ConfigurationBuild to allow for building binary modules (C#) and doing Import-Module in PSM1 for them
  • Improves detection of used functions/aliases in module (now it's more accurate)
  • When using New-ConfigurationArtefact with Script artefact type and removal of comments it would break the whole script. This is now fixed
  • Fixes case where during binary module building the DLL would not be used at all
  • Improvement to TargetFramework and TargetFrameworks support when using C# projects. TargetFrameworkVersion is not supported.
  • Added support for NETHandleAssemblyWithSameName in New-ConfigurationBuild to allow for handling of assemblies with same name (for example Newtonsoft.Json)
  • Improved loading of libraries to use foreach instead of loading one by one
  • Removed dependency on PlatyPS/HelpOut. Now it's optional and can be used if needed. HelpOut does have some issues that I hope will be solved sooner or later
  • Improved creating empty PSM1 file when Public/Private folders have no files or are not present
  • Added NETProjectPath to New-ConfigurationBuild to allow for specifying path to project file (csproj) when building binary modules
  • Improve New-ConfigurationManifest allowing to define FunctionsToExport and CmdletsToExport and AliasesToExport to allow for more control over exported functions/cmdlets/aliases (by default Aliaeses/Functions are exported but you can overwrite them)

2.0.11

  • Fix for broken script generation if New-ConfigurationCommand is used

2.0.10

  • Fix for paths that I've broken in 2.0.9

2.0.9

  • Added usage of OpenAuthenticode from @jborean93 to allow for signing of modules on Linux/MacOS
    • The module is not automatically installed, you need to install it yourself
    • May need to be updated, as haven't really tested it

2.0.8

  • Improve error handling
  • Added NETExcludeMainLibrary to not include Initialize library if no logic is required
  • Added NETExcludeLibraryFilter to allow for excluding libraries from being copied on build
  • Added PreScriptMergePath and PostScriptMergePath to allow for files implementation instead of direct code in New-ConfigurationArtefact
    • This supplements using PreScriptMerge and PostScriptMerge (which are still supported, but overwritten when using PreScriptMergePath and PostScriptMergePath)

2.0.7

  • Added to New-ConfigurationArtefact replacement of variables in paths (for example <TagModuleVersionWithPreRelease>)
  • Added to New-ConfigurationArtefact - DoNotClear parameter to allow for not clearing of artefact folder, by default it will delete it's content with excluded .zip files (if any)

2.0.6

  • Fixes problem with defaults such as IncludeAll, IncludePS1 and similar always being set preventing user from overwriting values on demand

2.0.5

What's new

  • Fixes remove comments functionality
  • Added remove empty lines functionality
  • Small fixes on PSD1 generation (sorting fixed)

Full Changelog: v2.0.4...v2.0.5

2.0.4

What's Changed

New Contributors

Full Changelog: v2.0.3...v2.0.4

2.0.3

  • Fix for non-terminating error when can't delete folders

2.0.2 - 2023.08.06

  • Fail build script if copying of module fails

2.0.1 - 2023.08.04

  • Improve New-ConfigurationArtefact to allow for more flexibility for "scripts"
    • Added PreScriptMerge and PostScriptMerge
    • Remove ScriptMerge (renamed to PostScriptMerge)
    • Added ScriptName to allow for custom name of script (renames the file from usual module name)

2.0.0 - 2023.08.03

  • Fixes to Import-Module functionality

1.8.0-Preview5

  • Fixes wrong path for packed zip

1.8.0-Preview4

  • Added handling of wrong certificate thumbrint
  • Prevent failure on required modules
  • Stop on missing requirede module

1.8.0-Preview3

  • Fixes ModuleVersionWithPreRelease -and TagModuleVersionWithPreRelease tags

1.8.0-Preview2

  • Fix for old configuration supporting releases and releases unpacked
  • Fix for ModuleName in old configuration
  • Allow to publish module Artefacts to GitHub using single New-ConfigurationPublish by marking multiple artefacts with same ID
  • Small visual improvements

1.8.0-Preview1

  • Added RequiredVersion to New-ConfigurationModule to allow specifying exact version instead the minimum version
  • Minor bugfix for potential issue with CommandModuleDependencies
  • Added ability to create multiple artefacts (not just 2) by using New-ConfigurationArtefact
  • Added ability to pick from 4 types of artefacts (Unpacked, Packed, Script, PackedScript)
    • Unpacked - unpacked module
    • Packed - packed module (as zip)
    • Script - script that is module in form of PS1 without PSD1 - only applicable to very simple modules
    • PackedScript - packed module (as zip) that is script that is module in form of PS1 without PSD1 - only applicable to very simple modules
  • Added ability to publish multiple artefacts (not just 2) by using New-ConfigurationPublish
  • Removed PreReleaseTag from New-ConfigurationPublish
  • Added PreReleaseTag to New-ConfigurationManifest to allow specifying pre-release tag
  • PreReleaseTag is autoamtically used for GitHub publishing when Manifest is set to prerelease

1.7.0

  • Improve New-ConfigurationBuild by adding CertificatePFXPath, CertificatePFXBase64, CertificatePFXPassword to support GitHub Actions/AzureDevOps
  • Fixes copy of files during build process
  • Fix exit code when build fails

1.6.0 - 2023.07.11

  • Improve logic for replacing expandable variables and allow disabling this feature
  • Impovements overall

1.5.1 - 2023.07.06

  • Improve logic for detection of used functions
  • Update default configuration for new modules

1.5.0 - 2023.07.04

  • Fixes detection of unknown functions in module

1.4.0 - 2023.07.03

  • Fixes minor issue with reading required modules

1.3.0 - 2023.07.03

  • Improve termination when things go wrong
  • Fix replacement of GUID in module manifest when creating new module
  • Fix for duplicate detection of used functions

1.2.0 - 2023.06.23

  • Improve termination when things go wrong
  • Small fixes here and there
  • Added ability to test after merge before publishing

1.1.0 - 2023.06.19

  • Improve initially provided files when creating new module for easy use
  • Autogenerate the example Build-Module.ps1 file for new projects
  • Update GUID/ModuleName based on data provided by user
  • Other small improvements

1.0.0 - 2023.06.18

  • Big rewrite of PSPublishModule allowing more configuration options
  • New configuration options
  • New safety checks in place

0.9.53 - 2022.12.31

  • Adds local versioning via BuildModule\LocalVersion property (bumps version on every build)
  • No longer delete older tagged unpacked releases (only current) when tagging is enabled
  • Allows to specify Releases property in BuildModule\Releases. Both Enabled/Path need to be specified. If not specified it will use default values.

0.9.52 - 2022.12.30

  • Changed loading of libraries so it's always done in following way:
  • If Standard, Core and Default are present -> load Default when PS 5, load Standard when PS 6+
  • If Standard, Default are present -> load Default when PS 5, load Standard when PS 6+
  • If Standard and Core are present -> load Standard only
  • If Core and Default are present -> load Default when PS 5, load Core when PS 6+
  • If Standard is present -> load Standard only for all PowerShell

I've removed the logic where Standard would always get loaded even if Default/Core was also provided which could lead to unexpected behavior.

  • Added ability to build libraries on demand (when using -BuildLibraries). Requires more testing.
  • Cleaned code a bit, moved things around
  • Added ability to ResolveBinaryConflicts (when using -ResolveBinaryConflicts). Requires more testing. Visible in project ImagePlayground
  • Removed need for DirectoryProjects in $Configuration.Information - if not present it will get the value itself

0.9.51 - 2022.09.26

  • Fixed path behavior for ReleasedUnpacked (hopefully)

0.9.43 - 2022.04.14

  • Small fixes for publishing modules with Standard Libraries only
  • Improved building of Artefacts

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Code Review: Refactor reusable YAML, markdown, and HTML builders\n\nGood refactoring overall.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

line1
line2
line3

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Agent Guide (PSPublishModule / PowerForge.Web + Websites)

Last updated: 2026-03-29

This file is the "start here" context for any agent working on the PowerForge.Web engine and the tracked sibling repos that build on it.

Repos / Local Paths

These are the maintainer's default Windows paths (safe to assume in this workspace):

  • Engine (this repo): C:\Support\GitHub\PSPublishModule

    • Web engine: PowerForge.Web\
    • Web CLI: PowerForge.Web.Cli\
    • Web docs: Docs\PowerForge.Web.*.md
    • PowerShell module: PSPublishModule\ (+ packaging under Module\)
    • Core .NET libs/CLI: PowerForge\, PowerForge.Cli\
  • HtmlForgeX website: C:\Support\GitHub\HtmlForgeX\Website

    • Remote: https://github.com/EvotecIT/HtmlForgeX.git (website lives under Website\)
    • Use this location for all HtmlForgeX website work.
  • IntelligenceX website: C:\Support\GitHub\IntelligenceX\Website

    • Remote: https://github.com/EvotecIT/IntelligenceX.git
  • CodeGlyphX website: C:\Support\GitHub\CodeMatrix\Website

    • Remote: https://github.com/EvotecIT/CodeGlyphX.git
  • OfficeIMO website/docs: C:\Support\GitHub\OfficeIMO\Website

    • Remote: https://github.com/EvotecIT/OfficeIMO.git
    • Track this repo when standardizing website workflows, docs generation, and PowerForge-based housekeeping.
  • DomainDetective repo: C:\Support\GitHub\DomainDetective

    • Remote: https://github.com/EvotecIT/DomainDetective.git
    • Track this repo when standardizing PowerForge-adjacent workflows, module usage, and release/build consistency.
  • TestimoX website/docs: C:\Support\GitHub\TestimoX\Website

    • Remote: https://github.com/EvotecIT/TestimoX.git
    • Track this repo when standardizing PowerForge-based website workflows, docs generation, and quality gates.

Portable Path Discovery (WSL/macOS/Linux)

If you're not on Windows or you don't have C:\Support\GitHub, use this layout heuristic:

  • Common layout: the repos are siblings under one parent folder:
    • <root>/PSPublishModule
    • <root>/HtmlForgeX/Website
    • <root>/IntelligenceX/Website
    • <root>/CodeMatrix/Website
    • <root>/OfficeIMO/Website
    • <root>/DomainDetective
    • <root>/TestimoX/Website

Practical search strategy:

  1. Start at the current repo root (where this AGENTS.md lives).
  2. Check .. (parent) and ../.. (grandparent) for sibling repo folders above.
  3. If you're in WSL, Windows drives usually live under /mnt/c:
    • Example: /mnt/c/Support/GitHub/PSPublishModule

Recommended environment variable (makes site scripts deterministic):

  • Set POWERFORGE_ROOT to the engine repo root (path to PSPublishModule).
    • Website build.ps1 scripts prefer POWERFORGE_ROOT when resolving PowerForge.Web.Cli.

What To Read First (Canonical)

  1. Docs\PowerForge.Web.Roadmap.md (inventory: Have/Partial/Missing + milestones)
  2. Docs\PowerForge.Web.AgentHandoff.md (high-signal handoff + commands)
  3. Docs\PowerForge.Web.QualityGates.md (CI/dev contract, baselines, budgets)
  4. Docs\PowerForge.Web.WebsiteStarter.md (golden path for building new sites without surprises)
  5. Docs\PowerForge.Web.Parity.md (rough parity notes vs DocFX/Hugo/Astro/etc.)

Reference docs (as needed):

  • Docs\PowerForge.Web.ContentSpec.md (content model + navigation)
  • Docs\PowerForge.Web.Theme.md (theme anatomy + shortcodes)
  • Docs\PowerForge.Web.Pipeline.md (pipeline tasks)
  • Docs\PowerForge.Web.ApiDocs.md (API generator)
  • Docs\PSPublishModule.ProjectBuild.md (PowerShell module build/publish pipeline)

Repo Skills (.agents/skills)

This repo ships agent skills under .agents/skills so new contributors/agents don't
need per-user global skill installs.

  • Website scaffolding skill: .agents/skills/powerforge-website-starter
  • Module pipeline skill: .agents/skills/powerforge-module-builder
  • Library/release pipeline skill: .agents/skills/powerforge-library-builder

Working Agreements (Best Practices)

  • Prefer engine fixes over theme hacks when the same issue can recur across sites.
  • Keep PSPublishModule cmdlets thin:
    • parameter binding
    • ShouldProcess / prompting / PowerShell UX
    • output mapping back to PowerShell-facing contract types
  • Move reusable logic into shared services first:
    • PowerForge for host-agnostic logic
    • PowerForge.PowerShell for logic that still needs PowerShell-host/runtime concepts
  • Do not add new business logic to PSPublishModule\Cmdlets\ when the same behavior could be reused by PowerForge.Cli, PowerForge Studio, tests, or another C# host.
  • If a public PSPublishModule result type must stay stable, keep the reusable internal model in PowerForge/PowerForge.PowerShell and map it back in PSPublishModule instead of forcing cmdlet-specific types into shared layers.
  • CI/release should fail on regressions; dev should warn and summarize:
    • Verify: use baselines + failOnNewWarnings:true in CI.
    • Audit: use baselines + failOnNewIssues:true in CI.
  • Prefer stable theme helpers over ad-hoc rendering:
    • Scriban: use pf.nav_links / pf.nav_actions / pf.menu_tree (avoid navigation.menus[0]).
  • Commit frequently. Avoid "big bang" diffs that mix unrelated changes.

Module Layering

When touching the PowerShell module stack, prefer this boundary:

  • PSPublishModule\Cmdlets\
    • PowerShell-only surface area
    • minimal orchestration
    • no reusable build/publish/install rules unless they are truly cmdlet-specific
  • PSPublishModule\Services\
    • cmdlet host adapters or PowerShell-facing compatibility mappers only
  • PowerForge\
    • reusable domain logic, pipelines, models, filesystem/process/network orchestration
  • PowerForge.PowerShell\
    • reusable services that still depend on PowerShell-host concepts, module registration, manifest editing, or other SMA-adjacent behavior

Quick smell test before adding code to a cmdlet:

  1. Could this be called from a test, CLI, Studio app, or another C# host?
  2. Could two cmdlets share it?
  3. Does it manipulate files, versions, dependencies, repositories, GitHub, NuGet, or build plans?

If the answer to any of those is yes, the code probably belongs in PowerForge or PowerForge.PowerShell, not directly in the cmdlet.

Stop extracting when the remaining code is only:

  • PowerShell parameter binding and parameter-set branching
  • ShouldProcess, WhatIf, credential prompts, and PowerShell stream routing
  • host-only rendering such as Host.UI.Write*, Spectre.Console tables/rules, or pipeline-friendly WriteObject behavior
  • compatibility adapters that intentionally map shared models back to stable cmdlet-facing contracts

Preferred pattern for the last 10-20%:

  • extract reusable workflow, validation, planning, summary-shaping, and display-line composition into PowerForge / PowerForge.PowerShell
  • keep the final host-specific rendering in the cmdlet when the rendering technology itself is PowerShell- or Spectre-specific
  • avoid creating fake abstractions just to move AnsiConsole.Write, Host.UI.WriteLine, or WriteObject calls out of cmdlets

Quality Gates (Pattern)

Each website should have:

  • site.json with explicit Features (docs/apiDocs/blog/search/notFound as applicable)
  • pipeline.json with:
    • verify-ci step (modes:["ci"]) with baseline + failOnNewWarnings:true
    • doctor or audit step with baseline + failOnNewIssues:true
    • a simple budget like maxTotalFiles to catch output explosions early
  • .powerforge/verify-baseline.json committed
  • .powerforge/audit-baseline.json committed
  • .gitignore ignoring _site/, _temp/, _reports/ (keep baselines committed)

Commands (Engine)

  • Tests:
    • dotnet test .\PSPublishModule.sln -c Release
  • File size discipline (line limit):
    • node .\Build\linecount.js . 800
    • Optional: check more file types:
      • node .\Build\linecount.js --root . --max 800 --ext .cs,.ps1,.md

Commands (PowerShell Module)

  • Module lives under PSPublishModule\ (plus packaging helpers under Module\).
  • The PowerForge.Web engine is .NET; the PSPublishModule is the PowerShell-facing surface.

Commands (Websites)

From a website repo folder:

  • Full build:
    • .\build.ps1
  • Fast dev loop:
    • .\build.ps1 -Serve -Watch -Dev
  • Run CI-gated steps locally:
    • powerforge-web pipeline --config .\pipeline.json --mode ci

Baselines:

  • Verify baseline:
    • powerforge-web verify --config .\site.json --baseline .\.powerforge\verify-baseline.json --baseline-generate
  • Audit baseline:
    • powerforge-web audit --site-root .\_site --baseline .\.powerforge\audit-baseline.json --baseline-generate

Current State (As Of 2026-02-09)

  • Engine branch: feature/web-engine-contracts contains recent quality-gate and contract hardening work.
  • Website quality gates (baselines/budgets/CI-vs-dev pattern) were merged to the default branches
    of the three site repos. Avoid long-lived "quality-gates" branches; prefer small PR branches
    that get merged and deleted.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

line1
line2
line3

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Code Review: Refactor reusable YAML, markdown, and HTML builders

Good refactoring overall. Extracting YamlTextWriter, MarkdownDocumentBuilder, MarkdownTableBuilder, HtmlFragmentBuilder, and MarkdownThreadBuilder into dedicated classes reduces duplication and makes the rendering pipeline significantly easier to follow. The new tests give each builder a clear contract. A few things worth addressing:

Bug / Correctness

YAML scalar escaping misses common special-value cases

YamlTextWriter.EscapeScalar does not quote YAML boolean/null literals or the document-separator token, so values like true, false, yes, no, on, off, null, ~, or --- will be written unquoted. A YAML parser will interpret them as typed values rather than strings. In a winget manifest context, a ShortDescription or tag of "null" or "true" would silently round-trip incorrectly.

Suggested fix — add a reserved-word set and check it in EscapeScalar:

private static readonly HashSet<string> _yamlReserved = new(StringComparer.OrdinalIgnoreCase)
{
    "true", "false", "yes", "no", "on", "off", "null", "~", "---", "..."
};
// extend the escape condition:
|| _yamlReserved.Contains(normalized)

Whitespace-only blank lines in HtmlFragmentBuilder

html.Line(string.Empty) (used in BuildApiOpenGraphMetaTags) appends the current indentation level followed by a newline, producing a line that contains only spaces. The previous sb.AppendLine() produced a bare newline. Because indentation is 0 at that call site the lines are equivalent today, but this is a latent trap if the call is ever moved inside an Indent() block. Consider adding a dedicated BlankLine() method (mirroring MarkdownDocumentBuilder) that always emits a bare newline:

public void BlankLine() => _builder.AppendLine();

Design

MarkdownThreadBuilder duplicates MarkdownDocumentBuilder

The two classes share Paragraph, BlankLine, Heading, and the same Normalize helper, differing only in whether Heading emits a trailing blank line. The current duplication means bug fixes need to be applied twice. Even a lightweight shared base (or a constructor flag like blankLineAfterHeading) would eliminate the drift risk.

MarkdownDocumentBuilder.FrontMatter allocates a new YamlTextWriter per call

The front-matter methods each construct a fresh YamlTextWriter, write a single entry, and immediately discard the writer. Holding a single YamlTextWriter _frontMatterWriter as a field (parallel to _frontMatter) would avoid the repeated allocations and the intermediate ToString() + Append copy.

WingetManifestInstallerEntry.InstallerType default is redundant with NormalizeInstallerType

The property defaults to "zip" and NormalizeInstallerType also defaults blank/whitespace to "zip". A deliberately blank InstallerType will silently become "zip" regardless. It may be cleaner to default the property to string.Empty and rely solely on the normalizer — that way the normalization policy lives in one place.

Missing test coverage

  • MarkdownDocumentBuilder.Heading — not exercised; the trailing blank line it emits is a meaningful contract.
  • HtmlFragmentBuilder.AppendRaw — untested.
  • MarkdownFrontMatterWriter.Append — no tests at all for this new helper.
  • YamlTextWriter.EscapeScalar with true / false / null inputs (see escaping gap above).

Minor nits

  • BuildDropdownItemsHtml builds a List<string> then string.Concat(fragments) — since the list is only ever used for the final concat, collecting into a StringBuilder or using a direct string.Concat over a filtered enumerable would be slightly cleaner.
  • The html is null guard in AppendMembers(HtmlFragmentBuilder html, ...) is unreachable because the builder is always constructed locally in BuildSimpleTypeMemberSections — safe to remove.

Overall this is a well-scoped refactor with good test hygiene. The YAML escaping gap is the only correctness risk worth fixing before merge.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

PR Review: Refactor reusable YAML, markdown, and HTML builders

This is a solid refactoring effort. The new builder abstractions (YamlTextWriter, MarkdownDocumentBuilder, HtmlFragmentBuilder, MarkdownTableBuilder, MarkdownThreadBuilder) are well-designed and the tests cover the key contracts. Below are my findings.


Bugs / Correctness

1. Dead code in WingetManifestWriter.Build — unreachable null check

PowerForge/Services/WingetManifestWriter.cs

The second if (firstInstaller is null) guard is unreachable. The earlier installers.Count == 0 check already guarantees the list is non-empty, so FirstOrDefault() will never return null.

// First guard (correct):
if (installers is null || installers.Count == 0)
    throw new InvalidOperationException(...);

var writer = new YamlTextWriter();
// ...

var firstInstaller = installers.FirstOrDefault();
if (firstInstaller is null)           // ← unreachable
    throw new InvalidOperationException(...);

Either remove the second check, or replace FirstOrDefault() with First() to make the intent obvious.


2. Subtle output change in GitHubDiscussionMarkdownBuilder — blank lines between discussion entries

PowerForgeStudio.Domain/Hub/GitHubDiscussionMarkdownBuilder.cs

Old code placed builder.AppendLine() (a blank line) before every ### author heading in the loop. New code uses isFirstDiscussionEntry to add a blank line only before the first heading; subsequent headings rely on the preceding Paragraph() call's trailing blank line.

Since Paragraph() appends text\n\n, subsequent headings render as text\n\n### heading\n instead of text\n\n\n### heading\n. The blank-line count between entries drops from 2 to 1. Most Markdown renderers treat both identically, but if downstream consumers diff the raw Markdown output this will show as a change.


Design / Code Quality

3. FrontMatterRaw values are not YAML-escaped

PowerForge/Services/Documentation/MarkdownDocumentBuilder.cs

FrontMatterRaw appends key/value pairs without calling YamlTextWriter.EscapeScalar, yet call-sites in MarkdownHelpWriter.cs pass values such as moduleName, onlineVersion, and download URLs — strings that could contain colons, hashes, or other YAML-special characters and produce malformed front matter. Consider routing these through FrontMatter(string, string) (which uses YamlTextWriter) or at minimum documenting that callers are responsible for pre-sanitising values.


4. MarkdownFrontMatterWriter duplicates MarkdownDocumentBuilder.FrontMatter*

PowerForge/Services/Documentation/MarkdownFrontMatterWriter.cs is a thin static helper that writes a YAML front matter block to a raw StringBuilder. Now that MarkdownDocumentBuilder exists, these two paths serve the same purpose. Is MarkdownFrontMatterWriter still needed, or can its callers migrate to MarkdownDocumentBuilder?


5. YamlTextWriter.EscapeScalar — unquoted leading-dash scalars

PowerForge/Services/YamlTextWriter.cs

Values like -leading-dash (hyphen not followed by a space) are intentionally left unquoted (the test documents this). That is valid YAML 1.2 plain scalar, but some tools and older YAML 1.1 parsers may misinterpret it as a sequence item. Since this writer targets WinGet manifests specifically, verify WinGet's YAML parser handles unquoted leading-dash values as expected.


Test Coverage

6. No test for FrontMatterRaw

PowerForge.Tests/MarkdownDocumentBuilderTests.cs covers FrontMatter(key, string) and FrontMatter(key, IReadOnlyList<string>) but not FrontMatterRaw. Given the escaping concern above (#3), a test that includes a value with a YAML-special character (e.g. "online version: https://...") would be useful to confirm the intended contract.


7. WingetManifestWriter has minimal test coverage

Only two scenarios are tested: no-installer guard and blank-type defaulting. Consider adding a round-trip test that builds a multi-architecture manifest and asserts the full YAML structure, which would also catch the duplicate-null-check dead code (#1).


Minor Notes

  • HtmlFragmentBuilder — the AppendRaw method has no test. It is used in production code (WebApiDocsGenerator) but the existing test only exercises Line and Indent. A test for AppendRaw interaction with indented output would be useful.

  • MarkdownTableBuilder.EscapePipe — the test for pipe escaping (ToString_EscapesPipesInHeadersAndCells) passes @"Widget \| Gadget" as input and expects Widget \\\| Gadget in output. The double-escaping behaviour is correct for GFM, but a comment inline would help future readers understand why the backslash itself is escaped before the pipe.

  • The packages.lock.json update adds powerforgestudio.domain as a project reference to the test project. This is correct for the new MarkdownThreadBuilderTests, but InternalsVisibleTo.cs in PowerForgeStudio.Domain does not include a public key token. If the test project ever gets strong-naming this will need updating.


Overall, the refactoring is clean, the indented-builder pattern is a nice improvement over the previous append-heavy style, and the new tests provide meaningful coverage of the key contracts. The main items worth addressing before merge are the dead code (#1) and the unescaped FrontMatterRaw values (#3).

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

test comment - please approve

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Replacing test comment with actual review - see next comment.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Code Review - Part 1: Bugs and Performance

Bug 1 - Silent behavior change in GitHubDiscussionMarkdownBuilder: The old loop added a blank line before every discussion heading entry. The new code adds a blank line only before the first entry via isFirstDiscussionEntry. Subsequent entries get no blank line because MarkdownThreadBuilder.Heading does not append a trailing blank, unlike MarkdownDocumentBuilder.Heading. This silently removes visual separation between thread entries.

Bug 2 - Unreachable guard in WingetManifestWriter.Build: The method opens with a guard that throws for empty/null installer lists, then ~15 lines later checks if firstInstaller is null after calling installers.FirstOrDefault(). Because the first guard already handles the empty-collection case, this second check is dead code. Either add null-element validation to the first guard, or remove the unreachable check.

Perf 1 - YamlTextWriter.EscapeScalar allocates a char array on every call: The IndexOfAny call uses an inline char array literal, allocating a new array on every invocation. Promoting it to a static readonly field means the allocation happens once.

Perf 2 - string.Concat with LINQ Select materialises every fragment as a separate string: Several refactored methods use string.Concat(collection.Select(BuildXxxHtml)). The HtmlFragmentBuilder introduced in this same PR keeps a single StringBuilder throughout and would be more consistent and efficient.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Code Review - Part 2: Code Quality and Test Gaps

Quality 1 - MarkdownDocumentBuilder.FrontMatter creates a new YamlTextWriter per entry: Each call instantiates a YamlTextWriter, writes one value, calls ToString(), then appends the result into _frontMatter. A single YamlTextWriter field for the front matter section would be cleaner and remove the intermediate string allocation on every call.

Quality 2 - Duplicated logic between MarkdownDocumentBuilder and MarkdownThreadBuilder: Both classes contain nearly identical Paragraph, BlankLine, Heading, and Normalize implementations. The only real difference is whether Heading appends a trailing blank line. A shared base class or static helper would keep the Normalize logic in sync and reduce copy-paste drift.

Quality 3 - No bounds check in BuildFieldTable: The method calls table.AddRow(row[0], row[1]) without verifying that row has at least two elements. An IndexOutOfRangeException here does not point back to the caller. A guard with a clearer message would be easier to diagnose.

Tests 1 - HtmlFragmentBuilder.AppendRaw has no test: The method intentionally skips indentation. A test verifying it does not prefix spaces when called inside an Indent() scope would pin that contract.

Tests 2 - YamlTextWriter indent path is only tested indirectly: The Indent() + WriteScalar/WriteKey path is exercised implicitly via WingetManifestWriterTests. A direct test in YamlTextWriterTests for nested output would make the contract explicit at the unit level.

Tests 3 - Refactored ScribanThemeHelpers HTML builders have no new tests: The helpers were changed from void/StringBuilder helpers to pure string-returning functions, which is exactly the shape that makes unit testing straightforward. Contract tests for edge cases such as empty items, items with children, and external links getting rel=noopener would protect against regressions.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 11, 2026

Code Review - Part 3: Minor Nits and Summary

Nit 1: The long inline interpolated string in ScribanThemeHelpers.MenuTree is harder to review than the original chained appends. A local variable for the inner HTML would help readability.

Nit 2: EscapeScalar quotes a lone dash but not a dash followed by a non-space character. This is correct per YAML 1.2 (a bare sequence indicator only when followed by a space), but a short comment would help readers who wonder if it is a bug.

Nit 3: MarkdownFrontMatterWriter has a single caller in WebSiteScaffolder. Now that MarkdownDocumentBuilder handles front matter directly, consider whether this helper is still needed.


Good work overall. The builder pattern is much cleaner than the inline StringBuilder chains it replaces, and the new test files are well-structured. The main items to address are the silent behavior change in the discussion thread output (Bug 1) and the unreachable null guard (Bug 2). The EscapeScalar char array allocation (Perf 1) is a quick one-line fix worth picking up before merge.

@PrzemyslawKlys PrzemyslawKlys merged commit dace40c into main Apr 11, 2026
6 checks passed
@PrzemyslawKlys PrzemyslawKlys deleted the codex/reusable-yaml-builders branch April 11, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant