mirror of https://github.com/abpframework/abp.git
3 changed files with 472 additions and 0 deletions
@ -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> |
|||
@ -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); |
|||
} |
|||
@ -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…
Reference in new issue