diff --git a/Directory.Packages.props b/Directory.Packages.props index a33c0da98..beaa4fe95 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/README.md b/README.md index 4abb8c8ef..7423ec4dd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# docs-builder +#ocs-builder [![ci](https://github.com/elastic/docs-builder/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/elastic/docs-builder/actions/workflows/ci.yml) diff --git a/docs-builder.sln b/docs-builder.sln index 51348c58e..bbb9cf6a5 100644 --- a/docs-builder.sln +++ b/docs-builder.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Markdown", "src\Elastic.Markdown\Elastic.Markdown.csproj", "{4D198E25-C211-41DC-9E84-B15E89BD7048}" EndProject @@ -158,153 +158,442 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Links EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Isolated", "src\services\Elastic.Documentation.Isolated\Elastic.Documentation.Isolated.csproj", "{AABD3EF7-8C86-4981-B1D2-B1F786F33069}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Navigation", "src\Elastic.Documentation.Navigation\Elastic.Documentation.Navigation.csproj", "{2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Configuration.Tests", "tests\Elastic.Documentation.Configuration.Tests\Elastic.Documentation.Configuration.Tests.csproj", "{A8952020-F843-41B6-B456-BE95AFEBBBCA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Navigation.Tests", "tests\Navigation.Tests\Navigation.Tests.csproj", "{E9514A33-3DC1-48B5-9131-FDBDD492A833}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|x64.Build.0 = Debug|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|x86.Build.0 = Debug|Any CPU {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|Any CPU.ActiveCfg = Release|Any CPU {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|Any CPU.Build.0 = Release|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x64.ActiveCfg = Release|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x64.Build.0 = Release|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x86.ActiveCfg = Release|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x86.Build.0 = Release|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x64.ActiveCfg = Debug|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x64.Build.0 = Debug|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x86.ActiveCfg = Debug|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x86.Build.0 = Debug|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|Any CPU.Build.0 = Release|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x64.ActiveCfg = Release|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x64.Build.0 = Release|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x86.ActiveCfg = Release|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x86.Build.0 = Release|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x64.ActiveCfg = Debug|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x64.Build.0 = Debug|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x86.ActiveCfg = Debug|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x86.Build.0 = Debug|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|Any CPU.ActiveCfg = Release|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|Any CPU.Build.0 = Release|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x64.ActiveCfg = Release|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x64.Build.0 = Release|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x86.ActiveCfg = Release|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x86.Build.0 = Release|Any CPU {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x64.Build.0 = Debug|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x86.Build.0 = Debug|Any CPU {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|Any CPU.Build.0 = Release|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x64.ActiveCfg = Release|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x64.Build.0 = Release|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x86.ActiveCfg = Release|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x86.Build.0 = Release|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x64.ActiveCfg = Debug|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x64.Build.0 = Debug|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x86.Build.0 = Debug|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|Any CPU.Build.0 = Release|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x64.ActiveCfg = Release|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x64.Build.0 = Release|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x86.ActiveCfg = Release|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x86.Build.0 = Release|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x64.ActiveCfg = Debug|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x64.Build.0 = Debug|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x86.ActiveCfg = Debug|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x86.Build.0 = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x64.ActiveCfg = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x64.Build.0 = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x86.ActiveCfg = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x86.Build.0 = Release|Any CPU {28350800-B44B-479B-86E2-1D39E321C0B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {28350800-B44B-479B-86E2-1D39E321C0B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28350800-B44B-479B-86E2-1D39E321C0B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {28350800-B44B-479B-86E2-1D39E321C0B4}.Debug|x64.Build.0 = Debug|Any CPU + {28350800-B44B-479B-86E2-1D39E321C0B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {28350800-B44B-479B-86E2-1D39E321C0B4}.Debug|x86.Build.0 = Debug|Any CPU {28350800-B44B-479B-86E2-1D39E321C0B4}.Release|Any CPU.ActiveCfg = Release|Any CPU {28350800-B44B-479B-86E2-1D39E321C0B4}.Release|Any CPU.Build.0 = Release|Any CPU + {28350800-B44B-479B-86E2-1D39E321C0B4}.Release|x64.ActiveCfg = Release|Any CPU + {28350800-B44B-479B-86E2-1D39E321C0B4}.Release|x64.Build.0 = Release|Any CPU + {28350800-B44B-479B-86E2-1D39E321C0B4}.Release|x86.ActiveCfg = Release|Any CPU + {28350800-B44B-479B-86E2-1D39E321C0B4}.Release|x86.Build.0 = Release|Any CPU {018F959E-824B-4664-B345-066784478D24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {018F959E-824B-4664-B345-066784478D24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Debug|x64.ActiveCfg = Debug|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Debug|x64.Build.0 = Debug|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Debug|x86.ActiveCfg = Debug|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Debug|x86.Build.0 = Debug|Any CPU {018F959E-824B-4664-B345-066784478D24}.Release|Any CPU.ActiveCfg = Release|Any CPU {018F959E-824B-4664-B345-066784478D24}.Release|Any CPU.Build.0 = Release|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Release|x64.ActiveCfg = Release|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Release|x64.Build.0 = Release|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Release|x86.ActiveCfg = Release|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Release|x86.Build.0 = Release|Any CPU {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|x64.Build.0 = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|x86.Build.0 = Debug|Any CPU {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|Any CPU.Build.0 = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|x64.ActiveCfg = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|x64.Build.0 = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|x86.ActiveCfg = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|x86.Build.0 = Release|Any CPU {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|x64.Build.0 = Debug|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|x86.Build.0 = Debug|Any CPU {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|Any CPU.ActiveCfg = Release|Any CPU {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|Any CPU.Build.0 = Release|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|x64.ActiveCfg = Release|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|x64.Build.0 = Release|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|x86.ActiveCfg = Release|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|x86.Build.0 = Release|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|x64.ActiveCfg = Debug|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|x64.Build.0 = Debug|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|x86.ActiveCfg = Debug|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|x86.Build.0 = Debug|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|Any CPU.ActiveCfg = Release|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|Any CPU.Build.0 = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x64.ActiveCfg = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x64.Build.0 = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x86.ActiveCfg = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x86.Build.0 = Release|Any CPU {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Debug|x64.ActiveCfg = Debug|Any CPU + {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Debug|x64.Build.0 = Debug|Any CPU + {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Debug|x86.Build.0 = Debug|Any CPU {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Release|Any CPU.ActiveCfg = Release|Any CPU {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Release|Any CPU.Build.0 = Release|Any CPU + {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Release|x64.ActiveCfg = Release|Any CPU + {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Release|x64.Build.0 = Release|Any CPU + {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Release|x86.ActiveCfg = Release|Any CPU + {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3}.Release|x86.Build.0 = Release|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x64.ActiveCfg = Debug|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x64.Build.0 = Debug|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x86.ActiveCfg = Debug|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x86.Build.0 = Debug|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|Any CPU.Build.0 = Release|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|x64.ActiveCfg = Release|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|x64.Build.0 = Release|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|x86.ActiveCfg = Release|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|x86.Build.0 = Release|Any CPU {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|x64.ActiveCfg = Debug|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|x64.Build.0 = Debug|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|x86.ActiveCfg = Debug|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|x86.Build.0 = Debug|Any CPU {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|Any CPU.ActiveCfg = Release|Any CPU {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|Any CPU.Build.0 = Release|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|x64.ActiveCfg = Release|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|x64.Build.0 = Release|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|x86.ActiveCfg = Release|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|x86.Build.0 = Release|Any CPU {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|x64.Build.0 = Debug|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|x86.Build.0 = Debug|Any CPU {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|Any CPU.ActiveCfg = Release|Any CPU {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|Any CPU.Build.0 = Release|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|x64.ActiveCfg = Release|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|x64.Build.0 = Release|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|x86.ActiveCfg = Release|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|x86.Build.0 = Release|Any CPU {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|x64.ActiveCfg = Debug|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|x64.Build.0 = Debug|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|x86.ActiveCfg = Debug|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|x86.Build.0 = Debug|Any CPU {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|Any CPU.ActiveCfg = Release|Any CPU {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|Any CPU.Build.0 = Release|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|x64.ActiveCfg = Release|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|x64.Build.0 = Release|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|x86.ActiveCfg = Release|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|x86.Build.0 = Release|Any CPU {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|x64.ActiveCfg = Debug|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|x64.Build.0 = Debug|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|x86.ActiveCfg = Debug|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|x86.Build.0 = Debug|Any CPU {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|Any CPU.ActiveCfg = Release|Any CPU {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|Any CPU.Build.0 = Release|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|x64.ActiveCfg = Release|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|x64.Build.0 = Release|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|x86.ActiveCfg = Release|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|x86.Build.0 = Release|Any CPU {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|x64.ActiveCfg = Debug|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|x64.Build.0 = Debug|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|x86.ActiveCfg = Debug|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|x86.Build.0 = Debug|Any CPU {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|Any CPU.ActiveCfg = Release|Any CPU {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|Any CPU.Build.0 = Release|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|x64.ActiveCfg = Release|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|x64.Build.0 = Release|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|x86.ActiveCfg = Release|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|x86.Build.0 = Release|Any CPU {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|x64.ActiveCfg = Debug|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|x64.Build.0 = Debug|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|x86.ActiveCfg = Debug|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|x86.Build.0 = Debug|Any CPU {111E7029-BB29-4039-9B45-04776798A8DD}.Release|Any CPU.ActiveCfg = Release|Any CPU {111E7029-BB29-4039-9B45-04776798A8DD}.Release|Any CPU.Build.0 = Release|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Release|x64.ActiveCfg = Release|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Release|x64.Build.0 = Release|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Release|x86.ActiveCfg = Release|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Release|x86.Build.0 = Release|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|x64.Build.0 = Debug|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|x86.Build.0 = Debug|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|Any CPU.Build.0 = Release|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|x64.ActiveCfg = Release|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|x64.Build.0 = Release|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|x86.ActiveCfg = Release|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|x86.Build.0 = Release|Any CPU {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|x64.ActiveCfg = Debug|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|x64.Build.0 = Debug|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|x86.ActiveCfg = Debug|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|x86.Build.0 = Debug|Any CPU {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|Any CPU.ActiveCfg = Release|Any CPU {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|Any CPU.Build.0 = Release|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|x64.ActiveCfg = Release|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|x64.Build.0 = Release|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|x86.ActiveCfg = Release|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|x86.Build.0 = Release|Any CPU {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|x64.ActiveCfg = Debug|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|x64.Build.0 = Debug|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|x86.ActiveCfg = Debug|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|x86.Build.0 = Debug|Any CPU {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|Any CPU.ActiveCfg = Release|Any CPU {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|Any CPU.Build.0 = Release|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|x64.ActiveCfg = Release|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|x64.Build.0 = Release|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|x86.ActiveCfg = Release|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|x86.Build.0 = Release|Any CPU {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|x64.Build.0 = Debug|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|x86.Build.0 = Debug|Any CPU {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|Any CPU.Build.0 = Release|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|x64.ActiveCfg = Release|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|x64.Build.0 = Release|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|x86.ActiveCfg = Release|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|x86.Build.0 = Release|Any CPU {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|x64.ActiveCfg = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|x64.Build.0 = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|x86.ActiveCfg = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|x86.Build.0 = Debug|Any CPU {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|Any CPU.ActiveCfg = Release|Any CPU {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|Any CPU.Build.0 = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|x64.ActiveCfg = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|x64.Build.0 = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|x86.ActiveCfg = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|x86.Build.0 = Release|Any CPU {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|x64.Build.0 = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|x86.Build.0 = Debug|Any CPU {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|Any CPU.Build.0 = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|x64.ActiveCfg = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|x64.Build.0 = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|x86.ActiveCfg = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|x86.Build.0 = Release|Any CPU {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|x64.Build.0 = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|x86.Build.0 = Debug|Any CPU {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|Any CPU.ActiveCfg = Release|Any CPU {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|Any CPU.Build.0 = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|x64.ActiveCfg = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|x64.Build.0 = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|x86.ActiveCfg = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|x86.Build.0 = Release|Any CPU {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|x64.ActiveCfg = Debug|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|x64.Build.0 = Debug|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|x86.ActiveCfg = Debug|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|x86.Build.0 = Debug|Any CPU {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|Any CPU.ActiveCfg = Release|Any CPU {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|Any CPU.Build.0 = Release|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|x64.ActiveCfg = Release|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|x64.Build.0 = Release|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|x86.ActiveCfg = Release|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|x86.Build.0 = Release|Any CPU {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|x64.Build.0 = Debug|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|x86.Build.0 = Debug|Any CPU {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|Any CPU.ActiveCfg = Release|Any CPU {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|Any CPU.Build.0 = Release|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|x64.ActiveCfg = Release|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|x64.Build.0 = Release|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|x86.ActiveCfg = Release|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|x86.Build.0 = Release|Any CPU {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|x64.ActiveCfg = Debug|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|x64.Build.0 = Debug|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|x86.ActiveCfg = Debug|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|x86.Build.0 = Debug|Any CPU {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|Any CPU.ActiveCfg = Release|Any CPU {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|Any CPU.Build.0 = Release|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|x64.ActiveCfg = Release|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|x64.Build.0 = Release|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|x86.ActiveCfg = Release|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|x86.Build.0 = Release|Any CPU {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|x64.ActiveCfg = Debug|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|x64.Build.0 = Debug|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|x86.ActiveCfg = Debug|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|x86.Build.0 = Debug|Any CPU {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|Any CPU.ActiveCfg = Release|Any CPU {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|Any CPU.Build.0 = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x64.ActiveCfg = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x64.Build.0 = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x86.ActiveCfg = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x86.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x64.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x64.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x86.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x86.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|Any CPU.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x64.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x64.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x86.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x86.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x64.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x86.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|Any CPU.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x64.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x64.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x86.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x86.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x64.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x86.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|Any CPU.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x64.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x64.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x86.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {4D198E25-C211-41DC-9E84-B15E89BD7048} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0} = {73ABAE37-118F-4A53-BC2C-F19333555C90} {B27C5107-128B-465A-B8F8-8985399E4CFB} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} {CD2887E3-BDA9-434B-A5BF-9ED38DE20332} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {A2A34BBC-CB5E-4100-9529-A12B6ECB769C} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} + {28350800-B44B-479B-86E2-1D39E321C0B4} = {73ABAE37-118F-4A53-BC2C-F19333555C90} {018F959E-824B-4664-B345-066784478D24} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166} = {059E787F-85C1-43BE-9DD6-CE319E106383} {CFEE9FAD-9E0C-4C0E-A0C2-B97D594C14B5} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA} = {73ABAE37-118F-4A53-BC2C-F19333555C90} {6E2ED6CC-AFC1-4E58-965D-6AEC500EBB46} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {6554F917-73CE-4B3D-9101-F28EAA762C6B} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} + {C559D52D-100B-4B2B-BE87-2344D835761D} = {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} {CDC0ECF4-6597-4FBA-8D25-5C244F0877E3} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} - {C559D52D-100B-4B2B-BE87-2344D835761D} = {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} {BB789671-B262-43DD-91DB-39F9186B8257} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {09CE30F6-013A-49ED-B3D6-60AFA84682AC} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {CD94F9E4-7FCD-4152-81F1-4288C6B75367} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {73ABAE37-118F-4A53-BC2C-F19333555C90} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} - {28350800-B44B-479B-86E2-1D39E321C0B4} = {73ABAE37-118F-4A53-BC2C-F19333555C90} - {01F05AD0-E0E0-401F-A7EC-905928E1E9F0} = {73ABAE37-118F-4A53-BC2C-F19333555C90} - {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA} = {73ABAE37-118F-4A53-BC2C-F19333555C90} {059E787F-85C1-43BE-9DD6-CE319E106383} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} - {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166} = {059E787F-85C1-43BE-9DD6-CE319E106383} {FB1C1954-D8E2-4745-BA62-04DD82FB4792} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {E20FEEF9-1D1A-4CDA-A546-7FDC573BE399} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {FD1AC230-798B-4AB9-8CE6-A06264885DBC} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} @@ -325,5 +614,8 @@ Global {E6EA955D-D0A7-4749-9586-0F7256EF5C5E} = {7AACA67B-3C56-4C7C-9891-558589FC52DB} {153FC4AD-F5B0-4100-990E-0987C86DBF01} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {AABD3EF7-8C86-4981-B1D2-B1F786F33069} = {7AACA67B-3C56-4C7C-9891-558589FC52DB} + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} + {A8952020-F843-41B6-B456-BE95AFEBBBCA} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} + {E9514A33-3DC1-48B5-9131-FDBDD492A833} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} EndGlobalSection EndGlobal diff --git a/docs/_docset.yml b/docs/_docset.yml index bad2fe7e4..5f94bdd03 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -174,6 +174,8 @@ toc: - file: index.md - file: req.md - folder: nested + children: + - file: index.md - file: cross-links.md children: - title: "Getting Started Guide" diff --git a/src/Elastic.ApiExplorer/ApiIndexLeafNavigation.cs b/src/Elastic.ApiExplorer/ApiIndexLeafNavigation.cs new file mode 100644 index 000000000..dadab3f12 --- /dev/null +++ b/src/Elastic.ApiExplorer/ApiIndexLeafNavigation.cs @@ -0,0 +1,39 @@ +// 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.Navigation; + +namespace Elastic.ApiExplorer; + +public class ApiIndexLeafNavigation( + TModel model, string url, string navigationTitle, + IRootNavigationItem rootNavigation, + INodeNavigationItem? parent = null +) : ILeafNavigationItem + where TModel : IApiModel +{ + /// + public string Url { get; } = url; + + /// + public string NavigationTitle { get; } = navigationTitle; + + /// + public IRootNavigationItem NavigationRoot { get; } = rootNavigation; + + /// + public INodeNavigationItem? Parent { get; set; } = parent; + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink { get; } + + /// + public TModel Model { get; } = model; +} diff --git a/src/Elastic.ApiExplorer/ApiRenderContext.cs b/src/Elastic.ApiExplorer/ApiRenderContext.cs index 0a60880e6..749ebc42a 100644 --- a/src/Elastic.ApiExplorer/ApiRenderContext.cs +++ b/src/Elastic.ApiExplorer/ApiRenderContext.cs @@ -4,6 +4,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; using Microsoft.OpenApi.Models; diff --git a/src/Elastic.ApiExplorer/ApiViewModel.cs b/src/Elastic.ApiExplorer/ApiViewModel.cs index 0cc7d464e..048190738 100644 --- a/src/Elastic.ApiExplorer/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/ApiViewModel.cs @@ -7,9 +7,9 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site; using Elastic.Documentation.Site.FileProviders; -using Elastic.Documentation.Site.Navigation; using Microsoft.AspNetCore.Html; namespace Elastic.ApiExplorer; diff --git a/src/Elastic.ApiExplorer/Endpoints/ApiEndpoint.cs b/src/Elastic.ApiExplorer/Endpoints/ApiEndpoint.cs index 4e918aacc..b79b83b14 100644 --- a/src/Elastic.ApiExplorer/Endpoints/ApiEndpoint.cs +++ b/src/Elastic.ApiExplorer/Endpoints/ApiEndpoint.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.Navigation; using Microsoft.OpenApi.Models.Interfaces; using RazorSlices; diff --git a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs index 9c636420e..d0326fc84 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using Elastic.ApiExplorer.Operations; using Elastic.Documentation.Extensions; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation; using RazorSlices; namespace Elastic.ApiExplorer.Landing; @@ -29,27 +29,23 @@ public class LandingNavigationItem : IApiGroupingNavigationItem NavigationRoot { get; } public string Id { get; } public int Depth { get; } - public ApiLanding Index { get; } + public ILeafNavigationItem Index { get; } public IReadOnlyCollection NavigationItems { get; set; } = []; public INodeNavigationItem? Parent { get; set; } public int NavigationIndex { get; set; } public bool IsCrossLink => false; // API landing items are never cross-links - public string Url { get; } + public string Url => Index.Url; public bool Hidden => false; - //TODO - public string NavigationTitle { get; } = "API Overview"; + public string NavigationTitle => Index.NavigationTitle; public LandingNavigationItem(string url) { Depth = 0; NavigationRoot = this; Id = ShortId.Create("root"); - var landing = new ApiLanding(); - Url = url; - - Index = landing; + Index = new ApiIndexLeafNavigation(landing, url, "Api Overview", this); } /// @@ -63,7 +59,8 @@ public interface IApiGroupingNavigationItem( TGroupingModel groupingModel, IRootNavigationItem rootNavigation, - INodeNavigationItem parent) + INodeNavigationItem parent +) : IApiGroupingNavigationItem where TGroupingModel : IApiGroupingModel where TNavigationItem : INavigationItem @@ -91,8 +88,10 @@ public abstract class ApiGroupingNavigationItem /// public abstract string Id { get; } + + //TODO ensure Index is not newed everytime /// - public TGroupingModel Index { get; } = groupingModel; + public ILeafNavigationItem Index => new ApiIndexLeafNavigation(groupingModel, Url, NavigationTitle, rootNavigation, Parent); /// public IReadOnlyCollection NavigationItems { get; set; } = []; @@ -151,8 +150,9 @@ public class EndpointNavigationItem(ApiEndpoint endpoint, IRootNavigationItem public string Id { get; } = ShortId.Create(nameof(EndpointNavigationItem), endpoint.Operations.First().ApiName, endpoint.Operations.First().Route); + //TODO ensure Index is not newed everytime /// - public ApiEndpoint Index { get; } = endpoint; + public ILeafNavigationItem Index => new ApiIndexLeafNavigation(endpoint, Url, NavigationTitle, rootNavigation, Parent); /// public IReadOnlyCollection NavigationItems { get; set; } = []; diff --git a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml index 8d81b5502..f3fd31b30 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml +++ b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml @@ -1,6 +1,7 @@ @inherits RazorSliceHttpResult @using Elastic.ApiExplorer.Landing @using Elastic.ApiExplorer.Operations +@using Elastic.Documentation.Navigation @using Elastic.Documentation.Site.Navigation @implements IUsesLayout @functions { diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 50860c849..e31f6f298 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -8,6 +8,7 @@ using Elastic.ApiExplorer.Operations; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.Logging; @@ -265,7 +266,7 @@ public async Task Generate(Cancel ctx = default) CurrentNavigation = navigation, MarkdownRenderer = markdownStringRenderer }; - _ = await Render(prefix, navigation, navigation.Index, renderContext, navigationRenderer, ctx); + _ = await Render(prefix, navigation, navigation.Index.Model, renderContext, navigationRenderer, ctx); await RenderNavigationItems(prefix, renderContext, navigationRenderer, navigation, ctx); } @@ -275,7 +276,7 @@ private async Task RenderNavigationItems(string prefix, ApiRenderContext renderC { if (currentNavigation is INodeNavigationItem node) { - _ = await Render(prefix, node, node.Index, renderContext, navigationRenderer, ctx); + _ = await Render(prefix, node, node.Index.Model, renderContext, navigationRenderer, ctx); foreach (var child in node.NavigationItems) await RenderNavigationItems(prefix, renderContext, navigationRenderer, child, ctx); } diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index 1c0f51fdb..c57bb85e6 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using Elastic.ApiExplorer.Landing; using Elastic.Documentation.Extensions; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models.Interfaces; using RazorSlices; diff --git a/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs b/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs index 3cccb9cf2..52d5cb1c5 100644 --- a/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs @@ -5,7 +5,6 @@ using System.Text.RegularExpressions; using Elastic.Documentation.Extensions; using YamlDotNet.Serialization; -using YamlStaticContext = Elastic.Documentation.Configuration.Serialization.YamlStaticContext; namespace Elastic.Documentation.Configuration.Assembler; diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 6b3b383c3..37e80ea39 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -6,6 +6,7 @@ using System.Reflection; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.Configuration.DocSet; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; @@ -28,6 +29,8 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public ConfigurationFile Configuration { get; } + public DocumentationSetFile ConfigurationYaml { get; set; } + public VersionsConfiguration VersionsConfiguration { get; } public ConfigurationFileProvider ConfigurationFileProvider { get; } public DocumentationEndpoints Endpoints { get; } @@ -108,9 +111,13 @@ public BuildContext( Git = gitCheckoutInformation ?? GitCheckoutInformation.Create(DocumentationCheckoutDirectory, ReadFileSystem); Configuration = new ConfigurationFile(this, VersionsConfiguration, ProductsConfiguration); + + var yaml = readFileSystem.File.ReadAllText(Configuration.SourceFile.FullName); + ConfigurationYaml = DocumentationSetFile.Deserialize(yaml); GoogleTagManager = new GoogleTagManagerConfiguration { Enabled = false }; } + } diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index a5b1c94de..3fe004f4f 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -39,10 +39,6 @@ public record ConfigurationFile : ITableOfContentsScope public HashSet Products { get; } = []; - public HashSet ImplicitFolders { get; } = new(StringComparer.OrdinalIgnoreCase); - - public Glob[] Globs { get; } = []; - private readonly Dictionary _substitutions = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyDictionary Substitutions => _substitutions; @@ -195,8 +191,6 @@ public ConfigurationFile(IDocumentationSetContext context, VersionsConfiguration reader.EmitError("Could not load docset.yml", e); throw; } - - Globs = [.. ImplicitFolders.Select(f => Glob.Parse($"{f}{Path.DirectorySeparatorChar}*.md"))]; } } diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs index 346734fbe..dd9a041df 100644 --- a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs @@ -240,12 +240,7 @@ file is null && crossLink is null && folder is null && toc is null && } if (folder is not null) - { - if (children is null) - _ = _configuration.ImplicitFolders.Add(parentPath.TrimStart(Path.DirectorySeparatorChar)); - return [new FolderReference(this, $"{parentPath}".TrimStart(Path.DirectorySeparatorChar), children ?? [])]; - } return null; } diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index 7b6f5c03b..42e1952af 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.Text.RegularExpressions; using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.DocSet; using Elastic.Documentation.Configuration.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -21,6 +22,10 @@ public partial class ConfigurationFileProvider internal static IDeserializer Deserializer { get; } = new StaticDeserializerBuilder(new YamlStaticContext()) .WithNamingConvention(UnderscoredNamingConvention.Instance) + .WithTypeConverter(new TocItemCollectionYamlConverter()) + .WithTypeConverter(new TocItemYamlConverter()) + .WithTypeConverter(new SiteTableOfContentsCollectionYamlConverter()) + .WithTypeConverter(new SiteTableOfContentsRefYamlConverter()) .Build(); public ConfigurationSource ConfigurationSource { get; } diff --git a/src/Elastic.Documentation.Configuration/DocSet/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/DocSet/DocumentationSetFile.cs new file mode 100644 index 000000000..870ff94a3 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/DocSet/DocumentationSetFile.cs @@ -0,0 +1,208 @@ +// 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 YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.Configuration.DocSet; + +[YamlSerializable] +public class TableOfContentsFile +{ + [YamlMember(Alias = "toc")] + public TableOfContents Toc { get; set; } = []; + + public static TableOfContentsFile Deserialize(string json) => + ConfigurationFileProvider.Deserializer.Deserialize(json); +} + +[YamlSerializable] +public class DocumentationSetFile : TableOfContentsFile +{ + [YamlMember(Alias = "project")] + public string? Project { get; set; } + + [YamlMember(Alias = "max_toc_depth")] + public int MaxTocDepth { get; set; } = 2; + + [YamlMember(Alias = "dev_docs")] + public bool DevDocs { get; set; } + + [YamlMember(Alias = "cross_links")] + public List CrossLinks { get; set; } = []; + + [YamlMember(Alias = "exclude")] + public List Exclude { get; set; } = []; + + [YamlMember(Alias = "subs")] + public Dictionary Subs { get; set; } = []; + + [YamlMember(Alias = "features")] + public DocumentationSetFeatures Features { get; set; } = new(); + + [YamlMember(Alias = "api")] + public Dictionary Api { get; set; } = []; + + [YamlMember(Alias = "toc")] + public new TableOfContents Toc { get; set; } = []; + + public static new DocumentationSetFile Deserialize(string json) => + ConfigurationFileProvider.Deserializer.Deserialize(json); +} + +[YamlSerializable] +public class DocumentationSetFeatures +{ + [YamlMember(Alias = "primary-nav", ApplyNamingConventions = false)] + public bool? PrimaryNav { get; set; } +} + +public class TableOfContents : List +{ + public TableOfContents() { } + + public TableOfContents(IEnumerable items) : base(items) { } +} + + +public interface ITableOfContentsItem; + +public record FileRef(string Path, bool Hidden, IReadOnlyCollection Children) + : ITableOfContentsItem; + +public record IndexFileRef(string Path, bool Hidden, IReadOnlyCollection Children) + : FileRef(Path, Hidden, Children); + +public record CrossLinkRef(Uri CrossLinkUri, string? Title, bool Hidden, IReadOnlyCollection Children) + : ITableOfContentsItem; + +public record FolderRef(string Path, IReadOnlyCollection Children) + : ITableOfContentsItem; + +public record IsolatedTableOfContentsRef(string Source, IReadOnlyCollection Children) + : ITableOfContentsItem; + + +public class TocItemCollectionYamlConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(TableOfContents); + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var collection = new TableOfContents(); + + if (!parser.TryConsume(out _)) + return collection; + + while (!parser.TryConsume(out _)) + { + var item = rootDeserializer(typeof(ITableOfContentsItem)); + if (item is ITableOfContentsItem tocItem) + collection.Add(tocItem); + } + + return collection; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} + +public class TocItemYamlConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(ITableOfContentsItem); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + if (!parser.TryConsume(out _)) + return null; + + var dictionary = new Dictionary(); + + while (!parser.TryConsume(out _)) + { + var key = parser.Consume(); + + // Parse the value based on what type it is + object? value = null; + if (parser.Accept(out var scalarValue)) + { + value = scalarValue.Value; + _ = parser.MoveNext(); + } + else if (parser.Accept(out _)) + { + // This is a list - parse it manually for "children" + if (key.Value == "children") + { + // Parse the children list manually + var childrenList = new List(); + _ = parser.Consume(); + while (!parser.TryConsume(out _)) + { + var child = rootDeserializer(typeof(ITableOfContentsItem)); + if (child is ITableOfContentsItem tocItem) + childrenList.Add(tocItem); + } + value = childrenList; + } + else + { + // For other lists, just skip them + parser.SkipThisAndNestedEvents(); + } + } + else if (parser.Accept(out _)) + { + // This is a nested mapping - skip it + parser.SkipThisAndNestedEvents(); + } + + dictionary[key.Value] = value; + } + + var children = GetChildren(dictionary); + + // Check for file reference (file: or hidden:) + if (dictionary.TryGetValue("file", out var filePath) && filePath is string file) + return file == "index.md" ? new IndexFileRef(file, false, children) : new FileRef(file, false, children); + + if (dictionary.TryGetValue("hidden", out var hiddenPath) && hiddenPath is string p) + return p == "index.md" ? new IndexFileRef(p, true, children) : new FileRef(p, true, children); + + // Check for crosslink reference + if (dictionary.TryGetValue("crosslink", out var crosslink) && crosslink is string crosslinkStr) + { + var title = dictionary.TryGetValue("title", out var t) && t is string titleStr ? titleStr : null; + var isHidden = dictionary.TryGetValue("hidden", out var h) && h is bool hiddenBool && hiddenBool; + return new CrossLinkRef(new Uri(crosslinkStr), title, isHidden, children); + } + + // Check for folder reference + if (dictionary.TryGetValue("folder", out var folderPath) && folderPath is string folder) + return new FolderRef(folder, children); + + // Check for toc reference + if (dictionary.TryGetValue("toc", out var tocPath) && tocPath is string source) + return new IsolatedTableOfContentsRef(source, children); + + return null; + } + + private IReadOnlyCollection GetChildren(Dictionary dictionary) + { + if (!dictionary.TryGetValue("children", out var childrenObj)) + return []; + + // Children have already been deserialized as List + if (childrenObj is List tocItems) + return tocItems; + + return []; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} diff --git a/src/Elastic.Documentation.Configuration/DocSet/SiteNavigationFile.cs b/src/Elastic.Documentation.Configuration/DocSet/SiteNavigationFile.cs new file mode 100644 index 000000000..17f83d0b9 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/DocSet/SiteNavigationFile.cs @@ -0,0 +1,155 @@ +// 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.Configuration.Serialization; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.Configuration.DocSet; + +[YamlSerializable] +public class SiteNavigationFile +{ + [YamlMember(Alias = "phantoms")] + public IReadOnlyCollection Phantoms { get; set; } = []; + + [YamlMember(Alias = "toc")] + public SiteTableOfContents TableOfContents { get; set; } = []; + + public static SiteNavigationFile Deserialize(string yaml) => + ConfigurationFileProvider.Deserializer.Deserialize(yaml); +} + +public class PhantomRegistration +{ + [YamlMember(Alias = "toc")] + public string Source { get; set; } = null!; +} + +public class SiteTableOfContents : List +{ + public SiteTableOfContents() { } + + public SiteTableOfContents(IEnumerable items) : base(items) { } +} + +public record SiteTableOfContentsRef(Uri Source, string PathPrefix, IReadOnlyCollection Children) + : ITableOfContentsItem; + +public class SiteTableOfContentsCollectionYamlConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(SiteTableOfContents); + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var collection = new SiteTableOfContents(); + + if (!parser.TryConsume(out _)) + return collection; + + while (!parser.TryConsume(out _)) + { + var item = rootDeserializer(typeof(SiteTableOfContentsRef)); + if (item is SiteTableOfContentsRef tocRef) + collection.Add(tocRef); + } + + return collection; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} + +public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(SiteTableOfContentsRef); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + if (!parser.TryConsume(out _)) + return null; + + var dictionary = new Dictionary(); + + while (!parser.TryConsume(out _)) + { + var key = parser.Consume(); + + // Parse the value based on what type it is + object? value = null; + if (parser.Accept(out var scalarValue)) + { + value = scalarValue.Value; + _ = parser.MoveNext(); + } + else if (parser.Accept(out _)) + { + // This is a list - parse it manually for "children" + if (key.Value == "children") + { + // Parse the children list manually + var childrenList = new List(); + _ = parser.Consume(); + while (!parser.TryConsume(out _)) + { + var child = rootDeserializer(typeof(SiteTableOfContentsRef)); + if (child is SiteTableOfContentsRef childRef) + childrenList.Add(childRef); + } + value = childrenList; + } + else + { + // For other lists, just skip them + parser.SkipThisAndNestedEvents(); + } + } + else if (parser.Accept(out _)) + { + // This is a nested mapping - skip it + parser.SkipThisAndNestedEvents(); + } + + dictionary[key.Value] = value; + } + + var children = GetChildren(dictionary); + + // Check for toc reference - required + if (dictionary.TryGetValue("toc", out var tocPath) && tocPath is string sourceString) + { + // Convert string to Uri - if no scheme, prepend "docs-content://" + var uriString = sourceString.Contains("://") ? sourceString : $"docs-content://{sourceString}"; + + if (!Uri.TryCreate(uriString, UriKind.Absolute, out var source)) + throw new InvalidOperationException($"Invalid TOC source: '{sourceString}' could not be parsed as a URI"); + + var pathPrefix = dictionary.TryGetValue("path_prefix", out var pathValue) && pathValue is string path + ? path + : string.Empty; + + return new SiteTableOfContentsRef(source, pathPrefix, children); + } + + return null; + } + + private IReadOnlyCollection GetChildren(Dictionary dictionary) + { + if (!dictionary.TryGetValue("children", out var childrenObj)) + return []; + + // Children have already been deserialized as List + if (childrenObj is List tocRefs) + return tocRefs; + + return []; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} + diff --git a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs index eab68d0bb..f46b187dd 100644 --- a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs +++ b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs @@ -4,6 +4,7 @@ using System.Collections.Frozen; using Elastic.Documentation.Configuration.Versions; +using YamlDotNet.Serialization; namespace Elastic.Documentation.Configuration.Products; @@ -35,10 +36,14 @@ public static ProductsConfiguration CreateProducts(this ConfigurationFileProvide internal sealed record ProductConfigDto { + [YamlMember(Alias = "products")] public Dictionary Products { get; set; } = []; } internal sealed record ProductDto { + [YamlMember(Alias = "display")] public string Display { get; set; } = string.Empty; + + [YamlMember(Alias = "versioning")] public string? Versioning { get; set; } } diff --git a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs index 5a9c6ef8a..fc39310b7 100644 --- a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs +++ b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs @@ -3,8 +3,10 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.DocSet; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.TableOfContents; using Elastic.Documentation.Configuration.Versions; using YamlDotNet.Serialization; @@ -23,4 +25,8 @@ namespace Elastic.Documentation.Configuration.Serialization; [YamlSerializable(typeof(ProductDto))] [YamlSerializable(typeof(LegacyUrlMappingDto))] [YamlSerializable(typeof(LegacyUrlMappingConfigDto))] +[YamlSerializable(typeof(DocumentationSetFile))] +[YamlSerializable(typeof(TableOfContentsFile))] +[YamlSerializable(typeof(SiteNavigationFile))] +[YamlSerializable(typeof(PhantomRegistration))] public partial class YamlStaticContext; diff --git a/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs b/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs index 00ae91b88..290b88c75 100644 --- a/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs +++ b/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs @@ -2,6 +2,8 @@ // 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 YamlDotNet.Serialization; + namespace Elastic.Documentation.Configuration.Versions; public static class VersionsConfigurationExtensions @@ -56,7 +58,9 @@ internal sealed record VersionsConfigDto internal sealed record VersioningSystemDto { + [YamlMember(Alias = "base")] public string Base { get; set; } = string.Empty; + [YamlMember(Alias = "current")] public string Current { get; set; } = string.Empty; } diff --git a/src/Elastic.Documentation.Navigation/Assembler/AssembledNavigation.cs b/src/Elastic.Documentation.Navigation/Assembler/AssembledNavigation.cs new file mode 100644 index 000000000..e7ed68ec8 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Assembler/AssembledNavigation.cs @@ -0,0 +1,224 @@ +// 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.Configuration.DocSet; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated; + +namespace Elastic.Documentation.Navigation.Assembler; + +public record SiteNavigationNoIndexFile(string NavigationTitle) : IDocumentationFile; + + +public class SiteNavigation : IRootNavigationItem +{ + public SiteNavigation( + SiteNavigationFile siteNavigationFile, + IDocumentationContext context, + IReadOnlyCollection documentationSetNavigations + ) + { + // Initialize root properties + NavigationRoot = this; + Parent = null; + Depth = 0; + Hidden = false; + IsCrossLink = false; + Id = ShortId.Create("site"); + IsUsingNavigationDropdown = false; + _nodes = []; + foreach (var setNavigation in documentationSetNavigations) + { + foreach (var (identifier, node) in setNavigation.TableOfContentNodes) + { + if (!_nodes.TryAdd(identifier, node)) + { + //TODO configurationFileProvider navigation path + context.EmitError(context.ConfigurationPath, $"Duplicate navigation identifier: {identifier} in navigation.yml"); + } + } + } + + // Build NavigationItems from SiteTableOfContentsRef items + var items = new List(); + var index = 0; + foreach (var tocRef in siteNavigationFile.TableOfContents) + { + var navItem = CreateSiteTableOfContentsNavigation( + tocRef, + index++, + context + ); + + if (navItem != null) + items.Add(navItem); + } + + NavigationItems = items; + _ = this.UpdateNavigationIndex(context); + Index = this.FindIndex(new NotFoundModel("/index.md")); + } + + private readonly Dictionary> _nodes; + public IReadOnlyDictionary> Nodes => _nodes; + + + //TODO Obsolete? + public IReadOnlyCollection> TopLevelItems => + NavigationItems.OfType>().ToList(); + + /// + public string Url { get; set; } = "/"; + + /// + public string NavigationTitle => Index.NavigationTitle; + + /// + public IRootNavigationItem NavigationRoot { get; } + + /// + public INodeNavigationItem? Parent { get; set; } + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink { get; } + + /// + public int Depth { get; } + + /// + public string Id { get; } + + /// + public ILeafNavigationItem Index { get; } + + /// + public bool IsUsingNavigationDropdown { get; } + + /// + public IReadOnlyCollection NavigationItems { get; } + + private INavigationItem? CreateSiteTableOfContentsNavigation( + SiteTableOfContentsRef tocRef, + int index, + IDocumentationContext context + ) + { + var pathPrefix = tocRef.PathPrefix; + // Validate that path_prefix is set + if (string.IsNullOrWhiteSpace(pathPrefix)) + { + context.EmitError(context.ConfigurationPath, $"path_prefix is required for TOC reference: {tocRef.Source}"); + pathPrefix = tocRef.Source.Scheme; + if (!string.IsNullOrEmpty(tocRef.Source.Host)) + pathPrefix += $"/{tocRef.Source.Host}"; + if (!string.IsNullOrEmpty(tocRef.Source.AbsolutePath) && tocRef.Source.AbsolutePath != "/") + pathPrefix += $"/{tocRef.Source.AbsolutePath}"; + } + + // Look up the node in the collected nodes + if (!_nodes.TryGetValue(tocRef.Source, out var node)) + { + context.EmitError(context.ConfigurationPath, $"Could not find navigation node for identifier: {tocRef.Source} (from source: {tocRef.Source})"); + return null; + } + if (node is not INavigationPathPrefixProvider prefixProvider) + { + context.EmitError(context.ConfigurationPath, $"Navigation contains an node navigation that does not implement: {nameof(IPathPrefixProvider)} (from source: {tocRef.Source})"); + return null; + } + + // Set the navigation index + node.NavigationIndex = index; + prefixProvider.PathPrefixProvider = new PathPrefixProvider(pathPrefix); + + // Recursively create child navigation items if children are specified + var children = new List(); + if (tocRef.Children.Count > 0) + { + var childIndex = 0; + foreach (var child in tocRef.Children) + { + var childItem = CreateSiteTableOfContentsNavigation( + child, + childIndex++, + context + ); + if (childItem != null) + children.Add(childItem); + } + } + else + { + // If no children specified, use the node's original children + children = node.NavigationItems.ToList(); + } + + // Always return a wrapper to ensure path_prefix is the URL (not path_prefix + node's URL) + return new SiteTableOfContentsNavigation(node, prefixProvider.PathPrefixProvider, children); + } +} + +/// +/// Wrapper for a navigation node that applies a path prefix to URLs and optionally +/// overrides the children to show only the children specified in the site navigation configuration. +/// +/// +/// Wrapper for a navigation node that applies a path prefix to URLs and optionally +/// overrides the children to show only the children specified in the site navigation configuration. +/// +public sealed class SiteTableOfContentsNavigation( + INodeNavigationItem wrappedNode, + IPathPrefixProvider pathPrefixProvider, + IReadOnlyCollection children + ) : INodeNavigationItem, INavigationPathPrefixProvider + where TModel : IDocumentationFile +{ + // For site navigation TOC references, the path_prefix IS the URL + // We don't append the wrapped node's URL + public string Url + { + get + { + var url = PathPrefixProvider.PathPrefix.TrimEnd('/'); + return string.IsNullOrEmpty(url) ? "/" : url; + } + } + + public string NavigationTitle => wrappedNode.NavigationTitle; + public IRootNavigationItem NavigationRoot => wrappedNode.NavigationRoot; + + public INodeNavigationItem? Parent + { + get => wrappedNode.Parent; + set => wrappedNode.Parent = value; + } + + public bool Hidden => wrappedNode.Hidden; + + public int NavigationIndex + { + get => wrappedNode.NavigationIndex; + set => wrappedNode.NavigationIndex = value; + } + + public bool IsCrossLink => wrappedNode.IsCrossLink; + public int Depth => wrappedNode.Depth; + public string Id => wrappedNode.Id; + public ILeafNavigationItem Index => wrappedNode.Index; + + // Override to return the specified children from site navigation + // Wrap children to apply path prefix recursively - but don't wrap children that are + // already SiteTableOfContentsNavigation (they have their own path prefix) + public IReadOnlyCollection NavigationItems { get; } = children; + + /// + public IPathPrefixProvider PathPrefixProvider { get; set; } = pathPrefixProvider; +} + diff --git a/src/Elastic.Documentation.Navigation/Elastic.Documentation.Navigation.csproj b/src/Elastic.Documentation.Navigation/Elastic.Documentation.Navigation.csproj new file mode 100644 index 000000000..5448edb21 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Elastic.Documentation.Navigation.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/Elastic.Documentation.Site/Navigation/INavigationItem.cs b/src/Elastic.Documentation.Navigation/INavigationItem.cs similarity index 96% rename from src/Elastic.Documentation.Site/Navigation/INavigationItem.cs rename to src/Elastic.Documentation.Navigation/INavigationItem.cs index 4ec006872..430726252 100644 --- a/src/Elastic.Documentation.Site/Navigation/INavigationItem.cs +++ b/src/Elastic.Documentation.Navigation/INavigationItem.cs @@ -2,7 +2,7 @@ // 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.Documentation.Site.Navigation; +namespace Elastic.Documentation.Navigation; /// Represents navigation model data for documentation elements. public interface INavigationModel @@ -63,7 +63,7 @@ public interface INodeNavigationItem : INaviga string Id { get; } /// Gets the index model associated with this node. - TIndex Index { get; } + ILeafNavigationItem Index { get; } /// /// Gets the collection of child navigation items. diff --git a/src/Elastic.Documentation.Navigation/Isolated/CrossLinkNavigationLeaf.cs b/src/Elastic.Documentation.Navigation/Isolated/CrossLinkNavigationLeaf.cs new file mode 100644 index 000000000..04779e6a9 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/CrossLinkNavigationLeaf.cs @@ -0,0 +1,41 @@ +// 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.Documentation.Navigation.Isolated; + +public record CrossLinkModel(Uri CrossLinkUri, string NavigationTitle) : IDocumentationFile; + +public class CrossLinkNavigationLeaf( + CrossLinkModel model, + string url, + bool hidden, + INodeNavigationItem? parent, + IRootNavigationItem navigationRoot +) : ILeafNavigationItem +{ + /// + public CrossLinkModel Model { get; init; } = model; + + /// + public string Url { get; init; } = url; + + /// + public bool Hidden { get; init; } = hidden; + + /// + public IRootNavigationItem NavigationRoot { get; init; } = navigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } = parent; + + /// + public string NavigationTitle => Model.NavigationTitle; + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink => true; + +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/DocumentationSetNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/DocumentationSetNavigation.cs new file mode 100644 index 000000000..3b78df7e1 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/DocumentationSetNavigation.cs @@ -0,0 +1,648 @@ +// 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.IO.Abstractions; +using Elastic.Documentation.Configuration.DocSet; +using Elastic.Documentation.Extensions; + +namespace Elastic.Documentation.Navigation.Isolated; + +// A model for nodes in the navigation representing directories e.g., sets, toc's and folders. +public interface IDocumentationFileFactory where TModel : IDocumentationFile +{ + TModel? TryCreateDocumentationFile(IFileInfo path, IFileSystem readFileSystem); +} + +public static class DocumentationNavigationFactory +{ + public static ILeafNavigationItem CreateFileNavigationLeaf(TModel model, IFileInfo fileInfo, FileNavigationArgs args) + where TModel : IDocumentationFile => + new FileNavigationLeaf(model, fileInfo, args) { NavigationIndex = args.NavigationIndex }; + + public static INodeNavigationItem CreateVirtualFileNavigation(TModel model, IFileInfo fileInfo, + VirtualFileNavigationArgs args) + where TModel : IDocumentationFile => + new VirtualFileNavigation(model, fileInfo, args) { NavigationIndex = args.NavigationIndex }; +} + +public interface IDocumentationSetNavigation : IRootNavigationItem +{ + IReadOnlyDictionary> TableOfContentNodes { get; } +} + +public class DocumentationSetNavigation + : IDocumentationSetNavigation, INavigationPathPrefixProvider, IPathPrefixProvider + + where TModel : IDocumentationFile +{ + private readonly IDocumentationFileFactory _factory; + + public DocumentationSetNavigation( + DocumentationSetFile documentationSet, + IDocumentationSetContext context, + IDocumentationFileFactory factory, + IRootNavigationItem? parent = null, + IRootNavigationItem? root = null, + IPathPrefixProvider? pathPrefixProvider = null + ) + { + _factory = factory; + // Initialize root properties + NavigationRoot = root ?? this; + Parent = parent; + Depth = 0; + Hidden = false; + IsCrossLink = false; + PathPrefixProvider = pathPrefixProvider ?? this; + _pathPrefix = pathPrefixProvider?.PathPrefix ?? string.Empty; + Id = ShortId.Create(documentationSet.Project ?? "root"); + IsUsingNavigationDropdown = documentationSet.Features.PrimaryNav ?? false; + Git = context.Git; + Identifier = new Uri($"{Git.RepositoryName}://"); + _ = _tableOfContentNodes.TryAdd(Identifier, this); + + // Convert TOC items to navigation items + var items = new List(); + var index = -1; + foreach (var tocItem in documentationSet.Toc) + { + var navItem = ConvertToNavigationItem( + tocItem, + index++, + context, + parent: null, + root: NavigationRoot, + prefixProvider: PathPrefixProvider, + depth: Depth, + parentPath: "" + ); + + if (navItem != null) + items.Add(navItem); + } + + NavigationItems = items; + _ = this.UpdateNavigationIndex(context); + Index = this.FindIndex(new NotFoundModel($"{PathPrefix}/index.md")); + + } + + private readonly string _pathPrefix; + + /// + /// Gets the path prefix. When PathPrefixProvider is set to a different instance, returns that provider's prefix. + /// Otherwise returns the prefix set during construction. + /// + public string PathPrefix => PathPrefixProvider == this ? _pathPrefix : PathPrefixProvider.PathPrefix; + + public IPathPrefixProvider PathPrefixProvider { get; set; } + + public GitCheckoutInformation Git { get; } + + private readonly Dictionary> _tableOfContentNodes = []; + public IReadOnlyDictionary> TableOfContentNodes => _tableOfContentNodes; + + public Uri Identifier { get; } + + /// + public string Url + { + get + { + var rootUrl = PathPrefixProvider.PathPrefix.TrimEnd('/'); + return string.IsNullOrEmpty(rootUrl) ? "/" : rootUrl; + } + } + + /// + public string NavigationTitle => Index.NavigationTitle; + + /// + public IRootNavigationItem NavigationRoot { get; } + + /// + public INodeNavigationItem? Parent { get; set; } + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink { get; } + + /// + public int Depth { get; } + + /// + public string Id { get; } + + /// + public ILeafNavigationItem Index { get; } + + /// + public bool IsUsingNavigationDropdown { get; } + + /// + public IReadOnlyCollection NavigationItems { get; } + + private INavigationItem? ConvertToNavigationItem( + ITableOfContentsItem tocItem, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + IRootNavigationItem root, + IPathPrefixProvider prefixProvider, + int depth, + string parentPath + ) => + tocItem switch + { + FileRef fileRef => CreateFileNavigation(fileRef, index, context, parent, root, prefixProvider, parentPath), + CrossLinkRef crossLinkRef => CreateCrossLinkNavigation(crossLinkRef, index, parent, root), + FolderRef folderRef => CreateFolderNavigation(folderRef, index, context, parent, root, prefixProvider, depth, parentPath), + IsolatedTableOfContentsRef tocRef => CreateTocNavigation(tocRef, index, context, parent, root, prefixProvider, depth, parentPath), + _ => null + }; + + #region CreateFileNavigation Helper Methods + + /// + /// Creates a temporary file navigation placeholder used during construction before children are processed. + /// This is distinct from the factory method to make it clear this is a temporary instance. + /// + private INodeNavigationItem CreateTemporaryFileNavigation( + TModel documentationFile, + IFileInfo fileInfo, + string fullPath, + bool hidden, + int index, + INodeNavigationItem? parent, + IRootNavigationItem root, + IPathPrefixProvider prefixProvider) + { + var virtualFileNavigationArgs = new VirtualFileNavigationArgs(fullPath, hidden, index, 0, parent, root, prefixProvider, []); + return new VirtualFileNavigation(documentationFile, fileInfo, virtualFileNavigationArgs); + } + + /// + /// Resolves the relative path for URL generation, handling parent path and deeplinked paths. + /// + private static string ResolveFileRelativePath(string fileRefPath, string parentPath) + { + if (string.IsNullOrEmpty(parentPath)) + return fileRefPath; + + // Extract parent's directory (everything before the last /) + var parentDir = parentPath.Contains('/') + ? parentPath[..parentPath.LastIndexOf('/')] + : ""; + + // Extract child's directory from fileRef.Path + var childDir = fileRefPath.Contains('/') + ? fileRefPath[..fileRefPath.LastIndexOf('/')] + : ""; + + // Check for deeplinked paths where child's directory is already in parent path + // Case 1: parentDir ends with childDir (e.g., parentPath="guides/clients/getting-started", childDir="clients") + // Case 2: parentPath ends with childDir (e.g., parentPath="guides/clients", childDir="clients") + return !string.IsNullOrEmpty(childDir) && + (parentDir == childDir || parentDir.EndsWith($"/{childDir}", StringComparison.Ordinal) || + parentPath.EndsWith(childDir, StringComparison.Ordinal)) + ? fileRefPath[(childDir.Length + 1)..] // Strip child's directory from path + : fileRefPath.StartsWith($"{parentPath}/", StringComparison.Ordinal) + ? fileRefPath[(parentPath.Length + 1)..] // If file path starts with parent path, extract just the relative part + : fileRefPath; + } + + /// + /// Combines parent path with relative path to create the full file path. + /// + private static string CreateFullFilePath(string relativePathForUrl, string parentPath) => + string.IsNullOrEmpty(parentPath) + ? relativePathForUrl + : $"{parentPath}/{relativePathForUrl}"; + + /// + /// Resolves the file info based on the context and prefix provider. + /// + private static IFileInfo ResolveFileInfo( + IDocumentationSetContext context, + IPathPrefixProvider prefixProvider, + string relativePathForUrl, + string fullPath) + { + var fs = context.ReadFileSystem; + + // When inside a TOC, files are relative to the TOC directory, not the parent path + // Check both actual TableOfContentsNavigation and temporary placeholders + var tocDirectory = prefixProvider switch + { + TableOfContentsNavigation toc => toc.TableOfContentsDirectory, + TemporaryNavigationPlaceholder placeholder => placeholder.TableOfContentsDirectory, + _ => null + }; + + if (tocDirectory != null) + { + // For TOC children, use the TOC directory as the base + return fs.FileInfo.New(fs.Path.Combine(tocDirectory.FullName, relativePathForUrl)); + } + + // For other files, use the documentation source directory + full path + return fs.FileInfo.New(fs.Path.Combine(context.DocumentationSourceDirectory.FullName, fullPath)); + } + + /// + /// Creates the documentation file from the factory, emitting an error if creation fails. + /// + private TModel? CreateDocumentationFile( + IFileInfo fileInfo, + IFileSystem fileSystem, + IDocumentationSetContext context, + string fullPath) + { + var documentationFile = _factory.TryCreateDocumentationFile(fileInfo, fileSystem); + if (documentationFile == null) + context.EmitError(context.ConfigurationPath, $"File navigation '{fullPath}' could not be created"); + + return documentationFile; + } + + /// + /// Computes the parent path for children by removing .md extension and /index suffix if present. + /// + private static string DetermineParentPathForChildren(string fullPath) + { + // Remove .md extension + var parentPathForChildren = fullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? fullPath[..^3] + : fullPath; + + // If this is an index file, also remove the /index suffix for children's parent path + if (parentPathForChildren.EndsWith("/index", StringComparison.OrdinalIgnoreCase)) + parentPathForChildren = parentPathForChildren[..^6]; // Remove "/index" + + return parentPathForChildren; + } + + /// + /// Processes children recursively and returns the list of navigation items. + /// + private List ProcessFileChildren( + FileRef fileRef, + IDocumentationSetContext context, + INodeNavigationItem tempFileNavigation, + IRootNavigationItem root, + IPathPrefixProvider prefixProvider, + string parentPathForChildren) + { + var children = new List(); + var childIndex = 0; + + foreach (var child in fileRef.Children) + { + var childNav = ConvertToNavigationItem( + child, childIndex++, context, + (INodeNavigationItem)tempFileNavigation, root, + prefixProvider, // Files don't change the URL root + 0, // Depth will be set by child + parentPathForChildren + ); + if (childNav != null) + children.Add(childNav); + } + + return children; + } + + /// + /// Ensures the first item in the navigation items is the index file (index.md or the first file in the list). + /// + private static void EnsureIndexIsFirst(List children) + { + if (children.Count == 0) + return; + + // Find an item named "index" or "index.md" + var indexItem = children.FirstOrDefault(c => + c is ILeafNavigationItem leaf && + (leaf.Model.NavigationTitle.Equals("index", StringComparison.OrdinalIgnoreCase) || + (leaf is FileNavigationLeaf fileLeaf && + fileLeaf.FileInfo.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase)))); + + // If found and it's not already first, move it to the front + if (indexItem != null && children[0] != indexItem) + { + _ = children.Remove(indexItem); + children.Insert(0, indexItem); + } + } + + /// + /// Validates that navigation items has at least one item, emitting an error if not. + /// + private static void ValidateNavigationItems( + List children, + IDocumentationSetContext context, + string fullPath) + { + if (children.Count < 1) + { + context.EmitError(context.ConfigurationPath, + $"File navigation '{fullPath}' has children defined but none could be created"); + } + } + + #endregion + + private INavigationItem? CreateFileNavigation( + FileRef fileRef, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + IRootNavigationItem root, + IPathPrefixProvider prefixProvider, + string parentPath + ) + { + // Resolve paths + var relativePathForUrl = ResolveFileRelativePath(fileRef.Path, parentPath); + var fullPath = CreateFullFilePath(relativePathForUrl, parentPath); + + // Create file info and documentation file + var fileInfo = ResolveFileInfo(context, prefixProvider, relativePathForUrl, fullPath); + var documentationFile = CreateDocumentationFile(fileInfo, context.ReadFileSystem, context, fullPath); + if (documentationFile == null) + return null; + + // Handle leaf case (no children) + if (fileRef.Children.Count <= 0) + { + var leafNavigationArgs = new FileNavigationArgs(fullPath, fileRef.Hidden, index, parent, root, prefixProvider); + return DocumentationNavigationFactory.CreateFileNavigationLeaf(documentationFile, fileInfo, leafNavigationArgs); + } + + // Create temporary file navigation for children to reference + var tempFileNavigation = CreateTemporaryFileNavigation(documentationFile, fileInfo, fullPath, fileRef.Hidden, index, parent, root, prefixProvider); + + // Process children recursively + var parentPathForChildren = DetermineParentPathForChildren(fullPath); + var children = ProcessFileChildren(fileRef, context, tempFileNavigation, root, prefixProvider, parentPathForChildren); + + // Validate and order children + ValidateNavigationItems(children, context, fullPath); + EnsureIndexIsFirst(children); + + // Create final file navigation with actual children + var virtualFileNavigationArgs = new VirtualFileNavigationArgs( + fullPath, + fileRef.Hidden, + index, + parent?.Depth + 1 ?? 0, + parent, + root, + prefixProvider, + children + ); + + var finalFileNavigation = DocumentationNavigationFactory.CreateVirtualFileNavigation(documentationFile, fileInfo, virtualFileNavigationArgs); + + // Update children's Parent to point to the final file navigation + foreach (var child in children) + child.Parent = (INodeNavigationItem)finalFileNavigation; + + return finalFileNavigation; + } + + private INavigationItem CreateCrossLinkNavigation( + CrossLinkRef crossLinkRef, + int index, + INodeNavigationItem? parent, + IRootNavigationItem root) + { + var title = crossLinkRef.Title ?? crossLinkRef.CrossLinkUri.OriginalString; + var model = new CrossLinkModel(crossLinkRef.CrossLinkUri, title); + + return new CrossLinkNavigationLeaf( + model, + crossLinkRef.CrossLinkUri.OriginalString, + crossLinkRef.Hidden, + parent, + root + ) + { + NavigationIndex = index + }; + } + + private INavigationItem? CreateFolderNavigation( + FolderRef folderRef, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + IRootNavigationItem root, + IPathPrefixProvider prefixProvider, + int depth, + string parentPath + ) + { + var folderPath = string.IsNullOrEmpty(parentPath) + ? folderRef.Path + : $"{parentPath}/{folderRef.Path}"; + + // Create temporary placeholder for parent reference + var children = new List(); + var childIndex = 0; + + var placeholderNavigation = new TemporaryNavigationPlaceholder( + depth + 1, + ShortId.Create(folderPath), + parent, + root, + prefixProvider, + folderPath + ); + + foreach (var child in folderRef.Children) + { + var childNav = ConvertToNavigationItem( + child, + childIndex++, + context, + placeholderNavigation, + root, + prefixProvider, // Keep parent's prefix provider + depth + 1, + folderPath // Pass folder path for children + ); + + if (childNav != null) + children.Add(childNav); + } + + // Validate folders have children + if (children.Count == 0) + { + if (folderRef.Children.Count == 0) + context.EmitError(context.ConfigurationPath, $"Folder navigation '{folderPath}' has no children defined"); + else + context.EmitError(context.ConfigurationPath, $"Folder navigation '{folderPath}' has children defined but none could be created"); + return null; + } + + // Create folder navigation with actual children + var finalFolderNavigation = new FolderNavigation(depth + 1, folderPath, parent, root, children) + { + NavigationIndex = index + }; + + // Update children's Parent to point to the final folder navigation + foreach (var child in children) + child.Parent = finalFolderNavigation; + + return finalFolderNavigation; + } + + private INavigationItem? CreateTocNavigation( + IsolatedTableOfContentsRef tocRef, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + IRootNavigationItem root, + IPathPrefixProvider prefixProvider, + int depth, + string parentPath + ) + { + // Determine the full TOC path for file system operations + IDirectoryInfo tocDirectory; + string tocPath; + + // Check if parent is a TOC (or placeholder for a TOC being constructed) + var parentTocDirectory = parent switch + { + TableOfContentsNavigation toc => toc.TableOfContentsDirectory, + TemporaryNavigationPlaceholder placeholder when placeholder.TableOfContentsDirectory != null => placeholder.TableOfContentsDirectory, + _ => null + }; + + if (parentTocDirectory != null) + { + // Nested TOC: use parent TOC's directory as base + tocDirectory = context.ReadFileSystem.DirectoryInfo.New( + context.ReadFileSystem.Path.Combine(parentTocDirectory.FullName, tocRef.Source) + ); + // Extract the relative path from the documentation source directory + var fullPath = tocDirectory.FullName; + var basePath = context.DocumentationSourceDirectory.FullName.TrimEnd('/'); + tocPath = fullPath.StartsWith(basePath, StringComparison.Ordinal) + ? fullPath[(basePath.Length + 1)..] + : tocRef.Source; + } + else + { + // Root-level TOC: use parentPath (which comes from folder structure) + tocPath = string.IsNullOrEmpty(parentPath) + ? tocRef.Source + : $"{parentPath}/{tocRef.Source}"; + tocDirectory = context.ReadFileSystem.DirectoryInfo.New( + context.ReadFileSystem.Path.Combine(context.DocumentationSourceDirectory.FullName, tocPath) + ); + } + + // Read and deserialize the toc.yml file + var tocFilePath = context.ReadFileSystem.Path.Combine(tocDirectory.FullName, "toc.yml"); + TableOfContentsFile? tocFile = null; + + if (context.ReadFileSystem.File.Exists(tocFilePath)) + tocFile = TableOfContentsFile.Deserialize(context.ReadFileSystem.File.ReadAllText(tocFilePath)); + else + context.EmitError(context.ConfigurationPath, $"Table of contents file not found: {tocFilePath}"); + + // Create the TOC navigation that will be the parent for children + // For TOCs nested under other TOCs, use just the source name since the parent TOC's path is the base + // For TOCs at root or under folders, use the full tocPath + var navigationParentPath = (parent != null && parentTocDirectory != null) ? tocRef.Source : tocPath; + + var placeholderNavigation = new TemporaryNavigationPlaceholder( + depth + 1, + ShortId.Create(navigationParentPath), + parent, + root, + prefixProvider, + navigationParentPath, + tocDirectory + ); + + // Convert children + var children = new List(); + var childIndex = 0; + + // First, process items from the toc.yml file if it exists + if (tocFile != null) + { + foreach (var child in tocFile.Toc) + { + var childNav = ConvertToNavigationItem( + child, + childIndex++, + context, + placeholderNavigation, + root, + placeholderNavigation, // Placeholder acts as the new prefix provider for children + depth + 1, + "" // Reset parentPath since TOC is new prefixProvider - children paths are relative to this TOC + ); + + if (childNav != null) + children.Add(childNav); + } + } + + // Validate that TOC references should not have children defined in navigation + if (tocRef.Children.Count > 0) + { + context.EmitError( + context.ConfigurationPath, + $"TableOfContents '{tocRef.Source}' may not contain children, define children in '{tocRef.Source}/toc.yml' instead." + ); + } + + // Validate TOCs have children + if (children.Count == 0) + { + var hasTocFileChildren = tocFile?.Toc.Count > 0; + var hasTocRefChildren = tocRef.Children.Count > 0; + + if (!hasTocFileChildren && !hasTocRefChildren) + context.EmitError(context.ConfigurationPath, $"Table of contents navigation '{navigationParentPath}' has no children defined"); + else + context.EmitError(context.ConfigurationPath, $"Table of contents navigation '{navigationParentPath}' has children defined but none could be created"); + return null; + } + + var finalTocNavigation = new TableOfContentsNavigation( + tocDirectory, + depth + 1, + navigationParentPath, + parent, + prefixProvider, + children, + Git, + _tableOfContentNodes + ) + { + NavigationIndex = index + }; + + // Update children's Parent to point to the final TOC navigation + foreach (var child in children) + child.Parent = finalTocNavigation; + + return finalTocNavigation; + } + +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/FileNavigationLeaf.cs b/src/Elastic.Documentation.Navigation/Isolated/FileNavigationLeaf.cs new file mode 100644 index 000000000..7e386ae11 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/FileNavigationLeaf.cs @@ -0,0 +1,69 @@ +// 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.IO.Abstractions; + +namespace Elastic.Documentation.Navigation.Isolated; + +public record FileNavigationArgs( + string RelativePath, + bool Hidden, + int NavigationIndex, + INodeNavigationItem? Parent, + IRootNavigationItem NavigationRoot, + IPathPrefixProvider PrefixProvider +); + +public class FileNavigationLeaf(TModel model, IFileInfo fileInfo, FileNavigationArgs args) + : ILeafNavigationItem + where TModel : IDocumentationFile +{ + public IFileInfo FileInfo { get; } = fileInfo; + + /// + public TModel Model { get; init; } = model; + + /// + public string Url + { + get + { + var rootUrl = args.PrefixProvider.PathPrefix.TrimEnd('/'); + // Remove extension while preserving the directory path + var relativePath = args.RelativePath; + var path = relativePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? relativePath[..^3] // Remove last 3 characters (.md) + : relativePath; + + // If a path ends with /index or is just index, omit it from the URL + if (path.EndsWith("/index", StringComparison.OrdinalIgnoreCase)) + path = path[..^6]; // Remove "/index" + else if (path.Equals("index", StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrEmpty(rootUrl) ? "/" : rootUrl; + + if (string.IsNullOrEmpty(path)) + return string.IsNullOrEmpty(rootUrl) ? "/" : rootUrl; + + return $"{rootUrl}/{path}"; + } + } + + /// + public bool Hidden { get; init; } = args.Hidden; + + /// + public IRootNavigationItem NavigationRoot { get; init; } = args.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } = args.Parent; + + /// + public string NavigationTitle => Model.NavigationTitle; + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink { get; } +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/FolderNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/FolderNavigation.cs new file mode 100644 index 000000000..355710555 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/FolderNavigation.cs @@ -0,0 +1,63 @@ +// 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.Extensions; + +namespace Elastic.Documentation.Navigation.Isolated; + +public class FolderNavigation : INodeNavigationItem +{ + public FolderNavigation( + int depth, + string parentPath, + INodeNavigationItem? parent, + IRootNavigationItem navigationRoot, + IReadOnlyCollection navigationItems + ) + { + FolderPath = parentPath; + NavigationItems = navigationItems; + Index = this.FindIndex(new NotFoundModel($"{FolderPath}/index.md")); + NavigationRoot = navigationRoot; + Parent = parent; + Depth = depth; + Hidden = false; + IsCrossLink = false; + Id = ShortId.Create(parentPath); + } + + public string FolderPath { get; } + + /// + public string Url => Index.Url; + + /// + public string NavigationTitle => Index.NavigationTitle; + + /// + public IRootNavigationItem NavigationRoot { get; } + + /// + public INodeNavigationItem? Parent { get; set; } + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink { get; } + + /// + public int Depth { get; } + + /// + public string Id { get; } + + /// + public ILeafNavigationItem Index { get; } + + public IReadOnlyCollection NavigationItems { get; } +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/IPathPrefixProvider.cs b/src/Elastic.Documentation.Navigation/Isolated/IPathPrefixProvider.cs new file mode 100644 index 000000000..efa35bb05 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/IPathPrefixProvider.cs @@ -0,0 +1,22 @@ +// 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.Documentation.Navigation.Isolated; + +public interface IPathPrefixProvider +{ + string PathPrefix { get; } +} + +public interface INavigationPathPrefixProvider +{ + IPathPrefixProvider PathPrefixProvider { get; set; } +} + +public class PathPrefixProvider(string pathPrefix) : IPathPrefixProvider +{ + /// + public string PathPrefix { get; } = pathPrefix; +} + diff --git a/src/Elastic.Documentation.Navigation/Isolated/TableOfContentsNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/TableOfContentsNavigation.cs new file mode 100644 index 000000000..993b43bce --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/TableOfContentsNavigation.cs @@ -0,0 +1,103 @@ +// 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.IO.Abstractions; +using Elastic.Documentation.Extensions; + +namespace Elastic.Documentation.Navigation.Isolated; + +public interface IDocumentationFile : INavigationModel +{ + string NavigationTitle { get; } +} + +public class TableOfContentsNavigation : IRootNavigationItem + , INavigationPathPrefixProvider + , IPathPrefixProvider +{ + public TableOfContentsNavigation( + IDirectoryInfo tableOfContentsDirectory, + int depth, + string parentPath, + INodeNavigationItem? parent, + IPathPrefixProvider pathPrefixProvider, + IReadOnlyCollection navigationItems, + GitCheckoutInformation git, + Dictionary> tocNodes + ) + { + TableOfContentsDirectory = tableOfContentsDirectory; + NavigationItems = navigationItems; + Index = this.FindIndex(new NotFoundModel($"{parentPath}/index.md")); + Parent = parent; + PathPrefixProvider = pathPrefixProvider; + NavigationRoot = this; + Hidden = false; + IsUsingNavigationDropdown = false; + IsCrossLink = false; + Id = ShortId.Create(parentPath); + Depth = depth; + ParentPath = parentPath; + + // Create an identifier for this TOC + Identifier = new Uri($"{git.RepositoryName}://{parentPath}"); + _ = tocNodes.TryAdd(Identifier, this); + } + + /// + /// The composed path prefix for this TOC, which is the parent's prefix + this TOC's parent path. + /// This is used by children to build their URLs. + /// + public string PathPrefix + { + get + { + var parentPrefix = PathPrefixProvider.PathPrefix.TrimEnd('/'); + return string.IsNullOrEmpty(parentPrefix) ? $"/{ParentPath}" : $"{parentPrefix}/{ParentPath}"; + } + } + + /// + public string Url => Index.Url; + + /// + public string NavigationTitle => Index.NavigationTitle; + + /// + public IRootNavigationItem NavigationRoot { get; } + + /// + public INodeNavigationItem? Parent { get; set; } + + public IPathPrefixProvider PathPrefixProvider { get; set; } + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink { get; } + + /// + public int Depth { get; } + + public string ParentPath { get; } + + /// + public string Id { get; } + + /// + public ILeafNavigationItem Index { get; } + + /// + public bool IsUsingNavigationDropdown { get; } + + public IDirectoryInfo TableOfContentsDirectory { get; } + + public Uri Identifier { get; } + + public IReadOnlyCollection NavigationItems { get; } +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/TemporaryNavigationPlaceholder.cs b/src/Elastic.Documentation.Navigation/Isolated/TemporaryNavigationPlaceholder.cs new file mode 100644 index 000000000..8d23c20ba --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/TemporaryNavigationPlaceholder.cs @@ -0,0 +1,59 @@ +// 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.IO.Abstractions; + +namespace Elastic.Documentation.Navigation.Isolated; + +/// +/// Temporary placeholder used during navigation construction when children need a parent reference +/// before the final navigation item is created with its children collection. +/// This placeholder should never appear in the final navigation tree. +/// +internal sealed class TemporaryNavigationPlaceholder( + int depth, + string id, + INodeNavigationItem? parent, + IRootNavigationItem navigationRoot, + IPathPrefixProvider pathPrefixProvider, + string parentPath, + IDirectoryInfo? tocDirectory = null) : INodeNavigationItem, IPathPrefixProvider +{ + public int Depth { get; } = depth; + public string Id { get; } = id; + public INodeNavigationItem? Parent { get; set; } = parent; + public IRootNavigationItem NavigationRoot { get; } = navigationRoot; + + /// + /// The parent path used for constructing nested paths, matching TableOfContentsNavigation behavior. + /// + public string ParentPath { get; } = parentPath; + + /// + /// When this placeholder represents a TOC, this contains the TOC directory. + /// This is needed for resolving file paths relative to the TOC directory. + /// + public IDirectoryInfo? TableOfContentsDirectory { get; } = tocDirectory; + + /// + /// Computes the path prefix for this placeholder, mimicking TableOfContentsNavigation behavior. + /// + public string PathPrefix + { + get + { + var parentPrefix = pathPrefixProvider.PathPrefix.TrimEnd('/'); + return string.IsNullOrEmpty(parentPrefix) ? $"/{ParentPath}" : $"{parentPrefix}/{ParentPath}"; + } + } + + // Properties that should never be accessed on a placeholder + public string Url => throw new InvalidOperationException("TemporaryNavigationPlaceholder should not appear in final navigation"); + public string NavigationTitle => throw new InvalidOperationException("TemporaryNavigationPlaceholder should not appear in final navigation"); + public bool Hidden => throw new InvalidOperationException("TemporaryNavigationPlaceholder should not appear in final navigation"); + public int NavigationIndex { get; set; } + public bool IsCrossLink => throw new InvalidOperationException("TemporaryNavigationPlaceholder should not appear in final navigation"); + public ILeafNavigationItem Index => throw new InvalidOperationException("TemporaryNavigationPlaceholder should not appear in final navigation"); + public IReadOnlyCollection NavigationItems => throw new InvalidOperationException("TemporaryNavigationPlaceholder should not appear in final navigation"); +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/VirtualFileNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/VirtualFileNavigation.cs new file mode 100644 index 000000000..392d6051a --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/VirtualFileNavigation.cs @@ -0,0 +1,58 @@ +// 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.IO.Abstractions; +using Elastic.Documentation.Extensions; + +namespace Elastic.Documentation.Navigation.Isolated; + +public record VirtualFileNavigationArgs( + string RelativePath, + bool Hidden, + int NavigationIndex, + int Depth, + INodeNavigationItem? Parent, + IRootNavigationItem NavigationRoot, + IPathPrefixProvider PrefixProvider, + IReadOnlyCollection NavigationItems +); + +/// Represents a file navigation item that defines children which are not part of the file tree. +public class VirtualFileNavigation(TModel model, IFileInfo fileInfo, VirtualFileNavigationArgs args) + : INodeNavigationItem + where TModel : IDocumentationFile +{ + /// + public string Url => Index.Url; + + /// + public string NavigationTitle => Index.NavigationTitle; + + /// + public IRootNavigationItem NavigationRoot { get; init; } = args.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } = args.Parent; + + /// + public bool Hidden { get; init; } = args.Hidden; + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink { get; } + + /// + public int Depth { get; init; } = args.Depth; + + /// + public string Id { get; } = ShortId.Create(args.RelativePath); + + /// + public ILeafNavigationItem Index { get; init; } = + new FileNavigationLeaf(model, fileInfo, new FileNavigationArgs(args.RelativePath, args.Hidden, args.NavigationIndex, args.Parent, args.NavigationRoot, args.PrefixProvider)); + + public IReadOnlyCollection NavigationItems { get; init; } = args.NavigationItems; +} diff --git a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs new file mode 100644 index 000000000..1119dcc58 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs @@ -0,0 +1,90 @@ +// 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.Navigation.Isolated; + +namespace Elastic.Documentation.Navigation; + +public record NotFoundModel(string NavigationTitle) : IDocumentationFile; + +public class NotFoundLeafNavigationItem(TModel model, INodeNavigationItem parent +) + : ILeafNavigationItem + where TModel : IDocumentationFile +{ + /// + public string Url => string.Empty; + + /// + public string NavigationTitle => string.Empty; + + /// + public IRootNavigationItem NavigationRoot { get; } = parent.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } = parent; + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public bool IsCrossLink { get; } + + /// + public TModel Model { get; } = model; +} + +public static class NavigationItemExtensions +{ + public static ILeafNavigationItem FindIndex(this INodeNavigationItem node, TModel fallback) + where TModel : IDocumentationFile + { + var leaf = node.NavigationItems.OfType>().FirstOrDefault(); + if (leaf is not null) + return leaf; + + var nodes = node.NavigationItems.OfType>().ToList(); + if (nodes.Count == 0) + return new NotFoundLeafNavigationItem(fallback, node); + + return nodes.First().Index; + + } + + public static int UpdateNavigationIndex(this IRootNavigationItem node, IDocumentationContext context) + { + var navigationIndex = -1; + ProcessNavigationItem(context, ref navigationIndex, node); + return navigationIndex; + + } + + private static void UpdateNavigationIndex(IReadOnlyCollection navigationItems, IDocumentationContext context, ref int navigationIndex) + { + foreach (var item in navigationItems) + ProcessNavigationItem(context, ref navigationIndex, item); + } + + private static void ProcessNavigationItem(IDocumentationContext context, ref int navigationIndex, INavigationItem item) + { + switch (item) + { + case ILeafNavigationItem leaf: + var fileIndex = Interlocked.Increment(ref navigationIndex); + leaf.NavigationIndex = fileIndex; + break; + case INodeNavigationItem node: + var groupIndex = Interlocked.Increment(ref navigationIndex); + node.NavigationIndex = groupIndex; + UpdateNavigationIndex(node.NavigationItems, context, ref navigationIndex); + break; + default: + context.EmitError(context.ConfigurationPath, $"{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); + break; + } + } +} diff --git a/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj b/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj index c8b68628d..5e230f469 100644 --- a/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj +++ b/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs index 3f598bcac..cdb149f3a 100644 --- a/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs @@ -2,6 +2,7 @@ // 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.Navigation; using RazorSlices; namespace Elastic.Documentation.Site.Navigation; diff --git a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs index 198405d18..89bbfd6b8 100644 --- a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using Elastic.Documentation.Configuration; using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation; namespace Elastic.Documentation.Site.Navigation; diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs b/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs index 8d05df7ca..f5b074d6f 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs @@ -2,6 +2,8 @@ // 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.Navigation; + namespace Elastic.Documentation.Site.Navigation; public class NavigationTreeItem diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs index 0c21cf7a0..035f69e89 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs @@ -2,6 +2,8 @@ // 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.Navigation; + namespace Elastic.Documentation.Site.Navigation; public class NavigationViewModel diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index fefab98fa..27a0f8936 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -1,3 +1,4 @@ +@using Elastic.Documentation.Navigation @using Elastic.Documentation.Site.Navigation @inherits RazorSlice @{ @@ -9,6 +10,11 @@ { continue; } + + if (item.Parent is not null && item.Parent.Index == item) + { + continue; + } if (item is INodeNavigationItem { NavigationItems.Count: 0, Index: not null } group) {
  • diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs index 26894bb2a..25b95a962 100644 --- a/src/Elastic.Documentation.Site/_ViewModels.cs +++ b/src/Elastic.Documentation.Site/_ViewModels.cs @@ -4,8 +4,8 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; -using Elastic.Documentation.Site.Navigation; namespace Elastic.Documentation.Site; diff --git a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs index 38cf92d35..b49b65f1a 100644 --- a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs +++ b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs @@ -34,6 +34,13 @@ public static bool HasParent(this IFileInfo file, string parentName) var parent = file.Directory; return parent is not null && parent.HasParent(parentName); } + + public static IFileInfo NewCombine(this IFileInfoFactory fileInfo, params string[] paths) + { + paths = paths.Select(f => f.OptionalWindowsReplace()).ToArray(); + var fi = fileInfo.New(Path.Combine(paths)); + return fi; + } } public static class IDirectoryInfoExtensions @@ -95,4 +102,20 @@ public static bool HasParent(this IDirectoryInfo directory, string parentName, S return false; } + + /// Gets the first , parent of + public static IDirectoryInfo? GetParent(this IDirectoryInfo directory, string parentName, StringComparison comparison = OrdinalIgnoreCase) + { + if (string.Equals(directory.Name, parentName, comparison)) + return directory; + var parent = directory; + do + { + if (string.Equals(parent.Name, parentName, comparison)) + return parent; + parent = parent.Parent; + } while (parent != null); + + return null; + } } diff --git a/src/Elastic.Documentation/Extensions/StringExtensions.cs b/src/Elastic.Documentation/Extensions/StringExtensions.cs new file mode 100644 index 000000000..5a5daf85c --- /dev/null +++ b/src/Elastic.Documentation/Extensions/StringExtensions.cs @@ -0,0 +1,17 @@ +// 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.Runtime.InteropServices; + +namespace Elastic.Documentation.Extensions; + +public static class StringExtensions +{ + public static string OptionalWindowsReplace(this string relativePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + relativePath = relativePath.Replace('\\', '/'); + return relativePath; + } +} diff --git a/src/Elastic.Documentation/GitCheckoutInformation.cs b/src/Elastic.Documentation/GitCheckoutInformation.cs index 729377bb3..aa0dda3b7 100644 --- a/src/Elastic.Documentation/GitCheckoutInformation.cs +++ b/src/Elastic.Documentation/GitCheckoutInformation.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Elastic.Documentation.Extensions; using Microsoft.Extensions.Logging; using SoftCircuits.IniFileParser; @@ -48,23 +49,39 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem RepositoryName = "docs-builder" }; } - var fakeRef = Guid.NewGuid().ToString()[..16]; - var gitConfig = Git(source, Path.Combine(".git", "config")); + + var gitDir = GitDir(source, ".git"); + if (!gitDir.Exists) + { + // try a worktree .git file + var worktreeFile = Git(source, ".git"); + if (!worktreeFile.Exists) + return Unavailable; + var workTreePath = Read(source, ".git")?.Replace("gitdir: ", string.Empty); + if (workTreePath is null) + return Unavailable; + //TODO read branch info from worktree do not fall through + gitDir = fileSystem.DirectoryInfo.New(workTreePath).GetParent(".git"); + if (gitDir is null || !gitDir.Exists) + return Unavailable; + } + + var gitConfig = Git(gitDir, "config"); if (!gitConfig.Exists) { logger?.LogInformation("Git checkout information not available."); return Unavailable; } - var head = Read(source, Path.Combine(".git", "HEAD")) ?? fakeRef; + var head = Read(gitDir, "HEAD") ?? fakeRef; var gitRef = head; var branch = head.Replace("refs/heads/", string.Empty); //not detached HEAD if (head.StartsWith("ref:", StringComparison.OrdinalIgnoreCase)) { head = head.Replace("ref: ", string.Empty); - gitRef = Read(source, Path.Combine(".git", head)) ?? fakeRef; + gitRef = Read(gitDir, head) ?? fakeRef; branch = branch.Replace("ref: ", string.Empty); } else @@ -117,6 +134,9 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem IFileInfo Git(IDirectoryInfo directoryInfo, string path) => fileSystem.FileInfo.New(Path.Combine(directoryInfo.FullName, path)); + IDirectoryInfo GitDir(IDirectoryInfo directoryInfo, string path) => + fileSystem.DirectoryInfo.New(Path.Combine(directoryInfo.FullName, path)); + string? Read(IDirectoryInfo directoryInfo, string path) { var gitPath = Git(directoryInfo, path).FullName; diff --git a/src/Elastic.Documentation/IDocumentationContext.cs b/src/Elastic.Documentation/IDocumentationContext.cs index f21e8568c..1dd420537 100644 --- a/src/Elastic.Documentation/IDocumentationContext.cs +++ b/src/Elastic.Documentation/IDocumentationContext.cs @@ -13,21 +13,21 @@ public interface IDocumentationContext IFileSystem ReadFileSystem { get; } IFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } + IFileInfo ConfigurationPath { get; } } public interface IDocumentationSetContext : IDocumentationContext { IDirectoryInfo DocumentationSourceDirectory { get; } GitCheckoutInformation Git { get; } - IFileInfo ConfigurationPath { get; } } public static class DocumentationContextExtensions { - public static void EmitError(this IDocumentationSetContext context, IFileInfo file, string message, Exception? e = null) => + public static void EmitError(this IDocumentationContext context, IFileInfo file, string message, Exception? e = null) => context.Collector.EmitError(file, message, e); - public static void EmitWarning(this IDocumentationSetContext context, IFileInfo file, string message) => + public static void EmitWarning(this IDocumentationContext context, IFileInfo file, string message) => context.Collector.EmitWarning(file, message); } diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 97e7a1db8..ef7c06be2 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -48,7 +48,6 @@ public class DocumentationGenerator public DocumentationSet DocumentationSet { get; } public BuildContext Context { get; } - public ICrossLinkResolver CrossLinkResolver { get; } public IMarkdownStringRenderer MarkdownStringRenderer => HtmlWriter; public DocumentationGenerator( @@ -58,8 +57,7 @@ public DocumentationGenerator( IDocumentationFileOutputProvider? documentationFileOutputProvider = null, IMarkdownExporter[]? markdownExporters = null, IConversionCollector? conversionCollector = null, - ILegacyUrlMapper? legacyUrlMapper = null, - IPositionalNavigation? positionalNavigation = null + ILegacyUrlMapper? legacyUrlMapper = null ) { _markdownExporters = markdownExporters ?? []; @@ -70,9 +68,7 @@ public DocumentationGenerator( DocumentationSet = docSet; Context = docSet.Context; - CrossLinkResolver = docSet.CrossLinkResolver; - HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator(), navigationHtmlWriter, legacyUrlMapper, - positionalNavigation); + HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator(), navigationHtmlWriter, legacyUrlMapper); _documentationFileExporter = docSet.Context.AvailableExporters.Contains(Exporter.Html) ? docSet.EnabledExtensions.FirstOrDefault(e => e.FileExporter != null)?.FileExporter @@ -155,9 +151,10 @@ private async Task ProcessDocumentationFiles(HashSet offendingFiles, Dat await Parallel.ForEachAsync(DocumentationSet.Files, ctx, async (file, token) => { var processedFiles = Interlocked.Increment(ref processedFileCount); + var (fp, doc) = file; try { - await ProcessFile(offendingFiles, file, outputSeenChanges, token); + await ProcessFile(offendingFiles, doc, outputSeenChanges, token); } catch (Exception e) { @@ -165,7 +162,7 @@ await Parallel.ForEachAsync(DocumentationSet.Files, ctx, async (file, token) => // this is not the main error logging mechanism // if we hit this from too many files fail hard if (currentCount <= 25) - Context.Collector.EmitError(file.RelativePath, "Uncaught exception while processing file", e); + Context.Collector.EmitError(fp.RelativePath, "Uncaught exception while processing file", e); else throw; } @@ -252,7 +249,8 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile { foreach (var exporter in _markdownExporters) { - var document = context.MarkdownDocument ??= await markdown.ParseFullAsync(ctx); + var document = context.MarkdownDocument ??= await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); + var navigationItem = DocumentationSet.FindNavigationByMarkdown(markdown); _ = await exporter.ExportAsync(new MarkdownExportFileContext { BuildContext = Context, @@ -260,7 +258,8 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile Document = document, SourceFile = markdown, DefaultOutputFile = outputFile, - DocumentationSet = DocumentationSet + DocumentationSet = DocumentationSet, + NavigationItem = navigationItem }, ctx); } } @@ -349,14 +348,14 @@ private async Task GenerateDocumentationState(Cancel ctx) public async Task RenderLlmMarkdown(MarkdownFile markdown, Cancel ctx) { - await DocumentationSet.Tree.Resolve(ctx); - var document = await markdown.ParseFullAsync(ctx); + await DocumentationSet.ResolveDirectoryTree(ctx); + var document = await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); return LlmMarkdownExporter.ConvertToLlmMarkdown(document, DocumentationSet.Context); } public async Task RenderLayout(MarkdownFile markdown, Cancel ctx) { - await DocumentationSet.Tree.Resolve(ctx); + await DocumentationSet.ResolveDirectoryTree(ctx); return await HtmlWriter.RenderLayout(markdown, ctx); } } diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj index 9d5529c8d..117d86c3f 100644 --- a/src/Elastic.Markdown/Elastic.Markdown.csproj +++ b/src/Elastic.Markdown/Elastic.Markdown.csproj @@ -18,6 +18,7 @@ + @@ -37,6 +38,7 @@ + diff --git a/src/Elastic.Markdown/Exporters/ElasticsearchMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/ElasticsearchMarkdownExporter.cs index b3747212d..1353ad404 100644 --- a/src/Elastic.Markdown/Exporters/ElasticsearchMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/ElasticsearchMarkdownExporter.cs @@ -256,7 +256,7 @@ private async ValueTask TryWrite(DocumentationDocument document, Cancel ct public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, Cancel ctx) { var file = fileContext.SourceFile; - var url = file.Url; + var url = fileContext.NavigationItem.Url; if (url is "/docs" or "/docs/404") { diff --git a/src/Elastic.Markdown/Exporters/IMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/IMarkdownExporter.cs index 492df8ced..8df5c6133 100644 --- a/src/Elastic.Markdown/Exporters/IMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/IMarkdownExporter.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation; using Elastic.Markdown.IO; using Elastic.Markdown.Myst; using Markdig.Syntax; @@ -19,6 +20,7 @@ public record MarkdownExportFileContext public required IFileInfo DefaultOutputFile { get; init; } public string? LLMText { get; set; } public required DocumentationSet DocumentationSet { get; init; } + public required INavigationItem NavigationItem { get; init; } } public interface IMarkdownExporter diff --git a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs index 365e1286f..c337f285b 100644 --- a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs @@ -19,26 +19,26 @@ public class LlmMarkdownExporter : IMarkdownExporter { private const string LlmsTxtTemplate = """ # Elastic Documentation - + > Elastic provides an open source search, analytics, and AI platform, and out-of-the-box solutions for observability and security. The Search AI platform combines the power of search and generative AI to provide near real-time search and analysis with relevance to reduce your time to value. > >Elastic offers the following solutions or types of projects: > - >* [Elasticsearch](https://www.elastic.co/docs/solutions/search): Build powerful search and RAG applications using Elasticsearch's vector database, AI toolkit, and advanced retrieval capabilities. + >* [Elasticsearch](https://www.elastic.co/docs/solutions/search): Build powerful search and RAG applications using Elasticsearch's vector database, AI toolkit, and advanced retrieval capabilities. >* [Elastic Observability](https://www.elastic.co/docs/solutions/observability): Gain comprehensive visibility into applications, infrastructure, and user experience through logs, metrics, traces, and other telemetry data, all in a single interface. >* [Elastic Security](https://www.elastic.co/docs/solutions/security): Combine SIEM, endpoint security, and cloud security to provide comprehensive tools for threat detection and prevention, investigation, and response. - + The documentation is organized to guide you through your journey with Elastic, from learning the basics to deploying and managing complex solutions. Here is a detailed breakdown of the documentation structure: - - * [**Elastic fundamentals**](https://www.elastic.co/docs/get-started): Understand the basics about the deployment options, platform, and solutions, and features of the documentation. - * [**Solutions and use cases**](https://www.elastic.co/docs/solutions): Learn use cases, evaluate, and implement Elastic's solutions: Observability, Search, and Security. - * [**Manage data**](https://www.elastic.co/docs/manage-data): Learn about data store primitives, ingestion and enrichment, managing the data lifecycle, and migrating data. - * [**Explore and analyze**](https://www.elastic.co/docs/explore-analyze): Get value from data through querying, visualization, machine learning, and alerting. - * [**Deploy and manage**](https://www.elastic.co/docs/deploy-manage): Deploy and manage production-ready clusters. Covers deployment options and maintenance tasks. - * [**Manage your Cloud account**](https://www.elastic.co/docs/cloud-account): A dedicated section for user-facing cloud account tasks like resetting passwords. - * [**Troubleshoot**](https://www.elastic.co/docs/troubleshoot): Identify and resolve problems. - * [**Extend and contribute**](https://www.elastic.co/docs/extend): How to contribute to or integrate with Elastic, from open source to plugins to integrations. - * [**Release notes**](https://www.elastic.co/docs/release-notes): Contains release notes and changelogs for each new release. + + * [**Elastic fundamentals**](https://www.elastic.co/docs/get-started): Understand the basics about the deployment options, platform, and solutions, and features of the documentation. + * [**Solutions and use cases**](https://www.elastic.co/docs/solutions): Learn use cases, evaluate, and implement Elastic's solutions: Observability, Search, and Security. + * [**Manage data**](https://www.elastic.co/docs/manage-data): Learn about data store primitives, ingestion and enrichment, managing the data lifecycle, and migrating data. + * [**Explore and analyze**](https://www.elastic.co/docs/explore-analyze): Get value from data through querying, visualization, machine learning, and alerting. + * [**Deploy and manage**](https://www.elastic.co/docs/deploy-manage): Deploy and manage production-ready clusters. Covers deployment options and maintenance tasks. + * [**Manage your Cloud account**](https://www.elastic.co/docs/cloud-account): A dedicated section for user-facing cloud account tasks like resetting passwords. + * [**Troubleshoot**](https://www.elastic.co/docs/troubleshoot): Identify and resolve problems. + * [**Extend and contribute**](https://www.elastic.co/docs/extend): How to contribute to or integrate with Elastic, from open source to plugins to integrations. + * [**Release notes**](https://www.elastic.co/docs/release-notes): Contains release notes and changelogs for each new release. * [**Reference**](https://www.elastic.co/docs/reference): Reference material for core tasks and manuals for optional products. """; @@ -145,8 +145,7 @@ private string CreateLlmContentWithMetadata(MarkdownExportFileContext context, s _ = metadata.AppendLine($"description: {generateDescription}"); } - if (!string.IsNullOrEmpty(sourceFile.Url)) - _ = metadata.AppendLine($"url: {context.BuildContext.CanonicalBaseUrl?.Scheme}://{context.BuildContext.CanonicalBaseUrl?.Host}{sourceFile.Url}"); + _ = metadata.AppendLine($"url: {context.BuildContext.CanonicalBaseUrl?.Scheme}://{context.BuildContext.CanonicalBaseUrl?.Host}{context.NavigationItem.Url}"); var pageProducts = GetPageProducts(sourceFile.YamlFrontMatter?.Products); if (pageProducts.Count > 0) diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs index babc2258a..3dab100bc 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs @@ -14,8 +14,8 @@ namespace Elastic.Markdown.Extensions.DetectionRules; public record DetectionRuleOverviewFile : MarkdownFile { - public DetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build, DocumentationSet set) - : base(sourceFile, rootPath, parser, build, set) + public DetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) + : base(sourceFile, rootPath, parser, build) { } @@ -68,7 +68,8 @@ private string GetMarkdown() """; foreach (var r in group.OrderBy(r => r.Rule.Name)) { - var url = Files[r.RelativePath].Url; + // TODO update this to use the new URL from navigation + var url = "does-not-exist-yet"; markdown += $""" [{r.Rule.Name}](!{url})
    @@ -96,9 +97,8 @@ public DetectionRuleFile( IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, - BuildContext build, - DocumentationSet set - ) : base(sourceFile, rootPath, parser, build, set) + BuildContext build + ) : base(sourceFile, rootPath, parser, build) { RuleSourceMarkdownPath = SourcePath(sourceFile, build); LinkReferenceRelativePath = Path.GetRelativePath(build.DocumentationSourceDirectory.FullName, RuleSourceMarkdownPath.FullName); @@ -121,7 +121,7 @@ public static IFileInfo OutputPath(IFileInfo rulePath, BuildContext build) return rulePath.FileSystem.FileInfo.New(newPath); } - protected override string RelativePathUrl => RelativePath.AsSpan().TrimStart("../").ToString(); + //protected override string RelativePathUrl => RelativePath.AsSpan().TrimStart("../").ToString(); protected override Task GetMinimalParseDocumentAsync(Cancel ctx) { diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs index ee3257e75..859fdb0f0 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs @@ -8,6 +8,8 @@ using Elastic.Documentation.Configuration.TableOfContents; using Elastic.Markdown.Exporters; using Elastic.Markdown.IO; +using Elastic.Markdown.IO.NewNavigation; +using Elastic.Markdown.Myst; namespace Elastic.Markdown.Extensions.DetectionRules; @@ -37,26 +39,27 @@ public void Visit(DocumentationFile file, ITocItem tocItem) } } - public DocumentationFile? CreateDocumentationFile(IFileInfo file, DocumentationSet documentationSet) + public DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser) { if (file.Extension != ".toml") return null; - return new DetectionRuleFile(file, Build.DocumentationSourceDirectory, documentationSet.MarkdownParser, Build, documentationSet); + return new DetectionRuleFile(file, Build.DocumentationSourceDirectory, markdownParser, Build); } - public MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, DocumentationSet documentationSet) => + public MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser) => file.Name == "index.md" - ? new DetectionRuleOverviewFile(file, sourceDirectory, documentationSet.MarkdownParser, Build, documentationSet) + ? new DetectionRuleOverviewFile(file, sourceDirectory, markdownParser, Build) : null; public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, string slug, out DocumentationFile? documentationFile) { var tomlFile = $"../{slug}.toml"; - return documentationSet.FlatMappedFiles.TryGetValue(tomlFile, out documentationFile); + var filePath = new FilePath(tomlFile, Build.DocumentationSourceDirectory); + return documentationSet.Files.TryGetValue(filePath, out documentationFile); } - public IReadOnlyCollection ScanDocumentationFiles( + public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles( Func defaultFileHandling ) { @@ -69,7 +72,7 @@ Func defaultFileHandling return rules.Select(r => { var file = Build.ReadFileSystem.FileInfo.New(Path.Combine(sourceDirectory.FullName, r.RelativePath)); - return defaultFileHandling(file, sourceDirectory); + return (file, defaultFileHandling(file, sourceDirectory)); }).ToArray(); } diff --git a/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs index d5c4fd7d5..0a9f4e667 100644 --- a/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs @@ -6,6 +6,7 @@ using Elastic.Documentation.Configuration.TableOfContents; using Elastic.Markdown.Exporters; using Elastic.Markdown.IO; +using Elastic.Markdown.Myst; namespace Elastic.Markdown.Extensions; @@ -18,13 +19,13 @@ public interface IDocsBuilderExtension /// Create an instance of if it matches the . /// Return `null` to let another extension handle this. - DocumentationFile? CreateDocumentationFile(IFileInfo file, DocumentationSet documentationSet); + DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser); /// Attempts to locate a documentation file by slug, used to locate the document for `docs-builder serve` command bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, string slug, out DocumentationFile? documentationFile); /// Allows the extension to discover more documentation files for - IReadOnlyCollection ScanDocumentationFiles(Func defaultFileHandling); + IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling); - MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, DocumentationSet documentationSet); + MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser); } diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 804d12e01..0654e8d05 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Extensions.DetectionRules; @@ -32,7 +33,7 @@ public class HtmlWriter( private DocumentationSet DocumentationSet { get; } = documentationSet; private INavigationHtmlWriter NavigationHtmlWriter { get; } = - navigationHtmlWriter ?? new IsolatedBuildNavigationHtmlWriter(documentationSet.Context, documentationSet.Tree); + navigationHtmlWriter ?? new IsolatedBuildNavigationHtmlWriter(documentationSet.Context, documentationSet.Navigation); private StaticFileContentHashProvider StaticFileContentHashProvider { get; } = new(new EmbeddedOrPhysicalFileProvider(documentationSet.Context)); private ILegacyUrlMapper LegacyUrlMapper { get; } = legacyUrlMapper ?? new NoopLegacyUrlMapper(); @@ -48,17 +49,20 @@ public string Render(string markdown, IFileInfo? source) public async Task RenderLayout(MarkdownFile markdown, Cancel ctx = default) { - var document = await markdown.ParseFullAsync(ctx); + var document = await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); return await RenderLayout(markdown, document, ctx); } private async Task RenderLayout(MarkdownFile markdown, MarkdownDocument document, Cancel ctx = default) { var html = MarkdownFile.CreateHtml(document); - await DocumentationSet.Tree.Resolve(ctx); + await DocumentationSet.ResolveDirectoryTree(ctx); + var navigationItem = DocumentationSet.FindNavigationByMarkdown(markdown); - var fullNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(markdown.NavigationRoot, INavigationHtmlWriter.AllLevels, ctx); - var miniNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(markdown.NavigationRoot, 1, ctx); + var root = navigationItem.NavigationRoot; + + var fullNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(root, INavigationHtmlWriter.AllLevels, ctx); + var miniNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(root, 1, ctx); var navigationHtmlRenderResult = DocumentationSet.Context.Configuration.Features.LazyLoadNavigation ? miniNavigationRenderResult @@ -81,18 +85,19 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc Uri? reportLinkParameter = null; if (DocumentationSet.Context.CanonicalBaseUrl is not null) - reportLinkParameter = new Uri(DocumentationSet.Context.CanonicalBaseUrl, Path.Combine(DocumentationSet.Context.UrlPathPrefix ?? string.Empty, markdown.Url)); + reportLinkParameter = new Uri(DocumentationSet.Context.CanonicalBaseUrl, Path.Combine(DocumentationSet.Context.UrlPathPrefix ?? string.Empty, current.Url)); var reportUrl = $"https://github.com/elastic/docs-content/issues/new?template=issue-report.yaml&link={reportLinkParameter}&labels=source:web"; - var siteName = DocumentationSet.Tree.Index.Title ?? "Elastic Documentation"; + var siteName = DocumentationSet.Navigation.NavigationTitle; var legacyPages = LegacyUrlMapper.MapLegacyUrl(markdown.YamlFrontMatter?.MappedPages); var pageProducts = GetPageProducts(markdown.YamlFrontMatter?.Products); string? allVersionsUrl = null; - if (PositionalNavigation.MarkdownNavigationLookup.TryGetValue("docs-content://versions.md", out var item)) - allVersionsUrl = item.Url; + // TODO exposese allversions again + //if (PositionalNavigation.MarkdownNavigationLookup.TryGetValue("docs-content://versions.md", out var item)) + // allVersionsUrl = item.Url; var navigationFileName = $"{fullNavigationRenderResult.Id}.nav.html"; @@ -131,7 +136,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc UrlPathPrefix = markdown.UrlPathPrefix, AppliesTo = markdown.YamlFrontMatter?.AppliesTo, GithubEditUrl = editUrl, - MarkdownUrl = markdown.Url.TrimEnd('/') + ".md", + MarkdownUrl = current.Url.TrimEnd('/') + ".md", AllowIndexing = DocumentationSet.Context.AllowIndexing && (markdown.CrossLink.Equals("docs-content://index.md", StringComparison.OrdinalIgnoreCase) || markdown is DetectionRuleFile || !current.Hidden), CanonicalBaseUrl = DocumentationSet.Context.CanonicalBaseUrl, GoogleTagManager = DocumentationSet.Context.GoogleTagManager, @@ -203,7 +208,7 @@ public async Task WriteAsync(IDirectoryInfo outBaseDir, IFileI : Path.Combine(dir, "index.html"); } - var document = await markdown.ParseFullAsync(ctx); + var document = await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); var rendered = await RenderLayout(markdown, document, ctx); collector?.Collect(markdown, document, rendered.Html); diff --git a/src/Elastic.Markdown/IO/DocumentationFile.cs b/src/Elastic.Markdown/IO/DocumentationFile.cs index a4d53b89e..7b0ecd50a 100644 --- a/src/Elastic.Markdown/IO/DocumentationFile.cs +++ b/src/Elastic.Markdown/IO/DocumentationFile.cs @@ -1,7 +1,10 @@ // 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.Collections.Frozen; using System.IO.Abstractions; +using Elastic.Documentation.Diagnostics; using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.FrontMatter; @@ -44,7 +47,8 @@ public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string private bool _parsed; public SnippetAnchors? GetAnchors( - DocumentationSet set, + IDiagnosticsCollector collector, + Func documentationFileLookup, MarkdownParser parser, YamlFrontMatter? frontMatter ) @@ -58,7 +62,7 @@ public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string } var document = parser.MinimalParseAsync(SourceFile, default).GetAwaiter().GetResult(); - var toc = MarkdownFile.GetAnchors(set, parser, frontMatter, document, new Dictionary(), out var anchors); + var toc = MarkdownFile.GetAnchors(collector, documentationFileLookup, parser, frontMatter, document, new Dictionary(), out var anchors); Anchors = new SnippetAnchors(anchors, toc); _parsed = true; return Anchors; diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 303f637fa..acb88a53f 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -5,101 +5,25 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using System.IO.Abstractions; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; -using Elastic.Documentation.Configuration.TableOfContents; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Extensions; using Elastic.Markdown.Extensions.DetectionRules; -using Elastic.Markdown.IO.Navigation; +using Elastic.Markdown.IO.NewNavigation; using Elastic.Markdown.Myst; using Microsoft.Extensions.Logging; namespace Elastic.Markdown.IO; -public interface INavigationLookups -{ - FrozenDictionary FlatMappedFiles { get; } - IReadOnlyCollection TableOfContents { get; } - IReadOnlyCollection EnabledExtensions { get; } - FrozenDictionary FilesGroupedByFolder { get; } - ICrossLinkResolver CrossLinkResolver { get; } -} - -public interface IPositionalNavigation -{ - FrozenDictionary MarkdownNavigationLookup { get; } - FrozenDictionary NavigationIndexedByOrder { get; } - - INavigationItem? GetPrevious(MarkdownFile current) - { - if (!MarkdownNavigationLookup.TryGetValue(current.CrossLink, out var currentNavigation)) - return null; - var index = currentNavigation.NavigationIndex; - do - { - var previous = NavigationIndexedByOrder.GetValueOrDefault(index - 1); - if (previous is not null && !previous.Hidden) - return previous; - index--; - } while (index > 0); - - return null; - } - - INavigationItem? GetNext(MarkdownFile current) - { - if (!MarkdownNavigationLookup.TryGetValue(current.CrossLink, out var currentNavigation)) - return null; - var index = currentNavigation.NavigationIndex; - do - { - var next = NavigationIndexedByOrder.GetValueOrDefault(index + 1); - if (next is not null && !next.Hidden && next.Url != currentNavigation.Url) - return next; - index++; - } while (index <= NavigationIndexedByOrder.Count - 1); - - return null; - } - - INavigationItem GetCurrent(MarkdownFile file) => - MarkdownNavigationLookup.GetValueOrDefault(file.CrossLink) ?? throw new InvalidOperationException($"Could not find {file.CrossLink} in navigation"); - - INavigationItem[] GetParents(INavigationItem current) - { - var parents = new List(); - var parent = current.Parent; - do - { - if (parent is null) - continue; - if (parents.All(i => i.Url != parent.Url)) - parents.Add(parent); - - parent = parent.Parent; - } while (parent != null); - - return [.. parents]; - } - INavigationItem[] GetParentsOfMarkdownFile(MarkdownFile file) => - MarkdownNavigationLookup.TryGetValue(file.CrossLink, out var navigationItem) ? GetParents(navigationItem) : []; -} - -public record NavigationLookups : INavigationLookups -{ - public required FrozenDictionary FlatMappedFiles { get; init; } - public required IReadOnlyCollection TableOfContents { get; init; } - public required IReadOnlyCollection EnabledExtensions { get; init; } - public required FrozenDictionary FilesGroupedByFolder { get; init; } - public required ICrossLinkResolver CrossLinkResolver { get; init; } -} - -public class DocumentationSet : INavigationLookups, IPositionalNavigation +public class DocumentationSet : IPositionalNavigation { private readonly ILogger _logger; public BuildContext Context { get; } @@ -118,19 +42,9 @@ public class DocumentationSet : INavigationLookups, IPositionalNavigation public ICrossLinkResolver CrossLinkResolver { get; } - public TableOfContentsTree Tree { get; } - - public Uri Source { get; } - - public IReadOnlyCollection Files { get; } - - public FrozenDictionary FilesGroupedByFolder { get; } - - public FrozenDictionary FlatMappedFiles { get; } + public FrozenDictionary Files { get; } - IReadOnlyCollection INavigationLookups.TableOfContents => Configuration.TableOfContents; - - public FrozenDictionary MarkdownNavigationLookup { get; } + public ConditionalWeakTable MarkdownNavigationLookup { get; } public IReadOnlyCollection EnabledExtensions { get; } @@ -139,178 +53,109 @@ public class DocumentationSet : INavigationLookups, IPositionalNavigation public DocumentationSet( BuildContext context, ILoggerFactory logFactory, - ICrossLinkResolver linkResolver, - TableOfContentsTreeCollector? treeCollector = null + ICrossLinkResolver linkResolver ) { _logger = logFactory.CreateLogger(); Context = context; - Source = ContentSourceMoniker.Create(context.Git.RepositoryName, null); SourceDirectory = context.DocumentationSourceDirectory; OutputDirectory = context.OutputDirectory; CrossLinkResolver = linkResolver; Configuration = context.Configuration; EnabledExtensions = InstantiateExtensions(); - treeCollector ??= new TableOfContentsTreeCollector(); var resolver = new ParserResolvers { CrossLinkResolver = CrossLinkResolver, - DocumentationFileLookup = DocumentationFileLookup + TryFindDocument = TryFindDocument, + TryFindDocumentByRelativePath = TryFindDocumentByRelativePath, + PositionalNavigation = this }; MarkdownParser = new MarkdownParser(context, resolver); + var fileFactory = new MarkdownFileFactory(context, MarkdownParser, EnabledExtensions); + Navigation = new DocumentationSetNavigation(context.ConfigurationYaml, context, fileFactory); + Name = Context.Git != GitCheckoutInformation.Unavailable ? Context.Git.RepositoryName : Context.DocumentationCheckoutDirectory?.Name ?? $"unknown-{Context.DocumentationSourceDirectory.Name}"; OutputStateFile = OutputDirectory.FileSystem.FileInfo.New(Path.Combine(OutputDirectory.FullName, ".doc.state")); LinkReferenceFile = OutputDirectory.FileSystem.FileInfo.New(Path.Combine(OutputDirectory.FullName, "links.json")); - var files = ScanDocumentationFiles(context, SourceDirectory); - var additionalSources = EnabledExtensions - .SelectMany(extension => extension.ScanDocumentationFiles(DefaultFileHandling)) - .ToArray(); - - Files = files.Concat(additionalSources).Where(f => f is not ExcludedFile).ToArray(); - - LastWrite = Files.Max(f => f.SourceFile.LastWriteTimeUtc); - - FlatMappedFiles = Files.ToDictionary(file => file.RelativePath, file => file).ToFrozenDictionary(); + Files = fileFactory.Files; + var files = Files.Values.ToArray(); + LastWrite = files.Max(f => f.SourceFile.LastWriteTimeUtc); - FilesGroupedByFolder = Files - .GroupBy(file => file.RelativeFolder) - .ToDictionary(g => g.Key, g => g.ToArray()) - .ToFrozenDictionary(); - - var fileIndex = 0; - var lookups = new NavigationLookups - { - FlatMappedFiles = FlatMappedFiles, - TableOfContents = Configuration.TableOfContents, - EnabledExtensions = EnabledExtensions, - FilesGroupedByFolder = FilesGroupedByFolder, - CrossLinkResolver = CrossLinkResolver - }; + var markdownFiles = files.OfType().ToArray(); + MarkdownFiles = markdownFiles.ToFrozenSet(); - Tree = new TableOfContentsTree(Source, Context, lookups, treeCollector, ref fileIndex); - - var navigationIndex = 0; - UpdateNavigationIndex(Tree.NavigationItems, ref navigationIndex); - var markdownFiles = Files.OfType().ToArray(); - - var excludedChildren = markdownFiles.Where(f => !f.PartOfNavigation).ToArray(); - foreach (var excludedChild in excludedChildren) - Context.EmitError(Context.ConfigurationPath, $"{excludedChild.RelativePath} is unreachable in the TOC because one of its parents matches exclusion glob"); - - MarkdownFiles = markdownFiles.Where(f => f.PartOfNavigation).ToFrozenSet(); - NavigationIndexedByOrder = CreateNavigationLookup(Tree) + MarkdownNavigationLookup = []; + var navigationFlatList = CreateNavigationLookup(Navigation); + NavigationIndexedByOrder = navigationFlatList .ToDictionary(n => n.NavigationIndex, n => n) .ToFrozenDictionary(); - MarkdownNavigationLookup = Tree.NavigationItems - .SelectMany(Pairs) - .Concat(Pairs(Tree)) - .DistinctBy(kv => kv.Item1) - .ToDictionary(kv => kv.Item1, kv => kv.Item2) + // Build cross-link dictionary including both: + // 1. Direct leaf items (files without children) + // 2. Index property of node items (files with children) + var leafItems = navigationFlatList.OfType>(); + var nodeIndexes = navigationFlatList + .OfType>() + .Select(node => node.Index); + + NavigationIndexedByCrossLink = leafItems + .Concat(nodeIndexes) + .DistinctBy(n => n.Model.CrossLink) + .ToDictionary(n => n.Model.CrossLink, n => n) .ToFrozenDictionary(); ValidateRedirectsExists(); } - private void UpdateNavigationIndex(IReadOnlyCollection navigationItems, ref int navigationIndex) - { - foreach (var item in navigationItems) - { - switch (item) - { - case FileNavigationItem fileNavigationItem: - var fileIndex = Interlocked.Increment(ref navigationIndex); - fileNavigationItem.NavigationIndex = fileIndex; - break; - case CrossLinkNavigationItem crossLinkNavigationItem: - var crossLinkIndex = Interlocked.Increment(ref navigationIndex); - crossLinkNavigationItem.NavigationIndex = crossLinkIndex; - break; - case DocumentationGroup documentationGroup: - var groupIndex = Interlocked.Increment(ref navigationIndex); - documentationGroup.NavigationIndex = groupIndex; - UpdateNavigationIndex(documentationGroup.NavigationItems, ref navigationIndex); - break; - default: - Context.EmitError(Context.ConfigurationPath, $"{nameof(DocumentationSet)}.{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); - break; - } - } - } + public FrozenDictionary> NavigationIndexedByCrossLink { get; } + + public DocumentationSetNavigation Navigation { get; } public FrozenDictionary NavigationIndexedByOrder { get; } - private static IReadOnlyCollection CreateNavigationLookup(INavigationItem item) + private IReadOnlyCollection CreateNavigationLookup(INavigationItem item) { - if (item is ILeafNavigationItem leaf) - return [leaf]; - - if (item is CrossLinkNavigationItem crossLink) - return [crossLink]; - - if (item is INodeNavigationItem node) + switch (item) { - var items = node.NavigationItems.SelectMany(CreateNavigationLookup); - return items.Concat([node]).ToArray(); + case ILeafNavigationItem markdownLeaf: + MarkdownNavigationLookup.Add(markdownLeaf.Model, markdownLeaf); + return [markdownLeaf]; + case ILeafNavigationItem leaf: + return [leaf]; + case INodeNavigationItem node: + var items = node.NavigationItems.SelectMany(CreateNavigationLookup); + return items.Concat([node]).ToArray(); + default: + return []; } - - return []; } public static (string, INavigationItem)[] Pairs(INavigationItem item) { - if (item is FileNavigationItem f) - return [(f.Model.CrossLink, item)]; - if (item is CrossLinkNavigationItem cl) - return [(cl.Url, item)]; // Use the URL as the key for cross-links - if (item is DocumentationGroup g) - { - var index = new List<(string, INavigationItem)> - { - (g.Index.CrossLink, g) - }; - - return index.Concat(g.NavigationItems.SelectMany(Pairs).ToArray()) - .DistinctBy(kv => kv.Item1) - .ToArray(); - } - - return []; - } - - private DocumentationFile[] ScanDocumentationFiles(BuildContext build, IDirectoryInfo sourceDirectory) => - [.. build.ReadFileSystem.Directory - .EnumerateFiles(sourceDirectory.FullName, "*.*", SearchOption.AllDirectories) - .Select(f => build.ReadFileSystem.FileInfo.New(f)) - .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) - .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) - // skip hidden folders - .Where(f => !Path.GetRelativePath(sourceDirectory.FullName, f.FullName).StartsWith('.')) - .Select(file => file.Extension switch - { - ".jpg" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName, "image/jpeg"), - ".jpeg" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName, "image/jpeg"), - ".gif" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName, "image/gif"), - ".svg" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName, "image/svg+xml"), - ".png" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName), - ".md" => CreateMarkDownFile(file, build), - _ => DefaultFileHandling(file, sourceDirectory) - })]; - - private DocumentationFile DefaultFileHandling(IFileInfo file, IDirectoryInfo sourceDirectory) - { - foreach (var extension in EnabledExtensions) + //TODO add crosslink to navigation if still necessary later + switch (item) { - var documentationFile = extension.CreateDocumentationFile(file, this); - if (documentationFile is not null) - return documentationFile; + case ILeafNavigationItem { IsCrossLink: true } f: + return [(f.Url, item)]; + case ILeafNavigationItem f: + return [(f.Url, item)]; + case INodeNavigationItem g: + var index = new List<(string, INavigationItem)> + { + (g.Url, g) + }; + + return index.Concat(g.NavigationItems.SelectMany(Pairs).ToArray()) + .DistinctBy(kv => kv.Item1) + .ToArray(); + default: + return []; } - return new ExcludedFile(file, sourceDirectory, Context.Git.RepositoryName); } private void ValidateRedirectsExists() @@ -344,7 +189,8 @@ void ValidateExists(string from, string to, IReadOnlyDictionary if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) to = to.Replace('/', Path.DirectorySeparatorChar); - if (!FlatMappedFiles.TryGetValue(to, out var file)) + var fp = new FilePath(to, SourceDirectory); + if (!Files.TryGetValue(fp, out var file)) { Context.EmitError(Configuration.SourceFile, $"Redirect {from} points to {to} which does not exist"); return; @@ -371,49 +217,41 @@ void ValidateExists(string from, string to, IReadOnlyDictionary public FrozenSet MarkdownFiles { get; } public string FirstInterestingUrl => - NavigationIndexedByOrder.Values.OfType().First().Url; + NavigationIndexedByOrder.Values.OfType>().First().Url; - public DocumentationFile? DocumentationFileLookup(IFileInfo sourceFile) + public DocumentationFile? TryFindDocument(IFileInfo sourceFile) { var relativePath = Path.GetRelativePath(SourceDirectory.FullName, sourceFile.FullName); - return FlatMappedFiles.GetValueOrDefault(relativePath); + return TryFindDocumentByRelativePath(relativePath); } - - public async Task ResolveDirectoryTree(Cancel ctx) => await Tree.Resolve(ctx); - - private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext context) + public DocumentationFile? TryFindDocumentByRelativePath(string relativePath) { - var relativePath = Path.GetRelativePath(SourceDirectory.FullName, file.FullName); - if (Configuration.Exclude.Any(g => g.IsMatch(relativePath))) - return new ExcludedFile(file, SourceDirectory, context.Git.RepositoryName); - - if (relativePath.Contains("_snippets")) - return new SnippetFile(file, SourceDirectory, context.Git.RepositoryName); - - // we ignore files in folders that start with an underscore - var folder = Path.GetDirectoryName(relativePath); - if (folder is not null && (folder.Contains($"{Path.DirectorySeparatorChar}_", StringComparison.Ordinal) || folder.StartsWith('_'))) - return new ExcludedFile(file, SourceDirectory, context.Git.RepositoryName); + var fp = new FilePath(relativePath, SourceDirectory); + return Files.GetValueOrDefault(fp); + } - if (Configuration.Files.Contains(relativePath)) - return ExtensionOrDefaultMarkdown(); + public INavigationItem FindNavigationByMarkdown(MarkdownFile markdown) + { + if (MarkdownNavigationLookup.TryGetValue(markdown, out var navigation)) + return navigation; + throw new Exception($"Could not find navigation item for {markdown.CrossLink}"); + } + public INavigationItem FindNavigationByCrossLink(string crossLink) + { + if (NavigationIndexedByCrossLink.TryGetValue(crossLink, out var navigation)) + return navigation; + throw new Exception($"Could not find navigation item for {crossLink}"); + } - if (Configuration.Globs.Any(g => g.IsMatch(relativePath))) - return ExtensionOrDefaultMarkdown(); + private bool _resolved; + public async Task ResolveDirectoryTree(Cancel ctx) + { + if (_resolved) + return; - context.EmitError(Configuration.SourceFile, $"Not linked in toc: {relativePath}"); - return new ExcludedFile(file, SourceDirectory, context.Git.RepositoryName); + await Parallel.ForEachAsync(MarkdownFiles, ctx, async (file, token) => await file.MinimalParseAsync(TryFindDocumentByRelativePath, token)); - MarkdownFile ExtensionOrDefaultMarkdown() - { - foreach (var extension in EnabledExtensions) - { - var documentationFile = extension.CreateMarkdownFile(file, SourceDirectory, this); - if (documentationFile is not null) - return documentationFile; - } - return new MarkdownFile(file, SourceDirectory, MarkdownParser, context, this); - } + _resolved = true; } public RepositoryLinks CreateLinkReference() @@ -421,11 +259,11 @@ public RepositoryLinks CreateLinkReference() var redirects = Configuration.Redirects; var crossLinks = Context.Collector.CrossLinks.ToHashSet().ToArray(); var markdownInNavigation = NavigationIndexedByOrder.Values - .OfType() + .OfType>() .Select(m => (Markdown: m.Model, Navigation: (INavigationItem)m)) .Concat(NavigationIndexedByOrder.Values - .OfType() - .Select(g => (Markdown: g.Index, Navigation: (INavigationItem)g)) + .OfType>() + .Select(g => (Markdown: g.Index.Model, Navigation: (INavigationItem)g)) ) .ToList(); diff --git a/src/Elastic.Markdown/IO/IPositionalNavigation.cs b/src/Elastic.Markdown/IO/IPositionalNavigation.cs new file mode 100644 index 000000000..c1a9abe68 --- /dev/null +++ b/src/Elastic.Markdown/IO/IPositionalNavigation.cs @@ -0,0 +1,72 @@ +// 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.Collections.Frozen; +using System.Runtime.CompilerServices; +using Elastic.Documentation.Navigation; + +namespace Elastic.Markdown.IO; + +public interface IPositionalNavigation +{ + ConditionalWeakTable MarkdownNavigationLookup { get; } + FrozenDictionary NavigationIndexedByOrder { get; } + FrozenDictionary> NavigationIndexedByCrossLink { get; } + + INavigationItem? GetPrevious(MarkdownFile current) + { + if (!MarkdownNavigationLookup.TryGetValue(current, out var currentNavigation)) + return null; + var index = currentNavigation.NavigationIndex; + do + { + var previous = NavigationIndexedByOrder.GetValueOrDefault(index - 1); + if (previous is not null && !previous.Hidden && previous.Url != currentNavigation.Url) + return previous; + index--; + } while (index > 0); + + return null; + } + + INavigationItem? GetNext(MarkdownFile current) + { + if (!MarkdownNavigationLookup.TryGetValue(current, out var currentNavigation)) + return null; + var index = currentNavigation.NavigationIndex; + do + { + var next = NavigationIndexedByOrder.GetValueOrDefault(index + 1); + if (next is not null && !next.Hidden && next.Url != currentNavigation.Url) + return next; + index++; + } while (index <= NavigationIndexedByOrder.Count - 1); + + return null; + } + + INavigationItem GetCurrent(MarkdownFile file) => + MarkdownNavigationLookup.TryGetValue(file, out var navigation) + ? navigation : throw new InvalidOperationException($"Could not find {file.RelativePath} in navigation"); + + INavigationItem[] GetParents(INavigationItem current) + { + var parents = new List(); + var parent = current.Parent; + do + { + if (parent is null) + continue; + if (parents.All(i => i.Url != parent.Url)) + parents.Add(parent); + + parent = parent.Parent; + } while (parent != null); + + return [.. parents]; + } + + INavigationItem[] GetParentsOfMarkdownFile(MarkdownFile file) => + MarkdownNavigationLookup.TryGetValue(file, out var navigation) ? GetParents(navigation) : []; +} diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index d9dfb8358..2d0bbc8d0 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -2,14 +2,17 @@ // 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.Collections.Frozen; using System.IO.Abstractions; using System.Runtime.InteropServices; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation.Isolated; using Elastic.Markdown.Helpers; +using Elastic.Markdown.IO.NewNavigation; using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.Directives.Include; @@ -22,12 +25,10 @@ namespace Elastic.Markdown.IO; -public record MarkdownFile : DocumentationFile, ITableOfContentsScope, INavigationModel +public record MarkdownFile : DocumentationFile, ITableOfContentsScope, IDocumentationFile { private string? _navigationTitle; - private readonly DocumentationSet _set; - private readonly IFileInfo _configurationFile; private readonly IReadOnlyDictionary _globalSubstitutions; @@ -36,8 +37,7 @@ public MarkdownFile( IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, - BuildContext build, - DocumentationSet set + BuildContext build ) : base(sourceFile, rootPath, build.Git.RepositoryName) { @@ -49,20 +49,17 @@ DocumentationSet set Collector = build.Collector; _configurationFile = build.Configuration.SourceFile; _globalSubstitutions = build.Configuration.Substitutions; - _set = set; //may be updated by DocumentationGroup.ProcessTocItems //todo refactor mutability of MarkdownFile as a whole ScopeDirectory = build.Configuration.ScopeDirectory; + Products = build.ProductsConfiguration; - NavigationRoot = set.Tree; } - public bool PartOfNavigation { get; set; } + public ProductsConfiguration Products { get; } public IDirectoryInfo ScopeDirectory { get; set; } - public IRootNavigationItem NavigationRoot { get; set; } - private IDiagnosticsCollector Collector { get; } public string? UrlPathPrefix { get; } @@ -97,45 +94,6 @@ public string NavigationTitle public string FilePath { get; } public string FileName { get; } - protected virtual string RelativePathUrl => RelativePath; - - private string DefaultUrlPathSuffix - { - get - { - var relativePath = RelativePathUrl; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - relativePath = relativePath.Replace('\\', '/'); - return Path.GetFileName(relativePath) == "index.md" - ? $"/{relativePath.Remove(relativePath.LastIndexOf("index.md", StringComparison.Ordinal), "index.md".Length)}" - : $"/{relativePath.Remove(relativePath.LastIndexOf(SourceFile.Extension, StringComparison.Ordinal), SourceFile.Extension.Length)}"; - } - } - - private string DefaultUrlPath => $"{UrlPathPrefix}{DefaultUrlPathSuffix}"; - - private string? _url; - public string Url - { - get - { - if (_url is not null) - return _url; - if (_set.CrossLinkResolver.UriResolver is IsolatedBuildEnvironmentUriResolver) - { - _url = DefaultUrlPath; - return _url; - } - var crossLink = new Uri(CrossLink); - var uri = _set.CrossLinkResolver.UriResolver.Resolve(crossLink, DefaultUrlPathSuffix); - _url = uri.AbsolutePath; - return _url; - - } - } - - //public int NavigationIndex { get; set; } = -1; - private bool _instructionsParsed; private string? _title; @@ -164,18 +122,18 @@ protected virtual async Task GetMinimalParseDocumentAsync(Canc protected virtual async Task GetParseDocumentAsync(Cancel ctx) => await MarkdownParser.ParseAsync(SourceFile, YamlFrontMatter, ctx); - public async Task MinimalParseAsync(Cancel ctx) + public async Task MinimalParseAsync(Func documentationFileLookup, Cancel ctx) { var document = await GetMinimalParseDocumentAsync(ctx); - ReadDocumentInstructions(document); + ReadDocumentInstructions(document, documentationFileLookup); ValidateAnchorRemapping(); return document; } - public async Task ParseFullAsync(Cancel ctx) + public async Task ParseFullAsync(Func documentationFileLookup, Cancel ctx) { if (!_instructionsParsed) - _ = await MinimalParseAsync(ctx); + _ = await MinimalParseAsync(documentationFileLookup, ctx); var document = await GetParseDocumentAsync(ctx); return document; @@ -194,7 +152,7 @@ private IReadOnlyDictionary GetSubstitutions() return allProperties; } - protected void ReadDocumentInstructions(MarkdownDocument document) + protected void ReadDocumentInstructions(MarkdownDocument document, Func documentationFileLookup) { Title ??= document .FirstOrDefault(block => block is HeadingBlock { Level: 1 })? @@ -221,7 +179,7 @@ protected void ReadDocumentInstructions(MarkdownDocument document) else if (Title.AsSpan().ReplaceSubstitutions(subs, Collector, out var replacement)) Title = replacement; - var toc = GetAnchors(_set, MarkdownParser, YamlFrontMatter, document, subs, out var anchors); + var toc = GetAnchors(Collector, documentationFileLookup, MarkdownParser, YamlFrontMatter, document, subs, out var anchors); _pageTableOfContent.Clear(); foreach (var t in toc) @@ -235,7 +193,8 @@ protected void ReadDocumentInstructions(MarkdownDocument document) } public static List GetAnchors( - DocumentationSet set, + IDiagnosticsCollector collector, + Func documentationFileLookup, MarkdownParser parser, YamlFrontMatter? frontMatter, MarkdownDocument document, @@ -248,12 +207,13 @@ public static List GetAnchors( .Select(i => { var relativePath = i.IncludePathRelativeToSource; - if (relativePath is null - || !set.FlatMappedFiles.TryGetValue(relativePath, out var file) - || file is not SnippetFile snippet) + if (relativePath is null) + return null; + var doc = documentationFileLookup(relativePath); + if (doc is not SnippetFile snippet) return null; - var anchors = snippet.GetAnchors(set, parser, frontMatter); + var anchors = snippet.GetAnchors(collector, documentationFileLookup, parser, frontMatter); return new { Block = i, Anchors = anchors }; }) .Where(i => i is not null) @@ -295,7 +255,7 @@ public static List GetAnchors( { var processedTitle = step.Title; // Apply substitutions to step titles - if (subs.Count > 0 && processedTitle.AsSpan().ReplaceSubstitutions(subs, set.Context.Collector, out var replacement)) + if (subs.Count > 0 && processedTitle.AsSpan().ReplaceSubstitutions(subs, collector, out var replacement)) processedTitle = replacement; return new @@ -317,7 +277,7 @@ public static List GetAnchors( .Select(item => item.TocItem) .Select(toc => subs.Count == 0 ? toc - : toc.Heading.AsSpan().ReplaceSubstitutions(subs, set.Context.Collector, out var r) + : toc.Heading.AsSpan().ReplaceSubstitutions(subs, collector, out var r) ? toc with { Heading = r } : toc) .ToList(); @@ -369,9 +329,7 @@ private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document) foreach (var url in fm.MappedPages) { if (!string.IsNullOrEmpty(url) && (!url.StartsWith("https://www.elastic.co/guide", StringComparison.OrdinalIgnoreCase) || !Uri.IsWellFormedUriString(url, UriKind.Absolute))) - { Collector.EmitError(FilePath, $"Invalid mapped_pages URL: \"{url}\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide."); - } } } @@ -395,7 +353,7 @@ private YamlFrontMatter ReadYamlFrontMatter(string raw) { try { - return YamlSerialization.Deserialize(raw, _set.Context.ProductsConfiguration); + return YamlSerialization.Deserialize(raw, Products); } catch (InvalidProductException e) { diff --git a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs deleted file mode 100644 index 6dd24c352..000000000 --- a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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; -using System.Diagnostics.CodeAnalysis; -using Elastic.Documentation.Site.Navigation; - -namespace Elastic.Markdown.IO.Navigation; - -[DebuggerDisplay("CrossLink: {Url}")] -public record CrossLinkNavigationItem : ILeafNavigationItem -{ - public CrossLinkNavigationItem(Uri crossLinkUri, Uri resolvedUrl, string title, DocumentationGroup group, bool hidden = false) - { - CrossLink = crossLinkUri; - Url = resolvedUrl.ToString(); - NavigationTitle = title; - Parent = group; - NavigationRoot = group.NavigationRoot; - Hidden = hidden; - } - - public INodeNavigationItem? Parent { get; set; } - public IRootNavigationItem NavigationRoot { get; } - - public Uri CrossLink { get; } - public string Url { get; } - public string NavigationTitle { get; } - public int NavigationIndex { get; set; } - public bool Hidden { get; } - public bool IsCrossLink => true; // This is always a cross-link - public INavigationModel Model => null!; // Cross-link has no local model -} diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs deleted file mode 100644 index fd121a2c5..000000000 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ /dev/null @@ -1,255 +0,0 @@ -// 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; -using Elastic.Documentation; -using Elastic.Documentation.Configuration; -using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.Extensions; -using Elastic.Documentation.Site.Navigation; - -namespace Elastic.Markdown.IO.Navigation; - -[DebuggerDisplay("Toc: {Depth} {NavigationSource} > ({NavigationItems.Count} items)")] -public class DocumentationGroup : INodeNavigationItem -{ - private readonly TableOfContentsTreeCollector _treeCollector; - - public string Id { get; } - - public IRootNavigationItem NavigationRoot { get; protected init; } - - public Uri NavigationSource { get; set; } - - public MarkdownFile Index { get; } - - public string Url => Index.Url; - - public string NavigationTitle => Index.NavigationTitle; - - public bool Hidden { get; set; } - - public int NavigationIndex { get; set; } - - public bool IsCrossLink => false; // Documentation groups are never cross-links - - private IReadOnlyCollection FilesInOrder { get; } - - private IReadOnlyCollection GroupsInOrder { get; } - - public IReadOnlyCollection NavigationItems { get; set; } - - public int Depth { get; } - - public INodeNavigationItem? Parent { get; set; } - - public string FolderName { get; } - - private readonly IRootNavigationItem? _root; - - protected virtual IRootNavigationItem DefaultNavigation => - _root ?? throw new InvalidOperationException("root navigation's model is not of type MarkdownFile"); - - protected DocumentationGroup(string folderName, - TableOfContentsTreeCollector treeCollector, - BuildContext context, - NavigationLookups lookups, - Uri navigationSource, - ref int fileIndex, - int depth, - IRootNavigationItem? toplevelTree, - DocumentationGroup? parent, - MarkdownFile? virtualIndexFile = null - ) - { - Parent = parent; - FolderName = folderName; - NavigationSource = navigationSource; - _treeCollector = treeCollector; - Depth = depth; - // Virtual calls don't use state, so while ugly not an issue - // We'll need to address this more structurally - // ReSharper disable VirtualMemberCallInConstructor - _root = toplevelTree; - toplevelTree ??= DefaultNavigation; - if (parent?.Depth == 0) - toplevelTree = DefaultNavigation; - // ReSharper enable VirtualMemberCallInConstructor - NavigationRoot = toplevelTree; - Index = ProcessTocItems(context, toplevelTree, lookups, depth, virtualIndexFile, ref fileIndex, out var groups, out var files, out var navigationItems); - - GroupsInOrder = groups; - FilesInOrder = files; - NavigationItems = navigationItems; - Id = ShortId.Create(NavigationSource.ToString(), FolderName); - - FilesInOrder = [.. FilesInOrder.Except([Index])]; - } - - private MarkdownFile ProcessTocItems(BuildContext context, - IRootNavigationItem rootNavigationItem, - NavigationLookups lookups, - int depth, - MarkdownFile? virtualIndexFile, - ref int fileIndex, - out List groups, - out List files, - out List navigationItems - ) - { - groups = []; - navigationItems = []; - files = []; - var fileReferences = lookups.TableOfContents.OfType().ToArray(); - var indexFile = virtualIndexFile; - FileReference? indexReference = null; - if (indexFile is null) - { - indexReference = - fileReferences.FirstOrDefault(f => f.RelativePath.EndsWith("index.md")) - ?? fileReferences.FirstOrDefault(); - } - - var list = navigationItems; - - void AddToNavigationItems(INavigationItem item, ref int fileIndex) - { - item.NavigationIndex = Interlocked.Increment(ref fileIndex); - list.Add(item); - } - - foreach (var tocItem in lookups.TableOfContents) - { - if (tocItem is CrossLinkReference crossLink) - { - // Validate that cross-link has a title - if (string.IsNullOrWhiteSpace(crossLink.Title)) - { - context.EmitError(context.ConfigurationPath, - $"Cross-link entries must have a 'title' specified. Cross-link: {crossLink.CrossLinkUri}"); - continue; - } - - if (!lookups.CrossLinkResolver.TryResolve(msg => context.EmitError(context.ConfigurationPath, msg), crossLink.CrossLinkUri, out var resolvedUrl)) - continue; // the crosslink resolver will emit an error already - - // Create a special navigation item for cross-repository links - var crossLinkItem = new CrossLinkNavigationItem(crossLink.CrossLinkUri, resolvedUrl, crossLink.Title, this, crossLink.Hidden); - AddToNavigationItems(crossLinkItem, ref fileIndex); - - } - else if (tocItem is FileReference file) - { - if (!lookups.FlatMappedFiles.TryGetValue(file.RelativePath, out var d)) - { - context.EmitError(context.ConfigurationPath, - $"The following file could not be located: {file.RelativePath} it may be excluded from the build in docset.yml"); - continue; - } - - if (d is ExcludedFile excluded && excluded.RelativePath.EndsWith(".md")) - { - context.EmitError(context.ConfigurationPath, $"{excluded.RelativePath} matches exclusion glob from docset.yml yet appears in TOC"); - continue; - } - - if (d is not MarkdownFile md) - { - if (d is not SnippetFile) - context.EmitError(context.ConfigurationPath, $"{d.RelativePath} is not a Markdown file."); - continue; - } - - md.PartOfNavigation = true; - - // TODO these have to be refactor to be pure navigational properties - md.ScopeDirectory = file.TableOfContentsScope.ScopeDirectory; - md.NavigationRoot = rootNavigationItem; - - foreach (var extension in lookups.EnabledExtensions) - extension.Visit(d, tocItem); - - if (file.Children.Count > 0) - { - if (file.Hidden) - context.EmitError(context.ConfigurationPath, $"The following file is hidden but has children: {file.RelativePath}"); - var group = new DocumentationGroup(md.RelativePath, - _treeCollector, context, lookups with - { - TableOfContents = file.Children, - }, NavigationSource, ref fileIndex, depth + 1, rootNavigationItem, this, md); - groups.Add(group); - AddToNavigationItems(group, ref fileIndex); - indexFile ??= md; - continue; - } - - files.Add(md); - if (file.RelativePath.EndsWith("index.md")) - indexFile ??= md; - else if (indexReference == file) - indexFile ??= md; - - // Add the page to navigation items unless it's the index file - // the index file can either be the discovered `index.md` or the parent group's - // explicit index page. E.g., when grouping related files together. - // If the page is referenced as hidden in the TOC do not include it in the navigation - if (indexFile != md) - AddToNavigationItems(new FileNavigationItem(md, this, file.Hidden), ref fileIndex); - } - else if (tocItem is FolderReference folder) - { - var children = folder.Children; - if (children.Count == 0 && lookups.FilesGroupedByFolder.TryGetValue(folder.RelativePath, out var documentationFiles)) - { - children = - [ - .. documentationFiles - .Select(d => new FileReference(folder.TableOfContentsScope, d.RelativePath, false, [])) - ]; - } - - DocumentationGroup group; - if (folder is TocReference tocReference) - { - var toc = new TableOfContentsTree(tocReference.Source, folder.RelativePath, _treeCollector, context, lookups with - { - TableOfContents = children - }, ref fileIndex, depth + 1, rootNavigationItem, this); - - group = toc; - AddToNavigationItems(toc, ref fileIndex); - } - else - { - group = new DocumentationGroup(folder.RelativePath, _treeCollector, context, lookups with - { - TableOfContents = children - }, NavigationSource, ref fileIndex, depth + 1, rootNavigationItem, this); - AddToNavigationItems(group, ref fileIndex); - } - - groups.Add(group); - } - } - - var index = indexFile ?? files.FirstOrDefault() ?? groups.FirstOrDefault()?.Index; - return index ?? throw new InvalidOperationException($"No index file found. {depth}, {fileIndex}"); - } - - private bool _resolved; - - public async Task Resolve(Cancel ctx = default) - { - if (_resolved) - return; - - await Parallel.ForEachAsync(FilesInOrder, ctx, async (file, token) => await file.MinimalParseAsync(token)); - await Parallel.ForEachAsync(GroupsInOrder, ctx, async (group, token) => await group.Resolve(token)); - - _ = await Index.MinimalParseAsync(ctx); - - _resolved = true; - } -} diff --git a/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs deleted file mode 100644 index ff34bd750..000000000 --- a/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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; -using Elastic.Documentation.Site.Navigation; - -namespace Elastic.Markdown.IO.Navigation; - -[DebuggerDisplay("Current: {Model.RelativePath}")] -public record FileNavigationItem(MarkdownFile Model, DocumentationGroup Group, bool Hidden = false) - : ILeafNavigationItem -{ - public INodeNavigationItem? Parent { get; set; } = Group; - public IRootNavigationItem NavigationRoot { get; } = Group.NavigationRoot; - public string Url => Model.Url; - public string NavigationTitle => Model.NavigationTitle; - public int NavigationIndex { get; set; } - public bool IsCrossLink => false; // File navigation items are never cross-links -} diff --git a/src/Elastic.Markdown/IO/Navigation/TableOfContentsTree.cs b/src/Elastic.Markdown/IO/Navigation/TableOfContentsTree.cs deleted file mode 100644 index d9caeb648..000000000 --- a/src/Elastic.Markdown/IO/Navigation/TableOfContentsTree.cs +++ /dev/null @@ -1,62 +0,0 @@ -// 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; -using Elastic.Documentation.Configuration; -using Elastic.Documentation.Site.Navigation; - -namespace Elastic.Markdown.IO.Navigation; - -[DebuggerDisplay("Toc: {Depth} {NavigationSource} > ({NavigationItems.Count} items)")] -public class TableOfContentsTree : DocumentationGroup, IRootNavigationItem -{ - public Uri Source { get; } - - public TableOfContentsTreeCollector TreeCollector { get; } - - public TableOfContentsTree( - Uri source, - BuildContext context, - NavigationLookups lookups, - TableOfContentsTreeCollector treeCollector, - ref int fileIndex) - : base(".", treeCollector, context, lookups, source, ref fileIndex, 0, null, null) - { - TreeCollector = treeCollector; - NavigationRoot = this; - - Source = source; - TreeCollector.Collect(source, this); - - //edge case if a tree only holds a single group, ensure we collapse it down to the root (this) - if (NavigationItems.Count == 1 && NavigationItems.First() is DocumentationGroup { NavigationItems.Count: 0 }) - NavigationItems = []; - - - } - - internal TableOfContentsTree( - Uri source, - string folderName, - TableOfContentsTreeCollector treeCollector, - BuildContext context, - NavigationLookups lookups, - ref int fileIndex, - int depth, - IRootNavigationItem toplevelTree, - DocumentationGroup? parent - ) : base(folderName, treeCollector, context, lookups, source, ref fileIndex, depth, toplevelTree, parent) - { - Source = source; - TreeCollector = treeCollector; - NavigationRoot = this; - TreeCollector.Collect(source, this); - } - - protected override IRootNavigationItem DefaultNavigation => this; - - // We rely on IsPrimaryNavEnabled to determine if we should show the dropdown - /// - public bool IsUsingNavigationDropdown => false; -} diff --git a/src/Elastic.Markdown/IO/Navigation/TableOfContentsTreeCollector.cs b/src/Elastic.Markdown/IO/Navigation/TableOfContentsTreeCollector.cs deleted file mode 100644 index bee43324e..000000000 --- a/src/Elastic.Markdown/IO/Navigation/TableOfContentsTreeCollector.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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; - -namespace Elastic.Markdown.IO.Navigation; - -public class TableOfContentsTreeCollector -{ - private Dictionary NestedTableOfContentsTrees { get; } = []; - - public void Collect(Uri source, TableOfContentsTree tree) => NestedTableOfContentsTrees[source] = tree; - - public bool TryGetTableOfContentsTree(Uri source, [NotNullWhen(true)] out TableOfContentsTree? tree) => - NestedTableOfContentsTrees.TryGetValue(source, out tree); -} diff --git a/src/Elastic.Markdown/IO/NewNavigation/FileR.cs b/src/Elastic.Markdown/IO/NewNavigation/FileR.cs new file mode 100644 index 000000000..00e40975b --- /dev/null +++ b/src/Elastic.Markdown/IO/NewNavigation/FileR.cs @@ -0,0 +1,137 @@ +// 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.Collections.Frozen; +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Markdown.Extensions; +using Elastic.Markdown.Myst; +using Generator.Equals; + +namespace Elastic.Markdown.IO.NewNavigation; + +[Equatable] +public partial record FilePath +{ + public FilePath(IFileInfo fileInfo, IDirectoryInfo sourceDirectory) + { + FileInfo = fileInfo; + RelativePath = Path.GetRelativePath(sourceDirectory.FullName, fileInfo.FullName); + } + + public FilePath(string relativePath, IDirectoryInfo sourceDirectory) + { + FileInfo = sourceDirectory.FileSystem.FileInfo.New(Path.Combine(sourceDirectory.FullName, relativePath)); + RelativePath = Path.GetRelativePath(sourceDirectory.FullName, FileInfo.FullName); + } + + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string RelativePath { get; } + + [IgnoreEquality] + public IFileInfo FileInfo { get; } +} + +public class MarkdownFileFactory : IDocumentationFileFactory +{ + private readonly BuildContext _context; + private readonly MarkdownParser _markdownParser; + + public MarkdownFileFactory(BuildContext context, MarkdownParser markdownParser, IReadOnlyCollection enabledExtensions) + { + _context = context; + _markdownParser = markdownParser; + EnabledExtensions = enabledExtensions; + + var files = ScanDocumentationFiles(context, context.DocumentationSourceDirectory); + var additionalSources = enabledExtensions + .SelectMany(extension => extension.ScanDocumentationFiles(DefaultFileHandling)) + .ToArray(); + + Files = files.Concat(additionalSources) + .Where(t => t.Item2 is not ExcludedFile) + .ToDictionary(kv => new FilePath(kv.Item1, context.DocumentationSourceDirectory), kv => kv.Item2) + .ToFrozenDictionary(); + + } + + public FrozenDictionary Files { get; } + + private IReadOnlyCollection EnabledExtensions { get; } + + /// + public MarkdownFile? TryCreateDocumentationFile(IFileInfo path, IFileSystem readFileSystem) + { + if (Files.TryGetValue(new FilePath(path, _context.DocumentationSourceDirectory), out var file) && file is MarkdownFile markdown) + return markdown; + return null; + } + + private (IFileInfo, DocumentationFile)[] ScanDocumentationFiles(BuildContext build, IDirectoryInfo sourceDirectory) => + [.. build.ReadFileSystem.Directory + .EnumerateFiles(sourceDirectory.FullName, "*.*", SearchOption.AllDirectories) + .Select(f => build.ReadFileSystem.FileInfo.New(f)) + .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) + .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) + // skip hidden folders + .Where(f => !Path.GetRelativePath(sourceDirectory.FullName, f.FullName).StartsWith('.')) + .Select(file => file.Extension switch + { + ".jpg" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName, "image/jpeg")), + ".jpeg" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName, "image/jpeg")), + ".gif" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName, "image/gif")), + ".svg" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName, "image/svg+xml")), + ".png" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName)), + ".md" => (file, CreateMarkDownFile(file, build)), + _ => (file, DefaultFileHandling(file, sourceDirectory)) + })]; + + private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext context) + { + var sourceDirectory = context.DocumentationSourceDirectory; + var config = context.Configuration; + var relativePath = Path.GetRelativePath(sourceDirectory.FullName, file.FullName); + if (context.Configuration.Exclude.Any(g => g.IsMatch(relativePath))) + return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); + + if (relativePath.Contains("_snippets")) + return new SnippetFile(file, sourceDirectory, context.Git.RepositoryName); + + // we ignore files in folders that start with an underscore + var folder = Path.GetDirectoryName(relativePath); + if (folder is not null && (folder.Contains($"{Path.DirectorySeparatorChar}_", StringComparison.Ordinal) || folder.StartsWith('_'))) + return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); + + if (config.Files.Contains(relativePath)) + return ExtensionOrDefaultMarkdown(); + + context.Collector.EmitError(config.SourceFile, $"Not linked in toc: {relativePath}"); + return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); + + MarkdownFile ExtensionOrDefaultMarkdown() + { + foreach (var extension in EnabledExtensions) + { + var documentationFile = extension.CreateMarkdownFile(file, sourceDirectory, _markdownParser); + if (documentationFile is not null) + return documentationFile; + } + return new MarkdownFile(file, sourceDirectory, _markdownParser, context); + } + } + + + private DocumentationFile DefaultFileHandling(IFileInfo file, IDirectoryInfo sourceDirectory) + { + foreach (var extension in EnabledExtensions) + { + var documentationFile = extension.CreateDocumentationFile(file, _markdownParser); + if (documentationFile is not null) + return documentationFile; + } + return new ExcludedFile(file, sourceDirectory, _context.Git.RepositoryName); + } + +} diff --git a/src/Elastic.Markdown/MarkdownLayoutViewModel.cs b/src/Elastic.Markdown/MarkdownLayoutViewModel.cs index 7b947637b..954b0dc60 100644 --- a/src/Elastic.Markdown/MarkdownLayoutViewModel.cs +++ b/src/Elastic.Markdown/MarkdownLayoutViewModel.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Configuration.LegacyUrlMappings; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site; -using Elastic.Documentation.Site.Navigation; namespace Elastic.Markdown; diff --git a/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs b/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs index e9ede80fe..2a30bb35d 100644 --- a/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs @@ -118,7 +118,7 @@ private void ExtractImageUrl(ParserContext context) else this.EmitError($"`{imageUrl}` does not exist. resolved to `{file}"); - if (context.DocumentationFileLookup(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) + if (context.TryFindDocument(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) { if (!file.Directory!.FullName.StartsWith(currentMarkdown.ScopeDirectory.FullName + Path.DirectorySeparatorChar)) this.EmitWarning($"Image '{imageUrl}' is referenced out of table of contents scope '{currentMarkdown.ScopeDirectory}'."); diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs index 5369468df..32bd7c3ed 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs @@ -2,7 +2,13 @@ // 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.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation; using Elastic.Markdown.Helpers; +using Elastic.Markdown.IO; using Microsoft.AspNetCore.Html; namespace Elastic.Markdown.Myst.Directives.Stepper; @@ -13,6 +19,35 @@ public class StepViewModel : DirectiveViewModel public required string Anchor { get; init; } public required int HeadingLevel { get; init; } + public class StepCrossNavigationLookupProvider : IPositionalNavigation + { + public static StepCrossNavigationLookupProvider Instance { get; } = new(); + + /// + public FrozenDictionary NavigationIndexedByOrder { get; } = new Dictionary().ToFrozenDictionary(); + + /// + public FrozenDictionary> NavigationIndexedByCrossLink { get; } = + new Dictionary>().ToFrozenDictionary(); + + /// + public ConditionalWeakTable MarkdownNavigationLookup { get; } = []; + } + + public class StepCrossLinkResolver : ICrossLinkResolver + { + public static StepCrossLinkResolver Instance { get; } = new(); + /// + public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) + { + resolvedUri = null; + return false; + } + + /// + public IUriEnvironmentResolver UriResolver { get; } = new IsolatedBuildEnvironmentUriResolver(); + } + /// /// Renders the title with full markdown processing (substitutions, links, emphasis, etc.) /// @@ -29,8 +64,10 @@ public HtmlString RenderTitle() { MarkdownSourcePath = directiveBlock.CurrentFile, YamlFrontMatter = yamlFrontMatter, - DocumentationFileLookup = _ => null!, - CrossLinkResolver = null! + TryFindDocument = _ => null!, + TryFindDocumentByRelativePath = _ => null!, + CrossLinkResolver = StepCrossLinkResolver.Instance, + PositionalNavigation = StepCrossNavigationLookupProvider.Instance }); var document = Markdig.Markdown.Parse(Title, MarkdownParser.Pipeline, context); diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 0f2f1b614..110951ad7 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -209,12 +209,12 @@ private static void ProcessInternalLink(LinkInline link, InlineProcessor process UpdateLinkUrl(link, linkMarkdown, url, context, anchor); } - private static MarkdownFile? SetLinkData(LinkInline link, InlineProcessor processor, ParserContext context, - IFileInfo file, string url) + private static MarkdownFile? SetLinkData(LinkInline link, InlineProcessor processor, ParserContext context, IFileInfo file, string url) { - if (context.DocumentationFileLookup(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) + if (context.TryFindDocument(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) { - link.SetData(nameof(currentMarkdown.NavigationRoot), currentMarkdown.NavigationRoot); + if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(currentMarkdown, out var navigationLookup)) + link.SetData("NavigationRoot", navigationLookup.NavigationRoot); if (link.IsImage) { @@ -225,9 +225,13 @@ private static void ProcessInternalLink(LinkInline link, InlineProcessor process } - var linkMarkdown = context.DocumentationFileLookup(file) as MarkdownFile; + var linkMarkdown = context.TryFindDocument(file) as MarkdownFile; if (linkMarkdown is not null) - link.SetData($"Target{nameof(currentMarkdown.NavigationRoot)}", linkMarkdown.NavigationRoot); + { + if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(linkMarkdown, out var navigationLookup)) + link.SetData("TargetNavigationRoot", navigationLookup.NavigationRoot); + + } return linkMarkdown; } @@ -310,9 +314,16 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s var newUrl = url; if (linkMarkdown is not null) { - // if url is null it's an anchor link - if (!string.IsNullOrEmpty(url)) - newUrl = linkMarkdown.Url; + if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(linkMarkdown, out var navigationLookup) + && !string.IsNullOrEmpty(navigationLookup.Url)) + { + // Navigation URLs are absolute and start with / + // Apply the same prefix handling as UpdateRelativeUrl would for absolute paths + newUrl = navigationLookup.Url; + var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty; + if (!string.IsNullOrWhiteSpace(urlPathPrefix) && !newUrl.StartsWith(urlPathPrefix)) + newUrl = $"{urlPathPrefix.TrimEnd('/')}{newUrl}"; + } } else newUrl = UpdateRelativeUrl(context, url); @@ -351,7 +362,7 @@ public static string UpdateRelativeUrl(ParserContext context, string url) // if we are trying to resolve a relative url from a _snippet folder ensure we eat the _snippet folder // as it's not part of url by chopping of the extra parent navigation - if (newUrl.StartsWith("../") && context.DocumentationFileLookup(context.MarkdownSourcePath) is SnippetFile snippet) + if (newUrl.StartsWith("../") && context.TryFindDocument(context.MarkdownSourcePath) is SnippetFile snippet) { //figure out how many nested folders inside `_snippets` we need to ignore. var d = snippet.SourceFile.Directory; @@ -392,7 +403,7 @@ public static string UpdateRelativeUrl(ParserContext context, string url) newUrl = newUrl[2..]; } - if (!string.IsNullOrWhiteSpace(newUrl) && !string.IsNullOrWhiteSpace(urlPathPrefix)) + if (!string.IsNullOrWhiteSpace(newUrl) && !string.IsNullOrWhiteSpace(urlPathPrefix) && !newUrl.StartsWith(urlPathPrefix)) newUrl = $"{urlPathPrefix.TrimEnd('/')}{newUrl}"; // eat overall path prefix since its gets appended later diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 8eb3dde01..f61d3a25b 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -36,16 +36,16 @@ public Task ParseAsync(IFileInfo path, YamlFrontMatter? matter public Task MinimalParseAsync(IFileInfo path, Cancel ctx) => ParseFromFile(path, null, MinimalPipeline, true, ctx); - private Task ParseFromFile( - IFileInfo path, YamlFrontMatter? matter, MarkdownPipeline pipeline, bool skip, Cancel ctx - ) + private Task ParseFromFile(IFileInfo path, YamlFrontMatter? matter, MarkdownPipeline pipeline, bool skip, Cancel ctx) { var state = new ParserState(Build) { MarkdownSourcePath = path, YamlFrontMatter = matter, - DocumentationFileLookup = Resolvers.DocumentationFileLookup, + TryFindDocument = Resolvers.TryFindDocument, + TryFindDocumentByRelativePath = Resolvers.TryFindDocumentByRelativePath, CrossLinkResolver = Resolvers.CrossLinkResolver, + PositionalNavigation = Resolvers.PositionalNavigation, SkipValidation = skip }; var context = new ParserContext(state); @@ -67,7 +67,9 @@ public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IPar { MarkdownSourcePath = path, YamlFrontMatter = matter, - DocumentationFileLookup = resolvers.DocumentationFileLookup, + TryFindDocument = resolvers.TryFindDocument, + TryFindDocumentByRelativePath = resolvers.TryFindDocumentByRelativePath, + PositionalNavigation = resolvers.PositionalNavigation, CrossLinkResolver = resolvers.CrossLinkResolver }; var context = new ParserContext(state); @@ -86,8 +88,10 @@ public static Task ParseSnippetAsync(BuildContext build, IPars { MarkdownSourcePath = path, YamlFrontMatter = matter, - DocumentationFileLookup = resolvers.DocumentationFileLookup, + TryFindDocument = resolvers.TryFindDocument, + TryFindDocumentByRelativePath = resolvers.TryFindDocumentByRelativePath, CrossLinkResolver = resolvers.CrossLinkResolver, + PositionalNavigation = resolvers.PositionalNavigation, ParentMarkdownPath = parentPath }; var context = new ParserContext(state); diff --git a/src/Elastic.Markdown/Myst/ParserContext.cs b/src/Elastic.Markdown/Myst/ParserContext.cs index 5288ce886..d4c541035 100644 --- a/src/Elastic.Markdown/Myst/ParserContext.cs +++ b/src/Elastic.Markdown/Myst/ParserContext.cs @@ -28,14 +28,20 @@ processor.Context as ParserContext public interface IParserResolvers { ICrossLinkResolver CrossLinkResolver { get; } - Func DocumentationFileLookup { get; } + Func TryFindDocument { get; } + Func TryFindDocumentByRelativePath { get; } + IPositionalNavigation PositionalNavigation { get; } } public record ParserResolvers : IParserResolvers { public required ICrossLinkResolver CrossLinkResolver { get; init; } - public required Func DocumentationFileLookup { get; init; } + public required Func TryFindDocument { get; init; } + + public required Func TryFindDocumentByRelativePath { get; init; } + + public required IPositionalNavigation PositionalNavigation { get; init; } } public record ParserState(BuildContext Build) : ParserResolvers @@ -59,9 +65,11 @@ public class ParserContext : MarkdownParserContext, IParserResolvers public YamlFrontMatter? YamlFrontMatter { get; } public BuildContext Build { get; } public bool SkipValidation { get; } - public Func DocumentationFileLookup { get; } + public Func TryFindDocument { get; } + public Func TryFindDocumentByRelativePath { get; } public IReadOnlyDictionary Substitutions { get; } public IReadOnlyDictionary ContextSubstitutions { get; } + public IPositionalNavigation PositionalNavigation { get; } public ParserContext(ParserState state) { @@ -73,11 +81,16 @@ public ParserContext(ParserState state) CrossLinkResolver = state.CrossLinkResolver; MarkdownSourcePath = state.MarkdownSourcePath; - DocumentationFileLookup = state.DocumentationFileLookup; + TryFindDocument = state.TryFindDocument; + TryFindDocumentByRelativePath = state.TryFindDocumentByRelativePath; + PositionalNavigation = state.PositionalNavigation; + CurrentUrlPath = string.Empty; - CurrentUrlPath = DocumentationFileLookup(state.ParentMarkdownPath ?? MarkdownSourcePath) is MarkdownFile md - ? md.Url - : string.Empty; + if (TryFindDocument(state.ParentMarkdownPath ?? MarkdownSourcePath) is MarkdownFile document) + { + if (PositionalNavigation.MarkdownNavigationLookup.TryGetValue(document, out var navigationLookup)) + CurrentUrlPath = navigationLookup.Url; + } if (SkipValidation && string.IsNullOrEmpty(CurrentUrlPath)) { @@ -108,4 +121,5 @@ public ParserContext(ParserState state) ContextSubstitutions = contextSubs; } + } diff --git a/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs index 1fc323598..38a5390b3 100644 --- a/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs @@ -2,8 +2,8 @@ // 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.Navigation; using Elastic.Documentation.Site; -using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.IO; using Markdig; using Markdig.Renderers; @@ -36,8 +36,8 @@ protected override void Write(HtmlRenderer renderer, LinkInline link) if (link.Url?.StartsWith('/') == true || isCrossLink) { - var currentRootNavigation = link.GetData(nameof(MarkdownFile.NavigationRoot)) as INodeNavigationItem; - var targetRootNavigation = link.GetData($"Target{nameof(MarkdownFile.NavigationRoot)}") as INodeNavigationItem; + var currentRootNavigation = link.GetData("NavigationRoot") as INodeNavigationItem; + var targetRootNavigation = link.GetData("TargetNavigationRoot") as INodeNavigationItem; var hasSameTopLevelGroup = !isCrossLink && (currentRootNavigation?.Id == targetRootNavigation?.Id); _ = renderer.Write($" hx-select-oob=\"{Htmx.GetHxSelectOob(hasSameTopLevelGroup)}\""); _ = renderer.Write($" preload=\"{Htmx.Preload}\""); diff --git a/src/Elastic.Markdown/Page/IndexViewModel.cs b/src/Elastic.Markdown/Page/IndexViewModel.cs index 7c3ce4761..6915f8d17 100644 --- a/src/Elastic.Markdown/Page/IndexViewModel.cs +++ b/src/Elastic.Markdown/Page/IndexViewModel.cs @@ -9,10 +9,9 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; -using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; namespace Elastic.Markdown.Page; diff --git a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs index 7ef37b5e2..1a23a0465 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs @@ -36,6 +36,9 @@ public class AssembleContext : IDocumentationConfigurationContext public IDirectoryInfo OutputDirectory { get; } + /// + public IFileInfo ConfigurationPath { get; } + public PublishEnvironment Environment { get; } public AssembleContext( @@ -55,6 +58,7 @@ public AssembleContext( Configuration = configuration; ConfigurationFileProvider = configurationContext.ConfigurationFileProvider; + ConfigurationPath = ConfigurationFileProvider.AssemblerFile; VersionsConfiguration = configurationContext.VersionsConfiguration; Endpoints = configurationContext.Endpoints; ProductsConfiguration = configurationContext.ProductsConfiguration; diff --git a/src/services/Elastic.Documentation.Assembler/AssembleSources.cs b/src/services/Elastic.Documentation.Assembler/AssembleSources.cs index c7ed54546..3a057551b 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleSources.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleSources.cs @@ -14,7 +14,6 @@ using Elastic.Documentation.Configuration.Navigation; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links.CrossLinks; -using Elastic.Markdown.IO.Navigation; using Microsoft.Extensions.Logging; using YamlDotNet.RepresentationModel; @@ -32,8 +31,6 @@ public class AssembleSources public FrozenDictionary TocConfigurationMapping { get; } - public TableOfContentsTreeCollector TreeCollector { get; } = new(); - public PublishEnvironmentUriResolver UriResolver { get; } public static async Task AssembleAsync( @@ -87,7 +84,7 @@ IReadOnlySet availableExporters AssembleContext = assembleContext; AssembleSets = checkouts .Where(c => c.Repository is { Skip: false }) - .Select(c => new AssemblerDocumentationSet(logFactory, assembleContext, c, crossLinkResolver, TreeCollector, configurationContext, + .Select(c => new AssemblerDocumentationSet(logFactory, assembleContext, c, crossLinkResolver, configurationContext, availableExporters)) .ToDictionary(s => s.Checkout.Repository.Name, s => s) .ToFrozenDictionary(); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index a9c9aa77e..aaa7f42c1 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -2,7 +2,6 @@ // 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.Collections.Frozen; using System.IO.Abstractions; using System.Text; using Actions.Core.Services; @@ -10,9 +9,11 @@ using Elastic.Documentation.Assembler.Sourcing; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.DocSet; using Elastic.Documentation.Configuration.Navigation; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.LegacyDocs; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -78,14 +79,18 @@ Cancel ctx var navigationFile = new GlobalNavigationFile(collector, configurationContext.ConfigurationFileProvider, assemblyConfiguration, assembleSources.TocConfigurationMapping); _logger.LogInformation("Create global navigation"); - var navigation = new GlobalNavigation(assembleSources, navigationFile); + + var yaml = configurationContext.ConfigurationFileProvider.NavigationFile; + var siteNavigationFile = SiteNavigationFile.Deserialize(await fs.File.ReadAllTextAsync(yaml.FullName, ctx)); + var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); + var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets); var pathProvider = new GlobalNavigationPathProvider(navigationFile, assembleSources, assembleContext); var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector); var legacyPageChecker = new LegacyPageService(logFactory); var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleContext.VersionsConfiguration, assembleSources.LegacyUrlMappings); - var builder = new AssemblerBuilder(logFactory, assembleContext, navigation, htmlWriter, pathProvider, historyMapper); + var builder = new AssemblerBuilder(logFactory, assembleContext, htmlWriter, pathProvider, historyMapper); await builder.BuildAllAsync(assembleContext.Environment, assembleSources.AssembleSets, exporters, ctx); @@ -116,7 +121,7 @@ Cancel ctx return strict.Value ? collector.Errors + collector.Warnings == 0 : collector.Errors == 0; } - private static async Task EnhanceLlmsTxtFile(AssembleContext context, GlobalNavigation navigation, LlmsNavigationEnhancer enhancer, Cancel ctx) + private static async Task EnhanceLlmsTxtFile(AssembleContext context, SiteNavigation navigation, LlmsNavigationEnhancer enhancer, Cancel ctx) { var llmsTxtPath = Path.Combine(context.OutputDirectory.FullName, "docs", "llms.txt"); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs index e2ee2e24b..be9219d3c 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Serialization; using Elastic.Markdown; using Elastic.Markdown.Exporters; @@ -21,7 +22,6 @@ namespace Elastic.Documentation.Assembler.Building; public class AssemblerBuilder( ILoggerFactory logFactory, AssembleContext context, - GlobalNavigation navigation, GlobalNavigationHtmlWriter writer, GlobalNavigationPathProvider pathProvider, ILegacyUrlMapper? legacyUrlMapper @@ -136,7 +136,6 @@ private async Task BuildAsync(AssemblerDocumentationSet set, I logFactory, HtmlWriter, pathProvider, legacyUrlMapper: LegacyUrlMapper, - positionalNavigation: navigation, markdownExporters: markdownExporters ); return await generator.GenerateAll(ctx); diff --git a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs index 98156b4e0..aa20de536 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs @@ -5,9 +5,8 @@ using System.Globalization; using System.IO.Abstractions; using System.Xml.Linq; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation; using Elastic.Markdown.Extensions.DetectionRules; -using Elastic.Markdown.IO.Navigation; namespace Elastic.Documentation.Assembler.Building; @@ -38,8 +37,8 @@ public void Generate() flattenedNavigationItems .Select(n => n switch { - DocumentationGroup group => (group.Index.Url, NavigationItem: group), - FileNavigationItem file => (file.Model.Url, NavigationItem: file as INavigationItem), + INodeNavigationItem group => (group.Url, NavigationItem: group), + ILeafNavigationItem file => (file.Url, NavigationItem: file as INavigationItem), _ => throw new Exception($"{nameof(SitemapBuilder)}.{nameof(Generate)}: Unhandled navigation item type: {n.GetType()}") }) .Select(n => n.Url) @@ -64,22 +63,22 @@ private static IReadOnlyCollection GetNavigationItems(IReadOnly { switch (item) { - case FileNavigationItem file: + case ILeafNavigationItem file: // these are hidden from the navigation programatically. // TODO find a cleaner way to model this. if (item.Hidden && file.Model is not DetectionRuleFile) continue; + if (file.IsCrossLink) + continue; result.Add(file); break; - case DocumentationGroup group: + case INodeNavigationItem group: if (item.Hidden) continue; result.AddRange(GetNavigationItems(group.NavigationItems)); result.Add(group); break; - case CrossLinkNavigationItem: - continue; // we do not emit cross links in the sitemap default: throw new Exception($"{nameof(SitemapBuilder)}.{nameof(GetNavigationItems)}: Unhandled navigation item type: {item.GetType()}"); } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs index edfa03b44..3b7d20ded 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs @@ -7,7 +7,6 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Assembler.Navigation; @@ -27,7 +26,6 @@ public AssemblerDocumentationSet( AssembleContext context, Checkout checkout, ICrossLinkResolver crossLinkResolver, - TableOfContentsTreeCollector treeCollector, IConfigurationContext configurationContext, IReadOnlySet availableExporters ) @@ -77,6 +75,6 @@ IReadOnlySet availableExporters }; BuildContext = buildContext; - DocumentationSet = new DocumentationSet(buildContext, logFactory, crossLinkResolver, treeCollector); + DocumentationSet = new DocumentationSet(buildContext, logFactory, crossLinkResolver); } } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigation.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigation.cs deleted file mode 100644 index e0ba7e920..000000000 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigation.cs +++ /dev/null @@ -1,211 +0,0 @@ -// 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.Collections.Frozen; -using System.Collections.Immutable; -using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.Configuration.Navigation; -using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.Site.Navigation; -using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; - -namespace Elastic.Documentation.Assembler.Navigation; - -public record GlobalNavigation : IPositionalNavigation -{ - private readonly AssembleSources _assembleSources; - private readonly GlobalNavigationFile _navigationFile; - - public IReadOnlyCollection NavigationItems { get; } - - public IReadOnlyCollection TopLevelItems { get; } - - public IReadOnlyDictionary NavigationLookup { get; } - - public FrozenDictionary MarkdownNavigationLookup { get; } - - public FrozenDictionary NavigationIndexedByOrder { get; } - - private ImmutableHashSet Phantoms { get; } - - private TableOfContentsTree RootContentTree { get; } - - public GlobalNavigation(AssembleSources assembleSources, GlobalNavigationFile navigationFile) - { - _assembleSources = assembleSources; - _navigationFile = navigationFile; - - // the root files of `docs-content://` are special they contain several special pages such as 404, archive, versions etc. - // we inject them forcefully here - var source = new Uri($"{NarrativeRepository.RepositoryName}://"); - RootContentTree = assembleSources.TreeCollector.TryGetTableOfContentsTree(source, out var docsContentTree) - ? docsContentTree - : throw new Exception($"Could not locate: {source} as root of global navigation."); - Phantoms = [.. navigationFile.Phantoms.Select(p => p.Source)]; - NavigationItems = BuildNavigation(navigationFile.TableOfContents, 0); - - var navigationIndex = 0; - var allNavigationItems = new HashSet(); - UpdateParent(allNavigationItems, NavigationItems, null, null); - UpdateNavigationIndex(NavigationItems, ref navigationIndex); - TopLevelItems = NavigationItems.OfType().Where(t => !t.Hidden).ToList(); - NavigationLookup = TopLevelItems.ToDictionary(kv => kv.Source, kv => kv); - - NavigationIndexedByOrder = allNavigationItems.ToDictionary(i => i.NavigationIndex, i => i).ToFrozenDictionary(); - - MarkdownNavigationLookup = NavigationItems - .SelectMany(DocumentationSet.Pairs) - .ToDictionary(kv => kv.Item1, kv => kv.Item2) - .ToFrozenDictionary(); - - } - - private void UpdateParent( - HashSet allNavigationItems, - IReadOnlyCollection navigationItems, - INodeNavigationItem? parent, - IRootNavigationItem? topLevelNavigation - ) - { - if (parent is IRootNavigationItem tree) - topLevelNavigation ??= tree; - foreach (var item in navigationItems) - { - switch (item) - { - case FileNavigationItem fileNavigationItem: - if (parent is not null) - fileNavigationItem.Parent = parent; - if (topLevelNavigation is not null) - fileNavigationItem.Model.NavigationRoot = topLevelNavigation; - _ = allNavigationItems.Add(fileNavigationItem); - break; - case CrossLinkNavigationItem crossLinkNavigationItem: - if (parent is not null) - crossLinkNavigationItem.Parent = parent; - _ = allNavigationItems.Add(crossLinkNavigationItem); - break; - case DocumentationGroup documentationGroup: - if (parent is not null) - documentationGroup.Parent = parent; - if (topLevelNavigation is not null) - documentationGroup.Index.NavigationRoot = topLevelNavigation; - _ = allNavigationItems.Add(documentationGroup); - UpdateParent(allNavigationItems, documentationGroup.NavigationItems, documentationGroup, topLevelNavigation); - break; - default: - _navigationFile.EmitError($"{nameof(GlobalNavigation)}.{nameof(UpdateParent)}: Unhandled navigation item type: {item.GetType()}"); - break; - } - } - } - - - private void UpdateNavigationIndex(IReadOnlyCollection navigationItems, ref int navigationIndex) - { - foreach (var item in navigationItems) - { - switch (item) - { - case FileNavigationItem fileNavigationItem: - var fileIndex = Interlocked.Increment(ref navigationIndex); - fileNavigationItem.NavigationIndex = fileIndex; - break; - case DocumentationGroup documentationGroup: - var groupIndex = Interlocked.Increment(ref navigationIndex); - documentationGroup.NavigationIndex = groupIndex; - UpdateNavigationIndex(documentationGroup.NavigationItems, ref navigationIndex); - break; - case CrossLinkNavigationItem crossLinkNavigationItem: - var crossLinkIndex = Interlocked.Increment(ref navigationIndex); - crossLinkNavigationItem.NavigationIndex = crossLinkIndex; - break; - default: - _navigationFile.EmitError($"{nameof(GlobalNavigation)}.{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); - break; - } - } - } - - private IReadOnlyCollection BuildNavigation(IReadOnlyCollection references, int depth) - { - var list = new List(); - foreach (var tocReference in references) - { - if (!_assembleSources.TreeCollector.TryGetTableOfContentsTree(tocReference.Source, out var tree)) - { - _navigationFile.EmitError($"{tocReference.Source} does not define a toc.yml or docset.yml file"); - continue; - } - - var tocChildren = tocReference.Children.OfType().ToArray(); - var tocNavigationItems = BuildNavigation(tocChildren, depth + 1); - - if (depth == 0 && tree.Parent != RootContentTree) - { - tree.Parent = RootContentTree; - tree.Index.NavigationRoot = tree; - } - - var configuredNavigationItems = - depth == 0 - ? tocNavigationItems.Concat(tree.NavigationItems) - : tree.NavigationItems.Concat(tocNavigationItems); - - var cleanNavigationItems = new List(); - var seenSources = new HashSet(); - foreach (var item in configuredNavigationItems) - { - if (item is not TableOfContentsTree tocNav) - { - cleanNavigationItems.Add(item); - continue; - } - - if (seenSources.Contains(tocNav.Source)) - continue; - - if (Phantoms.Contains(tree.NavigationSource)) - continue; - - // toc is not part of `navigation.yml` - if (!_assembleSources.NavigationTocMappings.TryGetValue(tocNav.Source, out var mapping)) - continue; - - // this TOC was moved in navigation.yml to a new parent and should not be part of the current navigation items - if (mapping.ParentSource != tree.Source) - continue; - - _ = seenSources.Add(tocNav.Source); - cleanNavigationItems.Add(item); - item.Parent = tree; - } - - tree.NavigationItems = cleanNavigationItems.ToArray(); - list.Add(tree); - - if (tocReference.IsPhantom) - tree.Hidden = true; - } - - if (depth != 0) - return list.ToArray().AsReadOnly(); - - // the root files of `docs-content://` are special they contain several special pages such as 404, archive, versions etc. - // we inject them forcefully here - if (!RootContentTree.NavigationItems.OfType().Any()) - _navigationFile.EmitError($"Could not inject root file navigation items from: {RootContentTree.Source}."); - else - { - var filesAtRoot = RootContentTree.NavigationItems.OfType().ToArray(); - list.AddRange(filesAtRoot); - // ensure index exist as a single item rather than injecting the whole tree (which already exists in the returned list) - var index = new FileNavigationItem(RootContentTree.Index, RootContentTree, RootContentTree.Hidden); - list.Add(index); - } - - return list.ToArray().AsReadOnly(); - } -} diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs index a79fd8734..761077ce3 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs @@ -4,13 +4,14 @@ using System.Collections.Concurrent; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Site.Navigation; -using Elastic.Markdown.IO.Navigation; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Assembler.Navigation; -public class GlobalNavigationHtmlWriter(ILoggerFactory logFactory, GlobalNavigation globalNavigation, IDiagnosticsCollector collector) : INavigationHtmlWriter +public class GlobalNavigationHtmlWriter(ILoggerFactory logFactory, SiteNavigation globalNavigation, IDiagnosticsCollector collector) : INavigationHtmlWriter { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -32,7 +33,7 @@ public async Task RenderNavigation(IRootNavigationItem group) return NavigationRenderResult.Empty; var model = CreateNavigationModel(group, maxLevel); @@ -45,13 +46,13 @@ public async Task RenderNavigation(IRootNavigationItem group, int maxLevel) { var topLevelItems = globalNavigation.TopLevelItems; return new NavigationViewModel { - Title = group.Index.NavigationTitle, - TitleUrl = group.Index.Url, + Title = group.NavigationTitle, + TitleUrl = group.Url, Tree = group, IsPrimaryNavEnabled = true, IsUsingNavigationDropdown = true, diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs b/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs index e269cb7d3..a4c0ef933 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs @@ -2,15 +2,11 @@ // 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; using System.Globalization; -using System.Linq; using System.Text; -using Elastic.Documentation.Assembler; -using Elastic.Documentation.Assembler.Navigation; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; using Elastic.Markdown.Myst.Renderers.LlmMarkdown; namespace Elastic.Documentation.Assembler.Navigation; @@ -20,7 +16,7 @@ namespace Elastic.Documentation.Assembler.Navigation; ///
  • public class LlmsNavigationEnhancer { - public string GenerateNavigationSections(GlobalNavigation navigation, Uri canonicalBaseUrl) + public string GenerateNavigationSections(SiteNavigation navigation, Uri canonicalBaseUrl) { var content = new StringBuilder(); @@ -29,7 +25,7 @@ public string GenerateNavigationSections(GlobalNavigation navigation, Uri canoni foreach (var topLevelItem in topLevelItems) { - if (topLevelItem is not DocumentationGroup group) + if (topLevelItem is not { } group) continue; // Create H2 section for the category - use H1 title if available, fallback to navigation title @@ -40,7 +36,7 @@ public string GenerateNavigationSections(GlobalNavigation navigation, Uri canoni // Get first-level children var firstLevelChildren = GetFirstLevelChildren(group); - if (firstLevelChildren.Any()) + if (firstLevelChildren.Count != 0) { foreach (var child in firstLevelChildren) { @@ -60,25 +56,21 @@ public string GenerateNavigationSections(GlobalNavigation navigation, Uri canoni } - private static IEnumerable GetFirstLevelChildren(DocumentationGroup group) => - group.NavigationItems.Where(i => !i.Hidden); + private static IReadOnlyCollection GetFirstLevelChildren(INodeNavigationItem group) => + group.NavigationItems.Where(i => !i.Hidden).ToArray(); /// /// Gets the best title for a navigation item, preferring H1 content over navigation title /// private static string GetBestTitle(INavigationItem navigationItem) => navigationItem switch { - // For file navigation items, prefer the H1 title from the markdown content - FileNavigationItem fileItem when !string.IsNullOrEmpty(fileItem.Model.Title) - => fileItem.Model.Title, - FileNavigationItem fileItem - => fileItem.NavigationTitle, - - // For documentation groups, prefer the H1 title from the index file - DocumentationGroup group when !string.IsNullOrEmpty(group.Index?.Title) - => group.Index.Title, - DocumentationGroup group - => group.NavigationTitle, + // For file navigation items, prefer the H1 title from the Markdown content + ILeafNavigationItem markdownNavigation => + markdownNavigation.Model.Title ?? markdownNavigation.NavigationTitle, + + // For documentation groups, try to get the full title of the index + INodeNavigationItem markdownNodeNavigation => + markdownNodeNavigation.Index.Model.Title ?? markdownNodeNavigation.NavigationTitle, // For other navigation item types, use the navigation title _ => navigationItem.NavigationTitle @@ -86,24 +78,20 @@ DocumentationGroup group private static string? GetDescription(INavigationItem navigationItem) => navigationItem switch { + // Cross-repository links don't have descriptions in frontmatter + ILeafNavigationItem { IsCrossLink: true } => null, + // For file navigation items, extract from frontmatter - FileNavigationItem fileItem when fileItem.Model is MarkdownFile markdownFile - => markdownFile.YamlFrontMatter?.Description, + ILeafNavigationItem markdownNavigation => + markdownNavigation.Model.YamlFrontMatter?.Description, // For documentation groups, try to get from index file - DocumentationGroup group when group.Index is MarkdownFile indexFile - => indexFile.YamlFrontMatter?.Description, - - // For table of contents trees (inherits from DocumentationGroup, but handled explicitly) - TableOfContentsTree tocTree when tocTree.Index is MarkdownFile indexFile - => indexFile.YamlFrontMatter?.Description, - - // Cross-repository links don't have descriptions in frontmatter - CrossLinkNavigationItem => null, + INodeNavigationItem markdownNodeNavigation => + markdownNodeNavigation.Index.Model.YamlFrontMatter?.Description, // API-related navigation items (these don't have markdown frontmatter) // Check by namespace to avoid direct assembly references - INavigationItem item when item.GetType().FullName?.StartsWith("Elastic.ApiExplorer.", StringComparison.Ordinal) == true => null, + { } item when item.GetType().FullName?.StartsWith("Elastic.ApiExplorer.", StringComparison.Ordinal) == true => null, // Throw exception for any unhandled navigation item types _ => throw new InvalidOperationException($"Unhandled navigation item type: {navigationItem.GetType().FullName}") diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index dfc0a9aef..5e4165607 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -15,6 +15,7 @@ using Elastic.Documentation.ServiceDefaults; using Elastic.Documentation.Site.FileProviders; using Elastic.Markdown.IO; +using Elastic.Markdown.IO.NewNavigation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -193,11 +194,13 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta slug = slug.Replace('/', Path.DirectorySeparatorChar); var s = Path.GetExtension(slug) == string.Empty ? Path.Combine(slug, "index.md") : slug; + var fp = new FilePath(s, generator.DocumentationSet.SourceDirectory); - if (!generator.DocumentationSet.FlatMappedFiles.TryGetValue(s, out var documentationFile)) + if (!generator.DocumentationSet.Files.TryGetValue(fp, out var documentationFile)) { s = Path.GetExtension(slug) == string.Empty ? slug + ".md" : s.Replace($"{Path.DirectorySeparatorChar}index.md", ".md"); - if (!generator.DocumentationSet.FlatMappedFiles.TryGetValue(s, out documentationFile)) + fp = new FilePath(s, generator.DocumentationSet.SourceDirectory); + if (!generator.DocumentationSet.Files.TryGetValue(fp, out documentationFile)) { foreach (var extension in holder.Generator.DocumentationSet.EnabledExtensions) { @@ -225,9 +228,10 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta return Results.File(image.SourceFile.FullName, image.MimeType); default: if (s == "index.md") - return Results.Redirect(generator.DocumentationSet.MarkdownFiles.First().Url); + return Results.Redirect(generator.DocumentationSet.Navigation.Url); - if (!generator.DocumentationSet.FlatMappedFiles.TryGetValue("404.md", out var notFoundDocumentationFile)) + var fp404 = new FilePath("404.md", generator.DocumentationSet.SourceDirectory); + if (!generator.DocumentationSet.Files.TryGetValue(fp404, out var notFoundDocumentationFile)) return Results.NotFound(); if (Path.GetExtension(s) is "" or not ".md") diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs new file mode 100644 index 000000000..ef633a2a1 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs @@ -0,0 +1,585 @@ +// 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.Configuration.DocSet; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class DocumentationSetFileTests +{ + private DocumentationSetFile Deserialize(string yaml) => DocumentationSetFile.Deserialize(yaml); + + [Fact] + public void DeserializesBasicProperties() + { + // language=yaml + var yaml = """ + project: 'test-project' + max_toc_depth: 3 + dev_docs: true + cross_links: + - docs-content + - other-docs + exclude: + - '_*.md' + - '*.tmp' + """; + + var result = Deserialize(yaml); + + result.Project.Should().Be("test-project"); + result.MaxTocDepth.Should().Be(3); + result.DevDocs.Should().BeTrue(); + result.CrossLinks.Should().HaveCount(2) + .And.Contain("docs-content") + .And.Contain("other-docs"); + result.Exclude.Should().HaveCount(2) + .And.Contain("_*.md") + .And.Contain("*.tmp"); + } + + [Fact] + public void DeserializesSubstitutions() + { + // language=yaml + var yaml = """ + project: 'test-project' + subs: + stack: Elastic Stack + ecloud: Elastic Cloud + dbuild: docs-builder + """; + + var result = Deserialize(yaml); + + result.Subs.Should().HaveCount(3) + .And.ContainKey("stack").WhoseValue.Should().Be("Elastic Stack"); + result.Subs.Should().ContainKey("ecloud").WhoseValue.Should().Be("Elastic Cloud"); + result.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); + } + + [Fact] + public void DeserializesFeatures() + { + // language=yaml + var yaml = """ + project: 'test-project' + features: + primary-nav: false + """; + + var result = Deserialize(yaml); + + result.Features.Should().NotBeNull(); + result.Features.PrimaryNav.Should().BeFalse(); + } + + [Fact] + public void DeserializesApiConfiguration() + { + // language=yaml + var yaml = """ + project: 'test-project' + api: + elasticsearch: elasticsearch-openapi.json + kibana: kibana-openapi.json + """; + + var result = Deserialize(yaml); + + result.Api.Should().HaveCount(2) + .And.ContainKey("elasticsearch").WhoseValue.Should().Be("elasticsearch-openapi.json"); + result.Api.Should().ContainKey("kibana").WhoseValue.Should().Be("kibana-openapi.json"); + } + + [Fact] + public void DeserializesFileReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - file: getting-started.md + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(2); + result.Toc.ElementAt(0).Should().BeOfType() + .Which.Path.Should().Be("index.md"); + result.Toc.ElementAt(1).Should().BeOfType() + .Which.Path.Should().Be("getting-started.md"); + } + + [Fact] + public void DeserializesHiddenFileReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - hidden: 404.md + - hidden: developer-notes.md + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(3); + result.Toc.ElementAt(0).Should().BeOfType() + .Which.Hidden.Should().BeFalse(); + result.Toc.ElementAt(1).Should().BeOfType() + .Which.Hidden.Should().BeTrue(); + result.Toc.ElementAt(1).Should().BeOfType() + .Which.Path.Should().Be("404.md"); + result.Toc.ElementAt(2).Should().BeOfType() + .Which.Hidden.Should().BeTrue(); + } + + [Fact] + public void DeserializesFolderReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: contribute + children: + - file: index.md + - file: locally.md + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(1); + var folder = result.Toc.ElementAt(0).Should().BeOfType().Subject; + folder.Path.Should().Be("contribute"); + folder.Children.Should().HaveCount(2); + folder.Children.ElementAt(0).Should().BeOfType() + .Which.Path.Should().Be("index.md"); + folder.Children.ElementAt(1).Should().BeOfType() + .Which.Path.Should().Be("locally.md"); + } + + [Fact] + public void DeserializesTocReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - toc: development + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(2); + result.Toc.ElementAt(0).Should().BeOfType(); + result.Toc.ElementAt(1).Should().BeOfType() + .Which.Source.Should().Be("development"); + } + + [Fact] + public void DeserializesCrossLinkReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - file: cross-links.md + children: + - title: "Getting Started Guide" + crosslink: docs-content://get-started/introduction.md + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(2); + var fileWithChildren = result.Toc.ElementAt(1).Should().BeOfType().Subject; + fileWithChildren.Children.Should().HaveCount(1); + var crosslink = fileWithChildren.Children.ElementAt(0).Should().BeOfType().Subject; + crosslink.Title.Should().Be("Getting Started Guide"); + crosslink.CrossLinkUri.ToString().Should().Be("docs-content://get-started/introduction.md"); + } + + [Fact] + public void DeserializesNestedStructure() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: configure + children: + - file: index.md + - folder: site + children: + - file: index.md + - file: content.md + - file: navigation.md + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(1); + var topFolder = result.Toc.ElementAt(0).Should().BeOfType().Subject; + topFolder.Path.Should().Be("configure"); + topFolder.Children.Should().HaveCount(2); + + topFolder.Children.ElementAt(0).Should().BeOfType() + .Which.Path.Should().Be("index.md"); + + var nestedFolder = topFolder.Children.ElementAt(1).Should().BeOfType().Subject; + nestedFolder.Path.Should().Be("site"); + nestedFolder.Children.Should().HaveCount(3); + nestedFolder.Children.ElementAt(0).Should().BeOfType() + .Which.Path.Should().Be("index.md"); + nestedFolder.Children.ElementAt(1).Should().BeOfType() + .Which.Path.Should().Be("content.md"); + nestedFolder.Children.ElementAt(2).Should().BeOfType() + .Which.Path.Should().Be("navigation.md"); + } + + [Fact] + public void DeserializesCompleteDocsetYaml() + { + // language=yaml + var yaml = """ + project: 'doc-builder' + max_toc_depth: 2 + dev_docs: true + cross_links: + - docs-content + exclude: + - '_*.md' + subs: + stack: Elastic Stack + serverless-short: Serverless + ecloud: Elastic Cloud + features: + primary-nav: false + api: + elasticsearch: elasticsearch-openapi.json + kibana: kibana-openapi.json + toc: + - file: index.md + - hidden: 404.md + - folder: configure + children: + - file: index.md + - folder: site + children: + - file: index.md + - file: content.md + - file: navigation.md + - file: page.md + children: + - title: "Getting Started Guide" + crosslink: docs-content://get-started/introduction.md + - toc: development + """; + + var result = Deserialize(yaml); + + // Assert top-level docset properties + result.Project.Should().Be("doc-builder"); + result.MaxTocDepth.Should().Be(2); + result.DevDocs.Should().BeTrue(); + result.CrossLinks.Should().ContainSingle().Which.Should().Be("docs-content"); + result.Exclude.Should().ContainSingle().Which.Should().Be("_*.md"); + result.Subs.Should().HaveCount(3); + result.Features.PrimaryNav.Should().BeFalse(); + result.Api.Should().HaveCount(2); + + // Assert TOC structure - 4 root items + result.Toc.Should().HaveCount(4); + + // First item: simple file reference + var firstItem = result.Toc.ElementAt(0).Should().BeOfType().Subject; + firstItem.Path.Should().Be("index.md"); + firstItem.Hidden.Should().BeFalse(); + firstItem.Children.Should().BeEmpty(); + + // Second item: hidden file reference + var secondItem = result.Toc.ElementAt(1).Should().BeOfType().Subject; + secondItem.Path.Should().Be("404.md"); + secondItem.Hidden.Should().BeTrue(); + secondItem.Children.Should().BeEmpty(); + + // Third item: folder with a deeply nested structure + var configureFolder = result.Toc.ElementAt(2).Should().BeOfType().Subject; + configureFolder.Path.Should().Be("configure"); + configureFolder.Children.Should().HaveCount(3); + + // First child: file reference + var configureIndexFile = configureFolder.Children.ElementAt(0).Should().BeOfType().Subject; + configureIndexFile.Path.Should().Be("index.md"); + configureIndexFile.Hidden.Should().BeFalse(); + + // Second child: nested folder with 3 files + var siteFolder = configureFolder.Children.ElementAt(1).Should().BeOfType().Subject; + siteFolder.Path.Should().Be("site"); + siteFolder.Children.Should().HaveCount(3); + + // Assert nested folder's children + var siteIndexFile = siteFolder.Children.ElementAt(0).Should().BeOfType().Subject; + siteIndexFile.Path.Should().Be("index.md"); + + var contentFile = siteFolder.Children.ElementAt(1).Should().BeOfType().Subject; + contentFile.Path.Should().Be("content.md"); + + var navigationFile = siteFolder.Children.ElementAt(2).Should().BeOfType().Subject; + navigationFile.Path.Should().Be("navigation.md"); + + // Third child: file with crosslink child + var pageFile = configureFolder.Children.ElementAt(2).Should().BeOfType().Subject; + pageFile.Path.Should().Be("page.md"); + pageFile.Children.Should().HaveCount(1); + + // Assert crosslink reference as a child of page.md + var crosslink = pageFile.Children.ElementAt(0).Should().BeOfType().Subject; + crosslink.Title.Should().Be("Getting Started Guide"); + crosslink.CrossLinkUri.ToString().Should().Be("docs-content://get-started/introduction.md"); + crosslink.Hidden.Should().BeFalse(); + crosslink.Children.Should().BeEmpty(); + + // Fourth item: toc reference + var tocRef = result.Toc.ElementAt(3).Should().BeOfType().Subject; + tocRef.Source.Should().Be("development"); + tocRef.Children.Should().BeEmpty(); + } + + [Fact] + public void DeserializesFileWithChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + - file: chapter2.md + - file: chapter3.md + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(1); + var guide = result.Toc.ElementAt(0).Should().BeOfType().Subject; + guide.Path.Should().Be("guide.md"); + guide.Children.Should().HaveCount(3); + guide.Children.ElementAt(0).Should().BeOfType() + .Which.Path.Should().Be("chapter1.md"); + guide.Children.ElementAt(1).Should().BeOfType() + .Which.Path.Should().Be("chapter2.md"); + guide.Children.ElementAt(2).Should().BeOfType() + .Which.Path.Should().Be("chapter3.md"); + } + + [Fact] + public void DeserializesFileWithNestedPathsAsChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: api/guide.md + children: + - file: api/section1.md + - file: api/section2.md + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(1); + var guide = result.Toc.ElementAt(0).Should().BeOfType().Subject; + guide.Path.Should().Be("api/guide.md"); + guide.Children.Should().HaveCount(2); + guide.Children.ElementAt(0).Should().BeOfType() + .Which.Path.Should().Be("api/section1.md"); + guide.Children.ElementAt(1).Should().BeOfType() + .Which.Path.Should().Be("api/section2.md"); + } + + [Fact] + public void DeserializesDefaultValues() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + """; + + var result = Deserialize(yaml); + + result.MaxTocDepth.Should().Be(2); // Default value + result.DevDocs.Should().BeFalse(); // Default value + result.CrossLinks.Should().BeEmpty(); + result.Exclude.Should().BeEmpty(); + result.Subs.Should().BeEmpty(); + result.Api.Should().BeEmpty(); + result.Features.PrimaryNav.Should().BeNull(); + } + + [Fact] + public void DeserializesEmptyToc() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: [] + """; + + var result = Deserialize(yaml); + + result.Toc.Should().BeEmpty(); + } + + [Fact] + public void DeserializesCrossLinkWithoutTitle() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + children: + - crosslink: docs-content://get-started.md + """; + + var result = Deserialize(yaml); + + var file = result.Toc.ElementAt(0).Should().BeOfType().Subject; + var crosslink = file.Children.ElementAt(0).Should().BeOfType().Subject; + crosslink.CrossLinkUri.ToString().Should().Be("docs-content://get-started.md/"); // URI normalization adds trailing slash + crosslink.Title.Should().BeNull(); + } + + [Fact] + public void DeserializesMixedHiddenAndVisibleItems() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - hidden: _internal.md + - file: public.md + - hidden: _draft.md + """; + + var result = Deserialize(yaml); + + result.Toc.Should().HaveCount(4); + result.Toc.ElementAt(0).Should().BeOfType() + .Which.Hidden.Should().BeFalse(); + result.Toc.ElementAt(1).Should().BeOfType() + .Which.Hidden.Should().BeTrue(); + result.Toc.ElementAt(2).Should().BeOfType() + .Which.Hidden.Should().BeFalse(); + result.Toc.ElementAt(3).Should().BeOfType() + .Which.Hidden.Should().BeTrue(); + } + + [Fact] + public void DeserializesDeeplyNestedFileWithChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + children: + - file: section1.md + children: + - file: subsection1.md + """; + + var result = Deserialize(yaml); + + var guide = result.Toc.ElementAt(0).Should().BeOfType().Subject; + var chapter1 = guide.Children.ElementAt(0).Should().BeOfType().Subject; + var section1 = chapter1.Children.ElementAt(0).Should().BeOfType().Subject; + var subsection1 = section1.Children.ElementAt(0).Should().BeOfType().Subject; + + subsection1.Path.Should().Be("subsection1.md"); + subsection1.Children.Should().BeEmpty(); + } + + [Fact] + public void DeserializesMultipleExcludePatterns() + { + // language=yaml + var yaml = """ + project: 'test-project' + exclude: + - '_*.md' + - '*.tmp' + - '*.draft' + - '.DS_Store' + - 'node_modules/**' + toc: + - file: index.md + """; + + var result = Deserialize(yaml); + + result.Exclude.Should().HaveCount(5) + .And.ContainInOrder("_*.md", "*.tmp", "*.draft", ".DS_Store", "node_modules/**"); + } + + [Fact] + public void DeserializesMultipleCrossLinks() + { + // language=yaml + var yaml = """ + project: 'test-project' + cross_links: + - elasticsearch + - kibana + - docs-content + - cloud + toc: + - file: index.md + """; + + var result = Deserialize(yaml); + + result.CrossLinks.Should().HaveCount(4) + .And.ContainInOrder("elasticsearch", "kibana", "docs-content", "cloud"); + } + + [Fact] + public void DeserializesFolderWithMixedChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: api + children: + - file: index.md + - folder: rest + children: + - file: index.md + - file: overview.md + """; + + var result = Deserialize(yaml); + + var apiFolder = result.Toc.ElementAt(0).Should().BeOfType().Subject; + apiFolder.Children.Should().HaveCount(3); + apiFolder.Children.ElementAt(0).Should().BeOfType(); + apiFolder.Children.ElementAt(1).Should().BeOfType(); + apiFolder.Children.ElementAt(2).Should().BeOfType(); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/Elastic.Documentation.Configuration.Tests.csproj b/tests/Elastic.Documentation.Configuration.Tests/Elastic.Documentation.Configuration.Tests.csproj new file mode 100644 index 000000000..f5acc84c4 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/Elastic.Documentation.Configuration.Tests.csproj @@ -0,0 +1,11 @@ + + + + net9.0 + + + + + + + diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs new file mode 100644 index 000000000..e9770e2a9 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -0,0 +1,134 @@ +// 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.Configuration.DocSet; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class PhysicalDocsetTests +{ + [Fact] + public void PhysicalDocsetFileCanBeDeserialized() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + File.Exists(docsetPath).Should().BeTrue($"Expected docset file to exist at {docsetPath}"); + + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + // Assert basic properties + docSet.Project.Should().Be("doc-builder"); + docSet.MaxTocDepth.Should().Be(2); + docSet.DevDocs.Should().BeTrue(); + docSet.Features.PrimaryNav.Should().BeFalse(); + + // Assert cross links + docSet.CrossLinks.Should().ContainSingle().Which.Should().Be("docs-content"); + + // Assert exclude patterns + docSet.Exclude.Should().ContainSingle().Which.Should().Be("_*.md"); + + // Assert substitutions + docSet.Subs.Should().NotBeEmpty(); + docSet.Subs.Should().ContainKey("stack").WhoseValue.Should().Be("Elastic Stack"); + docSet.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); + + // Assert API configuration + docSet.Api.Should().HaveCount(2); + docSet.Api.Should().ContainKey("elasticsearch").WhoseValue.Should().Be("elasticsearch-openapi.json"); + docSet.Api.Should().ContainKey("kibana").WhoseValue.Should().Be("kibana-openapi.json"); + + // Assert TOC structure + docSet.Toc.Should().NotBeEmpty(); + + // First item should be index.md + var firstItem = docSet.Toc.ElementAt(0).Should().BeOfType().Subject; + firstItem.Path.Should().Be("index.md"); + firstItem.Hidden.Should().BeFalse(); + + // Should have hidden files (404.md, developer-notes.md) + var hiddenFiles = docSet.Toc.OfType().Where(f => f.Hidden).ToList(); + hiddenFiles.Should().Contain(f => f.Path == "404.md"); + hiddenFiles.Should().Contain(f => f.Path == "developer-notes.md"); + + // Should have folders + docSet.Toc.OfType().Should().NotBeEmpty(); + var contributeFolder = docSet.Toc.OfType().FirstOrDefault(f => f.Path == "contribute"); + contributeFolder.Should().NotBeNull(); + contributeFolder.Children.Should().NotBeEmpty(); + + // Should have TOC references + var tocRefs = docSet.Toc.OfType().ToList(); + tocRefs.Should().NotBeEmpty(); + tocRefs.Should().Contain(toc => toc.Source == "development"); + + // Should have deeply nested structures + var testingFolder = docSet.Toc.OfType().FirstOrDefault(f => f.Path == "testing"); + testingFolder.Should().NotBeNull(); + testingFolder.Children.Should().NotBeEmpty(); + } + + [Fact] + public void PhysicalDocsetContainsExpectedFolders() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + var folderNames = docSet.Toc.OfType().Select(f => f.Path).ToList(); + + // Assert expected folders exist + folderNames.Should().Contain("contribute"); + folderNames.Should().Contain("building-blocks"); + folderNames.Should().Contain("configure"); + folderNames.Should().Contain("syntax"); + folderNames.Should().Contain("cli"); + folderNames.Should().Contain("migration"); + folderNames.Should().Contain("testing"); + } + + [Fact] + public void PhysicalDocsetHasValidNestedStructure() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + // Test the configure folder has nested folders + var configureFolder = docSet.Toc.OfType().First(f => f.Path == "configure"); + configureFolder.Children.Should().NotBeEmpty(); + + // Should have site and content-set folders + var nestedFolders = configureFolder.Children.OfType().Select(f => f.Path).ToList(); + nestedFolders.Should().Contain("site"); + nestedFolders.Should().Contain("content-set"); + + // Test the cli folder has nested folders + var cliFolder = docSet.Toc.OfType().First(f => f.Path == "cli"); + var cliNestedFolders = cliFolder.Children.OfType().Select(f => f.Path).ToList(); + cliNestedFolders.Should().Contain("docset"); + cliNestedFolders.Should().Contain("assembler"); + cliNestedFolders.Should().Contain("links"); + } + + [Fact] + public void PhysicalDocsetContainsFileReferencesWithChildren() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + // Find testing folder + var testingFolder = docSet.Toc.OfType().First(f => f.Path == "testing"); + + // Look for file with children (cross-links.md with crosslink children) + var fileWithChildren = testingFolder.Children.OfType() + .FirstOrDefault(f => f.Path == "cross-links.md" && f.Children.Count > 0); + + fileWithChildren.Should().NotBeNull(); + fileWithChildren.Children.Should().NotBeEmpty(); + fileWithChildren.Children.Should().Contain(c => c is CrossLinkRef); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs new file mode 100644 index 000000000..6f241e224 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs @@ -0,0 +1,145 @@ +// 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.Configuration.DocSet; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class SiteNavigationFileTests +{ + [Fact] + public void DeserializesSiteNavigationFile() + { + // language=yaml + var yaml = """ + phantoms: + - toc: elasticsearch://reference + - toc: docs-content:// + toc: + - toc: serverless/observability + path_prefix: /serverless/observability + - toc: serverless/search + path_prefix: /serverless/search + - toc: serverless/security + path_prefix: /serverless/security + """; + + var siteNav = SiteNavigationFile.Deserialize(yaml); + + siteNav.Should().NotBeNull(); + siteNav.Phantoms.Should().HaveCount(2); + siteNav.Phantoms.ElementAt(0).Source.Should().Be("elasticsearch://reference"); + siteNav.Phantoms.ElementAt(1).Source.Should().Be("docs-content://"); + + siteNav.TableOfContents.Should().HaveCount(3); + + var observability = siteNav.TableOfContents.ElementAt(0); + observability.Source.ToString().Should().Be("docs-content://serverless/observability"); + observability.PathPrefix.Should().Be("/serverless/observability"); + observability.Children.Should().BeEmpty(); + + var search = siteNav.TableOfContents.ElementAt(1); + search.Source.ToString().Should().Be("docs-content://serverless/search"); + search.PathPrefix.Should().Be("/serverless/search"); + + var security = siteNav.TableOfContents.ElementAt(2); + security.Source.ToString().Should().Be("docs-content://serverless/security"); + security.PathPrefix.Should().Be("/serverless/security"); + } + + [Fact] + public void DeserializesSiteNavigationFileWithNestedChildren() + { + // language=yaml + var yaml = """ + toc: + - toc: platform + path_prefix: /platform + children: + - toc: platform/deployment-guide + path_prefix: /platform/deployment + - toc: platform/cloud-guide + path_prefix: /platform/cloud + """; + + var siteNav = SiteNavigationFile.Deserialize(yaml); + + siteNav.TableOfContents.Should().HaveCount(1); + + var platform = siteNav.TableOfContents.First(); + platform.Source.ToString().Should().Be("docs-content://platform/"); + platform.PathPrefix.Should().Be("/platform"); + platform.Children.Should().HaveCount(2); + + var deployment = platform.Children.ElementAt(0); + deployment.Source.ToString().Should().Be("docs-content://platform/deployment-guide"); + deployment.PathPrefix.Should().Be("/platform/deployment"); + + var cloud = platform.Children.ElementAt(1); + cloud.Source.ToString().Should().Be("docs-content://platform/cloud-guide"); + cloud.PathPrefix.Should().Be("/platform/cloud"); + } + + [Fact] + public void DeserializesWithMissingPath() + { + // language=yaml + var yaml = """ + toc: + - toc: elasticsearch/reference + """; + + var siteNav = SiteNavigationFile.Deserialize(yaml); + + siteNav.TableOfContents.Should().HaveCount(1); + var ref1 = siteNav.TableOfContents.First(); + ref1.Source.ToString().Should().Be("docs-content://elasticsearch/reference"); + ref1.PathPrefix.Should().BeEmpty(); + } + + [Fact] + public void PreservesSchemeWhenPresent() + { + // language=yaml + var yaml = """ + toc: + - toc: elasticsearch://reference/current + - toc: kibana://reference/8.0 + - toc: serverless/observability + """; + + var siteNav = SiteNavigationFile.Deserialize(yaml); + + siteNav.TableOfContents.Should().HaveCount(3); + + // With elasticsearch:// scheme + var elasticsearch = siteNav.TableOfContents.ElementAt(0); + elasticsearch.Source.ToString().Should().Be("elasticsearch://reference/current"); + + // With kibana:// scheme + var kibana = siteNav.TableOfContents.ElementAt(1); + kibana.Source.ToString().Should().Be("kibana://reference/8.0"); + + // Without scheme - should get docs-content:// + var serverless = siteNav.TableOfContents.ElementAt(2); + serverless.Source.ToString().Should().Be("docs-content://serverless/observability"); + } + + [Fact] + public void ThrowsExceptionForInvalidUri() + { + // language=yaml + var yaml = """ + toc: + - toc: ://invalid + """; + + var act = () => SiteNavigationFile.Deserialize(yaml); + + act.Should().Throw() + .WithInnerException() + .WithMessage("Invalid TOC source: '://invalid' could not be parsed as a URI"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 788afe3d1..447364f1e 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -72,7 +72,7 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") var context = new BuildContext(Collector, FileSystem, configurationContext); var linkResolver = new TestCrossLinkResolver(); Set = new DocumentationSet(context, logger, linkResolver); - File = Set.DocumentationFileLookup(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); + File = Set.TryFindDocument(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); Html = default!; //assigned later Document = default!; } @@ -83,7 +83,7 @@ public virtual async ValueTask InitializeAsync() { _ = Collector.StartAsync(TestContext.Current.CancellationToken); - Document = await File.ParseFullAsync(TestContext.Current.CancellationToken); + Document = await File.ParseFullAsync(Set.TryFindDocumentByRelativePath, TestContext.Current.CancellationToken); var html = MarkdownFile.CreateHtml(Document).AsSpan(); var find = ""; var start = html.IndexOf(find, StringComparison.Ordinal); diff --git a/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs b/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs index cae721667..02d593599 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs @@ -2,6 +2,7 @@ // 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.Extensions; using Elastic.Markdown.IO; using FluentAssertions; @@ -12,20 +13,26 @@ public class BreadCrumbTests(ITestOutputHelper output) : NavigationTestsBase(out [Fact] public void ParsesATableOfContents() { - var doc = Generator.DocumentationSet.Files.FirstOrDefault(f => f.RelativePath == Path.Combine("testing", "nested", "index.md")) as MarkdownFile; + IPositionalNavigation positionalNavigation = Generator.DocumentationSet; + var allKeys = positionalNavigation.NavigationIndexedByCrossLink.Keys; + allKeys.Should().Contain("docs-builder://testing/nested/index.md"); + allKeys.Should().Contain("docs-builder://testing/nest-under-index/index.md"); - doc.Should().NotBeNull(); + var lookup = Path.Combine("testing", "nested", "index.md"); + var folder = Path.Combine(Generator.Context.DocumentationSourceDirectory.FullName, "testing"); + var testingFiles = Generator.DocumentationSet.MarkdownFiles + .Where(f => f.SourceFile.IsSubPathOf(f.SourceFile.FileSystem.DirectoryInfo.New(folder))); + var doc = Generator.DocumentationSet.MarkdownFiles + .FirstOrDefault(f => f.SourceFile.FullName.EndsWith(lookup, StringComparison.OrdinalIgnoreCase)); - IPositionalNavigation positionalNavigation = Generator.DocumentationSet; + doc.Should().NotBeNull(); - var allKeys = positionalNavigation.MarkdownNavigationLookup.Keys.ToList(); - allKeys.Should().Contain("docs-builder://testing/nested/index.md"); - var f = positionalNavigation.MarkdownNavigationLookup.FirstOrDefault(kv => kv.Key == "docs-builder://testing/deeply-nested/foo.md"); + var f = positionalNavigation.NavigationIndexedByCrossLink.FirstOrDefault(kv => kv.Key == "docs-builder://testing/deeply-nested/foo.md"); f.Should().NotBeNull(); - positionalNavigation.MarkdownNavigationLookup.Should().ContainKey(doc.CrossLink); - var nav = positionalNavigation.MarkdownNavigationLookup[doc.CrossLink]; + positionalNavigation.NavigationIndexedByCrossLink.Should().ContainKey(doc.CrossLink); + var nav = positionalNavigation.NavigationIndexedByCrossLink[doc.CrossLink]; nav.Parent.Should().NotBeNull(); diff --git a/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs b/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs index 64e52f786..7b5277dfd 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs @@ -12,14 +12,6 @@ public class NavigationTests(ITestOutputHelper output) : NavigationTestsBase(out public void ParsesATableOfContents() => Configuration.TableOfContents.Should().NotBeNullOrEmpty(); - [Fact] - public void ParsesNestedFoldersAndPrefixesPaths() - { - Configuration.ImplicitFolders.Should().NotBeNullOrEmpty(); - Configuration.ImplicitFolders.Should() - .Contain(Path.Combine("testing", "nested")); - } - [Fact] public void ParsesFilesAndPrefixesPaths() => Configuration.Files.Should() diff --git a/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs b/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs index ecf937845..5f6c2e688 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs @@ -2,8 +2,9 @@ // 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.Navigation; +using Elastic.Documentation.Navigation.Isolated; using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; using FluentAssertions; namespace Elastic.Markdown.Tests.DocSet; @@ -13,12 +14,13 @@ public class NestedTocTests(ITestOutputHelper output) : NavigationTestsBase(outp [Fact] public void InjectsNestedTocsIntoDocumentationSet() { - var doc = Generator.DocumentationSet.Files.FirstOrDefault(f => f.RelativePath == Path.Combine("development", "index.md")) as MarkdownFile; + var doc = Generator.DocumentationSet.MarkdownFiles.FirstOrDefault(f => f.RelativePath == Path.Combine("development", "index.md")); doc.Should().NotBeNull(); IPositionalNavigation positionalNavigation = Generator.DocumentationSet; - positionalNavigation.MarkdownNavigationLookup.Should().ContainKey(doc.CrossLink); - var nav = positionalNavigation.MarkdownNavigationLookup[doc.CrossLink]; + positionalNavigation.MarkdownNavigationLookup.Should().ContainKey(doc); + if (!positionalNavigation.MarkdownNavigationLookup.TryGetValue(doc, out var nav)) + throw new Exception($"Could not find nav item for {doc.CrossLink}"); var parent = nav.Parent; @@ -29,9 +31,11 @@ public void InjectsNestedTocsIntoDocumentationSet() parent.Parent.Should().BeNull(); // its parent should point to an index - var index = (parent as DocumentationGroup)?.Index; + var index = (parent as TableOfContentsNavigation)?.Index; index.Should().NotBeNull(); - index.RelativePath.Should().Be("index.md"); + var fileNav = index as FileNavigationLeaf; + fileNav.Should().NotBeNull(); + fileNav.Model.RelativePath.Should().Be("development/index.md"); } } diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs index d80533e41..b28cbd287 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs @@ -173,6 +173,6 @@ public void EmitsError() Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); Collector.Diagnostics.Should() - .OnlyContain(d => d.Message.Contains("cyclical include detected")); + .Contain(d => d.Message.Contains("cyclical include detected")); } } diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 936cf66de..3df2faf91 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -119,7 +119,7 @@ protected InlineTest( }; var linkResolver = new TestCrossLinkResolver(); Set = new DocumentationSet(context, logger, linkResolver); - File = Set.DocumentationFileLookup(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); + File = Set.TryFindDocument(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); Html = default!; //assigned later Document = default!; } @@ -132,7 +132,7 @@ public virtual async ValueTask InitializeAsync() await Set.ResolveDirectoryTree(TestContext.Current.CancellationToken); - Document = await File.ParseFullAsync(TestContext.Current.CancellationToken); + Document = await File.ParseFullAsync(Set.TryFindDocumentByRelativePath, TestContext.Current.CancellationToken); var html = MarkdownFile.CreateHtml(Document).AsSpan(); var find = "\n"; var start = html.IndexOf(find, StringComparison.Ordinal); diff --git a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs index cef4161bc..6986e38cb 100644 --- a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs +++ b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs @@ -21,6 +21,8 @@ public static void GenerateDocSetYaml(this MockFileSystem fileSystem, IDirectory .EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories); foreach (var markdownFile in markdownFiles) { + if (markdownFile.Contains("_snippet")) + continue; var relative = fileSystem.Path.GetRelativePath(root.FullName, markdownFile); yaml.WriteLine($" - file: {relative}"); } diff --git a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs new file mode 100644 index 000000000..a6c30cff8 --- /dev/null +++ b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs @@ -0,0 +1,336 @@ +// 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.Configuration.DocSet; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public class ComplexSiteNavigationTests(ITestOutputHelper output) +{ + [Fact] + public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + - toc: serverless-search:// + path_prefix: /serverless/search + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + - toc: elasticsearch-reference:// + path_prefix: /elasticsearch/reference + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create all DocumentationSetNavigation instances + var checkoutDir = fileSystem.DirectoryInfo.New("/checkouts/current"); + var repositories = checkoutDir.GetDirectories(); + + var documentationSets = new List(); + + foreach (var repo in repositories) + { + var context = SiteNavigationTestFixture.CreateContext(fileSystem, repo.FullName, output); + + var docsetPath = fileSystem.File.Exists($"{repo.FullName}/docs/docset.yml") + ? $"{repo.FullName}/docs/docset.yml" + : $"{repo.FullName}/docs/_docset.yml"; + + var docsetYaml = fileSystem.File.ReadAllText(docsetPath); + var docset = DocumentationSetFile.Deserialize(docsetYaml); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + documentationSets.Add(navigation); + } + + var siteContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/observability", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + // Verify we have all expected top-level items + siteNavigation.NavigationItems.Should().HaveCount(4); + + // Test 1: Observability - verify root URL has path prefix + var observability = siteNavigation.NavigationItems.ElementAt(0); + observability.Should().NotBeNull(); + observability.Url.Should().Be("/serverless/observability"); + observability.NavigationTitle.Should().Be("Serverless Observability"); + + // Test 2: Serverless Search - verify root URL has path prefix + var search = siteNavigation.NavigationItems.ElementAt(1); + search.Should().NotBeNull(); + search.Url.Should().Be("/serverless/search"); + + // Test 3: Platform - verify root URL has path prefix + var platform = siteNavigation.NavigationItems.ElementAt(2) as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/platform"); + platform.NavigationItems.Should().HaveCount(2, "platform should only show the two nested TOCs as children"); + + // Verify nested TOC URLs have their specified path prefixes + var deploymentGuide = platform.NavigationItems.ElementAt(0); + deploymentGuide.Should().NotBeNull(); + deploymentGuide.Url.Should().Be("/platform/deployment"); + deploymentGuide.NavigationTitle.Should().Be("Deployment Guide"); + + var cloudGuide = platform.NavigationItems.ElementAt(1); + cloudGuide.Should().NotBeNull(); + cloudGuide.Url.Should().Be("/platform/cloud"); + cloudGuide.NavigationTitle.Should().Be("Cloud Guide"); + + // Test 4: Elasticsearch Reference - verify root URL has path prefix + var elasticsearch = siteNavigation.NavigationItems.ElementAt(3) as INodeNavigationItem; + elasticsearch.Should().NotBeNull(); + elasticsearch.Url.Should().Be("/elasticsearch/reference"); + elasticsearch.NavigationItems.Should().HaveCount(3, "elasticsearch should have read its toc"); + + // rest-apis is a folder (not a TOC) + var restApis = elasticsearch.NavigationItems.ElementAt(1).Should().BeOfType().Subject; + restApis.Url.Should().Be("/elasticsearch/reference/rest-apis"); + restApis.NavigationItems.Should().HaveCount(3, "rest-apis folder should have 3 files"); + + // Verify the file inside the folder has the correct path prefix + var documentApisFile = restApis.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + documentApisFile.Url.Should().Be("/elasticsearch/reference/rest-apis/document-apis"); + documentApisFile.NavigationTitle.Should().Be("Document APIs"); + } + + [Fact] + public void DeeplyNestedNavigationMaintainsPathPrefixThroughoutHierarchy() + { + // language=YAML - test without specifying children for nested TOCs + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /docs/platform + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + + var documentationSets = new List + { + new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) + }; + + var siteContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/docs/platform"); + + // Platform should have its children (index, deployment-guide, cloud-guide) + platform.NavigationItems.Should().HaveCount(3); + + // Find the deployment-guide TOC (it's the second item after index) + var deploymentGuide = platform.NavigationItems.ElementAt(1) as INodeNavigationItem; + deploymentGuide.Should().NotBeNull(); + deploymentGuide.Should().BeOfType(); + deploymentGuide.Url.Should().StartWith("/docs/platform"); + + // Walk through the entire tree and verify every single URL starts with a path prefix + var allUrls = CollectAllUrls(platform.NavigationItems); + allUrls.Should().NotBeEmpty(); + allUrls.Should().OnlyContain(url => url.StartsWith("/docs/platform"), + "all URLs in platform should start with /docs/platform"); + } + + [Fact] + public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() + { + // language=YAML - don't specify children so we can access the actual file leaves + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /platform + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + + var documentationSets = new List + { + new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) + }; + + var siteContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + + // Platform should have its children including deployment-guide TOC + platform.NavigationItems.Should().HaveCount(3); + + // Get deployment-guide TOC (second item after index) + var deploymentGuide = platform.NavigationItems.ElementAt(1) as INodeNavigationItem; + deploymentGuide.Should().NotBeNull(); + deploymentGuide.Should().BeOfType(); + + // Find all FileNavigationLeaf items recursively + var fileLeaves = CollectAllFileLeaves(deploymentGuide.NavigationItems); + fileLeaves.Should().NotBeEmpty("deployment-guide should contain file leaves"); + + // Verify every single file leaf has the correct path prefix + foreach (var fileLeaf in fileLeaves) + { + fileLeaf.Url.Should().StartWith("/platform", + $"file '{fileLeaf.NavigationTitle}' should have URL starting with /platform but got '{fileLeaf.Url}'"); + } + + // Verify at least one specific file to ensure we're testing real data + var indexFile = fileLeaves.OfType>() + .FirstOrDefault(f => f.FileInfo.FullName.EndsWith("/index.md", StringComparison.OrdinalIgnoreCase)); + indexFile.Should().NotBeNull(); + indexFile.Url.Should().StartWith("/platform"); + } + + [Fact] + public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() + { + // language=YAML - don't specify children so we can access the actual folders + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /platform/cloud + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + + var documentationSets = new List + { + new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) + }; + + var siteContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + + // Platform should have its children including cloud-guide TOC + platform.NavigationItems.Should().HaveCount(3); + + // Get cloud-guide TOC (third item after index and deployment-guide) + var cloudGuide = platform.NavigationItems.ElementAt(2) as INodeNavigationItem; + cloudGuide.Should().NotBeNull(); + cloudGuide.Should().BeOfType(); + + // cloud-guide should have folders (index, aws, azure) + var folders = cloudGuide.NavigationItems + .OfType() + .ToList(); + + folders.Should().NotBeEmpty("cloud-guide should contain folders"); + + // Verify each folder and all its contents have a correct path prefix + foreach (var folder in folders) + { + folder.Url.Should().StartWith("/platform/cloud", + $"folder '{folder.NavigationTitle}' should have URL starting with /platform/cloud"); + + // Verify all items within the folder + AssertAllUrlsStartWith(folder.NavigationItems, "/platform/cloud"); + + // Verify specific file leaves within the folder + var filesInFolder = CollectAllFileLeaves(folder.NavigationItems); + foreach (var file in filesInFolder) + { + file.Url.Should().StartWith("/platform/cloud", + $"file '{file.NavigationTitle}' in folder '{folder.NavigationTitle}' should have URL starting with /platform/cloud"); + } + } + } + + /// + /// Helper method to recursively assert all URLs start with a given prefix + /// + private static void AssertAllUrlsStartWith(IEnumerable items, string expectedPrefix) + { + foreach (var item in items) + { + item.Url.Should().StartWith(expectedPrefix, + $"item '{item.NavigationTitle}' should have URL starting with '{expectedPrefix}' but got '{item.Url}'"); + + if (item is INodeNavigationItem nodeItem) + AssertAllUrlsStartWith(nodeItem.NavigationItems, expectedPrefix); + } + } + + /// + /// Helper method to collect all URLs recursively + /// + private static List CollectAllUrls(IEnumerable items) + { + var urls = new List(); + + foreach (var item in items) + { + urls.Add(item.Url); + + if (item is INodeNavigationItem nodeItem) + urls.AddRange(CollectAllUrls(nodeItem.NavigationItems)); + } + + return urls; + } + + /// + /// Helper method to collect all FileNavigationLeaf items recursively + /// + private static List> CollectAllFileLeaves(IEnumerable items) + { + var fileLeaves = new List>(); + + foreach (var item in items) + { + switch (item) + { + case ILeafNavigationItem fileLeaf: + fileLeaves.Add(fileLeaf); + break; + case INodeNavigationItem: + break; + } + } + + return fileLeaves; + } +} diff --git a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs new file mode 100644 index 000000000..2bfca8b56 --- /dev/null +++ b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs @@ -0,0 +1,120 @@ +// 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.Configuration.DocSet; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public class IdentifierCollectionTests(ITestOutputHelper output) +{ + [Fact] + public void DocumentationSetNavigationCollectsRootIdentifier() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test platform repository + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + // Root identifier should be :// + platformNav.Identifier.Should().Be(new Uri("platform://")); + platformNav.TableOfContentNodes.Keys.Should().Contain(new Uri("platform://")); + } + + [Fact] + public void DocumentationSetNavigationCollectsNestedTocIdentifiers() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test platform repository with nested TOCs + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + // Should collect identifiers from nested TOCs + platformNav.TableOfContentNodes.Keys.Should().Contain( + [ + new Uri("platform://"), + new Uri("platform://deployment-guide"), + new Uri("platform://cloud-guide") + ]); + + platformNav.TableOfContentNodes.Should().HaveCount(3); + } + + [Fact] + public void DocumentationSetNavigationWithSimpleStructure() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test observability repository (no nested TOCs) + var observabilityContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/observability/docs/docset.yml")); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + // Should only have root identifier + observabilityNav.TableOfContentNodes.Keys.Should().Contain(new Uri("observability://")); + observabilityNav.TableOfContentNodes.Should().HaveCount(1); + } + + [Fact] + public void TableOfContentsNavigationHasCorrectIdentifier() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test platform repository with nested TOCs + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + // Get the deployment-guide TOC + var deploymentGuide = platformNav.NavigationItems.ElementAt(1) as TableOfContentsNavigation; + deploymentGuide.Should().NotBeNull(); + deploymentGuide.Identifier.Should().Be(new Uri("platform://deployment-guide")); + + // Get the cloud-guide TOC + var cloudGuide = platformNav.NavigationItems.ElementAt(2) as TableOfContentsNavigation; + cloudGuide.Should().NotBeNull(); + cloudGuide.Identifier.Should().Be(new Uri("platform://cloud-guide")); + } + + [Fact] + public void MultipleDocumentationSetsHaveDistinctIdentifiers() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create multiple documentation sets + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + var observabilityContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.Deserialize( + fileSystem.File.ReadAllText("/checkouts/current/observability/docs/docset.yml")); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + // Each should have its own set of identifiers + platformNav.TableOfContentNodes.Keys.Should().NotIntersectWith(observabilityNav.TableOfContentNodes.Keys); + + // Platform should have repository name in its identifiers + platformNav.TableOfContentNodes.Keys.Should().AllSatisfy(id => id.Scheme.Should().Be("platform")); + + // Observability should have repository name in its identifiers + observabilityNav.TableOfContentNodes.Keys.Should().AllSatisfy(id => id.Scheme.Should().Be("observability")); + } +} diff --git a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs new file mode 100644 index 000000000..01d8c0a96 --- /dev/null +++ b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs @@ -0,0 +1,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 Elastic.Documentation.Configuration.DocSet; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public class SiteDocumentationSetsTests(ITestOutputHelper output) +{ + [Fact] + public void CreatesDocumentationSetNavigationsFromCheckoutFolders() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Discover all repositories in /checkouts/current + var checkoutDir = fileSystem.DirectoryInfo.New("/checkouts/current"); + var repositories = checkoutDir.GetDirectories(); + + repositories.Should().HaveCount(5); + repositories.Select(r => r.Name).Should().Contain( + [ + "observability", + "serverless-search", + "serverless-security", + "platform", + "elasticsearch-reference" + ]); + + // Create DocumentationSetNavigation for each repository + var documentationSets = new List(); + + foreach (var repo in repositories) + { + var context = SiteNavigationTestFixture.CreateContext(fileSystem, repo.FullName, output); + + // Read the docset file + var docsetPath = fileSystem.File.Exists($"{repo.FullName}/docs/docset.yml") + ? $"{repo.FullName}/docs/docset.yml" + : $"{repo.FullName}/docs/_docset.yml"; + + var docsetYaml = fileSystem.File.ReadAllText(docsetPath); + var docset = DocumentationSetFile.Deserialize(docsetYaml); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + documentationSets.Add(navigation); + } + + documentationSets.Should().HaveCount(5); + + // Verify each documentation set has navigation items + foreach (var docSet in documentationSets) + docSet.NavigationItems.Should().NotBeEmpty(); + } + + [Fact] + public void SiteNavigationIntegratesWithDocumentationSets() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + - toc: serverless-search:// + path_prefix: /serverless/search + - toc: serverless-security:// + path_prefix: /serverless/security + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create DocumentationSetNavigation instances + var documentationSets = new List(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/observability/docs/docset.yml")); + documentationSets.Add(new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance)); + + var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); + var searchDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/serverless-search/docs/docset.yml")); + documentationSets.Add(new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance)); + + var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); + var securityDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/serverless-security/docs/_docset.yml")); + documentationSets.Add(new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance)); + + // Create site navigation context (using any repository's filesystem) + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + siteNavigation.Should().NotBeNull(); + siteNavigation.NavigationItems.Should().HaveCount(3); + + var observability = siteNavigation.NavigationItems.ElementAt(0); + observability.Url.Should().Be("/serverless/observability"); + observability.NavigationTitle.Should().NotBeNullOrEmpty(); + + var search = siteNavigation.NavigationItems.ElementAt(1); + search.Url.Should().Be("/serverless/search"); + + var security = siteNavigation.NavigationItems.ElementAt(2); + security.Url.Should().Be("/serverless/security"); + } + + [Fact] + public void SiteNavigationWithNestedTocs() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create DocumentationSetNavigation for platform + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + var documentationSets = new List { platformNav }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + siteNavigation.NavigationItems.Should().HaveCount(1); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/platform"); + platform.NavigationItems.Should().HaveCount(2); + + var deployment = platform.NavigationItems.ElementAt(0); + deployment.Url.Should().Be("/platform/deployment"); + + var cloud = platform.NavigationItems.ElementAt(1); + cloud.Url.Should().Be("/platform/cloud"); + } + + [Fact] + public void SiteNavigationWithAllRepositories() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + - toc: serverless-search:// + path_prefix: /serverless/search + - toc: serverless-security:// + path_prefix: /serverless/security + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + - toc: elasticsearch-reference:// + path_prefix: /elasticsearch/reference + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create all DocumentationSetNavigation instances + var checkoutDir = fileSystem.DirectoryInfo.New("/checkouts/current"); + var repositories = checkoutDir.GetDirectories(); + + var documentationSets = new List(); + + foreach (var repo in repositories) + { + var context = SiteNavigationTestFixture.CreateContext(fileSystem, repo.FullName, output); + + var docsetPath = fileSystem.File.Exists($"{repo.FullName}/docs/docset.yml") + ? $"{repo.FullName}/docs/docset.yml" + : $"{repo.FullName}/docs/_docset.yml"; + + var docsetYaml = fileSystem.File.ReadAllText(docsetPath); + var docset = DocumentationSetFile.Deserialize(docsetYaml); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + documentationSets.Add(navigation); + } + + var siteContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/observability", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + siteNavigation.Should().NotBeNull(); + siteNavigation.NavigationItems.Should().HaveCount(5); + + // Verify top-level items + var observability = siteNavigation.NavigationItems.ElementAt(0); + observability.Url.Should().Be("/serverless/observability"); + + var search = siteNavigation.NavigationItems.ElementAt(1); + search.Url.Should().Be("/serverless/search"); + + var security = siteNavigation.NavigationItems.ElementAt(2); + security.Url.Should().Be("/serverless/security"); + + var platform = siteNavigation.NavigationItems.ElementAt(3) as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/platform"); + platform.NavigationItems.Should().HaveCount(2); + + var elasticsearch = siteNavigation.NavigationItems.ElementAt(4); + elasticsearch.Url.Should().Be("/elasticsearch/reference"); + } + + [Fact] + public void DocumentationSetNavigationHasCorrectStructure() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test observability repository structure + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/observability/docs/docset.yml")); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + observabilityNav.NavigationTitle.Should().Be("Serverless Observability"); + observabilityNav.NavigationItems.Should().HaveCount(3); // index.md, getting-started folder, monitoring folder + + var indexFile = observabilityNav.NavigationItems.ElementAt(0); + indexFile.Should().BeOfType>(); + indexFile.Url.Should().Be("/"); + + var gettingStarted = observabilityNav.NavigationItems.ElementAt(1); + gettingStarted.Should().BeOfType(); + var gettingStartedFolder = (FolderNavigation)gettingStarted; + gettingStartedFolder.NavigationItems.Should().HaveCount(2); // quick-start.md, installation.md + + var monitoring = observabilityNav.NavigationItems.ElementAt(2); + monitoring.Should().BeOfType(); + var monitoringFolder = (FolderNavigation)monitoring; + monitoringFolder.NavigationItems.Should().HaveCount(4); // index.md, logs.md, metrics.md, traces.md + } + + [Fact] + public void DocumentationSetWithNestedTocs() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test platform repository with nested TOCs + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + platformNav.NavigationTitle.Should().Be("Platform"); + platformNav.NavigationItems.Should().HaveCount(3); // index.md, deployment-guide TOC, cloud-guide TOC + + var indexFile = platformNav.NavigationItems.ElementAt(0); + indexFile.Should().BeOfType>(); + indexFile.Url.Should().Be("/"); + + var deploymentGuide = platformNav.NavigationItems.ElementAt(1); + deploymentGuide.Should().BeOfType(); + deploymentGuide.Url.Should().Be("/deployment-guide"); + var deploymentToc = (TableOfContentsNavigation)deploymentGuide; + deploymentToc.NavigationItems.Should().HaveCount(2); // index.md, self-managed folder + + var cloudGuide = platformNav.NavigationItems.ElementAt(2); + cloudGuide.Should().BeOfType(); + cloudGuide.Url.Should().Be("/cloud-guide"); + var cloudToc = (TableOfContentsNavigation)cloudGuide; + cloudToc.NavigationItems.Should().HaveCount(3); // index.md, aws folder, azure folder + } + + [Fact] + public void DocumentationSetWithUnderscoreDocset() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test serverless-security repository with _docset.yml + var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); + var securityDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/serverless-security/docs/_docset.yml")); + var securityNav = new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance); + + securityNav.NavigationTitle.Should().Be("Serverless Security"); + securityNav.NavigationItems.Should().HaveCount(3); // index.md, authentication folder, authorization folder + + var authentication = securityNav.NavigationItems.ElementAt(1); + authentication.Should().BeOfType(); + var authenticationFolder = (FolderNavigation)authentication; + authenticationFolder.NavigationItems.Should().HaveCount(3); // index.md, api-keys.md, oauth.md + + var authorization = securityNav.NavigationItems.ElementAt(2); + authorization.Should().BeOfType(); + var authorizationFolder = (FolderNavigation)authorization; + authorizationFolder.NavigationItems.Should().HaveCount(2); // index.md, rbac.md + } + + [Fact] + public void SiteNavigationAppliesPathPrefixToAllUrls() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/observability/docs/docset.yml")); + var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + // Verify root URL has path prefix + var root = siteNavigation.NavigationItems.First(); + root.Url.Should().StartWith("/serverless/observability"); + + // Verify all nested items also have the path prefix + if (root is INodeNavigationItem nodeItem) + { + foreach (var item in nodeItem.NavigationItems) + item.Url.Should().StartWith("/serverless/observability"); + } + } + + [Fact] + public void SiteNavigationWithNestedTocsAppliesCorrectPathPrefixes() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + var documentationSets = new List { new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/platform"); + + // Verify child TOCs have their specific path prefixes + var deployment = platform.NavigationItems.ElementAt(0); + deployment.Url.Should().StartWith("/platform/deployment"); + + var cloud = platform.NavigationItems.ElementAt(1); + cloud.Url.Should().StartWith("/platform/cloud"); + } + + [Fact] + public void SiteNavigationRequiresPathPrefix() + { + // language=yaml - missing path_prefix + var siteNavYaml = """ + toc: + - toc: observability:// + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/observability/docs/docset.yml")); + var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + // navigation will still be build + siteNavigation.NavigationItems.Should().NotBeEmpty(); + + var toc = siteNavigation.NavigationItems.First() as SiteTableOfContentsNavigation; + toc.Should().NotBeNull(); + toc.PathPrefixProvider.PathPrefix.Should().Be("observability"); //constructed from toc URI as fallback + } +} diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs new file mode 100644 index 000000000..26798c293 --- /dev/null +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs @@ -0,0 +1,234 @@ +// 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.IO.Abstractions.TestingHelpers; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public static class SiteNavigationTestFixture +{ + public static MockFileSystem CreateMultiRepositoryFileSystem() + { + var fileSystem = new MockFileSystem(); + + // Repository 1: serverless-observability + SetupServerlessObservabilityRepository(fileSystem); + + // Repository 2: serverless-search + SetupServerlessSearchRepository(fileSystem); + + // Repository 3: serverless-security + SetupServerlessSecurityRepository(fileSystem); + + // Repository 4: platform + SetupPlatformRepository(fileSystem); + + // Repository 5: elasticsearch-reference + SetupElasticsearchReferenceRepository(fileSystem); + + return fileSystem; + } + + private static void SetupServerlessObservabilityRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/observability"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml + // language=yaml + var docsetYaml = """ + project: serverless-observability + toc: + - file: index.md + - folder: getting-started + children: + - file: quick-start.md + - file: installation.md + - folder: monitoring + children: + - file: index.md + - file: logs.md + - file: metrics.md + - file: traces.md + """; + fileSystem.AddFile($"{baseDir}/docs/docset.yml", new MockFileData(docsetYaml)); + + // Add markdown files + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Serverless Observability")); + fileSystem.AddFile($"{baseDir}/docs/getting-started/quick-start.md", new MockFileData("# Quick Start")); + fileSystem.AddFile($"{baseDir}/docs/getting-started/installation.md", new MockFileData("# Installation")); + fileSystem.AddFile($"{baseDir}/docs/monitoring/index.md", new MockFileData("# Monitoring")); + fileSystem.AddFile($"{baseDir}/docs/monitoring/logs.md", new MockFileData("# Logs")); + fileSystem.AddFile($"{baseDir}/docs/monitoring/metrics.md", new MockFileData("# Metrics")); + fileSystem.AddFile($"{baseDir}/docs/monitoring/traces.md", new MockFileData("# Traces")); + } + + private static void SetupServerlessSearchRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/serverless-search"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml + // language=yaml + var docsetYaml = """ + project: serverless-search + toc: + - file: index.md + - folder: indexing + children: + - file: index.md + - file: documents.md + - file: bulk-api.md + - folder: searching + children: + - file: index.md + - file: query-dsl.md + """; + fileSystem.AddFile($"{baseDir}/docs/docset.yml", new MockFileData(docsetYaml)); + + // Add markdown files + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Serverless Search")); + fileSystem.AddFile($"{baseDir}/docs/indexing/index.md", new MockFileData("# Indexing")); + fileSystem.AddFile($"{baseDir}/docs/indexing/documents.md", new MockFileData("# Documents")); + fileSystem.AddFile($"{baseDir}/docs/indexing/bulk-api.md", new MockFileData("# Bulk API")); + fileSystem.AddFile($"{baseDir}/docs/searching/index.md", new MockFileData("# Searching")); + fileSystem.AddFile($"{baseDir}/docs/searching/query-dsl.md", new MockFileData("# Query DSL")); + } + + private static void SetupServerlessSecurityRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/serverless-security"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml with underscore prefix + // language=yaml + var docsetYaml = """ + project: serverless-security + toc: + - file: index.md + - folder: authentication + children: + - file: index.md + - file: api-keys.md + - file: oauth.md + - folder: authorization + children: + - file: index.md + - file: rbac.md + """; + fileSystem.AddFile($"{baseDir}/docs/_docset.yml", new MockFileData(docsetYaml)); + + // Add markdown files + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Serverless Security")); + fileSystem.AddFile($"{baseDir}/docs/authentication/index.md", new MockFileData("# Authentication")); + fileSystem.AddFile($"{baseDir}/docs/authentication/api-keys.md", new MockFileData("# API Keys")); + fileSystem.AddFile($"{baseDir}/docs/authentication/oauth.md", new MockFileData("# OAuth")); + fileSystem.AddFile($"{baseDir}/docs/authorization/index.md", new MockFileData("# Authorization")); + fileSystem.AddFile($"{baseDir}/docs/authorization/rbac.md", new MockFileData("# RBAC")); + } + + private static void SetupPlatformRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/platform"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml + // language=yaml + var docsetYaml = """ + project: platform + toc: + - file: index.md + - toc: deployment-guide + - toc: cloud-guide + """; + fileSystem.AddFile($"{baseDir}/docs/docset.yml", new MockFileData(docsetYaml)); + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Platform")); + + // Deployment guide sub-TOC + var deploymentBaseDir = $"{baseDir}/docs/deployment-guide"; + fileSystem.AddDirectory(deploymentBaseDir); + // language=yaml + var deploymentTocYaml = """ + toc: + - file: index.md + - folder: self-managed + children: + - file: installation.md + - file: configuration.md + """; + fileSystem.AddFile($"{deploymentBaseDir}/toc.yml", new MockFileData(deploymentTocYaml)); + fileSystem.AddFile($"{deploymentBaseDir}/index.md", new MockFileData("# Deployment Guide")); + fileSystem.AddFile($"{deploymentBaseDir}/self-managed/installation.md", new MockFileData("# Installation")); + fileSystem.AddFile($"{deploymentBaseDir}/self-managed/configuration.md", new MockFileData("# Configuration")); + + // Cloud guide sub-TOC + var cloudBaseDir = $"{baseDir}/docs/cloud-guide"; + fileSystem.AddDirectory(cloudBaseDir); + // language=yaml + var cloudTocYaml = """ + toc: + - file: index.md + - folder: aws + children: + - file: setup.md + - folder: azure + children: + - file: setup.md + """; + fileSystem.AddFile($"{cloudBaseDir}/toc.yml", new MockFileData(cloudTocYaml)); + fileSystem.AddFile($"{cloudBaseDir}/index.md", new MockFileData("# Cloud Guide")); + fileSystem.AddFile($"{cloudBaseDir}/aws/setup.md", new MockFileData("# AWS Setup")); + fileSystem.AddFile($"{cloudBaseDir}/azure/setup.md", new MockFileData("# Azure Setup")); + } + + private static void SetupElasticsearchReferenceRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/elasticsearch-reference"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml + // language=yaml + var docsetYaml = """ + project: elasticsearch-reference + toc: + - file: index.md + - folder: rest-apis + children: + - file: index.md + - file: document-apis.md + - file: search-apis.md + - folder: query-dsl + children: + - file: index.md + - file: term-queries.md + - file: full-text-queries.md + """; + fileSystem.AddFile($"{baseDir}/docs/docset.yml", new MockFileData(docsetYaml)); + + // Add markdown files + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Elasticsearch Reference")); + fileSystem.AddFile($"{baseDir}/docs/rest-apis/index.md", new MockFileData("# REST APIs")); + fileSystem.AddFile($"{baseDir}/docs/rest-apis/document-apis.md", new MockFileData("# Document APIs")); + fileSystem.AddFile($"{baseDir}/docs/rest-apis/search-apis.md", new MockFileData("# Search APIs")); + fileSystem.AddFile($"{baseDir}/docs/query-dsl/index.md", new MockFileData("# Query DSL")); + fileSystem.AddFile($"{baseDir}/docs/query-dsl/term-queries.md", new MockFileData("# Term Queries")); + fileSystem.AddFile($"{baseDir}/docs/query-dsl/full-text-queries.md", new MockFileData("# Full Text Queries")); + } + + public static TestDocumentationSetContext CreateContext(MockFileSystem fileSystem, string repositoryPath, ITestOutputHelper output) + { + var sourceDir = fileSystem.DirectoryInfo.New($"{repositoryPath}/docs"); + var outputDir = fileSystem.DirectoryInfo.New("/output"); + + // Try to find docset.yml or _docset.yml + var configPath = fileSystem.File.Exists($"{sourceDir.FullName}/docset.yml") + ? fileSystem.FileInfo.New($"{sourceDir.FullName}/docset.yml") + : fileSystem.FileInfo.New($"{sourceDir.FullName}/_docset.yml"); + + // Extract repository name from path (e.g., "/checkouts/current/platform" -> "platform") + var repositoryName = fileSystem.Path.GetFileName(repositoryPath); + + return new TestDocumentationSetContext(fileSystem, sourceDir, outputDir, configPath, output, repositoryName); + } +} diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs new file mode 100644 index 000000000..2172073fa --- /dev/null +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -0,0 +1,94 @@ +// 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.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Configuration.DocSet; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Tests.Isolation; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public class SiteNavigationTests(ITestOutputHelper output) +{ + private TestDocumentationSetContext CreateContext(MockFileSystem? fileSystem = null) + { + fileSystem ??= new MockFileSystem(); + var sourceDir = fileSystem.DirectoryInfo.New("/docs"); + var outputDir = fileSystem.DirectoryInfo.New("/output"); + var configPath = fileSystem.FileInfo.New("/docs/navigation.yml"); + + return new TestDocumentationSetContext(fileSystem, sourceDir, outputDir, configPath, output); + } + + [Fact] + public void ConstructorCreatesSiteNavigation() + { + // language=yaml + var yaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + - toc: serverless-search:// + path_prefix: /serverless/search + """; + + var siteNavFile = SiteNavigationFile.Deserialize(yaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create DocumentationSetNavigation instances for the referenced repos + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/observability/docs/docset.yml")); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); + var searchDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/serverless-search/docs/docset.yml")); + var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); + + var documentationSets = new List { observabilityNav, searchNav }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + navigation.Should().NotBeNull(); + navigation.Url.Should().Be("/"); + navigation.NavigationTitle.Should().Be("Serverless Observability"); + navigation.NavigationItems.Should().HaveCount(2); + } + + [Fact] + public void SiteNavigationWithNestedChildren() + { + // language=yaml + var yaml = """ + toc: + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + """; + + var siteNavFile = SiteNavigationFile.Deserialize(yaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create DocumentationSetNavigation for platform + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.Deserialize(fileSystem.File.ReadAllText("/checkouts/current/platform/docs/docset.yml")); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + var documentationSets = new List { platformNav }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets); + + navigation.NavigationItems.Should().HaveCount(1); + + var platform = navigation.NavigationItems.First(); + platform.Should().NotBeNull(); + } +} diff --git a/tests/Navigation.Tests/Isolation/ConstructorTests.cs b/tests/Navigation.Tests/Isolation/ConstructorTests.cs new file mode 100644 index 000000000..125619767 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/ConstructorTests.cs @@ -0,0 +1,278 @@ +// 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.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Configuration.DocSet; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class ConstructorTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void ConstructorInitializesRootProperties() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationRoot.Should().BeSameAs(navigation); + navigation.Parent.Should().BeNull(); + navigation.Depth.Should().Be(0); + navigation.Hidden.Should().BeFalse(); + navigation.IsCrossLink.Should().BeFalse(); + navigation.Id.Should().NotBeNullOrEmpty(); + navigation.NavigationTitle.Should().Be("index"); + navigation.IsUsingNavigationDropdown.Should().BeFalse(); + navigation.Url.Should().Be("/"); + } + + [Fact] + public void ConstructorSetsIsUsingNavigationDropdownFromFeatures() + { + // language=yaml + var yaml = """ + project: 'test-project' + features: + primary-nav: true + toc: + - file: index.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.IsUsingNavigationDropdown.Should().BeTrue(); + } + + [Fact] + public void ConstructorCreatesFileNavigationLeafFromFileRef() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: getting-started.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var fileNav = navigation.NavigationItems.First().Should().BeOfType>().Subject; + fileNav.NavigationTitle.Should().Be("getting-started"); + fileNav.Url.Should().Be("/getting-started"); + fileNav.Hidden.Should().BeFalse(); + fileNav.NavigationRoot.Should().BeSameAs(navigation); + fileNav.Parent.Should().BeNull(); + } + + [Fact] + public void ConstructorCreatesHiddenFileNavigationLeaf() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - hidden: 404.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var fileNav = navigation.NavigationItems.First().Should().BeOfType>().Subject; + fileNav.Hidden.Should().BeTrue(); + fileNav.Url.Should().Be("/404"); + } + + [Fact] + public void ConstructorCreatesCrossLinkNavigation() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - title: "External Guide" + crosslink: docs-content://guide.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var crossLink = navigation.NavigationItems.First().Should().BeOfType().Subject; + crossLink.NavigationTitle.Should().Be("External Guide"); + crossLink.Url.Should().Be("docs-content://guide.md"); + crossLink.IsCrossLink.Should().BeTrue(); + } + + [Fact] + public void ConstructorCreatesFolderNavigationWithChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - file: index.md + - file: install.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var folder = navigation.NavigationItems.First().Should().BeOfType().Subject; + folder.Depth.Should().Be(1); + folder.Url.Should().Be("/setup"); + folder.NavigationItems.Should().HaveCount(2); + + var firstFile = folder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + firstFile.Url.Should().Be("/setup"); // index.md becomes /setup + firstFile.Parent.Should().BeSameAs(folder); + + var secondFile = folder.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + secondFile.Url.Should().Be("/setup/install"); + } + + [Fact] + public void ConstructorCreatesTableOfContentsNavigationWithChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - toc: api + """; + + // language=yaml + var tocYaml = """ + toc: + - file: index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + fileSystem.AddFile("/docs/api/toc.yml", new MockFileData(tocYaml)); + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var toc = navigation.NavigationItems.First().Should().BeOfType().Subject; + toc.Depth.Should().Be(1); + toc.Url.Should().Be("/api"); + toc.NavigationItems.Should().HaveCount(1); + + var file = toc.NavigationItems.First().Should().BeOfType>().Subject; + file.Url.Should().Be("/api"); // index.md becomes /api + file.Parent.Should().BeSameAs(toc); + file.NavigationRoot.Should().BeSameAs(navigation); + } + + [Fact] + public void ConstructorReadsTableOfContentsFromTocYmlFile() + { + // language=yaml + var docSetYaml = """ + project: 'test-project' + toc: + - toc: api + """; + + // language=yaml + var tocYaml = """ + toc: + - file: overview.md + - file: reference.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + fileSystem.AddFile("/docs/api/toc.yml", new MockFileData(tocYaml)); + + var docSet = DocumentationSetFile.Deserialize(docSetYaml); + var context = CreateContext(fileSystem); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var toc = navigation.NavigationItems.First().Should().BeOfType().Subject; + toc.NavigationItems.Should().HaveCount(2); + + var overview = toc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + overview.Url.Should().Be("/api/overview"); + + var reference = toc.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + reference.Url.Should().Be("/api/reference"); + } + + [Fact] + public async Task ConstructorProcessesTocYmlItemsBeforeChildrenFromNavigation() + { + // language=yaml + var docSetYaml = """ + project: 'test-project' + toc: + - toc: api + children: + - toc: extra + """; + + // language=yaml + var tocYaml = """ + toc: + - file: from-toc.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + fileSystem.AddDirectory("/docs/api/extra"); + fileSystem.AddFile("/docs/api/toc.yml", new MockFileData(tocYaml)); + + var docSet = DocumentationSetFile.Deserialize(docSetYaml); + var context = CreateContext(fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + var apiToc = navigation.NavigationItems.First().Should().BeOfType().Subject; + apiToc.NavigationItems.Should().HaveCount(1); + + // First item should be from api/toc.yml + var fromToc = apiToc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + fromToc.NavigationTitle.Should().Be("from-toc"); + fromToc.Url.Should().Be("/api/from-toc"); + + apiToc.NavigationItems.Should().HaveCount(1); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var diagnostics = context.Diagnostics; + diagnostics.Should().ContainSingle(d => + d.Message.Contains("TableOfContents 'api' may not contain children, define children in 'api/toc.yml' instead.")); + } +} diff --git a/tests/Navigation.Tests/Isolation/DocumentationSetNavigationTestBase.cs b/tests/Navigation.Tests/Isolation/DocumentationSetNavigationTestBase.cs new file mode 100644 index 000000000..b81d584eb --- /dev/null +++ b/tests/Navigation.Tests/Isolation/DocumentationSetNavigationTestBase.cs @@ -0,0 +1,20 @@ +// 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.IO.Abstractions.TestingHelpers; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public abstract class DocumentationSetNavigationTestBase(ITestOutputHelper output) +{ + protected TestDocumentationSetContext CreateContext(MockFileSystem? fileSystem = null) + { + fileSystem ??= new MockFileSystem(); + var sourceDir = fileSystem.DirectoryInfo.New("/docs"); + var outputDir = fileSystem.DirectoryInfo.New("/output"); + var configPath = fileSystem.FileInfo.New("/docs/docset.yml"); + + return new TestDocumentationSetContext(fileSystem, sourceDir, outputDir, configPath, output, "docs-builder"); + } +} diff --git a/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs b/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs new file mode 100644 index 000000000..abda9df98 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs @@ -0,0 +1,271 @@ +// 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.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Configuration.DocSet; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class DynamicUrlTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void DynamicUrlUpdatesWhenRootUrlChanges() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - file: install.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + var file = folder!.NavigationItems.First(); + + // Initial URL + file.Url.Should().Be("/setup/install"); + + // Change root URL + navigation.PathPrefixProvider = new PathPrefixProvider("/v8.0"); + + // URLs should update dynamically + // Since folder has no index child, its URL is the first child's URL + folder.Url.Should().Be("/v8.0/setup/install"); + file.Url.Should().Be("/v8.0/setup/install"); + + // Change root URL + navigation.PathPrefixProvider = new PathPrefixProvider("/v9.0"); + + // URLs should update dynamically + // Since folder has no index child, its URL is the first child's URL + folder.Url.Should().Be("/v9.0/setup/install"); + file.Url.Should().Be("/v9.0/setup/install"); + } + + [Fact] + public void UrlRootPropagatesCorrectlyThroughFolders() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: outer + children: + - folder: inner + children: + - file: deep.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var outerFolder = navigation.NavigationItems.First() as FolderNavigation; + var innerFolder = outerFolder!.NavigationItems.First() as FolderNavigation; + var file = innerFolder!.NavigationItems.First(); + + file.Url.Should().Be("/outer/inner/deep"); + + // Change root URL + navigation.PathPrefixProvider = new PathPrefixProvider("/base"); + + file.Url.Should().Be("/base/outer/inner/deep"); + } + + [Fact] + public void FolderWithoutIndexUsesFirstChildUrl() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: getting-started.md + - file: advanced.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has no index.md, so URL should be the first child's URL + folder!.Url.Should().Be("/guides/getting-started"); + } + + [Fact] + public void FolderWithNestedChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: getting-started.md + children: + - file: advanced.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has no index.md, so URL should be the first child's URL + folder!.Url.Should().Be("/guides/getting-started"); + + var gettingStarted = folder.NavigationItems.First() as VirtualFileNavigation; + gettingStarted.Should().NotBeNull(); + gettingStarted.Url.Should().Be("/guides/getting-started"); + var advanced = gettingStarted.NavigationItems.First() as FileNavigationLeaf; + advanced.Should().NotBeNull(); + advanced.Url.Should().Be("/guides/getting-started/advanced"); + + advanced.Parent.Should().BeSameAs(gettingStarted); + gettingStarted.Parent.Should().BeSameAs(folder); + } + + [Fact] + public void FolderWithNestedDeeplinkedChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: clients/getting-started.md + children: + - file: clients/advanced.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has no index.md, so URL should be the first child's URL + folder!.Url.Should().Be("/guides/clients/getting-started"); + + var gettingStarted = folder.NavigationItems.First() as VirtualFileNavigation; + gettingStarted.Should().NotBeNull(); + gettingStarted.Url.Should().Be("/guides/clients/getting-started"); + var advanced = gettingStarted.NavigationItems.First() as FileNavigationLeaf; + advanced.Should().NotBeNull(); + advanced.Url.Should().Be("/guides/clients/getting-started/advanced"); + + advanced.Parent.Should().BeSameAs(gettingStarted); + gettingStarted.Parent.Should().BeSameAs(folder); + } + + [Fact] + public void FolderWithNestedDeeplinkedOfIndexChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: clients/index.md + children: + - file: clients/advanced.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has no index.md, so URL should be the first child's URL + folder!.Url.Should().Be("/guides/clients"); + + var gettingStarted = folder.NavigationItems.First() as VirtualFileNavigation; + gettingStarted.Should().NotBeNull(); + gettingStarted.Url.Should().Be("/guides/clients"); + var advanced = gettingStarted.NavigationItems.First() as FileNavigationLeaf; + advanced.Should().NotBeNull(); + advanced.Url.Should().Be("/guides/clients/advanced"); + + advanced.Parent.Should().BeSameAs(gettingStarted); + gettingStarted.Parent.Should().BeSameAs(folder); + } + + [Fact] + public void FolderWithIndexUsesOwnUrl() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: index.md + - file: advanced.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has index.md, so URL should be the folder path + folder!.Url.Should().Be("/guides"); + } + + [Fact] + public void UrlRootChangesForTableOfContentsNavigation() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - toc: api + """; + + // language=yaml + var tocYaml = """ + toc: + - file: reference.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/guides/api"); + fileSystem.AddFile("/docs/guides/api/toc.yml", new MockFileData(tocYaml)); + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + var toc = folder!.NavigationItems.First() as TableOfContentsNavigation; + var file = toc!.NavigationItems.First(); + + // The TOC becomes the new URL root, so the file URL is based on TOC's URL + toc.Url.Should().Be("/guides/api/reference"); + file.Url.Should().Be("/guides/api/reference"); + + // Change root URL + navigation.PathPrefixProvider = new PathPrefixProvider("/v2"); + + // Both TOC and file URLs should update + toc.Url.Should().Be("/v2/guides/api/reference"); + file.Url.Should().Be("/v2/guides/api/reference"); + } +} diff --git a/tests/Navigation.Tests/Isolation/FileNavigationTests.cs b/tests/Navigation.Tests/Isolation/FileNavigationTests.cs new file mode 100644 index 000000000..06a2f4360 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/FileNavigationTests.cs @@ -0,0 +1,195 @@ +// 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.Configuration.DocSet; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class FileNavigationTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void FileWithNoChildrenCreatesFileNavigationLeaf() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: getting-started.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var fileNav = navigation.NavigationItems.First().Should().BeOfType>().Subject; + fileNav.Url.Should().Be("/getting-started"); + } + + [Fact] + public void FileWithChildrenCreatesFileNavigation() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: section1.md + - file: section2.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var fileNav = navigation.NavigationItems.First().Should().BeOfType>().Subject; + fileNav.Url.Should().Be("/guide"); + fileNav.NavigationItems.Should().HaveCount(2); + + var section1 = fileNav.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + section1.Url.Should().Be("/guide/section1"); + section1.Parent.Should().BeSameAs(fileNav); + + var section2 = fileNav.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + section2.Url.Should().Be("/guide/section2"); + section2.Parent.Should().BeSameAs(fileNav); + } + + [Fact] + public void FileWithChildrenDeeplinksPreservesPaths() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: nest/guide.md + children: + - file: nest/section1.md + - file: nest/section2.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var fileNav = navigation.NavigationItems.First().Should().BeOfType>().Subject; + fileNav.Url.Should().Be("/nest/guide"); + fileNav.NavigationItems.Should().HaveCount(2); + + var section1 = fileNav.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + section1.Url.Should().Be("/nest/guide/section1"); + section1.Parent.Should().BeSameAs(fileNav); + + var section2 = fileNav.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + section2.Url.Should().Be("/nest/guide/section2"); + section2.Parent.Should().BeSameAs(fileNav); + } + + [Fact] + public void FileWithNestedChildrenBuildsCorrectly() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + children: + - file: subsection.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var guideFile = navigation.NavigationItems.First().Should().BeOfType>().Subject; + guideFile.Url.Should().Be("/guide"); + guideFile.NavigationItems.Should().HaveCount(1); + + var chapter1 = guideFile.NavigationItems.First().Should().BeOfType>().Subject; + chapter1.Url.Should().Be("/guide/chapter1"); + chapter1.Parent.Should().BeSameAs(guideFile); + chapter1.NavigationItems.Should().HaveCount(1); + + var subsection = chapter1.NavigationItems.First().Should().BeOfType>().Subject; + subsection.Url.Should().Be("/guide/chapter1/subsection"); + subsection.Parent.Should().BeSameAs(chapter1); + } + + [Fact] + public void FileNavigationUrlUpdatesWhenRootChanges() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: section1.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var fileNav = navigation.NavigationItems.First() as INodeNavigationItem; + var child = fileNav!.NavigationItems.First(); + + // Initial URLs + fileNav.Url.Should().Be("/guide"); + child.Url.Should().Be("/guide/section1"); + + // Change root URL + navigation.PathPrefixProvider = new PathPrefixProvider("/v2"); + + // URLs should update dynamically + fileNav.Url.Should().Be("/v2/guide"); + child.Url.Should().Be("/v2/guide/section1"); + } + + [Fact] + public void FileNavigationMixedWithFolderChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: intro.md + - folder: advanced + children: + - file: topics.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + var guideFile = navigation.NavigationItems.First().Should().BeOfType>().Subject; + guideFile.NavigationItems.Should().HaveCount(2); + + var intro = guideFile.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + intro.Url.Should().Be("/guide/intro"); + + var advancedFolder = guideFile.NavigationItems.ElementAt(1).Should().BeOfType().Subject; + advancedFolder.Url.Should().Be("/guide/advanced/topics"); // No index, uses first child + advancedFolder.NavigationItems.Should().HaveCount(1); + + var topics = advancedFolder.NavigationItems.First().Should().BeOfType>().Subject; + topics.Url.Should().Be("/guide/advanced/topics"); + } +} diff --git a/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs b/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs new file mode 100644 index 000000000..7c54a8468 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs @@ -0,0 +1,255 @@ +// 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.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Configuration.DocSet; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class NavigationStructureTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void NavigationIndexIsSetCorrectly() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: first.md + - file: second.md + - file: third.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + navigation.NavigationIndex.Should().Be(0); + navigation.NavigationItems.ElementAt(0).NavigationIndex.Should().Be(1); + navigation.NavigationItems.ElementAt(1).NavigationIndex.Should().Be(2); + navigation.NavigationItems.ElementAt(2).NavigationIndex.Should().Be(3); + } + + [Fact] + public void CanQueryNavigationForBothInterfaceAndConcreteTypes() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: first.md + - folder: guides + children: + - file: second.md + - file: third.md + - file: fourth.md + """; + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(); + + // Create navigation using the covariant factory interface + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + // Query for all leaf items using the base interface type + var allLeafItems = navigation.NavigationItems + .SelectMany(item => item is INodeNavigationItem node + ? node.NavigationItems.OfType>() + : item is ILeafNavigationItem leaf + ? [leaf] + : []) + .ToList(); + + // All items are queryable as ILeafNavigationItem due to covariance + allLeafItems.Should().HaveCount(4); + allLeafItems.Should().AllBeAssignableTo>(); + allLeafItems.Should().AllBeAssignableTo>(); + allLeafItems.Select(l => l.NavigationTitle).Should().BeEquivalentTo(["first", "second", "third", "fourth"]); + + // The navigation items themselves are FileNavigationLeaf at runtime + allLeafItems.Should().AllBeOfType>(); + + // And the Model property on each leaf contains TestDocumentationFile instances + var allModels = allLeafItems.Select(l => l.Model).ToList(); + allModels.Should().AllBeOfType(); + + // Access the underlying model through the interface + foreach (var leaf in allLeafItems) + { + // The Model property returns IDocumentationFile due to covariance + leaf.Model.Should().BeAssignableTo(); + leaf.Model.NavigationTitle.Should().NotBeNullOrEmpty(); + + // But at runtime, it's still TestDocumentationFile + leaf.Model.Should().BeOfType(); + + // Can access concrete type through pattern matching without explicit cast + if (leaf.Model is TestDocumentationFile concreteFile) + concreteFile.NavigationTitle.Should().Be(leaf.NavigationTitle); + } + + // Demonstrate type-safe LINQ queries work with the interface type + var firstItem = allLeafItems.FirstOrDefault(l => l.Model.NavigationTitle == "first"); + firstItem.Should().NotBeNull(); + firstItem.Url.Should().Be("/first"); + } + + [Fact] + public async Task ComplexNestedStructureBuildsCorrectly() + { + // language=yaml + var yaml = """ + project: 'docs-builder' + features: + primary-nav: true + toc: + - file: index.md + - folder: setup + children: + - file: index.md + - toc: advanced + - title: "External" + crosslink: other://link.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/setup/advanced"); + fileSystem.AddDirectory("/docs/setup/advanced/performance"); + fileSystem.AddFile("/docs/setup/advanced/toc.yml", new MockFileData( + // language=yaml + """ + toc: + - file: index.md + - toc: performance + """)); + + // language=yaml + var performanceTocYaml = """ + toc: + - file: index.md + - file: tuning.md + - file: benchmarks.md + """; + fileSystem.AddFile("/docs/setup/advanced/performance/toc.yml", new MockFileData(performanceTocYaml)); + // Add index.md files that should be automatically discovered as placeholders + fileSystem.AddFile("/docs/setup/advanced/index.md", new MockFileData("# Advanced")); + fileSystem.AddFile("/docs/setup/advanced/performance/index.md", new MockFileData("# Performance")); + fileSystem.AddFile("/docs/setup/advanced/performance/tuning.md", new MockFileData("# Tuning")); + fileSystem.AddFile("/docs/setup/advanced/performance/benchmarks.md", new MockFileData("# Benchmarks")); + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + navigation.NavigationItems.Should().HaveCount(3); + navigation.IsUsingNavigationDropdown.Should().BeTrue(); + + // First item: simple file + var indexFile = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + indexFile.Url.Should().Be("/"); // index.md becomes / + + // Second item: complex nested structure + var setupFolder = navigation.NavigationItems.ElementAt(1).Should().BeOfType().Subject; + setupFolder.NavigationItems.Should().HaveCount(2); + setupFolder.Url.Should().Be("/setup"); + + var setupIndex = setupFolder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + setupIndex.Url.Should().Be("/setup"); // index.md becomes /setup + + var advancedToc = setupFolder.NavigationItems.ElementAt(1).Should().BeOfType().Subject; + advancedToc.Url.Should().Be("/setup/advanced"); + // Advanced TOC has index.md and the nested performance TOC as children + advancedToc.NavigationItems.Should().HaveCount(2); + + var advancedIndex = advancedToc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + advancedIndex.Url.Should().Be("/setup/advanced"); + + var performanceToc = advancedToc.NavigationItems.ElementAt(1).Should().BeOfType().Subject; + performanceToc.Url.Should().Be("/setup/advanced/performance"); + performanceToc.NavigationItems.Should().HaveCount(3); + + var performanceIndex = performanceToc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + performanceIndex.Url.Should().Be("/setup/advanced/performance"); + + var tuning = performanceToc.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + tuning.Url.Should().Be("/setup/advanced/performance/tuning"); + + var benchmarks = performanceToc.NavigationItems.ElementAt(2).Should().BeOfType>().Subject; + benchmarks.Url.Should().Be("/setup/advanced/performance/benchmarks"); + + // Third item: crosslink + var crosslink = navigation.NavigationItems.ElementAt(2).Should().BeOfType().Subject; + crosslink.IsCrossLink.Should().BeTrue(); + + // Verify no errors were emitted + context.Diagnostics.Should().BeEmpty(); + } + + [Fact] + public void NestedTocUrlsDoNotDuplicatePath() + { + // This test verifies that nested TOC URLs are constructed correctly + // without duplicating path segments (e.g., /setup/advanced not /setup/setup/advanced) + + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - file: index.md + - toc: advanced + """; + + // language=yaml + var advancedTocYaml = """ + toc: + - file: index.md + - toc: performance + """; + + // language=yaml + var performanceTocYaml = """ + toc: + - file: index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddFile("/docs/setup/index.md", new MockFileData("# Setup")); + fileSystem.AddFile("/docs/setup/advanced/index.md", new MockFileData("# Advanced Setup")); + fileSystem.AddFile("/docs/setup/advanced/toc.yml", new MockFileData(advancedTocYaml)); + fileSystem.AddFile("/docs/setup/advanced/performance/index.md", new MockFileData("# Performance")); + fileSystem.AddFile("/docs/setup/advanced/performance/toc.yml", new MockFileData(performanceTocYaml)); + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + var setupFolder = navigation.NavigationItems.First().Should().BeOfType().Subject; + setupFolder.Url.Should().Be("/setup"); + + // Setup folder has index.md and advanced TOC + setupFolder.NavigationItems.Should().HaveCount(2); + + var advancedToc = setupFolder.NavigationItems.ElementAt(1).Should().BeOfType().Subject; + // Verify the URL is /setup/advanced and not /setup/setup/advanced + advancedToc.Url.Should().Be("/setup/advanced"); + + // Advanced TOC has index.md and performance TOC + advancedToc.NavigationItems.Should().HaveCount(2); + + var performanceToc = advancedToc.NavigationItems.ElementAt(1).Should().BeOfType().Subject; + // Verify the URL is /setup/advanced/performance and not /setup/advanced/setup/advanced/performance + performanceToc.Url.Should().Be("/setup/advanced/performance"); + + context.Diagnostics.Should().BeEmpty(); + } +} diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs new file mode 100644 index 000000000..8e8a7fcc3 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -0,0 +1,183 @@ +// 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.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.DocSet; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class PhysicalDocsetTests(ITestOutputHelper output) +{ + [Fact] + public async Task PhysicalDocsetCanBeNavigated() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + File.Exists(docsetPath).Should().BeTrue($"Expected docset file to exist at {docsetPath}"); + + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Assert navigation was built successfully + navigation.NavigationItems.Should().NotBeEmpty(); + + // Assert index.md is first + var firstItem = navigation.NavigationItems.ElementAt(0); + firstItem.Should().BeOfType>(); + firstItem.Url.Should().Be("/"); // index.md becomes / + + // Assert folders exist + var folders = navigation.NavigationItems.OfType().ToList(); + folders.Should().NotBeEmpty(); + + // Check by URL since folder names derive from index file titles + var folderUrls = folders.Select(f => f.Url).ToList(); + folderUrls.Should().Contain("/contribute"); + + // No errors should be emitted during navigation construction + context.Diagnostics.Should().BeEmpty(); + } + + [Fact] + public async Task PhysicalDocsetNavigationHasCorrectUrls() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Find the contribute folder by URL + var contributeFolder = navigation.NavigationItems.OfType() + .FirstOrDefault(f => f.Url == "/contribute"); + contributeFolder.Should().NotBeNull(); + + // Verify nested structure + contributeFolder.NavigationItems.Should().NotBeEmpty(); + } + + [Fact] + public async Task PhysicalDocsetNavigationIncludesNestedTocs() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Find TOC references in the navigation + var tocNavs = navigation.NavigationItems.OfType().ToList(); + tocNavs.Should().NotBeEmpty(); + + // development TOC should exist (check by URL) + var developmentToc = tocNavs.FirstOrDefault(t => t.Url == "/development"); + developmentToc.Should().NotBeNull(); + + developmentToc.NavigationItems.Should().HaveCount(2); + developmentToc.NavigationItems.OfType>().Should().HaveCount(1); + developmentToc.NavigationItems.OfType().Should().HaveCount(1); + + var developmentIndex = developmentToc.NavigationItems.OfType>().First(); + developmentIndex.FileInfo.FullName.Should().Be(Path.Combine(docsDir.FullName, "development", "index.md")); + + + } + + [Fact] + public async Task PhysicalDocsetNavigationHandlesHiddenFiles() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Find hidden files + var allItems = GetAllNavigationItems(navigation.NavigationItems); + var hiddenItems = allItems.Where(i => i.Hidden).ToList(); + hiddenItems.Should().NotBeEmpty(); + } + + [Fact] + public async Task PhysicalDocsetNavigationHandlesCrossLinks() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + var docSet = DocumentationSetFile.Deserialize(yaml); + + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Find cross-link items + var allItems = GetAllNavigationItems(navigation.NavigationItems); + var crossLinks = allItems.OfType().ToList(); + crossLinks.Should().NotBeEmpty(); + crossLinks.Should().AllSatisfy(cl => cl.IsCrossLink.Should().BeTrue()); + } + + private static List GetAllNavigationItems(IReadOnlyCollection items) + { + var result = new List(); + foreach (var item in items) + { + result.Add(item); + if (item is INodeNavigationItem node) + result.AddRange(GetAllNavigationItems(node.NavigationItems)); + } + return result; + } +} diff --git a/tests/Navigation.Tests/Isolation/ValidationTests.cs b/tests/Navigation.Tests/Isolation/ValidationTests.cs new file mode 100644 index 000000000..6ea828a4b --- /dev/null +++ b/tests/Navigation.Tests/Isolation/ValidationTests.cs @@ -0,0 +1,172 @@ +// 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.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Configuration.DocSet; +using Elastic.Documentation.Navigation.Isolated; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class ValidationTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public async Task ValidationEmitsErrorWhenTableOfContentsHasNonTocChildrenAndNestedTocNotAllowed() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - toc: api + children: + - toc: nested-toc + children: + - file: should-error.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + fileSystem.AddDirectory("/docs/api/nested-toc"); + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Message.Contains("may not contain children, define children in") && + d.Message.Contains("toc.yml")); + } + + [Fact] + public async Task ValidationEmitsErrorWhenTableOfContentsHasNonTocChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - toc: api + children: + - file: should-error.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Check using Errors count instead of Diagnostics collection + context.Collector.Errors.Should().BeGreaterThan(0); + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Message.Contains("may not contain children, define children in") && + d.Message.Contains("toc.yml")); + } + + [Fact] + public async Task ValidationEmitsErrorForNestedTocWithFileChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - toc: advanced + children: + - toc: performance + children: + - file: index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/setup/advanced"); + fileSystem.AddDirectory("/docs/setup/advanced/performance"); + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Nested TOC under a root-level TOC should not allow file children + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Message.Contains("may not contain children, define children in") && + d.Message.Contains("toc.yml")); + } + + [Fact] + public async Task ValidationEmitsErrorForDeeplyNestedFolderWithInvalidTocStructure() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: docs + children: + - folder: guides + children: + - toc: api + children: + - toc: endpoints + children: + - file: users.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/docs/guides/api"); + fileSystem.AddDirectory("/docs/docs/guides/api/endpoints"); + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Nested TOC structure under folders should still validate correctly + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Message.Contains("may not contain children, define children in") && + d.Message.Contains("toc.yml")); + } + + [Fact] + public async Task ValidationEmitsErrorWhenTocYmlFileNotFound() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - toc: api + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + // Note: not adding /docs/api/toc.yml file + + var docSet = DocumentationSetFile.Deserialize(yaml); + var context = CreateContext(fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var diagnostics = context.Diagnostics; + diagnostics.Should().ContainSingle(d => + d.Message.Contains("Table of contents file not found") && + d.Message.Contains("api/toc.yml")); + } +} diff --git a/tests/Navigation.Tests/Navigation.Tests.csproj b/tests/Navigation.Tests/Navigation.Tests.csproj new file mode 100644 index 000000000..49dc92b5d --- /dev/null +++ b/tests/Navigation.Tests/Navigation.Tests.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + Elastic.Documentation.Navigation.Tests + + + + + + + + + + + + + + diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs new file mode 100644 index 000000000..a8da5be12 --- /dev/null +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -0,0 +1,127 @@ +// 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.IO.Abstractions; +using Elastic.Documentation; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated; +using Markdig; +using Markdig.Parsers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Elastic.Documentation.Navigation.Tests; + +public class TestDiagnosticsOutput(ITestOutputHelper output) : IDiagnosticsOutput +{ + public void Write(Diagnostic diagnostic) + { + if (diagnostic.Severity == Severity.Error) + output.WriteLine($"Error: {diagnostic.Message} ({diagnostic.File}:{diagnostic.Line})"); + else + output.WriteLine($"Warn : {diagnostic.Message} ({diagnostic.File}:{diagnostic.Line})"); + } +} + +public class TestDiagnosticsCollector(ITestOutputHelper output) + : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) +{ + private readonly List _diagnostics = []; + + public IReadOnlyCollection Diagnostics => _diagnostics; + + protected override void HandleItem(Diagnostic diagnostic) => _diagnostics.Add(diagnostic); +} + +public class TestDocumentationSetContext : IDocumentationSetContext +{ + public TestDocumentationSetContext( + IFileSystem fileSystem, + IDirectoryInfo sourceDirectory, + IDirectoryInfo outputDirectory, + IFileInfo configPath, + ITestOutputHelper output, + string? repository = null + ) + { + ReadFileSystem = fileSystem; + WriteFileSystem = fileSystem; + DocumentationSourceDirectory = sourceDirectory; + OutputDirectory = outputDirectory; + ConfigurationPath = configPath; + Collector = new TestDiagnosticsCollector(output); + Git = repository is null ? GitCheckoutInformation.Unavailable : new GitCheckoutInformation + { + Branch = "main", + Remote = $"elastic/{repository}", + Ref = "main", + RepositoryName = repository + }; + + // Start the diagnostics collector to process messages + _ = Collector.StartAsync(CancellationToken.None); + } + + public IDiagnosticsCollector Collector { get; } + public IFileSystem ReadFileSystem { get; } + public IFileSystem WriteFileSystem { get; } + public IDirectoryInfo OutputDirectory { get; } + public IDirectoryInfo DocumentationSourceDirectory { get; } + public GitCheckoutInformation Git { get; } + public IFileInfo ConfigurationPath { get; } + + public IReadOnlyCollection Diagnostics => ((TestDiagnosticsCollector)Collector).Diagnostics; +} + +public class TestDocumentationFile(string navigationTitle) : IDocumentationFile +{ + /// + public string NavigationTitle { get; } = navigationTitle; +} + +public class TestDocumentationFileFactory : IDocumentationFileFactory +{ + // Preserve concrete type to leverage covariance properly + public static TestDocumentationFileFactory Instance { get; } = new(); + + /// + public TestDocumentationFile TryCreateDocumentationFile(IFileInfo path, IFileSystem readFileSystem) + { + // Extract the title from the file name (without extension) + var fileName = path.Name; + var title = fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? fileName[..^3] + : fileName; + return new TestDocumentationFile(title); + } +} + +// Factory that creates base IDocumentationFile instances for tests that don't need concrete types +public class GenericDocumentationFileFactory : IDocumentationFileFactory +{ + public static GenericDocumentationFileFactory Instance { get; } = new(); + + /// + public IDocumentationFile TryCreateDocumentationFile(IFileInfo path, IFileSystem readFileSystem) + { + // Extract the title from the file name (without extension) + var fileName = path.Name; + var title = fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? fileName[..^3] + : fileName; + if (path.Exists) + { + var text = readFileSystem.File.ReadAllText(path.FullName); + var md = MarkdownParser.Parse(text); + var header = md.OfType().FirstOrDefault(); + var inline = header?.Inline?.OfType().FirstOrDefault()?.Content.Text; + if (inline != null) + title = inline.Trim(['#', ' ']); + } + + + return new TestDocumentationFile(title); + } +} diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 83ecd7a33..4c67def38 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -62,12 +62,18 @@ type Setup = yaml.WriteLine(" - docs-content") yaml.WriteLine(" - elasticsearch") yaml.WriteLine(" - kibana") + yaml.WriteLine("exclude:") + yaml.WriteLine(" - '_*.md'") yaml.WriteLine("toc:") let markdownFiles = fileSystem.Directory.EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories) markdownFiles |> Seq.iter(fun markdownFile -> let relative = fileSystem.Path.GetRelativePath(root.FullName, markdownFile); - yaml.WriteLine($" - file: {relative}"); + // Skip files that match the exclusion pattern (any path segment starting with _) + let pathSegments = relative.Split([|'/'; '\\'|], StringSplitOptions.RemoveEmptyEntries) + let shouldExclude = pathSegments |> Array.exists (fun segment -> segment.StartsWith("_")) + if not shouldExclude then + yaml.WriteLine($" - file: {relative}"); ) let redirectFiles = ["5th-page"; "second-page"; "third-page"; "first-page"] redirectFiles diff --git a/tests/authoring/Framework/TestValues.fs b/tests/authoring/Framework/TestValues.fs index 8ec8e319c..b2be8ff49 100644 --- a/tests/authoring/Framework/TestValues.fs +++ b/tests/authoring/Framework/TestValues.fs @@ -110,7 +110,7 @@ and MarkdownTestContext = let file = kv.Value.File let document = kv.Value.Document let html = kv.Value.Html - let! minimal = kv.Value.File.MinimalParseAsync(ctx) + let! minimal = kv.Value.File.MinimalParseAsync((fun s -> this.Set.TryFindDocumentByRelativePath s), ctx) return { File = file; Document = document; MinimalParse = minimal; Html = html; Context = this } }) // this is not great code, refactor or depend on FSharp.Control.TaskSeq diff --git a/tests/authoring/Generator/LinkReferenceFile.fs b/tests/authoring/Generator/LinkReferenceFile.fs index 8f4e65a1e..a7a474066 100644 --- a/tests/authoring/Generator/LinkReferenceFile.fs +++ b/tests/authoring/Generator/LinkReferenceFile.fs @@ -64,6 +64,13 @@ Through various means $$$including-this-inline-syntax$$$ "url_path_prefix": "", "links": { "file.md": {}, + "index.md": { + "anchors": [ + "including-this-inline-syntax", + "this-anchor-is-autogenerated", + "and-anchored" + ] + }, "testing/redirects/5th-page.md": { "anchors": [ "bb" ] }, @@ -75,13 +82,6 @@ Through various means $$$including-this-inline-syntax$$$ }, "testing/redirects/first-page.md": { "anchors": [ "has-an-anchor-as-well" ] - }, - "index.md": { - "anchors": [ - "including-this-inline-syntax", - "this-anchor-is-autogenerated", - "and-anchored" - ] } }, "cross_links": [], diff --git a/tests/docs-assembler.Tests/src/docs-assembler.Tests/GlobalNavigationTests.cs b/tests/docs-assembler.Tests/src/docs-assembler.Tests/GlobalNavigationTests.cs deleted file mode 100644 index 64acb2c3b..000000000 --- a/tests/docs-assembler.Tests/src/docs-assembler.Tests/GlobalNavigationTests.cs +++ /dev/null @@ -1,306 +0,0 @@ -// 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.IO.Abstractions; -using Elastic.Documentation; -using Elastic.Documentation.Assembler; -using Elastic.Documentation.Assembler.Navigation; -using Elastic.Documentation.Assembler.Sourcing; -using Elastic.Documentation.Configuration; -using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.Configuration.Navigation; -using Elastic.Documentation.Diagnostics; -using Elastic.Documentation.Site.Navigation; -using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Documentation.Assembler.Tests; - -public class GlobalNavigationPathProviderTests -{ - private DiagnosticsCollector Collector { get; } - private AssembleContext Context { get; } - private FileSystem FileSystem { get; } - private IDirectoryInfo CheckoutDirectory { get; set; } - - private bool HasCheckouts() => CheckoutDirectory.Exists; - - public GlobalNavigationPathProviderTests() - { - FileSystem = new FileSystem(); - var checkoutDirectory = FileSystem.DirectoryInfo.New( - FileSystem.Path.Combine(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts") - ); - CheckoutDirectory = checkoutDirectory.Exists - ? checkoutDirectory.GetDirectories().FirstOrDefault(d => d.Name is "next" or "current") ?? checkoutDirectory - : checkoutDirectory; - Collector = new DiagnosticsCollector([]); - var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); - var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - Context = new AssembleContext(config, configurationContext, "dev", Collector, FileSystem, FileSystem, CheckoutDirectory.FullName, null); - } - - private Checkout CreateCheckout(IFileSystem fs, string name) => - new() - { - Repository = new Repository - { - Name = name, - Origin = $"elastic/{name}" - }, - HeadReference = Guid.NewGuid().ToString(), - Directory = fs.DirectoryInfo.New(fs.Path.Combine(Path.Combine(CheckoutDirectory.FullName, name))) - }; - - private async Task Setup() - { - _ = Collector.StartAsync(TestContext.Current.CancellationToken); - - string[] nar = [NarrativeRepository.RepositoryName]; - var repos = nar.Concat(Context.Configuration.ReferenceRepositories - .Where(kv => !kv.Value.Skip) - .Select(kv => kv.Value.Name) - ) - .ToArray(); - var checkouts = repos.Select(r => CreateCheckout(FileSystem, r)).ToArray(); - var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var assembleSources = await AssembleSources.AssembleAsync( - NullLoggerFactory.Instance, Context, checkouts, configurationContext, ExportOptions.Default, TestContext.Current.CancellationToken - ); - return assembleSources; - } - - [Fact] - public async Task ReadAllPathPrefixes() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - await using var collector = new DiagnosticsCollector([]); - - var fileSystem = new FileSystem(); - var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var context = new AssembleContext(config, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); - - var pathPrefixes = GlobalNavigationFile.GetAllPathPrefixes(context.Collector, context.ConfigurationFileProvider, context.Configuration); - - pathPrefixes.Should().NotBeEmpty(); - pathPrefixes.Should().Contain(new Uri("eland://reference/elasticsearch/clients/eland/")); - } - - [Fact] - public async Task PathProvider() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - var assembleSources = await Setup(); - - var navigationFile = new GlobalNavigationFile(Context.Collector, Context.ConfigurationFileProvider, Context.Configuration, assembleSources.TocConfigurationMapping); - var pathProvider = new GlobalNavigationPathProvider(navigationFile, assembleSources, Context); - - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(new Uri("detection-rules://")); - pathProvider.TableOfContentsPrefixes.Should().Contain("detection-rules://"); - } - - - [Fact] - public async Task ParsesReferences() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - var expectedRoot = new Uri("docs-content://reference/"); - var expectedParent = new Uri("docs-content://reference/apm-agents/"); - var sut = new Uri("apm-agent-dotnet://reference/"); - var clients = new Uri("docs-content://reference/elasticsearch-clients/"); - var assembleSources = await Setup(); - - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(sut); - assembleSources.NavigationTocMappings[sut].TopLevelSource.Should().Be(expectedRoot); - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(expectedRoot); - assembleSources.NavigationTocMappings[sut].ParentSource.Should().Be(expectedParent); - - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(new Uri("detection-rules://")); - - var navigationFile = new GlobalNavigationFile(Context.Collector, Context.ConfigurationFileProvider, Context.Configuration, assembleSources.TocConfigurationMapping); - var referenceToc = navigationFile.TableOfContents.FirstOrDefault(t => t.Source == expectedRoot); - referenceToc.Should().NotBeNull(); - referenceToc.TocReferences.Should().NotContainKey(clients); - - var ingestTools = referenceToc.TocReferences[new Uri("docs-content://reference/ingestion-tools/")]; - ingestTools.Should().NotBeNull(); - - var apmReference = ingestTools.TocReferences[new Uri("docs-content://reference/apm/")]; - apmReference.Should().NotBeNull(); - - var agentsRef = apmReference.TocReferences[expectedParent]; - apmReference.Should().NotBeNull(); - - var agentsRefTocReference = agentsRef.TocReferences[sut]; - agentsRefTocReference.Should().NotBeNull(); - - var navigation = new GlobalNavigation(assembleSources, navigationFile); - var referenceNav = navigation.NavigationLookup[expectedRoot]; - navigation.NavigationItems.OfType() - .Should().HaveSameCount(navigation.NavigationLookup); - - referenceNav.Should().NotBeNull(); - var navigationLookup = referenceNav.NavigationItems.OfType().ToDictionary(i => i.Source, i => i); - navigationLookup.Should().NotContainKey(clients); - referenceNav.NavigationItems.OfType() - .Select(n => n.Source) - .Should().NotContain(clients); - referenceNav.NavigationItems.Should().HaveSameCount(navigationLookup); - - var ingestNav = navigationLookup[new Uri("docs-content://reference/ingestion-tools/")]; - ingestNav.Should().NotBeNull(); - var ingestLookup = ingestNav.NavigationItems.OfType().ToDictionary(i => i.Source, i => i); - ingestLookup.Should().NotContainKey(clients); - ingestNav.NavigationItems.OfType() - .Select(n => n.Source) - .Should().NotContain(clients); - - var apmNav = ingestLookup[new Uri("docs-content://reference/apm/")]; - apmNav.Should().NotBeNull(); - - var apmLookup = apmNav.NavigationItems.OfType().ToDictionary(i => i.Source, i => i); - var apmAgentsNav = apmLookup[expectedParent]; - apmAgentsNav.Should().NotBeNull(); - - var apmAgentLookup = apmAgentsNav.NavigationItems.OfType().ToDictionary(i => i.Source, i => i); - var dotnetAgentNav = apmAgentLookup[sut]; - dotnetAgentNav.Should().NotBeNull(); - - var resolved = navigation.NavigationItems; - resolved.Should().NotBeNull(); - - } - - - - [Fact] - public async Task ParsesGlobalNavigation() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - var expectedRoot = new Uri("docs-content://extend"); - var kibanaExtendMoniker = new Uri("kibana://extend/"); - - var assembleSources = await Setup(); - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(kibanaExtendMoniker); - assembleSources.NavigationTocMappings[kibanaExtendMoniker].TopLevelSource.Should().Be(expectedRoot); - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(new Uri("docs-content://reference/apm/")); - - var uri = new Uri("integration-docs://reference/"); - assembleSources.TreeCollector.Should().NotBeNull(); - _ = assembleSources.TreeCollector.TryGetTableOfContentsTree(uri, out var tree); - tree.Should().NotBeNull(); - - _ = assembleSources.TreeCollector.TryGetTableOfContentsTree(new Uri("docs-content://reference/"), out tree); - tree.Should().NotBeNull(); - - assembleSources.AssembleSets.Should().NotBeEmpty(); - - assembleSources.TocConfigurationMapping.Should().NotBeEmpty().And.ContainKey(kibanaExtendMoniker); - var kibanaConfigMapping = assembleSources.TocConfigurationMapping[kibanaExtendMoniker]; - kibanaConfigMapping.Should().NotBeNull(); - kibanaConfigMapping.TableOfContentsConfiguration.Should().NotBeNull(); - assembleSources.TocConfigurationMapping[kibanaExtendMoniker].Should().NotBeNull(); - - var navigationFile = new GlobalNavigationFile(Context.Collector, Context.ConfigurationFileProvider, Context.Configuration, assembleSources.TocConfigurationMapping); - navigationFile.TableOfContents.Should().NotBeNull().And.NotBeEmpty(); - navigationFile.TableOfContents.Count.Should().BeLessThan(20); - - var navigation = new GlobalNavigation(assembleSources, navigationFile); - navigation.TopLevelItems.Count.Should().BeLessThan(20); - var resolved = navigation.NavigationItems; - resolved.Should().NotBeNull(); - - - IPositionalNavigation positionalNavigation = navigation; - - var addToHelm = positionalNavigation.MarkdownNavigationLookup.GetValueOrDefault("apm-k8s-attacher://reference/apm-webhook-add-helm-repo.md"); - addToHelm.Should().NotBeNull(); - var parentGroup = addToHelm.Parent as DocumentationGroup; - var parents = AssertHasParents(parentGroup, positionalNavigation, addToHelm); - - parents - .Select(p => p.Url).Should().ContainInOrder( - [ - "/docs/reference/apm/k8s-attacher/apm-get-started-webhook", - "/docs/reference/apm/k8s-attacher", - "/docs/reference/apm/observability/apm", - "/docs/reference/ingestion-tools/", - "/docs/reference/", - "/docs/" - ]); - - var getStartedIntro = positionalNavigation.MarkdownNavigationLookup.GetValueOrDefault("docs-content://get-started/introduction.md"); - getStartedIntro.Should().NotBeNull(); - parentGroup = getStartedIntro.Parent as DocumentationGroup; - _ = AssertHasParents(parentGroup, positionalNavigation, getStartedIntro); - - } - - private static INavigationItem[] AssertHasParents( - DocumentationGroup? parent, - IPositionalNavigation positionalNavigation, - INavigationItem item - ) - { - parent.Should().NotBeNull(); - parent.Index.Should().NotBeNull(); - var parents2 = positionalNavigation.GetParents(item); - var parents3 = positionalNavigation.GetParents(item); - var markdown = (item as FileNavigationItem)?.Model!; - var parents = positionalNavigation.GetParentsOfMarkdownFile(markdown); - - parents.Should().NotBeEmpty().And.HaveCount(parents2.Length).And.HaveCount(parents3.Length); - return parents; - } - - [Fact] - public async Task UriResolving() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); - - var fs = new FileSystem(); - var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, fs, fs, null, null); - var repos = assembleContext.Configuration.ReferenceRepositories - .Where(kv => !kv.Value.Skip) - .Select(kv => kv.Value.Name) - .Concat([NarrativeRepository.RepositoryName]) - .ToArray(); - var checkouts = repos.Select(r => CreateCheckout(fs, r)).ToArray(); - var assembleSources = await AssembleSources.AssembleAsync( - NullLoggerFactory.Instance, assembleContext, checkouts, configurationContext, ExportOptions.Default, TestContext.Current.CancellationToken - ); - - var navigationFile = new GlobalNavigationFile(Context.Collector, Context.ConfigurationFileProvider, Context.Configuration, assembleSources.TocConfigurationMapping); - navigationFile.TableOfContents.Should().NotBeNull().And.NotBeEmpty(); - - var uriResolver = assembleSources.UriResolver; - - // docs-content://reference/apm/something.md - url hasn't changed - var resolvedUri = uriResolver.Resolve(new Uri("docs-content://reference/apm/something.md"), "/reference/apm/something"); - resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/something"); - - resolvedUri = uriResolver.Resolve(new Uri("apm-agent-nodejs://reference/instrumentation.md"), "/reference/instrumentation"); - resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/agents/nodejs/instrumentation"); - - resolvedUri = uriResolver.Resolve(new Uri("apm-agent-dotnet://reference/a/file.md"), "/reference/a/file"); - resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/agents/dotnet/a/file"); - - resolvedUri = uriResolver.Resolve(new Uri("elasticsearch-net://reference/b/file.md"), "/reference/b/file"); - resolvedUri.Should().Be("https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/b/file"); - - resolvedUri = uriResolver.Resolve(new Uri("elasticsearch://extend/c/file.md"), "/extend/c/file"); - resolvedUri.Should().Be("https://www.elastic.co/docs/extend/elasticsearch/c/file"); - } -}