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[@]}"