diff --git a/.github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj b/.github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj new file mode 100644 index 0000000000..b17b6c0369 --- /dev/null +++ b/.github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + Volo.Abp.Docs.SyntaxCheck + CheckDocsSyntax + + + + + + + diff --git a/.github/scripts/CheckDocsSyntax/Program.cs b/.github/scripts/CheckDocsSyntax/Program.cs new file mode 100644 index 0000000000..a57406f30c --- /dev/null +++ b/.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 +// `` and its `_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 `` / `_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(); + 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 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 CheckFile(string file, IReadOnlyDictionary renderParameters) + { + var issues = new List(); + 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 BuildRenderParameters(IEnumerable files) + { + // Reproduces the keys the docs renderer places into its parameter + // dictionary before rendering a documentation page. + var parameters = new Dictionary(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 DiscoverParameterNames(IEnumerable files) + { + var names = new HashSet(StringComparer.Ordinal); + var visitedDirs = new HashSet(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 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); +} diff --git a/.github/workflows/check-docs-syntax.yml b/.github/workflows/check-docs-syntax.yml new file mode 100644 index 0000000000..89b33c9bd4 --- /dev/null +++ b/.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[@]}"