55using System . IO . Abstractions ;
66using Elastic . Documentation . Configuration . Products ;
77using Elastic . Documentation . Diagnostics ;
8+ using Elastic . Documentation . Extensions ;
89using YamlDotNet . Core ;
910using YamlDotNet . Core . Events ;
1011using YamlDotNet . Serialization ;
@@ -20,6 +21,13 @@ public class TableOfContentsFile
2021 [ YamlMember ( Alias = "toc" ) ]
2122 public TableOfContents TableOfContents { get ; set ; } = [ ] ;
2223
24+ /// <summary>
25+ /// Set of diagnostic hint types to suppress. Deserialized directly from YAML list of strings.
26+ /// Valid values: "DeepLinkingVirtualFile", "FolderFileNameMismatch"
27+ /// </summary>
28+ [ YamlMember ( Alias = "suppress" ) ]
29+ public HashSet < HintType > SuppressDiagnostics { get ; set ; } = [ ] ;
30+
2331 public static TableOfContentsFile Deserialize ( string json ) =>
2432 ConfigurationFileProvider . Deserializer . Deserialize < TableOfContentsFile > ( json ) ;
2533}
@@ -93,8 +101,8 @@ public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collecto
93101 {
94102 fileSystem ??= sourceDirectory . FileSystem ;
95103 var docSet = Deserialize ( yaml ) ;
96- var docsetPath = fileSystem . Path . Combine ( sourceDirectory . FullName , "docset.yml" ) ;
97- docSet . TableOfContents = ResolveTableOfContents ( collector , docSet . TableOfContents , sourceDirectory , fileSystem , parentPath : "" , context : docsetPath ) ;
104+ var docsetPath = fileSystem . Path . Combine ( sourceDirectory . FullName , "docset.yml" ) . OptionalWindowsReplace ( ) ;
105+ docSet . TableOfContents = ResolveTableOfContents ( collector , docSet . TableOfContents , sourceDirectory , fileSystem , parentPath : "" , context : docsetPath , docSet . SuppressDiagnostics ) ;
98106 return docSet ;
99107 }
100108
@@ -111,7 +119,8 @@ private static TableOfContents ResolveTableOfContents(
111119 IDirectoryInfo baseDirectory ,
112120 IFileSystem fileSystem ,
113121 string parentPath ,
114- string context
122+ string context ,
123+ HashSet < HintType > ? suppressDiagnostics = null
115124 )
116125 {
117126 var resolved = new TableOfContents ( ) ;
@@ -120,9 +129,9 @@ string context
120129 {
121130 var resolvedItem = item switch
122131 {
123- IsolatedTableOfContentsRef tocRef => ResolveIsolatedToc ( collector , tocRef , baseDirectory , fileSystem , parentPath , context ) ,
124- FileRef fileRef => ResolveFileRef ( collector , fileRef , baseDirectory , fileSystem , parentPath , context ) ,
125- FolderRef folderRef => ResolveFolderRef ( collector , folderRef , baseDirectory , fileSystem , parentPath , context ) ,
132+ IsolatedTableOfContentsRef tocRef => ResolveIsolatedToc ( collector , tocRef , baseDirectory , fileSystem , parentPath , context , suppressDiagnostics ) ,
133+ FileRef fileRef => ResolveFileRef ( collector , fileRef , baseDirectory , fileSystem , parentPath , context , suppressDiagnostics ) ,
134+ FolderRef folderRef => ResolveFolderRef ( collector , folderRef , baseDirectory , fileSystem , parentPath , context , suppressDiagnostics ) ,
126135 CrossLinkRef crossLink => ResolveCrossLinkRef ( collector , crossLink , baseDirectory , fileSystem , parentPath , context ) ,
127136 _ => null
128137 } ;
@@ -139,14 +148,17 @@ string context
139148 /// Validates that the TOC has no children in parent YAML and that toc.yml exists.
140149 /// The TOC's path is set to the full path (including parent path) for consistency with files and folders.
141150 /// </summary>
151+ #pragma warning disable IDE0060 // Remove unused parameter - suppressDiagnostics is for consistency, nested TOCs use their own suppression config
142152 private static ITableOfContentsItem ? ResolveIsolatedToc (
143153 IDiagnosticsCollector collector ,
144154 IsolatedTableOfContentsRef tocRef ,
145155 IDirectoryInfo baseDirectory ,
146156 IFileSystem fileSystem ,
147157 string parentPath ,
148- string parentContext
158+ string parentContext ,
159+ HashSet < HintType > ? suppressDiagnostics = null
149160 )
161+ #pragma warning restore IDE0060
150162 {
151163 // TOC paths containing '/' are treated as relative to the context file's directory (full paths).
152164 // Simple TOC names (no '/') are resolved relative to the parent path in the navigation hierarchy.
@@ -190,12 +202,31 @@ string parentContext
190202 }
191203
192204 var tocYaml = fileSystem . File . ReadAllText ( tocFilePath ) ;
193- var tocFile = TableOfContentsFile . Deserialize ( tocYaml ) ;
205+ var nestedTocFile = TableOfContentsFile . Deserialize ( tocYaml ) ;
206+
207+ // this is temporary after this lands in main we can update these files to include
208+ // suppress:
209+ // - DeepLinkingVirtualFile
210+ string [ ] skip = [
211+ "docs-content/solutions/toc.yml" ,
212+ "docs-content/manage-data/toc.yml" ,
213+ "docs-content/explore-analyze/toc.yml" ,
214+ "docs-content/deploy-manage/toc.yml" ,
215+ "docs-content/troubleshoot/toc.yml" ,
216+ "docs-content/troubleshoot/ingest/opentelemetry/toc.yml" ,
217+ "docs-content/reference/security/toc.yml"
218+ ] ;
219+
220+ var path = tocFilePath . OptionalWindowsReplace ( ) ;
221+ // Hardcode suppression for known problematic files
222+ if ( skip . Any ( f => path . Contains ( f , StringComparison . OrdinalIgnoreCase ) ) )
223+ _ = nestedTocFile . SuppressDiagnostics . Add ( HintType . DeepLinkingVirtualFile ) ;
224+
194225
195226 // Recursively resolve children with the FULL TOC path as the parent path
196227 // This ensures all file paths within the TOC include the TOC directory path
197228 // The context for children is the toc.yml file that defines them
198- var resolvedChildren = ResolveTableOfContents ( collector , tocFile . TableOfContents , baseDirectory , fileSystem , fullTocPath , tocFilePath ) ;
229+ var resolvedChildren = ResolveTableOfContents ( collector , nestedTocFile . TableOfContents , baseDirectory , fileSystem , fullTocPath , tocFilePath , nestedTocFile . SuppressDiagnostics ) ;
199230
200231 // Validate: TOC must have at least one child
201232 if ( resolvedChildren . Count == 0 )
@@ -218,7 +249,8 @@ private static ITableOfContentsItem ResolveFileRef(
218249 IDirectoryInfo baseDirectory ,
219250 IFileSystem fileSystem ,
220251 string parentPath ,
221- string context )
252+ string context ,
253+ HashSet < HintType > ? suppressDiagnostics = null )
222254 {
223255 var fullPath = string . IsNullOrEmpty ( parentPath ) ? fileRef . Path : $ "{ parentPath } /{ fileRef . Path } ";
224256
@@ -241,18 +273,22 @@ private static ITableOfContentsItem ResolveFileRef(
241273 // Only check if we're in a folder context (parentPath is not empty)
242274 if ( ! string . IsNullOrEmpty ( parentPath ) && fileName != "index.md" )
243275 {
244- // Extract just the folder name from parentPath (in case it's nested like "guides/getting-started")
245- var folderName = parentPath . Contains ( '/' ) ? parentPath . Split ( '/' ) [ ^ 1 ] : parentPath ;
276+ // Check if this hint type should be suppressed
277+ if ( ! suppressDiagnostics . ShouldSuppress ( HintType . FolderFileNameMismatch ) )
278+ {
279+ // Extract just the folder name from parentPath (in case it's nested like "guides/getting-started")
280+ var folderName = parentPath . Contains ( '/' ) ? parentPath . Split ( '/' ) [ ^ 1 ] : parentPath ;
246281
247- // Normalize for comparison: remove hyphens, underscores, and lowercase
248- // This allows "getting-started" to match "GettingStarted" or "getting_started"
249- var normalizedFile = fileWithoutExtension . Replace ( "-" , "" , StringComparison . Ordinal ) . Replace ( "_" , "" , StringComparison . Ordinal ) . ToLowerInvariant ( ) ;
250- var normalizedFolder = folderName . Replace ( "-" , "" , StringComparison . Ordinal ) . Replace ( "_" , "" , StringComparison . Ordinal ) . ToLowerInvariant ( ) ;
282+ // Normalize for comparison: remove hyphens, underscores, and lowercase
283+ // This allows "getting-started" to match "GettingStarted" or "getting_started"
284+ var normalizedFile = fileWithoutExtension . Replace ( "-" , "" , StringComparison . Ordinal ) . Replace ( "_" , "" , StringComparison . Ordinal ) . ToLowerInvariant ( ) ;
285+ var normalizedFolder = folderName . Replace ( "-" , "" , StringComparison . Ordinal ) . Replace ( "_" , "" , StringComparison . Ordinal ) . ToLowerInvariant ( ) ;
251286
252- if ( ! normalizedFile . Equals ( normalizedFolder , StringComparison . Ordinal ) )
253- {
254- collector . EmitHint ( context ,
255- $ "File name '{ fileName } ' does not match folder name '{ folderName } '. Best practice is to name the file the same as the folder (e.g., 'folder: { folderName } , file: { folderName } .md').") ;
287+ if ( ! normalizedFile . Equals ( normalizedFolder , StringComparison . Ordinal ) )
288+ {
289+ collector . EmitHint ( context ,
290+ $ "File name '{ fileName } ' does not match folder name '{ folderName } '. Best practice is to name the file the same as the folder (e.g., 'folder: { folderName } , file: { folderName } .md').") ;
291+ }
256292 }
257293 }
258294 }
@@ -272,8 +308,12 @@ private static ITableOfContentsItem ResolveFileRef(
272308 // This suggests using 'folder' instead of 'file' would be better
273309 if ( fileRef . Path . Contains ( '/' ) && fileRef . Children . Count > 0 && fileRef is not FolderIndexFileRef )
274310 {
275- collector . EmitHint ( context ,
276- $ "File '{ fileRef . Path } ' uses deep-linking with children. Consider using 'folder' instead of 'file' for better navigation structure. Virtual files are primarily intended to group sibling files together.") ;
311+ // Check if this hint type should be suppressed
312+ if ( ! suppressDiagnostics . ShouldSuppress ( HintType . DeepLinkingVirtualFile ) )
313+ {
314+ collector . EmitHint ( context ,
315+ $ "File '{ fileRef . Path } ' uses deep-linking with children. Consider using 'folder' instead of 'file' for better navigation structure. Virtual files are primarily intended to group sibling files together.") ;
316+ }
277317 }
278318
279319 // Children of a file should be resolved in the same directory as the parent file.
@@ -304,7 +344,7 @@ private static ITableOfContentsItem ResolveFileRef(
304344 parentPathForChildren = parentPath ;
305345 }
306346
307- var resolvedChildren = ResolveTableOfContents ( collector , fileRef . Children , baseDirectory , fileSystem , parentPathForChildren , context ) ;
347+ var resolvedChildren = ResolveTableOfContents ( collector , fileRef . Children , baseDirectory , fileSystem , parentPathForChildren , context , suppressDiagnostics ) ;
308348
309349 // Preserve the specific type when creating the resolved reference
310350 return fileRef switch
@@ -325,7 +365,8 @@ private static ITableOfContentsItem ResolveFolderRef(
325365 IDirectoryInfo baseDirectory ,
326366 IFileSystem fileSystem ,
327367 string parentPath ,
328- string context )
368+ string context ,
369+ HashSet < HintType > ? suppressDiagnostics = null )
329370 {
330371 // Folder paths containing '/' are treated as relative to the context file's directory (full paths).
331372 // Simple folder names (no '/') are resolved relative to the parent path in the navigation hierarchy.
@@ -351,7 +392,7 @@ private static ITableOfContentsItem ResolveFolderRef(
351392 // If children are explicitly defined, resolve them
352393 if ( folderRef . Children . Count > 0 )
353394 {
354- var resolvedChildren = ResolveTableOfContents ( collector , folderRef . Children , baseDirectory , fileSystem , fullPath , context ) ;
395+ var resolvedChildren = ResolveTableOfContents ( collector , folderRef . Children , baseDirectory , fileSystem , fullPath , context , suppressDiagnostics ) ;
355396 return new FolderRef ( fullPath , resolvedChildren , context ) ;
356397 }
357398
0 commit comments