-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathConfigurationFile.cs
More file actions
404 lines (338 loc) · 14.4 KB
/
ConfigurationFile.cs
File metadata and controls
404 lines (338 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
// 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.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using DotNet.Globbing;
using Elastic.Documentation.Configuration.Products;
using Elastic.Documentation.Configuration.Toc;
using Elastic.Documentation.Configuration.Versions;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.Extensions;
using Elastic.Documentation.Links;
using static Elastic.Documentation.Configuration.SymlinkValidator;
namespace Elastic.Documentation.Configuration.Builder;
public record ConfigurationFile
{
private readonly IDocumentationSetContext _context;
public IFileInfo SourceFile => _context.ConfigurationPath;
public string? Project { get; }
private Glob[] Exclude { get; } = [];
private string[] Include { get; } = [];
public string[] CrossLinkRepositories { get; } = [];
/// <summary>
/// Registry for this documentation set. <c>Public</c> uses S3 link index; other values use codex-link-index.
/// </summary>
public DocSetRegistry Registry { get; } = DocSetRegistry.Public;
/// <summary>
/// Parsed cross-link entries with registry for each target.
/// </summary>
public CrossLinkEntry[] CrossLinkEntries { get; } = [];
/// The maximum depth `toc.yml` files may appear
public int MaxTocDepth { get; } = 1;
public EnabledExtensions Extensions { get; } = new([]);
public Dictionary<string, LinkRedirect>? Redirects { get; }
public HashSet<Product> Products { get; private set; } = [];
private readonly Dictionary<string, string> _substitutions = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, string> Substitutions => _substitutions;
private readonly Dictionary<string, bool> _features = new(StringComparer.OrdinalIgnoreCase);
[field: AllowNull, MaybeNull]
public FeatureFlags Features => field ??= new FeatureFlags(_features);
public IDirectoryInfo ScopeDirectory { get; }
public IReadOnlyDictionary<string, IFileInfo>? OpenApiSpecifications { get; }
/// <summary>
/// Resolved API configurations with template and specification file information.
/// </summary>
public IReadOnlyDictionary<string, ResolvedApiConfiguration>? ApiConfigurations { get; }
/// <summary>
/// Set of diagnostic hint types to suppress for this documentation set.
/// </summary>
public HashSet<HintType> SuppressDiagnostics { get; } = [];
/// <summary>
/// White-label branding overrides. When non-null, all Elastic-specific chrome is suppressed.
/// </summary>
public BrandingConfiguration? Branding { get; private set; }
/// This is a documentation set not linked to by assembler.
/// Setting this to true relaxes a few restrictions such as mixing toc references with file and folder reference
public bool DevelopmentDocs { get; }
// Files excluded via folder-level `exclude` in toc.yml need to be excluded from processing too,
// otherwise the builder crashes with "Could not find current in navigation" when rendering them.
private HashSet<string> FolderExcludedFiles { get; } = [];
public bool IsExcluded(string relativePath)
{
if (Include.Length > 0 && Include.Any(i => i.Equals(relativePath.OptionalWindowsReplace(), StringComparison.OrdinalIgnoreCase)))
return false;
if (FolderExcludedFiles.Contains(relativePath.OptionalWindowsReplace()))
return true;
return Exclude.Any(g => g.IsMatch(relativePath));
}
public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetContext context, VersionsConfiguration versionsConfig, ProductsConfiguration productsConfig)
{
_context = context;
ScopeDirectory = context.ConfigurationPath.Directory!;
if (!context.ConfigurationPath.Exists)
{
Project = "unknown";
context.EmitWarning(context.ConfigurationPath, "No configuration file found");
return;
}
var redirectFile = new RedirectFile(_context);
Redirects = redirectFile.Redirects;
try
{
// Read values from DocumentationSetFile
Project = docSetFile.Project;
MaxTocDepth = docSetFile.MaxTocDepth;
DevelopmentDocs = docSetFile.DevDocs;
// Convert exclude patterns to Glob
Exclude = [.. docSetFile.Exclude.Where(s => !string.IsNullOrEmpty(s) && !s.StartsWith('!')).Select(Glob.Parse)];
Include = [.. docSetFile.Exclude.Where(s => !string.IsNullOrEmpty(s) && s.StartsWith('!')).Select(s => s.TrimStart('!'))];
FolderExcludedFiles = docSetFile.FolderExcludedFiles;
// Parse registry (null/empty/"public" -> Public)
var registry = DocSetRegistry.Public;
if (!string.IsNullOrWhiteSpace(docSetFile.Registry) &&
DocSetRegistryExtensions.TryParse(docSetFile.Registry.Trim(), out var parsedRegistry, true))
registry = parsedRegistry;
Registry = registry;
// Parse cross-link entries with optional registry prefix (e.g. public://elasticsearch)
CrossLinkEntries = docSetFile.CrossLinks
.Where(raw => !string.IsNullOrWhiteSpace(raw))
.Select(raw => ParseCrossLinkEntry(raw.Trim(), registry, context.ConfigurationPath, context))
.Where(entry => entry is not null)
.Select(entry => entry!)
.ToArray();
CrossLinkRepositories = CrossLinkEntries.Select(e => e.Repository).ToArray();
// Extensions - assuming they're not in DocumentationSetFile yet
Extensions = new EnabledExtensions(docSetFile.Extensions);
// Copy suppression settings
SuppressDiagnostics = docSetFile.SuppressDiagnostics;
// Read substitutions
_substitutions = new(docSetFile.Subs, StringComparer.OrdinalIgnoreCase);
// Process API configurations
if (docSetFile.Api.Count > 0)
{
var specs = new Dictionary<string, IFileInfo>(StringComparer.OrdinalIgnoreCase);
var apiConfigs = new Dictionary<string, ResolvedApiConfiguration>(StringComparer.OrdinalIgnoreCase);
foreach (var (productKey, apiSequence) in docSetFile.Api)
{
if (!apiSequence.IsValid)
{
context.EmitError(
context.ConfigurationPath,
$"API configuration for '{productKey}' is invalid. Must have at least one spec and all entries must be valid."
);
continue;
}
// Resolve intro markdown files
var introMarkdownFiles = new List<IFileInfo>();
foreach (var introPath in apiSequence.GetIntroMarkdownFiles())
{
var fullPath = Path.Join(context.DocumentationSourceDirectory.FullName, introPath);
var introFile = context.ReadFileSystem.FileInfo.New(fullPath);
if (!introFile.Exists)
{
context.EmitWarning(
context.ConfigurationPath,
$"Intro markdown file '{introPath}' for API '{productKey}' does not exist."
);
}
else
{
introMarkdownFiles.Add(introFile);
}
}
// Resolve outro markdown files
var outroMarkdownFiles = new List<IFileInfo>();
foreach (var outroPath in apiSequence.GetOutroMarkdownFiles())
{
var fullPath = Path.Join(context.DocumentationSourceDirectory.FullName, outroPath);
var outroFile = context.ReadFileSystem.FileInfo.New(fullPath);
if (!outroFile.Exists)
{
context.EmitWarning(
context.ConfigurationPath,
$"Outro markdown file '{outroPath}' for API '{productKey}' does not exist."
);
}
else
{
outroMarkdownFiles.Add(outroFile);
}
}
// Resolve specification files
var specFiles = new List<IFileInfo>();
foreach (var specPath in apiSequence.GetSpecPaths())
{
var fullPath = Path.Join(context.DocumentationSourceDirectory.FullName, specPath);
var specFile = context.ReadFileSystem.FileInfo.New(fullPath);
if (!specFile.Exists)
{
context.EmitError(
context.ConfigurationPath,
$"API specification file '{specPath}' for product '{productKey}' does not exist."
);
continue;
}
specFiles.Add(specFile);
}
if (specFiles.Count == 0)
{
context.EmitError(
context.ConfigurationPath,
$"No valid specification files found for API product '{productKey}'."
);
continue;
}
// Create resolved configuration
var resolvedConfig = new ResolvedApiConfiguration
{
ProductKey = productKey,
IntroMarkdownFiles = introMarkdownFiles,
SpecFiles = specFiles,
OutroMarkdownFiles = outroMarkdownFiles
};
apiConfigs[productKey] = resolvedConfig;
// For backward compatibility, populate OpenApiSpecifications with primary spec
specs[productKey] = resolvedConfig.PrimarySpecFile;
}
OpenApiSpecifications = specs.Count > 0 ? specs : null;
ApiConfigurations = apiConfigs.Count > 0 ? apiConfigs : null;
}
// Process products from docset - resolve ProductLinks to Product objects
if (docSetFile.Products.Count > 0)
{
Products = docSetFile.Products
.Select(link => productsConfig.Products.GetValueOrDefault(link.Id.Replace('_', '-')))
.Where(product => product is not null)
.ToHashSet()!;
}
// Process branding with validation
if (docSetFile.Branding is not null)
Branding = ValidateBranding(docSetFile.Branding, context);
// Process features
_features = new(StringComparer.OrdinalIgnoreCase);
if (docSetFile.Features.PrimaryNav.HasValue)
_features["primary-nav"] = docSetFile.Features.PrimaryNav.Value;
if (docSetFile.Features.DisableGithubEditLink.HasValue)
_features["disable-github-edit-link"] = docSetFile.Features.DisableGithubEditLink.Value;
// primary-nav requires the Elastic global navigation which is not available for white-label builds
if (Branding is not null && docSetFile.Features.PrimaryNav is true)
context.EmitError(context.ConfigurationPath, "'features.primary-nav' cannot be used together with 'branding': the primary nav requires Elastic global navigation.");
// Add version substitutions
foreach (var (id, system) in versionsConfig.VersioningSystems)
{
var name = id.ToStringFast(true);
var alternativeName = name.Replace('-', '_');
_substitutions[$"version.{name}"] = system.Current;
_substitutions[$"version.{alternativeName}"] = system.Current;
_substitutions[$"version.{name}.base"] = system.Base;
_substitutions[$"version.{alternativeName}.base"] = system.Base;
}
// Add product substitutions (only for products with public-reference feature)
foreach (var product in productsConfig.PublicReferenceProducts.Values)
{
var alternativeProductId = product.Id.Replace('-', '_');
_substitutions[$"product.{product.Id}"] = product.DisplayName;
_substitutions[$".{product.Id}"] = product.DisplayName;
_substitutions[$"product.{alternativeProductId}"] = product.DisplayName;
_substitutions[$".{alternativeProductId}"] = product.DisplayName;
}
}
catch (Exception e)
{
context.EmitError(context.ConfigurationPath, $"Could not load docset.yml: {e.Message}");
throw;
}
}
private static readonly HashSet<string> AllowedImageExtensions =
[".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"];
private static BrandingConfiguration ValidateBranding(BrandingConfiguration branding, IDocumentationSetContext context)
{
branding.Icon = ValidateBrandingImage(branding.Icon, "branding.icon", context);
branding.OgImage = ValidateBrandingImage(branding.OgImage, "branding.og-image", context);
branding.Favicon = string.IsNullOrEmpty(branding.Favicon)
? DiscoverBrandingFile(["favicon.ico", "favicon.png", "favicon.svg"], context)
: ValidateBrandingImage(branding.Favicon, "branding.favicon", context);
branding.AppleTouchIcon = string.IsNullOrEmpty(branding.AppleTouchIcon)
? DiscoverBrandingFile(["apple-touch-icon.png"], context)
: ValidateBrandingImage(branding.AppleTouchIcon, "branding.apple-touch-icon", context);
return branding;
}
private static string? DiscoverBrandingFile(string[] candidates, IDocumentationSetContext context)
{
foreach (var name in candidates)
{
var f = context.ReadFileSystem.FileInfo.New(
Path.Join(context.DocumentationSourceDirectory.FullName, name));
if (f.Exists && f.LinkTarget is null)
return name;
}
return null;
}
private static string? ValidateBrandingImage(string? imagePath, string fieldName, IDocumentationSetContext context)
{
if (string.IsNullOrEmpty(imagePath))
return null;
var ext = Path.GetExtension(imagePath).ToLowerInvariant();
if (!AllowedImageExtensions.Contains(ext))
{
context.EmitError(context.ConfigurationPath,
$"'{fieldName}' has unsupported extension '{ext}'. Allowed: {string.Join(", ", AllowedImageExtensions)}");
return null;
}
var resolved = context.ReadFileSystem.FileInfo.New(
Path.GetFullPath(Path.Join(context.DocumentationSourceDirectory.FullName, imagePath))
);
if (!resolved.IsSubPathOf(context.DocumentationSourceDirectory))
{
context.EmitError(context.ConfigurationPath,
$"'{fieldName}' path '{imagePath}' escapes the documentation source directory.");
return null;
}
var symlinkError = ValidateFileAccess(resolved, context.DocumentationSourceDirectory);
if (symlinkError is not null)
{
context.EmitError(context.ConfigurationPath,
$"'{fieldName}' path '{imagePath}' is unsafe: {symlinkError}");
return null;
}
if (!resolved.Exists)
{
context.EmitError(context.ConfigurationPath, $"'{fieldName}' file '{imagePath}' does not exist.");
return null;
}
return imagePath;
}
private static CrossLinkEntry? ParseCrossLinkEntry(string raw, DocSetRegistry docsetRegistry, IFileInfo configPath, IDocumentationContext context)
{
DocSetRegistry entryRegistry;
string repository;
var colonSlash = raw.IndexOf("://", StringComparison.Ordinal);
if (colonSlash >= 0)
{
var prefix = raw[..colonSlash];
repository = raw[(colonSlash + 3)..];
if (string.IsNullOrWhiteSpace(repository))
{
context.EmitError(configPath, $"Cross-link '{raw}' has empty repository after registry prefix.");
return null;
}
if (!DocSetRegistryExtensions.TryParse(prefix, out entryRegistry, true))
{
context.EmitError(configPath, $"Cross-link '{raw}' uses unknown registry '{prefix}'. Use 'public' or 'internal'.");
return null;
}
}
else
{
repository = raw;
entryRegistry = docsetRegistry;
}
if (docsetRegistry == DocSetRegistry.Public && entryRegistry != DocSetRegistry.Public)
{
context.EmitError(configPath, $"Public documentation cannot link to codex docs. Cross-link '{raw}' targets registry '{entryRegistry.ToStringFast()}'. Remove it or use a public docset.");
return null;
}
return new CrossLinkEntry(repository, entryRegistry);
}
}