Browse Source

Add docs syntax check workflow for docs/en Markdown

pull/25415/head
maliming 5 days ago
parent
commit
7ec73fcdbe
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 16
      .github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj
  2. 347
      .github/scripts/CheckDocsSyntax/Program.cs
  3. 109
      .github/workflows/check-docs-syntax.yml

16
.github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Volo.Abp.Docs.SyntaxCheck</RootNamespace>
<AssemblyName>CheckDocsSyntax</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Scriban" />
</ItemGroup>
</Project>

347
.github/scripts/CheckDocsSyntax/Program.cs

@ -0,0 +1,347 @@
using System.Text.Json;
using Scriban;
using Scriban.Runtime;
using Scriban.Syntax;
// Validates the Scriban template syntax embedded in `docs/en/` Markdown files.
//
// For each input file we run `Template.Parse` and a strict-mode render with the
// same parameter set the docs renderer injects at runtime (each docs-params.json
// `<name>` and its `<name>_Value` companion, plus Document_Language_Code,
// Document_Version and Release_Status). StrictVariables is enabled on purpose so
// references that would otherwise be silently rendered as empty strings surface
// as build failures here.
//
// Known limitations:
// - Partial template inlining is not executed: partial bodies are loaded from
// external storage at render time, so they cannot be resolved in CI. Files
// under `docs/en/` currently have no `//[doc-template]` references; if one is
// added later, errors inside the partial body must be reviewed manually.
// - Cookie- and query-string-driven parameter overrides are not injected, but
// their keys still resolve to empty strings because they layer on top of the
// same `<name>` / `<name>_Value` entries that are already injected.
namespace Volo.Abp.Docs.SyntaxCheck;
internal static class Program
{
private const string DefaultDocsRoot = "docs/en";
private const string DocsParamsFileName = "docs-params.json";
private static readonly string[] BuiltInVariables =
{
"Document_Language_Code",
"Document_Version",
"Release_Status"
};
public static int Main(string[] args)
{
var useGitHubAnnotations = Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true";
var inputPaths = args.Length == 0
? new[] { DefaultDocsRoot }
: args;
var files = new List<string>();
foreach (var path in inputPaths)
{
if (File.Exists(path))
{
if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
files.Add(Path.GetFullPath(path));
}
}
else if (Directory.Exists(path))
{
foreach (var file in Directory.EnumerateFiles(path, "*.md", SearchOption.AllDirectories))
{
files.Add(Path.GetFullPath(file));
}
}
else
{
Console.Error.WriteLine($"WARN: path does not exist: {path}");
}
}
if (files.Count == 0)
{
Console.WriteLine("No markdown files to check.");
return 0;
}
Dictionary<string, string> renderParameters;
try
{
renderParameters = BuildRenderParameters(files);
}
catch (Exception ex)
{
Console.Error.WriteLine($"ERROR: failed to load docs-params: {ex.Message}");
if (useGitHubAnnotations)
{
Console.WriteLine($"::error::docs-params: {EscapeAnnotation(ex.Message)}");
}
return 1;
}
var errorCount = 0;
var warningCount = 0;
var fileIssueCount = 0;
var repoRoot = TryFindRepoRoot(Directory.GetCurrentDirectory());
foreach (var file in files)
{
var fileIssues = CheckFile(file, renderParameters);
if (fileIssues.Count == 0)
{
continue;
}
fileIssueCount++;
foreach (var issue in fileIssues)
{
if (issue.Severity == IssueSeverity.Error)
{
errorCount++;
}
else
{
warningCount++;
}
var displayPath = repoRoot != null
? Path.GetRelativePath(repoRoot, file)
: file;
var severityLabel = issue.Severity == IssueSeverity.Error ? "error" : "warning";
Console.WriteLine(
$"{displayPath}:{issue.Line}:{issue.Column}: {severityLabel}: [{issue.Kind}] {issue.Message}");
if (useGitHubAnnotations)
{
var command = issue.Severity == IssueSeverity.Error ? "error" : "warning";
Console.WriteLine(
$"::{command} file={displayPath},line={issue.Line},col={issue.Column}::" +
$"{issue.Kind}: {EscapeAnnotation(issue.Message)}");
}
}
}
Console.WriteLine();
Console.WriteLine($"Checked {files.Count} markdown file(s). " +
$"{fileIssueCount} file(s) with issues, " +
$"{errorCount} error(s), {warningCount} warning(s).");
if (errorCount > 0 || warningCount > 0)
{
Console.WriteLine();
Console.WriteLine("Tip: wrap inline Scriban-looking text with `{%{{{ ... }}}%}` " +
"or wrap whole code blocks with `{%{` ... `}%}` to escape Scriban parsing.");
}
return errorCount > 0 ? 1 : 0;
}
private static List<Issue> CheckFile(string file, IReadOnlyDictionary<string, string> renderParameters)
{
var issues = new List<Issue>();
string content;
try
{
content = File.ReadAllText(file);
}
catch (Exception ex)
{
issues.Add(new Issue("Read", 1, 1, ex.Message, IssueSeverity.Error));
return issues;
}
var template = Template.Parse(content, file);
foreach (var message in template.Messages)
{
var severity = message.Type switch
{
Scriban.Parsing.ParserMessageType.Error => IssueSeverity.Error,
Scriban.Parsing.ParserMessageType.Warning => IssueSeverity.Warning,
_ => (IssueSeverity?)null
};
if (severity is null)
{
continue;
}
var kind = severity == IssueSeverity.Error ? "ScribanParseError" : "ScribanParseWarning";
issues.Add(new Issue(
kind,
message.Span.Start.Line + 1,
message.Span.Start.Column + 1,
message.Message,
severity.Value));
}
if (template.HasErrors)
{
return issues;
}
try
{
var context = new TemplateContext
{
StrictVariables = true
};
var scriptObject = new ScriptObject();
foreach (var entry in renderParameters)
{
scriptObject[entry.Key] = entry.Value;
}
context.PushGlobal(scriptObject);
template.Render(context);
}
catch (ScriptRuntimeException ex)
{
issues.Add(new Issue(
"ScribanRenderError",
ex.Span.Start.Line + 1,
ex.Span.Start.Column + 1,
ex.OriginalMessage,
IssueSeverity.Error));
}
catch (Exception ex)
{
issues.Add(new Issue("ScribanRenderError", 1, 1, ex.Message, IssueSeverity.Error));
}
return issues;
}
private static Dictionary<string, string> BuildRenderParameters(IEnumerable<string> files)
{
// Reproduces the keys the docs renderer places into its parameter
// dictionary before rendering a documentation page.
var parameters = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var name in BuiltInVariables)
{
parameters[name] = string.Empty;
}
foreach (var paramName in DiscoverParameterNames(files))
{
parameters[paramName] = string.Empty;
parameters[paramName + "_Value"] = string.Empty;
}
return parameters;
}
private static HashSet<string> DiscoverParameterNames(IEnumerable<string> files)
{
var names = new HashSet<string>(StringComparer.Ordinal);
var visitedDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in files)
{
var dir = Path.GetDirectoryName(file);
while (!string.IsNullOrEmpty(dir) && visitedDirs.Add(dir))
{
var candidate = Path.Combine(dir, DocsParamsFileName);
if (File.Exists(candidate))
{
AddNamesFromDocsParamsFile(candidate, names);
}
var parent = Path.GetDirectoryName(dir);
if (string.IsNullOrEmpty(parent) || parent == dir)
{
break;
}
dir = parent;
}
}
return names;
}
private static void AddNamesFromDocsParamsFile(string path, HashSet<string> sink)
{
// A malformed docs-params.json would silently shrink the injected
// variable set and turn parameter file regressions into hard-to-debug
// "variable not found" errors on otherwise-fine markdown. Surface the
// failure directly so the contributor fixes the JSON instead.
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("parameters", out var parameters) ||
parameters.ValueKind != JsonValueKind.Array)
{
throw new InvalidDataException(
$"{path}: expected a top-level `parameters` array.");
}
foreach (var parameter in parameters.EnumerateArray())
{
if (parameter.TryGetProperty("name", out var nameElement) &&
nameElement.ValueKind == JsonValueKind.String)
{
var name = nameElement.GetString();
if (!string.IsNullOrWhiteSpace(name))
{
sink.Add(name);
}
}
}
}
private static string? TryFindRepoRoot(string startDir)
{
var current = new DirectoryInfo(startDir);
while (current != null)
{
var gitPath = Path.Combine(current.FullName, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
private static string EscapeAnnotation(string text)
{
// GitHub workflow command escaping. `%` must be encoded first so the
// sequences we introduce below are not double-escaped.
return text
.Replace("%", "%25")
.Replace("\r", "%0D")
.Replace("\n", "%0A")
.Replace(":", "%3A")
.Replace(",", "%2C");
}
private enum IssueSeverity
{
Warning,
Error
}
private readonly record struct Issue(
string Kind,
int Line,
int Column,
string Message,
IssueSeverity Severity);
}

109
.github/workflows/check-docs-syntax.yml

@ -0,0 +1,109 @@
# Validates Scriban template syntax in PR-changed Markdown files under docs/en/,
# so escape issues are caught before they reach the published documentation.
name: Check Docs Syntax
on:
pull_request:
paths:
- 'docs/en/**/*.md'
- 'docs/en/docs-params.json'
- '.github/scripts/CheckDocsSyntax/**'
- '.github/workflows/check-docs-syntax.yml'
permissions:
contents: read
pull-requests: read
jobs:
check-scriban-syntax:
name: Validate Scriban syntax in docs/en
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Build syntax checker
run: dotnet build .github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj -c Release --nologo -v minimal
- name: Get changed markdown files
id: changed
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const changed = [];
let paramsChanged = false;
let page = 1;
while (true) {
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
page,
});
const PARAMS_PATH = 'docs/en/docs-params.json';
for (const f of files) {
const isMutation =
f.status === 'added' || f.status === 'modified' || f.status === 'renamed';
if (!isMutation) continue;
// For renames, GitHub puts the new path in `filename` and the
// old one in `previous_filename`. Detect docs-params.json on
// either side so renames into / out of that path still trigger
// the parameter-file validation path.
if (f.filename === PARAMS_PATH || f.previous_filename === PARAMS_PATH) {
paramsChanged = true;
}
if (f.filename.startsWith('docs/en/') && f.filename.endsWith('.md')) {
changed.push(f.filename);
}
}
if (files.length < 100) break;
page++;
}
core.setOutput('files', changed.join('\n'));
core.setOutput('count', changed.length.toString());
core.setOutput('paramsChanged', paramsChanged ? 'true' : 'false');
core.info(`Markdown files to check: ${changed.length}`);
core.info(`docs-params.json changed: ${paramsChanged}`);
for (const f of changed) {
core.info(` - ${f}`);
}
- name: Run syntax checker
if: steps.changed.outputs.count != '0' || steps.changed.outputs.paramsChanged == 'true'
env:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
PARAMS_CHANGED: ${{ steps.changed.outputs.paramsChanged }}
run: |
mapfile -t files <<< "$CHANGED_FILES"
args=()
for f in "${files[@]}"; do
if [ -n "$f" ] && [ -f "$f" ]; then
args+=("$f")
fi
done
if [ ${#args[@]} -eq 0 ]; then
if [ "$PARAMS_CHANGED" = "true" ] && [ -f "docs/en/index.md" ]; then
# No markdown changed, but docs-params.json did. Run the checker
# against a single known-clean page so BuildRenderParameters /
# docs-params.json parsing actually executes and fails fast on a
# malformed parameter file.
echo "docs-params.json changed but no markdown changed; validating params via docs/en/index.md."
args+=("docs/en/index.md")
else
echo "No existing markdown files to check (all changes are deletions)."
exit 0
fi
fi
dotnet run --project .github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj \
-c Release --no-build -- "${args[@]}"
Loading…
Cancel
Save