Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/_docset.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
project: 'doc-builder'
max_toc_depth: 2
dev_docs: true
Expand Down Expand Up @@ -204,6 +204,7 @@
- file: llm.md
- file: okf.md
- file: plain-text.md
- file: git-diff.md
- folder: release-notes
children:
- file: index.md
Expand Down
63 changes: 63 additions & 0 deletions docs/data/exporters/git-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
navigation_title: Git diff
---

# Git diff exporter

The Git diff exporter maps the current branch diff onto published documentation pages and writes `changed-pages.json` to the build output. CI workflows use this file to post preview links that match the URLs the builder generates.

## How it works

During the HTML build, the exporter collects every published page (source path, navigation URL, title) and the include graph from `{include}` and `{csv-include}` directives. At the end of the build it reads the git diff against a base ref and writes a JSON artifact.

Changed snippet or data files map to the pages that include them. Changed configuration files set `config_changed: true` so workflows can link to the full preview instead of listing every page.

## Output

The exporter writes `changed-pages.json` next to `links.json`:

```json
{
"base": "origin/main",
"config_changed": false,
"pages": [
{
"source_path": "guides/start.md",
"url": "/_preview/org/repo/pull/1/guides/start",
"title": "Get started",
"change": "modified",
"included_from": []
}
],
"deleted": [{ "source_path": "guides/old.md" }]
}
```

URLs are path-only. Workflows prepend the preview host (for example `https://codex.elastic.dev`).

## Enabling

The exporter is **not** part of the default exporter set. Enable it explicitly:

```bash
docs-builder --exporters default,gitdiff
```

On CI (`GITHUB_ACTIONS` set), isolated builds enable it automatically.

## Diff base resolution

If `ADDED_FILES`, `MODIFIED_FILES`, `DELETED_FILES`, or `RENAMED_FILES` are set (GitHub Actions changed-file lists), the exporter uses those and does not run git.

Otherwise it resolves the git diff base in this order:

1. `DOCS_DIFF_BASE` environment variable
2. `GITHUB_BASE_REF` → `origin/<ref>`
3. `main`, then `master`, then `origin/HEAD`
4. `HEAD^1` when that first parent exists

Then it runs `git diff --name-status -z <base> HEAD`.

## Failure behavior

Git errors do not fail the build. The exporter logs a warning and writes an empty `pages` array.
4 changes: 4 additions & 0 deletions docs/data/exporters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ An [Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-cata
### [Plain Text](./plain-text.md)

Stripped-down plain text with all formatting removed. Used internally by other exporters (notably Elasticsearch) for search indexing.

### [Git diff](./git-diff.md)

Maps the git diff to published page URLs and titles. Writes `changed-pages.json` for CI preview comment jobs.
4 changes: 3 additions & 1 deletion src/Elastic.Documentation.Tooling/Exporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ public enum Exporter
LinkMetadata,
Redirects,
Okf,
Pagefind
Pagefind,
[EnumValue("gitdiff")]
GitDiff
}

public static class ExportOptions
Expand Down
56 changes: 56 additions & 0 deletions src/Elastic.Documentation/GitDiff/ChangedPagesExport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Text.Json;
using System.Text.Json.Serialization;
using Elastic.Documentation.Serialization;

namespace Elastic.Documentation.GitDiff;

public static class ChangedPagesExportFile
{
public const string FileName = "changed-pages.json";

public static string Serialize(ChangedPagesExport export) =>
JsonSerializer.Serialize(export, SourceGenerationContext.Default.ChangedPagesExport);
}

public record ChangedPagesExport
{
[JsonPropertyName("base")]
public required string Base { get; init; }

[JsonPropertyName("config_changed")]
public bool ConfigChanged { get; init; }

[JsonPropertyName("pages")]
public required IReadOnlyList<ChangedPageEntry> Pages { get; init; }

[JsonPropertyName("deleted")]
public required IReadOnlyList<DeletedPageEntry> Deleted { get; init; }
}

public record ChangedPageEntry
{
[JsonPropertyName("source_path")]
public required string SourcePath { get; init; }

[JsonPropertyName("url")]
public required string Url { get; init; }

[JsonPropertyName("title")]
public required string Title { get; init; }

[JsonPropertyName("change")]
public required string Change { get; init; }

[JsonPropertyName("included_from")]
public required IReadOnlyList<string> IncludedFrom { get; init; }
}

public record DeletedPageEntry
{
[JsonPropertyName("source_path")]
public required string SourcePath { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using System.Text.Json.Serialization;
using Elastic.Documentation.AppliesTo;
using Elastic.Documentation.GitDiff;
using Elastic.Documentation.Links;
using Elastic.Documentation.State;
using Elastic.Documentation.Versions;
Expand All @@ -29,4 +30,5 @@ namespace Elastic.Documentation.Serialization;
[JsonSerializable(typeof(SemVersion))]
[JsonSerializable(typeof(VersionSpec))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(ChangedPagesExport))]
public sealed partial class SourceGenerationContext : JsonSerializerContext;
3 changes: 3 additions & 0 deletions src/Elastic.Markdown/Exporters/ExporterExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Elastic.Documentation;
using Elastic.Documentation.Configuration;
using Elastic.Markdown.Exporters.Elasticsearch;
using Elastic.Markdown.Exporters.GitDiff;
using Elastic.Markdown.Exporters.Pagefind;
using Microsoft.Extensions.Logging;

Expand All @@ -30,6 +31,8 @@ public static IReadOnlyCollection<IMarkdownExporter> CreateMarkdownExporters(
markdownExporters.Add(new OkfMarkdownExporter());
if (exportOptions.Contains(Exporter.Pagefind))
markdownExporters.Add(new PagefindMarkdownExporter(logFactory));
if (exportOptions.Contains(Exporter.GitDiff))
markdownExporters.Add(new GitDiffMarkdownExporter(logFactory));
return markdownExporters;
}
}
7 changes: 7 additions & 0 deletions src/Elastic.Markdown/Exporters/GitDiff/BuiltPageInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

namespace Elastic.Markdown.Exporters.GitDiff;

internal record BuiltPageInfo(string Url, string Title);
156 changes: 156 additions & 0 deletions src/Elastic.Markdown/Exporters/GitDiff/ChangedPagesMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using Elastic.Documentation.GitDiff;

namespace Elastic.Markdown.Exporters.GitDiff;

internal static class ChangedPagesMapper
{
private static readonly HashSet<string> ConfigFileNames = new(
[
"docset.yml",
"_docset.yml",
"redirects.yml",
"toc.yml",
"navigation.yml",
"navigation_preview.yml",
"products.yml",
"versions.yml",
"legacy-url-mappings.yml",
"assembler.yml",
"search.yml",
],
StringComparer.OrdinalIgnoreCase);

public static ChangedPagesExport Map(
string diffBase,
string docsetPrefix,
IReadOnlyDictionary<string, BuiltPageInfo> builtPages,
IReadOnlyDictionary<string, IReadOnlyCollection<string>> includeIndex,
IReadOnlyList<SourceFileChange> changes
)
{
var configChanged = false;
var deleted = new List<DeletedPageEntry>();
var pageEntries = new Dictionary<string, ChangedPageEntry>(StringComparer.OrdinalIgnoreCase);

foreach (var change in changes)
{
if (!GitDiffPathNormalization.TryToDocsetRelative(change.Path, docsetPrefix, out var docsetPath))
continue;

if (IsConfigFile(docsetPath, change.Path))
{
configChanged = true;
continue;
}

switch (change.ChangeType)
{
case SourceFileChangeType.Deleted:
if (GitDiffPathNormalization.IsMarkdownPagePath(docsetPath))
deleted.Add(new DeletedPageEntry { SourcePath = docsetPath });
break;

case SourceFileChangeType.Renamed:
if (GitDiffPathNormalization.IsMarkdownPagePath(docsetPath))
deleted.Add(new DeletedPageEntry { SourcePath = docsetPath });
if (GitDiffPathNormalization.TryToDocsetRelative(change.NewPath ?? string.Empty, docsetPrefix, out var newDocsetPath))
{
TryAddDirectPage(pageEntries, builtPages, newDocsetPath, "renamed");
TryAddAffectedByInclude(pageEntries, builtPages, includeIndex, newDocsetPath);
}
break;

default:
var changeLabel = change.ChangeType == SourceFileChangeType.Added ? "added" : "modified";
TryAddDirectPage(pageEntries, builtPages, docsetPath, changeLabel);
TryAddAffectedByInclude(pageEntries, builtPages, includeIndex, docsetPath);
break;
}
}

var pages = pageEntries.Values
.OrderBy(p => p.SourcePath, StringComparer.OrdinalIgnoreCase)
.ToArray();

deleted.Sort(static (a, b) => string.Compare(a.SourcePath, b.SourcePath, StringComparison.OrdinalIgnoreCase));

return new ChangedPagesExport
{
Base = diffBase,
ConfigChanged = configChanged,
Pages = pages,
Deleted = deleted
};
}

private static bool IsConfigFile(string docsetPath, string repoPath)
{
var fileName = Path.GetFileName(string.IsNullOrEmpty(docsetPath) ? repoPath : docsetPath);
return ConfigFileNames.Contains(fileName);
}

private static void TryAddDirectPage(
Dictionary<string, ChangedPageEntry> pageEntries,
IReadOnlyDictionary<string, BuiltPageInfo> builtPages,
string docsetPath,
string change
)
{
if (!GitDiffPathNormalization.IsMarkdownPagePath(docsetPath))
return;

if (!builtPages.TryGetValue(docsetPath, out var page))
return;

pageEntries[docsetPath] = new ChangedPageEntry
{
SourcePath = docsetPath,
Url = page.Url,
Title = page.Title,
Change = change,
IncludedFrom = []
};
}

private static void TryAddAffectedByInclude(
Dictionary<string, ChangedPageEntry> pageEntries,
IReadOnlyDictionary<string, BuiltPageInfo> builtPages,
IReadOnlyDictionary<string, IReadOnlyCollection<string>> includeIndex,
string changedDocsetPath
)
{
if (!includeIndex.TryGetValue(changedDocsetPath, out var affectedPages))
return;

foreach (var pagePath in affectedPages)
{
if (!builtPages.TryGetValue(pagePath, out var page))
continue;

if (pageEntries.TryGetValue(pagePath, out var existing))
{
if (existing.IncludedFrom.Contains(changedDocsetPath))
continue;

pageEntries[pagePath] = existing with
{
IncludedFrom = [.. existing.IncludedFrom, changedDocsetPath]
};
continue;
}

pageEntries[pagePath] = new ChangedPageEntry
{
SourcePath = pagePath,
Url = page.Url,
Title = page.Title,
Change = "modified",
IncludedFrom = [changedDocsetPath]
};
}
}
}
Loading
Loading