Repository: SimonCropp/MarkdownSnippets Branch: main Commit: 7151bf7e45fa Files: 431 Total size: 495.7 KB Directory structure: gitextract_lnivcw9c/ ├── .claude/ │ └── settings.local.json ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ ├── dependabot.yml │ ├── stale.yml │ └── workflows/ │ ├── merge-dependabot.yml │ └── milestone-release.yml ├── .gitignore ├── claude.md ├── code_of_conduct.md ├── docs/ │ ├── api.md │ ├── config-file.md │ ├── exclusion.md │ ├── github-action.md │ ├── header.md │ ├── includes.md │ ├── indentation.md │ ├── max-width.md │ ├── mdsource/ │ │ ├── api.source.md │ │ ├── config-file.source.md │ │ ├── doc-index.include.md │ │ ├── exclusion.source.md │ │ ├── github-action.source.md │ │ ├── header.source.md │ │ ├── includes.source.md │ │ ├── indentation.source.md │ │ ├── max-width.source.md │ │ ├── msbuild.source.md │ │ ├── readme.source.md │ │ ├── toc/ │ │ │ ├── tocAfter.txt │ │ │ └── tocBefore.txt │ │ └── toc.source.md │ ├── msbuild.md │ ├── on-push-do-docs.yml │ ├── readme.md │ └── toc.md ├── license.txt ├── readme.md ├── readme.source.md ├── schema.json └── src/ ├── Benchmarks/ │ ├── Benchmarks.csproj │ ├── FullRenderBenchmarks.cs │ └── Program.cs ├── ConfigReader/ │ ├── AssemblyInfo.cs │ ├── ConfigDefaults.cs │ ├── ConfigInput.cs │ ├── ConfigReader.cs │ ├── ConfigReader.csproj │ ├── ConfigResult.cs │ ├── ConfigSerialization.cs │ ├── ConfigurationException.cs │ ├── ExcludeToFilterBuilder.cs │ ├── LogBuilder.cs │ └── SharedGlobalUsings.cs ├── ConfigReader.Tests/ │ ├── ConfigReader.Tests.csproj │ ├── ConfigReaderTests.BadJson.verified.txt │ ├── ConfigReaderTests.Empty.verified.txt │ ├── ConfigReaderTests.Values.verified.txt │ ├── ConfigReaderTests.cs │ ├── GlobalUsings.cs │ ├── InPlaceOverwrite.json │ ├── ModuleInitializer.cs │ ├── SourceTransform.json │ └── allConfig.json ├── Directory.Build.props ├── Directory.Packages.props ├── MarkdownSnippets/ │ ├── AssemblyInfo.cs │ ├── ContentValidation.cs │ ├── ContentValidationException.cs │ ├── Downloader/ │ │ ├── Downloader.cs │ │ ├── FileNameFromUrl.cs │ │ └── Timestamp.cs │ ├── Extensions.cs │ ├── FileEx.cs │ ├── GitRepoDirectoryFinder.cs │ ├── GlobalUsings.cs │ ├── Guard.cs │ ├── InterpretErrors.cs │ ├── KeyValidator.cs │ ├── MarkdownProcessingException.cs │ ├── MarkdownSnippets.csproj │ ├── MissingIncludesException.cs │ ├── MissingSnippetsException.cs │ ├── NewLineConfigReader.cs │ ├── Paths.cs │ ├── Processing/ │ │ ├── AppendSnippetsToMarkdown.cs │ │ ├── DirectoryMarkdownProcessor.cs │ │ ├── DocumentConvention.cs │ │ ├── HeaderWriter.cs │ │ ├── IncludeProcessor.cs │ │ ├── Line.cs │ │ ├── Lines.cs │ │ ├── LinkFormat.cs │ │ ├── Markdown.cs │ │ ├── MarkdownProcessor.cs │ │ ├── MissingInclude.cs │ │ ├── MissingSnippet.cs │ │ ├── ProcessResult.cs │ │ ├── RelativeFile.cs │ │ ├── SimpleSnippetMarkdownHandling.cs │ │ ├── SnippetKey.cs │ │ ├── SnippetMarkdownHandling.cs │ │ ├── TocBuilder.cs │ │ └── ValidationError.cs │ ├── Reading/ │ │ ├── EndFunc.cs │ │ ├── Exclusions/ │ │ │ ├── DefaultDirectoryExclusions.cs │ │ │ └── SnippetFileExclusions.cs │ │ ├── FileFinder.cs │ │ ├── FileSnippetExtractor.cs │ │ ├── IContent.cs │ │ ├── Include.cs │ │ ├── LineTooLongException.cs │ │ ├── LoopStack.cs │ │ ├── LoopState.cs │ │ ├── ReadSnippets.cs │ │ ├── ShouldIncludeDirectory.cs │ │ ├── ShouldIncludeFile.cs │ │ ├── Snippet.cs │ │ └── StartEndTester.cs │ ├── SnippetException.cs │ ├── SnippetExtensions.cs │ ├── SnippetReadingException.cs │ └── StringBuilderCache.cs ├── MarkdownSnippets.MsBuild/ │ ├── DocoTask.cs │ ├── LoggingHelper.cs │ ├── MarkdownSnippets.MsBuild.csproj │ └── MarkdownSnippets.MsBuild.targets ├── MarkdownSnippets.Tool/ │ ├── AssemblyInfo.cs │ ├── CommandLineException.cs │ ├── CommandRunner.cs │ ├── GlobalUsings.cs │ ├── Invoke.cs │ ├── MarkdownSnippets.Tool.csproj │ ├── Options.cs │ └── Program.cs ├── MarkdownSnippets.Tool.Tests/ │ ├── CommandRunnerTests.ConventionLong.verified.txt │ ├── CommandRunnerTests.ConventionShort.verified.txt │ ├── CommandRunnerTests.Empty.verified.txt │ ├── CommandRunnerTests.ExcludeLong.verified.txt │ ├── CommandRunnerTests.ExcludeMarkdownDirectoriesLong.verified.txt │ ├── CommandRunnerTests.ExcludeMultiple.verified.txt │ ├── CommandRunnerTests.ExcludeShort.verified.txt │ ├── CommandRunnerTests.ExcludeSnippetDirectoriesLong.verified.txt │ ├── CommandRunnerTests.Header.verified.txt │ ├── CommandRunnerTests.LinkFormatLong.verified.txt │ ├── CommandRunnerTests.LinkFormatShort.verified.txt │ ├── CommandRunnerTests.MaxWidthLong.verified.txt │ ├── CommandRunnerTests.OmitSnippetLinks.verified.txt │ ├── CommandRunnerTests.ReadOnlyLong.verified.txt │ ├── CommandRunnerTests.ReadOnlyShort.verified.txt │ ├── CommandRunnerTests.SingleUnNamedArg.verified.txt │ ├── CommandRunnerTests.TargetDirectoryLong.verified.txt │ ├── CommandRunnerTests.TargetDirectoryShort.verified.txt │ ├── CommandRunnerTests.TocLevelLong.verified.txt │ ├── CommandRunnerTests.UrlPrefix.verified.txt │ ├── CommandRunnerTests.UrlsAsSnippetsLong.verified.txt │ ├── CommandRunnerTests.UrlsAsSnippetsMultiple.verified.txt │ ├── CommandRunnerTests.UrlsAsSnippetsShort.verified.txt │ ├── CommandRunnerTests.ValidateContentLong.verified.txt │ ├── CommandRunnerTests.ValidateContentShort.verified.txt │ ├── CommandRunnerTests.VerifyContentLong.verified.txt │ ├── CommandRunnerTests.VerifyContentShort.verified.txt │ ├── CommandRunnerTests.WriteHeader.verified.txt │ ├── CommandRunnerTests.cs │ ├── GlobalUsings.cs │ ├── LogBuilderTests.BuildConfigLogMessage.DotNet10_0.verified.txt │ ├── LogBuilderTests.BuildConfigLogMessage.DotNet9_0.verified.txt │ ├── LogBuilderTests.BuildConfigLogMessageMinimal.DotNet10_0.verified.txt │ ├── LogBuilderTests.BuildConfigLogMessageMinimal.DotNet9_0.verified.txt │ ├── LogBuilderTests.BuildConfigLogMessageSourceTransform.DotNet10_0.verified.txt │ ├── LogBuilderTests.BuildConfigLogMessageSourceTransform.DotNet9_0.verified.txt │ ├── LogBuilderTests.cs │ ├── MarkdownSnippets.Tool.Tests.csproj │ └── ModuleInitializer.cs ├── MarkdownSnippets.slnx ├── MarkdownSnippets.slnx.DotSettings ├── Shared.sln.DotSettings ├── Tests/ │ ├── ContentValidationTest.CheckInvalidWord.verified.txt │ ├── ContentValidationTest.CheckInvalidWordIndicatesAllViolationsInTheExceptionMessage.verified.txt │ ├── ContentValidationTest.CheckInvalidWordIndicatesAllViolationsInTheExceptionMessageIgnoringCase.verified.txt │ ├── ContentValidationTest.CheckInvalidWordSentenceEnd.verified.txt │ ├── ContentValidationTest.CheckInvalidWordSentenceStart.verified.txt │ ├── ContentValidationTest.CheckInvalidWordStringEnd.verified.txt │ ├── ContentValidationTest.CheckInvalidWordWithComma.verified.txt │ ├── ContentValidationTest.CheckInvalidWordWithQuestionMark.verified.txt │ ├── ContentValidationTest.cs │ ├── DirectoryMarkdownProcessor/ │ │ ├── BinaryFileSnippet/ │ │ │ ├── one.source.md │ │ │ └── sourceFile.dot │ │ ├── Convention/ │ │ │ ├── mdsource/ │ │ │ │ └── two.source.md │ │ │ └── one.source.md │ │ ├── ConventionWithNestedDir/ │ │ │ └── mdsource/ │ │ │ └── Nested/ │ │ │ └── one.source.md │ │ ├── ExplicitFileInclude/ │ │ │ ├── Nested/ │ │ │ │ ├── fileToInclude3.txt │ │ │ │ ├── fileToInclude4.txt │ │ │ │ ├── fileToInclude5.txt │ │ │ │ └── fileToInclude6.txt │ │ │ ├── fileToInclude1.txt │ │ │ ├── fileToInclude2.txt │ │ │ └── one.source.md │ │ ├── ExplicitFileIncludeWithMergedSnippet/ │ │ │ ├── fileToInclude.txt │ │ │ └── one.source.md │ │ ├── ExplicitFileIncludeWithSnippetAtEnd/ │ │ │ ├── fileToInclude.txt │ │ │ └── one.source.md │ │ ├── FileSnippet/ │ │ │ ├── one.source.md │ │ │ └── sourceFile.txt │ │ ├── FileSnippetMissing/ │ │ │ └── one.source.md │ │ ├── FileSnippetWithHash/ │ │ │ ├── one.source.md │ │ │ └── source#File.txt │ │ ├── FileSnippetWithWhiteSpace/ │ │ │ ├── one.source.md │ │ │ └── sourceFile.txt │ │ ├── InPlaceOverwriteExists/ │ │ │ ├── file.md │ │ │ ├── fileToInclude.txt │ │ │ ├── includeWithCode.txt │ │ │ └── multiLineFileToInclude.txt │ │ ├── InPlaceOverwriteExistsMdx/ │ │ │ ├── file.mdx │ │ │ ├── fileToInclude.txt │ │ │ ├── includeWithCode.txt │ │ │ └── multiLineFileToInclude.txt │ │ ├── InPlaceOverwriteNotExists/ │ │ │ ├── file.md │ │ │ ├── fileToInclude.txt │ │ │ ├── includeWithCode.txt │ │ │ └── multiLineFileToInclude.txt │ │ ├── InPlaceOverwriteUrlInclude/ │ │ │ └── one.md │ │ ├── InPlaceOverwriteUrlSnippet/ │ │ │ └── one.md │ │ ├── InPlaceOverwriteWithFileSnippetMissing/ │ │ │ └── file.md │ │ ├── Mdx/ │ │ │ ├── one.source.mdx │ │ │ └── sourceFile.txt │ │ ├── MissingInclude/ │ │ │ ├── one.md │ │ │ └── one.source.md │ │ ├── MixedCaseInclude/ │ │ │ ├── fileToInclude.txt │ │ │ └── one.source.md │ │ ├── NonMd/ │ │ │ ├── mdsource/ │ │ │ │ └── two.source.txt │ │ │ └── one.source.txt │ │ ├── ReadOnly/ │ │ │ └── one.source.md │ │ ├── UrlInclude/ │ │ │ └── one.source.md │ │ ├── UrlIncludeMissing/ │ │ │ └── one.source.md │ │ ├── UrlSnippet/ │ │ │ └── one.source.md │ │ ├── UrlSnippetMissing/ │ │ │ └── one.source.md │ │ └── ValidationErrors/ │ │ ├── one.md │ │ └── one.source.md │ ├── DirectoryMarkdownProcessorTests.BinaryFileSnippet.verified.txt │ ├── DirectoryMarkdownProcessorTests.Convention.verified.txt │ ├── DirectoryMarkdownProcessorTests.ConventionWithNestedDir.verified.txt │ ├── DirectoryMarkdownProcessorTests.ExplicitFileInclude.verified.txt │ ├── DirectoryMarkdownProcessorTests.ExplicitFileIncludeWithMergedSnippet.verified.txt │ ├── DirectoryMarkdownProcessorTests.ExplicitFileIncludeWithSnippetAtEnd.verified.txt │ ├── DirectoryMarkdownProcessorTests.FileSnippet.verified.txt │ ├── DirectoryMarkdownProcessorTests.FileSnippetExplicitIncludeBypassesExcludeSnippetFiles.verified.txt │ ├── DirectoryMarkdownProcessorTests.FileSnippetMissing.verified.txt │ ├── DirectoryMarkdownProcessorTests.FileSnippetWithHash.verified.txt │ ├── DirectoryMarkdownProcessorTests.FileSnippetWithWhiteSpace.verified.txt │ ├── DirectoryMarkdownProcessorTests.InPlaceOverwriteExists.verified.md │ ├── DirectoryMarkdownProcessorTests.InPlaceOverwriteExistsMdx.verified.mdx │ ├── DirectoryMarkdownProcessorTests.InPlaceOverwriteNotExists.verified.md │ ├── DirectoryMarkdownProcessorTests.InPlaceOverwriteUrlInclude.verified.txt │ ├── DirectoryMarkdownProcessorTests.InPlaceOverwriteUrlSnippet.verified.txt │ ├── DirectoryMarkdownProcessorTests.InPlaceOverwriteWithFileSnippetMissing.verified.md │ ├── DirectoryMarkdownProcessorTests.Mdx.verified.txt │ ├── DirectoryMarkdownProcessorTests.MixedCaseInclude.verified.txt │ ├── DirectoryMarkdownProcessorTests.UrlInclude.verified.txt │ ├── DirectoryMarkdownProcessorTests.UrlIncludeMissing.verified.txt │ ├── DirectoryMarkdownProcessorTests.UrlSnippet.verified.txt │ ├── DirectoryMarkdownProcessorTests.UrlSnippetMissing.verified.txt │ ├── DirectoryMarkdownProcessorTests.ValidationErrors.verified.txt │ ├── DirectoryMarkdownProcessorTests.cs │ ├── DirectorySnippetExtractor/ │ │ ├── Case/ │ │ │ ├── code1.txt │ │ │ └── code2.txt │ │ ├── Nested/ │ │ │ └── nested/ │ │ │ └── nested/ │ │ │ └── code.txt │ │ ├── Simple/ │ │ │ ├── code1.txt │ │ │ ├── code2.txt │ │ │ ├── code3.txt │ │ │ └── code4.txt │ │ └── VerifyLambdasAreCalled/ │ │ └── subpath/ │ │ └── code4.txt │ ├── DownloaderTests.Valid.verified.txt │ ├── DownloaderTests.cs │ ├── FileExTests.cs │ ├── FileToUseAsSnippet.txt │ ├── GirRepoDirectoryFinderTests.cs │ ├── GitDirs/ │ │ ├── NoRef/ │ │ │ └── HEAD │ │ └── WithRef/ │ │ ├── HEAD │ │ └── refs/ │ │ └── heads/ │ │ └── master │ ├── GlobalUsings.cs │ ├── HeaderWriterTests.DefaultHeader.verified.txt │ ├── HeaderWriterTests.WriteHeaderDefaultHeader.verified.txt │ ├── HeaderWriterTests.WriteHeaderHeaderCustom.verified.txt │ ├── HeaderWriterTests.cs │ ├── IncludeFileFinder/ │ │ ├── Nested/ │ │ │ └── nested/ │ │ │ └── nested/ │ │ │ ├── file.include.md │ │ │ └── other.txt │ │ ├── Simple/ │ │ │ ├── file1.include.md │ │ │ ├── file2.include.md │ │ │ └── other.txt │ │ └── VerifyLambdasAreCalled/ │ │ └── subpath/ │ │ └── file.include.md │ ├── IncludeFinder/ │ │ └── file.include.md │ ├── IndexReaderTests.cs │ ├── LoopState/ │ │ ├── LoopStateTests.ExcludeEmptyPaddingLines.verified.txt │ │ ├── LoopStateTests.TrimIndentation.verified.txt │ │ ├── LoopStateTests.TrimIndentation_no_initial_padding.verified.txt │ │ ├── LoopStateTests.TrimIndentation_with_mis_match.verified.txt │ │ └── LoopStateTests.cs │ ├── MarkdownProcessor/ │ │ ├── MarkdownProcessorTests.Empty_snippet_key.verified.txt │ │ ├── MarkdownProcessorTests.MissingInclude.verified.txt │ │ ├── MarkdownProcessorTests.Missing_endInclude.verified.txt │ │ ├── MarkdownProcessorTests.Missing_endToc.verified.txt │ │ ├── MarkdownProcessorTests.MixedNewlinesInFile.verified.txt │ │ ├── MarkdownProcessorTests.Simple.verified.txt │ │ ├── MarkdownProcessorTests.Simple_Overwrite.verified.txt │ │ ├── MarkdownProcessorTests.SkipHeadingBeforeToc.verified.txt │ │ ├── MarkdownProcessorTests.SnippetInInclude.verified.txt │ │ ├── MarkdownProcessorTests.SnippetInIncludeLast.verified.txt │ │ ├── MarkdownProcessorTests.TableInInclude.verified.txt │ │ ├── MarkdownProcessorTests.Toc.verified.txt │ │ ├── MarkdownProcessorTests.Toc1.verified.txt │ │ ├── MarkdownProcessorTests.TocRetainedIfNoHeadingsInFile.verified.txt │ │ ├── MarkdownProcessorTests.Toc_Overwrite.verified.txt │ │ ├── MarkdownProcessorTests.Whitespace_snippet_key.verified.txt │ │ ├── MarkdownProcessorTests.WithCommentWebSnippetUpdate.verified.txt │ │ ├── MarkdownProcessorTests.WithCommentWebSnippetWithViewUrl.verified.txt │ │ ├── MarkdownProcessorTests.WithDoubleInclude.verified.txt │ │ ├── MarkdownProcessorTests.WithEmptyMultiLineInclude_Overwrite.verified.txt │ │ ├── MarkdownProcessorTests.WithEmptyMultipleInclude.verified.txt │ │ ├── MarkdownProcessorTests.WithIndentedCommentSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WithIndentedMultiLineSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WithIndentedSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WithIndentedSnippetMultipleSpaces.verified.txt │ │ ├── MarkdownProcessorTests.WithIndentedWebSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WithInlineWebSnippetWithViewUrl.verified.txt │ │ ├── MarkdownProcessorTests.WithMixedCaseInclude.verified.txt │ │ ├── MarkdownProcessorTests.WithMixedCaseSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WithMultiLineInclude_Overwrite.verified.txt │ │ ├── MarkdownProcessorTests.WithMultiLineSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WithMultipleInclude.verified.txt │ │ ├── MarkdownProcessorTests.WithSingleInclude.verified.txt │ │ ├── MarkdownProcessorTests.WithSingleInclude_Overwrite.verified.txt │ │ ├── MarkdownProcessorTests.WithSingleSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WithTabIndentedSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WithTwoLineSnippet.verified.txt │ │ ├── MarkdownProcessorTests.WrongNewlineInSnippet.verified.txt │ │ ├── MarkdownProcessorTests.cs │ │ ├── SnippetKey_ExtractStartCommentSnippet.cs │ │ ├── SnippetKey_ExtractStartCommentWebSnippet.cs │ │ ├── SnippetKey_ExtractTransform.cs │ │ ├── TocBuilderTests.Deep.verified.txt │ │ ├── TocBuilderTests.DuplicateNested.verified.txt │ │ ├── TocBuilderTests.Duplicates.verified.txt │ │ ├── TocBuilderTests.EmptyHeading.verified.txt │ │ ├── TocBuilderTests.Exclude.verified.txt │ │ ├── TocBuilderTests.IgnoreTop.verified.txt │ │ ├── TocBuilderTests.Nested.verified.txt │ │ ├── TocBuilderTests.SanitizeLink.verified.txt │ │ ├── TocBuilderTests.Single.verified.txt │ │ ├── TocBuilderTests.StopAtLevel.verified.txt │ │ ├── TocBuilderTests.StripMarkdown.verified.txt │ │ ├── TocBuilderTests.WithSpaces.verified.txt │ │ └── TocBuilderTests.cs │ ├── ModuleInitializer.cs │ ├── MsBuildIntegrationTests.cs │ ├── NewLineConfigReaderTests.cs │ ├── PathsTests.cs │ ├── ProcessResultConverter.cs │ ├── SimpleSnippetMarkdownHandlingTests.Append.verified.txt │ ├── SimpleSnippetMarkdownHandlingTests.ExpressiveCode.verified.txt │ ├── SimpleSnippetMarkdownHandlingTests.cs │ ├── SnippetConverter.cs │ ├── SnippetExtensionsTests.ToDictionary.verified.txt │ ├── SnippetExtensionsTests.ToDictionary_SameKey.verified.txt │ ├── SnippetExtensionsTests.cs │ ├── SnippetExtractor/ │ │ ├── SnippetExtractorTests.AppendFileAsSnippet.verified.txt │ │ ├── SnippetExtractorTests.AppendUrlAsSnippet.verified.txt │ │ ├── SnippetExtractorTests.AppendUrlAsSnippetInline.verified.txt │ │ ├── SnippetExtractorTests.CanExtractFromRegion.verified.txt │ │ ├── SnippetExtractorTests.CanExtractFromXml.verified.txt │ │ ├── SnippetExtractorTests.CanExtractWithExpressiveCode.verified.txt │ │ ├── SnippetExtractorTests.CanExtractWithInnerWhiteSpace.verified.txt │ │ ├── SnippetExtractorTests.CanExtractWithMissingSpaces.verified.txt │ │ ├── SnippetExtractorTests.CanExtractWithNoTrailingCharacters.verified.txt │ │ ├── SnippetExtractorTests.CanExtractWithTrailingWhitespace.verified.txt │ │ ├── SnippetExtractorTests.CanReadFileWhileLockedByAnotherProcess.verified.txt │ │ ├── SnippetExtractorTests.LanguageOverride.verified.txt │ │ ├── SnippetExtractorTests.LanguageOverrideWithExpressiveCode.verified.txt │ │ ├── SnippetExtractorTests.MixedNewLines.verified.txt │ │ ├── SnippetExtractorTests.NestedBroken.verified.txt │ │ ├── SnippetExtractorTests.NestedMixed1.verified.txt │ │ ├── SnippetExtractorTests.NestedMixed2.verified.txt │ │ ├── SnippetExtractorTests.NestedRegion.verified.txt │ │ ├── SnippetExtractorTests.NestedStartCode.verified.txt │ │ ├── SnippetExtractorTests.RemoveDuplicateNewlines.verified.txt │ │ ├── SnippetExtractorTests.TooWide.verified.txt │ │ ├── SnippetExtractorTests.UnClosedRegion.verified.txt │ │ ├── SnippetExtractorTests.UnClosedSnippet.verified.txt │ │ └── SnippetExtractorTests.cs │ ├── SnippetFileFinder/ │ │ ├── Nested/ │ │ │ └── nested/ │ │ │ └── nested/ │ │ │ └── code.txt │ │ ├── Simple/ │ │ │ ├── code1.txt │ │ │ ├── code2.txt │ │ │ ├── code3.txt │ │ │ └── code4.txt │ │ └── VerifyLambdasAreCalled/ │ │ └── subpath/ │ │ └── code4.txt │ ├── SnippetFileFinderTests.ExcludeSnippetFiles.verified.txt │ ├── SnippetFileFinderTests.Nested.verified.txt │ ├── SnippetFileFinderTests.Simple.verified.txt │ ├── SnippetFileFinderTests.VerifyLambdasAreCalled.verified.txt │ ├── SnippetFileFinderTests.cs │ ├── SnippetMarkdownHandlingTests.Append.verified.txt │ ├── SnippetMarkdownHandlingTests.AppendHashed.verified.txt │ ├── SnippetMarkdownHandlingTests.AppendOmitSnippetLinks.verified.txt │ ├── SnippetMarkdownHandlingTests.AppendOmitSourceLink.verified.txt │ ├── SnippetMarkdownHandlingTests.AppendPrefixed.verified.txt │ ├── SnippetMarkdownHandlingTests.AppendWebSnippet.verified.txt │ ├── SnippetMarkdownHandlingTests.AppendWebSnippetWithViewUrl.verified.txt │ ├── SnippetMarkdownHandlingTests.cs │ ├── SnippetVerifier.cs │ ├── Snippets/ │ │ └── Usage.cs │ ├── StartEndTester_IsBeginSnippetTests.ShouldThrowForEmptyLanguageValue.verified.txt │ ├── StartEndTester_IsBeginSnippetTests.ShouldThrowForInvalidLanguageValue.verified.txt │ ├── StartEndTester_IsBeginSnippetTests.ShouldThrowForKeyEndingWithSymbol.verified.txt │ ├── StartEndTester_IsBeginSnippetTests.ShouldThrowForKeyStartingWithSymbol.verified.txt │ ├── StartEndTester_IsBeginSnippetTests.ShouldThrowForNoKey.verified.txt │ ├── StartEndTester_IsBeginSnippetTests.cs │ ├── StartEndTester_IsStartRegionTests.cs │ ├── Tests.csproj │ ├── WebSnippetTests.cs │ └── badsnippets/ │ └── code.txt ├── appveyor.yml ├── context-menu.reg ├── global.json ├── key.snk ├── mdsnippets.json ├── nuget-readme.md └── nuget.config ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/settings.local.json ================================================ { "permissions": { "allow": [ "Bash(dotnet build:*)", "Bash(dotnet test:*)" ] } } ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space end_of_line = lf insert_final_newline = true [*.cs] indent_size = 4 charset = utf-8 # Redundant accessor body resharper_redundant_accessor_body_highlighting = error # Replace with field keyword resharper_replace_with_field_keyword_highlighting = error # Replace with single call to Single(..) resharper_replace_with_single_call_to_single_highlighting = error # Replace with single call to SingleOrDefault(..) resharper_replace_with_single_call_to_single_or_default_highlighting = error # Replace with single call to LastOrDefault(..) resharper_replace_with_single_call_to_last_or_default_highlighting = error # Element is localizable resharper_localizable_element_highlighting = none # Replace with single call to Last(..) resharper_replace_with_single_call_to_last_highlighting = error # Replace with single call to First(..) resharper_replace_with_single_call_to_first_highlighting = error # Replace with single call to FirstOrDefault(..) resharper_replace_with_single_call_to_first_or_default_highlighting = error # Replace with single call to Any(..) resharper_replace_with_single_call_to_any_highlighting = error # Simplify negative equality expression resharper_negative_equality_expression_highlighting = error # Replace with single call to Count(..) resharper_replace_with_single_call_to_count_highlighting = error # Declare types in namespaces dotnet_diagnostic.CA1050.severity = none # Use Literals Where Appropriate dotnet_diagnostic.CA1802.severity = error # Template should be a static expression dotnet_diagnostic.CA2254.severity = error # Potentially misleading parameter name in lambda or local function resharper_all_underscore_local_parameter_name_highlighting = none # Redundant explicit collection creation in argument of 'params' parameter resharper_redundant_explicit_params_array_creation_highlighting = error # Do not initialize unnecessarily dotnet_diagnostic.CA1805.severity = error # Avoid unsealed attributes dotnet_diagnostic.CA1813.severity = error # Test for empty strings using string length dotnet_diagnostic.CA1820.severity = none # Remove empty finalizers dotnet_diagnostic.CA1821.severity = error # Mark members as static dotnet_diagnostic.CA1822.severity = error # Avoid unused private fields dotnet_diagnostic.CA1823.severity = error # Avoid zero-length array allocations dotnet_diagnostic.CA1825.severity = error # Use property instead of Linq Enumerable method dotnet_diagnostic.CA1826.severity = error # Do not use Count()/LongCount() when Any() can be used dotnet_diagnostic.CA1827.severity = error dotnet_diagnostic.CA1828.severity = error # Use Length/Count property instead of Enumerable.Count method dotnet_diagnostic.CA1829.severity = error # Prefer strongly-typed Append and Insert method overloads on StringBuilder dotnet_diagnostic.CA1830.severity = error # Use AsSpan instead of Range-based indexers for string when appropriate dotnet_diagnostic.CA1831.severity = error # Use AsSpan instead of Range-based indexers for string when appropriate dotnet_diagnostic.CA1831.severity = error dotnet_diagnostic.CA1832.severity = error dotnet_diagnostic.CA1833.severity = error # Use StringBuilder.Append(char) for single character strings dotnet_diagnostic.CA1834.severity = error # Prefer IsEmpty over Count when available dotnet_diagnostic.CA1836.severity = error # Prefer IsEmpty over Count when available dotnet_diagnostic.CA1836.severity = error # Use Environment.ProcessId instead of Process.GetCurrentProcess().Id dotnet_diagnostic.CA1837.severity = error # Use Environment.ProcessPath instead of Process.GetCurrentProcess().MainModule.FileName dotnet_diagnostic.CA1839.severity = error # Use Environment.CurrentManagedThreadId instead of Thread.CurrentThread.ManagedThreadId dotnet_diagnostic.CA1840.severity = error # Prefer Dictionary Contains methods dotnet_diagnostic.CA1841.severity = error # Do not use WhenAll with a single task dotnet_diagnostic.CA1842.severity = error # Do not use WhenAll/WaitAll with a single task dotnet_diagnostic.CA1842.severity = error dotnet_diagnostic.CA1843.severity = error # Use span-based 'string.Concat' dotnet_diagnostic.CA1845.severity = error # Prefer AsSpan over Substring dotnet_diagnostic.CA1846.severity = error # Use string.Contains(char) instead of string.Contains(string) with single characters dotnet_diagnostic.CA1847.severity = error # Prefer static HashData method over ComputeHash dotnet_diagnostic.CA1850.severity = error # Possible multiple enumerations of IEnumerable collection dotnet_diagnostic.CA1851.severity = error # Unnecessary call to Dictionary.ContainsKey(key) dotnet_diagnostic.CA1853.severity = error # Prefer the IDictionary.TryGetValue(TKey, out TValue) method dotnet_diagnostic.CA1854.severity = error # Use Span.Clear() instead of Span.Fill() dotnet_diagnostic.CA1855.severity = error # Incorrect usage of ConstantExpected attribute dotnet_diagnostic.CA1856.severity = error # The parameter expects a constant for optimal performance dotnet_diagnostic.CA1857.severity = error # Use StartsWith instead of IndexOf dotnet_diagnostic.CA1858.severity = error # Avoid using Enumerable.Any() extension method dotnet_diagnostic.CA1860.severity = error # Avoid constant arrays as arguments dotnet_diagnostic.CA1861.severity = error # Use the StringComparison method overloads to perform case-insensitive string comparisons dotnet_diagnostic.CA1862.severity = error # Prefer the IDictionary.TryAdd(TKey, TValue) method dotnet_diagnostic.CA1864.severity = error # Use string.Method(char) instead of string.Method(string) for string with single char dotnet_diagnostic.CA1865.severity = error dotnet_diagnostic.CA1866.severity = error dotnet_diagnostic.CA1867.severity = error # Unnecessary call to 'Contains' for sets dotnet_diagnostic.CA1868.severity = error # Cache and reuse 'JsonSerializerOptions' instances dotnet_diagnostic.CA1869.severity = error # Use a cached 'SearchValues' instance dotnet_diagnostic.CA1870.severity = error # Microsoft .NET properties trim_trailing_whitespace = true csharp_preferred_modifier_order = public, private, protected, internal, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async:suggestion resharper_namespace_body = file_scoped dotnet_naming_rule.private_constants_rule.severity = warning dotnet_naming_rule.private_constants_rule.style = lower_camel_case_style dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols dotnet_naming_rule.private_instance_fields_rule.severity = warning dotnet_naming_rule.private_instance_fields_rule.style = lower_camel_case_style dotnet_naming_rule.private_instance_fields_rule.symbols = private_instance_fields_symbols dotnet_naming_rule.private_static_fields_rule.severity = warning dotnet_naming_rule.private_static_fields_rule.style = lower_camel_case_style dotnet_naming_rule.private_static_fields_rule.symbols = private_static_fields_symbols dotnet_naming_rule.private_static_readonly_rule.severity = warning dotnet_naming_rule.private_static_readonly_rule.style = lower_camel_case_style dotnet_naming_rule.private_static_readonly_rule.symbols = private_static_readonly_symbols dotnet_naming_style.lower_camel_case_style.capitalization = camel_case dotnet_naming_style.upper_camel_case_style.capitalization = pascal_case dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field dotnet_naming_symbols.private_constants_symbols.required_modifiers = const dotnet_naming_symbols.private_instance_fields_symbols.applicable_accessibilities = private dotnet_naming_symbols.private_instance_fields_symbols.applicable_kinds = field dotnet_naming_symbols.private_static_fields_symbols.applicable_accessibilities = private dotnet_naming_symbols.private_static_fields_symbols.applicable_kinds = field dotnet_naming_symbols.private_static_fields_symbols.required_modifiers = static dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static, readonly dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none # ReSharper properties resharper_object_creation_when_type_not_evident = target_typed # ReSharper inspection severities resharper_arrange_object_creation_when_type_evident_highlighting = error resharper_arrange_object_creation_when_type_not_evident_highlighting = error resharper_arrange_redundant_parentheses_highlighting = error resharper_arrange_static_member_qualifier_highlighting = error resharper_arrange_this_qualifier_highlighting = error resharper_arrange_type_member_modifiers_highlighting = none resharper_built_in_type_reference_style_for_member_access_highlighting = hint resharper_built_in_type_reference_style_highlighting = hint resharper_check_namespace_highlighting = none resharper_convert_to_using_declaration_highlighting = error resharper_field_can_be_made_read_only_local_highlighting = none resharper_merge_into_logical_pattern_highlighting = warning resharper_merge_into_pattern_highlighting = error resharper_method_has_async_overload_highlighting = warning # because stop rider giving errors before source generators have run resharper_partial_type_with_single_part_highlighting = warning resharper_redundant_base_qualifier_highlighting = warning resharper_redundant_cast_highlighting = error resharper_redundant_empty_object_creation_argument_list_highlighting = error resharper_redundant_empty_object_or_collection_initializer_highlighting = error resharper_redundant_name_qualifier_highlighting = error resharper_redundant_suppress_nullable_warning_expression_highlighting = error resharper_redundant_using_directive_highlighting = error resharper_redundant_verbatim_string_prefix_highlighting = error resharper_redundant_lambda_signature_parentheses_highlighting = error resharper_replace_substring_with_range_indexer_highlighting = warning resharper_suggest_var_or_type_built_in_types_highlighting = error resharper_suggest_var_or_type_elsewhere_highlighting = error resharper_suggest_var_or_type_simple_types_highlighting = error resharper_unnecessary_whitespace_highlighting = error resharper_use_await_using_highlighting = warning resharper_use_deconstruction_highlighting = warning # Sort using and Import directives with System.* appearing first dotnet_sort_system_directives_first = true # Avoid "this." and "Me." if not necessary dotnet_style_qualification_for_field = false:error dotnet_style_qualification_for_property = false:error dotnet_style_qualification_for_method = false:error dotnet_style_qualification_for_event = false:error # Use language keywords instead of framework type names for type references dotnet_style_predefined_type_for_locals_parameters_members = true:error dotnet_style_predefined_type_for_member_access = true:error # Suggest more modern language features when available dotnet_style_object_initializer = true:error dotnet_style_collection_initializer = true:error dotnet_style_coalesce_expression = false:error dotnet_style_null_propagation = true:error dotnet_style_explicit_tuple_names = true:error # Use collection expression syntax resharper_use_collection_expression_highlighting = error # Prefer "var" everywhere csharp_style_var_for_built_in_types = true:error csharp_style_var_when_type_is_apparent = true:error csharp_style_var_elsewhere = true:error # Prefer method-like constructs to have a block body csharp_style_expression_bodied_methods = true:error csharp_style_expression_bodied_local_functions = true:error csharp_style_expression_bodied_constructors = true:error csharp_style_expression_bodied_operators = true:error resharper_place_expr_method_on_single_line = false # Prefer property-like constructs to have an expression-body csharp_style_expression_bodied_properties = true:error csharp_style_expression_bodied_indexers = true:error csharp_style_expression_bodied_accessors = true:error # Suggest more modern language features when available csharp_style_pattern_matching_over_is_with_cast_check = true:error csharp_style_pattern_matching_over_as_with_null_check = true:error csharp_style_inlined_variable_declaration = true:suggestion csharp_style_throw_expression = true:suggestion csharp_style_conditional_delegate_call = true:suggestion # Newline settings #csharp_new_line_before_open_brace = all:error resharper_max_array_initializer_elements_on_line = 1 csharp_new_line_before_else = true csharp_new_line_before_catch = true csharp_new_line_before_finally = true csharp_new_line_before_members_in_object_initializers = true csharp_new_line_before_members_in_anonymous_types = true resharper_wrap_before_first_type_parameter_constraint = true resharper_wrap_extends_list_style = chop_always resharper_wrap_after_dot_in_method_calls = false resharper_wrap_before_binary_pattern_op = false resharper_wrap_object_and_collection_initializer_style = chop_always resharper_place_simple_initializer_on_single_line = false # space resharper_space_around_lambda_arrow = true dotnet_style_require_accessibility_modifiers = never:error resharper_place_type_constraints_on_same_line = false resharper_blank_lines_inside_namespace = 0 resharper_blank_lines_after_file_scoped_namespace_directive = 1 resharper_blank_lines_inside_type = 0 resharper_place_attribute_on_same_line = false #braces https://www.jetbrains.com/help/resharper/EditorConfig_CSHARP_CSharpCodeStylePageImplSchema.html#Braces resharper_braces_for_ifelse = required resharper_braces_for_foreach = required resharper_braces_for_while = required resharper_braces_for_dowhile = required resharper_braces_for_lock = required resharper_braces_for_fixed = required resharper_braces_for_for = required resharper_return_value_of_pure_method_is_not_used_highlighting = error resharper_member_hides_interface_member_with_default_implementation_highlighting = error resharper_misleading_body_like_statement_highlighting = error resharper_redundant_record_class_keyword_highlighting = error resharper_redundant_extends_list_entry_highlighting = error resharper_redundant_type_arguments_inside_nameof_highlighting = error # Xml files [*.{xml,config,nuspec,resx,vsixmanifest,csproj,targets,props,fsproj}] indent_size = 2 # https://www.jetbrains.com/help/resharper/EditorConfig_XML_XmlCodeStylePageSchema.html#resharper_xml_blank_line_after_pi resharper_blank_line_after_pi = false resharper_space_before_self_closing = true ij_xml_space_inside_empty_tag = true [*.json] indent_size = 2 # Verify settings [*.{received,verified}.{txt,xml,json,md,sql,csv,html,htm,nuspec,rels}] charset = utf-8-bom end_of_line = lf indent_size = unset indent_style = unset insert_final_newline = false tab_width = unset trim_trailing_whitespace = false ================================================ FILE: .gitattributes ================================================ # Auto detect text files and normalize line endings to LF * text=auto eol=lf *.png binary *.snk binary *.verified.txt text eol=lf working-tree-encoding=UTF-8 *.verified.xml text eol=lf working-tree-encoding=UTF-8 *.verified.json text eol=lf working-tree-encoding=UTF-8 .editorconfig text eol=lf working-tree-encoding=UTF-8 *.sln.DotSettings text eol=lf working-tree-encoding=UTF-8 *.slnx.DotSettings text eol=lf working-tree-encoding=UTF-8 ================================================ FILE: .github/FUNDING.yml ================================================ github: SimonCropp ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug fix about: Create a bug fix to help us improve --- Note: New issues raised, where it is clear the submitter has not read the issue template, are likely to be closed with "please read the issue template". Please don't take offense at this. It is simply a time management decision. If someone raises an issue, and can't be bothered to spend the time to read the issue template, then the project maintainers should not be expected to spend the time to read the submitted issue. Often too much time is spent going back and forth in issue comments asking for information that is outlined in the issue template. #### Preamble Where relevant, ensure you are using the current stable versions on your development stack. For example: * Visual Studio * [.NET SDK or .NET Core SDK](https://www.microsoft.com/net/download) * Any related NuGet packages Any code or stack traces must be properly formatted with [GitHub markdown](https://guides.github.com/features/mastering-markdown/). #### Describe the bug A clear and concise description of what the bug is. Include any relevant version information. A clear and concise description of what you expected to happen. Add any other context about the problem here. #### Minimal Repro Ensure you have replicated the bug in a minimal solution with the fewest moving parts. Often this will help point to the true cause of the problem. Upload this repro as part of the issue, preferably a public GitHub repository or a downloadable zip. The repro will allow the maintainers of this project to smoke test the any fix. #### Submit a PR that fixes the bug Submit a [Pull Request (PR)](https://help.github.com/articles/about-pull-requests/) that fixes the bug. Include in this PR a test that verifies the fix. If you were not able to fix the bug, a PR that illustrates your partial progress will suffice. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: How to raise feature requests --- Note: New issues raised, where it is clear the submitter has not read the issue template, are likely to be closed with "please read the issue template". Please don't take offense at this. It is simply a time management decision. If someone raises an issue, and can't be bothered to spend the time to read the issue template, then the project maintainers should not be expected to spend the time to read the submitted issue. Often too much time is spent going back and forth in issue comments asking for information that is outlined in the issue template. If you are certain the feature will be accepted, it is better to raise a [Pull Request (PR)](https://help.github.com/articles/about-pull-requests/). If you are uncertain if the feature will be accepted, outline the proposal below to confirm it is viable, prior to raising a PR that implements the feature. Note that even if the feature is a good idea and viable, it may not be accepted since the ongoing effort in maintaining the feature may outweigh the benefit it delivers. #### Is the feature request related to a problem A clear and concise description of what the problem is. #### Describe the solution A clear and concise proposal of how you intend to implement the feature. #### Describe alternatives considered A clear and concise description of any alternative solutions or features you've considered. #### Additional context Add any other context about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: nuget directory: "/src" schedule: interval: daily open-pull-requests-limit: 10 ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 7 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Set to true to ignore issues in a milestone (defaults to false) exemptMilestones: true # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': pulls: daysUntilStale: 30 exemptLabels: - Question - Bug - Feature - Improvement ================================================ FILE: .github/workflows/merge-dependabot.yml ================================================ name: merge-dependabot on: pull_request: jobs: automerge: runs-on: ubuntu-latest if: github.actor == 'dependabot[bot]' steps: - name: Dependabot Auto Merge uses: ahmadnassri/action-dependabot-auto-merge@v2.6.6 with: target: minor github-token: ${{ secrets.dependabot }} command: squash and merge ================================================ FILE: .github/workflows/milestone-release.yml ================================================ name: milestone-release on: push: tags: - '*' milestone: types: [created, edited, closed, opened] issues: types: [opened, edited, closed, reopened, deleted, milestoned, demilestoned] pull_request: types: [opened, edited, closed, reopened, milestoned, demilestoned] workflow_dispatch: inputs: milestone: description: 'Milestone title to rebuild (leave empty to rebuild all)' required: false type: string permissions: contents: write jobs: sync-release: runs-on: ubuntu-latest steps: - name: Sync Release with Milestone uses: actions/github-script@v7 with: script: | const { owner, repo } = context.repo; // Helper: Find release by tag name async function findRelease(tagName) { for await (const response of github.paginate.iterator( github.rest.repos.listReleases, { owner, repo, per_page: 100 } )) { const release = response.data.find(r => r.tag_name === tagName); if (release) return release; } return null; } // Helper: Find milestone by title async function findMilestone(title) { for await (const response of github.paginate.iterator( github.rest.issues.listMilestones, { owner, repo, state: 'all', per_page: 100 } )) { const milestone = response.data.find(m => m.title === title); if (milestone) return milestone; } return null; } // Helper: Generate release body from milestone async function generateBody(milestoneNumber) { const items = []; for await (const response of github.paginate.iterator( github.rest.issues.listForRepo, { owner, repo, milestone: milestoneNumber, state: 'all', per_page: 100 } )) { items.push(...response.data); } items.sort((a, b) => a.number - b.number); return items.map(item => { const checkbox = item.state === 'closed' ? '[x]' : '[ ]'; return `- ${checkbox} [#${item.number}](${item.html_url}) ${item.title}`; }).join('\n') || 'No issues in this milestone yet.'; } // Helper: Update existing release only async function updateReleaseIfExists(milestone) { const release = await findRelease(milestone.title); if (!release) { console.log(`No release found for ${milestone.title}, skipping`); return; } const body = await generateBody(milestone.number); await github.rest.repos.updateRelease({ owner, repo, release_id: release.id, body: body }); console.log(`Updated release: ${milestone.title}`); } // Handle tag push events if (context.eventName === 'push' && context.ref.startsWith('refs/tags/')) { const tagName = context.ref.replace('refs/tags/', ''); // Tag deleted if (context.payload.deleted) { const release = await findRelease(tagName); if (release) { await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id }); console.log(`Deleted release for tag: ${tagName}`); } return; } // Tag created - create release const milestone = await findMilestone(tagName); const body = milestone ? await generateBody(milestone.number) : ''; await github.rest.repos.createRelease({ owner, repo, tag_name: tagName, name: tagName, body: body, draft: false }); console.log(`Created release for tag: ${tagName}`); return; } // Handle workflow_dispatch - update only if (context.eventName === 'workflow_dispatch') { const inputMilestone = context.payload.inputs?.milestone; const milestones = []; for await (const response of github.paginate.iterator( github.rest.issues.listMilestones, { owner, repo, state: 'all', per_page: 100 } )) { milestones.push(...response.data); } for (const ms of milestones) { if (!inputMilestone || ms.title === inputMilestone) { await updateReleaseIfExists(ms); } } return; } // Handle milestone/issue/PR events - update only let milestone = context.payload.milestone; if (!milestone && context.payload.issue?.milestone) { milestone = context.payload.issue.milestone; } if (!milestone && context.payload.pull_request?.milestone) { milestone = context.payload.pull_request.milestone; } if (!milestone) { console.log('No milestone associated with this event'); return; } await updateReleaseIfExists(milestone); ================================================ FILE: .gitignore ================================================ *.suo *.user bin/ obj/ .vs/ *.DotSettings.user .idea/ *.received.* nugets/ nul /src/Benchmarks/BenchmarkDotNet.Artifacts ================================================ FILE: claude.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview MarkdownSnippets is a .NET tool/library that extracts code snippets from source files (via `begin-snippet`/`end-snippet` markers and C# regions) and merges them into markdown documents. Distributed as: - **dotnet global tool** (`MarkdownSnippets.Tool`, command: `mdsnippets`) - **MSBuild task** (`MarkdownSnippets.MsBuild`) - **Library** (`MarkdownSnippets`) ## Build Commands All commands must be run from the repo root directory. ```bash # Build everything dotnet build src # Run all tests dotnet test src # Run a single test project dotnet test src/Tests/Tests.csproj dotnet test src/ConfigReader.Tests/ConfigReader.Tests.csproj dotnet test src/MarkdownSnippets.Tool.Tests/MarkdownSnippets.Tool.Tests.csproj # Run a specific test by name dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~TestClassName.TestMethodName" # Pack the tool dotnet pack src/MarkdownSnippets.Tool/MarkdownSnippets.Tool.csproj ``` Requires .NET SDK 10.0 (preview). See `src/global.json` for exact version. ## Architecture All source is under `src/`. There is no `.sln` file; build by targeting `src/` or individual `.csproj` files. ### Core Library (`src/MarkdownSnippets/`) Two main subsystems: - **Reading** (`Reading/`): Extracts snippets from source files. `FileSnippetExtractor` scans files for `begin-snippet`/`end-snippet` markers. `StartEndTester` handles marker detection including C# regions. `Snippet` is the data model. - **Processing** (`Processing/`): Transforms markdown files. `DirectoryMarkdownProcessor` is the top-level orchestrator that scans directories, collects snippets, and processes markdown. `MarkdownProcessor` handles individual file transformation. `IncludeProcessor` handles include directives. ### Tool (`src/MarkdownSnippets.Tool/`) CLI entry point. `Program.cs` uses top-level statements. `CommandRunner` parses args via `CommandLineParser` and delegates to `DirectoryMarkdownProcessor`. Shares `ConfigReader` source files via `` (not a project reference). ### ConfigReader (`src/ConfigReader/`) Reads `mdsnippets.json` configuration files. Source files are compiled directly into the Tool project (not referenced as a library). ### MsBuild Task (`src/MarkdownSnippets.MsBuild/`) Wraps the library as an MSBuild task. Uses `PackageShader.MsBuild` for dependency isolation. Targets netstandard2.0 and net10.0. ## Testing - Framework: **xunit.v3** with **Verify** (snapshot testing) - Snapshots are `.verified.txt` files alongside tests. When a test fails due to output changes, review the `.received.txt` diff and accept with the Verify tooling if correct. - Main test project (`src/Tests/`) targets net10.0 (and net48 on Windows) - Test data directories (e.g., `DirectoryMarkdownProcessor/`, `SnippetExtractor/`) are copied to output via csproj settings - The markdown files in this repo are regenerated by running tests, not as part of the build ## Build Configuration - `src/Directory.Build.props`: Version (28.0.1), LangVersion preview, TreatWarningsAsErrors, EnforceCodeStyleInBuild - `src/Directory.Packages.props`: Central package management with transitive pinning - Global type alias: `CharSpan` = `System.ReadOnlySpan` (defined in Directory.Build.props) - Multi-targeting: Library targets netstandard2.0/2.1, net48, net8.0, net9.0, net10.0 ## Document Conventions The tool supports two modes for processing markdown: - **SourceTransform** (default): Reads `*.source.md`, writes output to `*.md` - **InPlaceOverwrite**: Modifies `*.md` files directly ================================================ FILE: code_of_conduct.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at simon.cropp@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: docs/api.md ================================================ # Library Usage ## NuGet package https://nuget.org/packages/MarkdownSnippets/ [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.svg)](https://www.nuget.org/packages/MarkdownSnippets/) ## Reading snippets from files ```cs var files = Directory.EnumerateFiles(@"C:\path", "*.cs", SearchOption.AllDirectories); var snippets = FileSnippetExtractor.Read(files); ``` snippet source | anchor ## Ignored paths To change conventions manipulate lists `MarkdownSnippets.Exclusions.NoAcceptCommentsExtensions` and `MarkdownSnippets.Exclusions.BinaryFileExtensions`. ================================================ FILE: docs/config-file.md ================================================ # Config File The [dotnet tool](/readme.md#installation) and the [MSBuild Task](msbuild.md) support a config file convention. Add a file named `mdsnippets.json` at the target directory with the following content: ## For [InPlaceOverwrite](https://github.com/SimonCropp/MarkdownSnippets#inplaceoverwrite) ```json { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json", "Convention": "InPlaceOverwrite" } ``` snippet source | anchor ## For [SourceTransform](https://github.com/SimonCropp/MarkdownSnippets#sourcetransform) ```json { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json", "Convention": "SourceTransform" } ``` snippet source | anchor ## All Settings ```json { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json", "ReadOnly": false, "LinkFormat": "Tfs", "TocLevel": 3, "ExcludeDirectories": [ "Dir1", "Dir2" ], "ExcludeMarkdownDirectories": [ "Dir2", "Dir3" ], "ExcludeSnippetDirectories": [ "Dir4", "Dir5" ], "ExcludeSnippetFiles": [ "*.verified.txt", "*.received.txt" ], "UrlsAsSnippets": [ "Url1", "Url2" ], "TocExcludes": [ "Exclude Heading1", "Exclude Heading2" ], "Convention": "InPlaceOverwrite", "WriteHeader": true, "MaxWidth": 80, "Header": "GENERATED FILE - Source File: {relativePath}", "UrlPrefix": "TheUrlPrefix", "TreatMissingAsWarning": true, "ValidateContent": true, "OmitSnippetLinks": true } ``` snippet source | anchor ## JSON Schema Editor help is available by adding the `$schema` field to the `mdsnippets.json` file. ```json { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json" } ``` In the screenshot, [JetBrains Rider](https://jetbrains.com/rider), is able to offer code completion support. ![IDE schema code completion](/docs/code-completion.png) The schema also includes `enum` values for constrained value types. ![IDE schema code completion](/docs/code-completion-values.png) ## More Info * [ReadOnly: Mark resulting files as read only](/readme.md#mark-resulting-files-as-read-only) * [LinkFormat](/readme.md#linkformat). * [Convention](/readme.md#document-convention). * [TocLevel: Heading level](/docs/toc.md#heading-level). * [TocExcludes: Ignore headings](/docs/toc.md#ignore-headings). * [Exclude: Exclude directories from discovery](/docs/exclusion.md). * [WriteHeader: Disable Header](/docs/header.md#disable-header). * [UrlPrefix](/readme.md#urlprefix). * [UrlsAsSnippets: Urls to files to be included as snippets](/readme.md#urlsassnippets). * TreatMissingAsWarning: The default behavior for a missing snippet/include is to log an error (or throw an exception). To change that behavior to a warning set TreatMissingAsWarning to true. ================================================ FILE: docs/exclusion.md ================================================ # Exclusions ## Exclude directories from snippet and markdown discovery To exclude directories use `-e` or `--exclude-directories`. For example the following will exclude any directory containing 'foo' or 'bar' ```ps mdsnippets -e foo:bar ``` ## Exclude snippets from directories To exclude directories from snippet discovery use `--exclude-snippet-directories`. For example the following will exclude any directory containing 'foo' or 'bar' ```ps mdsnippets --exclude-snippet-directories foo:bar ``` ## Exclude markdown from directories To exclude directories from markdown discovery use `--exclude-markdown-directories`. For example the following will exclude any directory containing 'foo' or 'bar' ```ps mdsnippets --exclude-markdown-directories foo:bar ``` ## Exclude files from snippet discovery To exclude specific files from snippet discovery, add an `ExcludeSnippetFiles` array to [`mdsnippets.json`](/docs/config-file.md). Each entry is a glob pattern matched (case-insensitively) against the file *name* — not the full path. Patterns support `*` (any sequence of characters) and `?` (a single character). For example, the following excludes all Verify snapshot files so that `` markers hand-added to a `*.verified.txt` are not picked up as snippet sources: ```json { "ExcludeSnippetFiles": [ "*.verified.txt", "*.received.txt" ] } ``` Matched files are still visible to the include/all-files enumeration — only snippet scanning skips them. ## Ignored paths ### Directory exclusion rules: ```cs namespace MarkdownSnippets; public static class DefaultDirectoryExclusions { public static bool ShouldExcludeDirectory(string path) { var suffix = Path .GetFileName(path) .ToLowerInvariant(); if (suffix is // source control ".git" or // ide temp files ".vs" or ".vscode" or ".idea" or // package cache "packages" or "node_modules" or // build output "dist" or ".angular" or "bin" or "obj") { return true; } var directory = new DirectoryInfo(path); return directory.Attributes.HasFlag(FileAttributes.Hidden); } } ``` snippet source | anchor ### File exclusion rules All binary files as defined by https://github.com/sindresorhus/binary-extensions/: ```cs "user", // extra binary "mdb", "binlog", "shp", "dbf", "shx", "pbf", "map", "sbn", //from https://github.com/sindresorhus/binary-extensions/blob/master/binary-extensions.json "3dm", "3ds", "3g2", "3gp", "7z", "a", "aac", "adp", "ai", "aif", "aiff", "alz", "ape", "apk", "appimage", "ar", "arj", "asf", "au", "avi", "bak", "baml", "bh", "bin", "bk", "bmp", "btif", "bz2", "bzip2", "cab", "caf", "cgm", "class", "cmx", "cpio", "cr2", "cur", "dat", "dcm", "deb", "dex", "djvu", "dll", "dmg", "dng", "doc", "docm", "docx", "dot", "dotm", "dra", "DS_Store", "dsk", "dts", "dtshd", "dvb", "dwg", "dxf", "ecelp4800", "ecelp7470", "ecelp9600", "egg", "eol", "eot", "epub", "exe", "f4v", "fbs", "fh", "fla", "flac", "flatpak", "fli", "flv", "fpx", "fst", "fvt", "g3", "gh", "gif", "graffle", "gz", "gzip", "h261", "h263", "h264", "icns", "ico", "ief", "img", "ipa", "iso", "jar", "jpeg", "jpg", "jpgv", "jpm", "jxr", "key", "ktx", "lha", "lib", "lvp", "lz", "lzh", "lzma", "lzo", "m3u", "m4a", "m4v", "mar", "mdi", "mht", "mid", "midi", "mj2", "mka", "mkv", "mmr", "mng", "mobi", "mov", "movie", "mp3", "mp4", "mp4a", "mpeg", "mpg", "mpga", "mxu", "nef", "npx", "numbers", "nupkg", "o", "oga", "ogg", "ogv", "otf", "pages", "pbm", "pcx", "pdb", "pdf", "pea", "pgm", "pic", "png", "pnm", "pot", "potm", "potx", "ppa", "ppam", "ppm", "pps", "ppsm", "ppsx", "ppt", "pptm", "pptx", "psd", "pya", "pyc", "pyo", "pyv", "qt", "rar", "ras", "raw", "resources", "rgb", "rip", "rlc", "rmf", "rmvb", "rpm", "rtf", "rz", "s3m", "s7z", "scpt", "sgi", "shar", "snap", "sil", "sketch", "slk", "smv", "snk", "so", "stl", "suo", "sub", "swf", "tar", "tbz", "tbz2", "tga", "tgz", "thmx", "tif", "tiff", "tlz", "ttc", "ttf", "txz", "udf", "uvh", "uvi", "uvm", "uvp", "uvs", "uvu", "viv", "vob", "war", "wav", "wax", "wbmp", "wdp", "weba", "webm", "webp", "whl", "wim", "wm", "wma", "wmv", "wmx", "woff", "woff2", "wrm", "wvx", "xbm", "xif", "xla", "xlam", "xls", "xlsb", "xlsm", "xlsx", "xlt", "xltm", "xltx", "xm", "xmind", "xpi", "xpm", "xwd", "xz", "z", "zip", "zipx" ``` snippet source | anchor ### No comment files Files that cannot contain comments are excluded. ```cs "DotSettings", "csv", "json", "geojson", "sln" ``` snippet source | anchor ================================================ FILE: docs/github-action.md ================================================ # GitHub Actions Markdown snippets can be run inside a [GitHub Action](https://help.github.com/en/actions) by installing and using [MarkdownSnippets.Tool](/readme.md#installation). This can be useful to ensure md docs are in sync when .source files are edited online, and without needing to re-generate the docs locally. Add the following to `.github\workflows\on-push-do-doco.yml` in the target repository. ```yml name: on-push-do-docs on: push: jobs: docs: runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Run MarkdownSnippets run: | dotnet tool install --global MarkdownSnippets.Tool mdsnippets ${GITHUB_WORKSPACE} shell: bash - name: Push changes run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git commit -m "Docs changes" -a || echo "nothing to commit" remote="https://${GITHUB_ACTOR}:${{secrets.GITHUB_TOKEN}}@github.com/${GITHUB_REPOSITORY}.git" branch="${GITHUB_REF:11}" git push "${remote}" ${branch} || echo "nothing to push" shell: bash ``` snippet source | anchor This action performs the following tasks: * Use the [Checkout Action](https://github.com/marketplace/actions/checkout) to pull down the source * Install the MarkdownSnippets dotnet tool * Run MarkdownSnippets against the current directory * Push any changes back to GitHub ## More Info * [Software installed on GitHub-hosted runners](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/software-installed-on-github-hosted-runners) ================================================ FILE: docs/header.md ================================================ # Header When a .md file is written, a header is include. The default header is: ```txt GENERATED FILE - DO NOT EDIT This file was generated by [MarkdownSnippets](https://github.com/SimonCropp/MarkdownSnippets). Source File: {relativePath} To change this file edit the source file and then run MarkdownSnippets. ``` snippet source | anchor ## Disable Header To disable the header use `--write-header` ```ps mdsnippets --write-header false ``` ## Custom Header To apply a custom header use `--header`. `{relativePath}` will be replaced with the relative path of the `.source.md` file. ```ps mdsnippets --header "GENERATED FILE - Source File: {relativePath}" ``` ## Newlines in Header To insert a newline use `\n` ```ps mdsnippets --header "GENERATED FILE\nSource File: {relativePath}" ``` ================================================ FILE: docs/includes.md ================================================ # Includes ## Including full code files When snippets are read all source files are stored in a list. When searching for a snippet with a specified key, and that key is not found, the list of files are used as a secondary lookup. The lookup is done by finding all files that have a suffix matching the key. This results in the ability to include full files as snippets using the following syntax:
snippet: directory/FileToInclude.txt
The path syntax uses forward slashes `/`. ## Including urls
snippet: http://myurl
## Including a snippet from an external URL To include a specific named snippet from a file using an external URL, use the `web-snippet:` keyword followed by the URL and the snippet key separated by a `#`:
web-snippet:https://raw.githubusercontent.com/owner/repo/branch/path/to/file.cs#snippetKey
This will fetch the file from the URL, extract the snippet with the given key, and embed it in your Markdown. ## Markdown includes Markdown includes are pulled into the document before passing the content through the snippet insertion. ### Defining an include Add a file anywhere in the target directory that is suffixed with `.include.md`. For example, the file might be named `theKey.include.md`. ### Using an include Add the following to the markdown: ``` include: theKey ``` ================================================ FILE: docs/indentation.md ================================================ # Code indentation The code snippets will do smart trimming of snippet indentation. For example given this snippet:
••// begin-snippet MySnippetName
••Line one of the snippet
••••Line two of the snippet
••// end-snippet
The leading two spaces (••) will be trimmed and the result will be: ``` Line one of the snippet ••Line two of the snippet ``` The same behavior will apply to leading tabs. ## Do not mix tabs and spaces If tabs and spaces are mixed there is no way for the snippets to work out what to trim. So given this snippet:
••// begin-snippet MySnippetNamea
••Line one of the snippet
➙➙Line one of the snippet
••// end-snippet
Where ➙ is a tab. The resulting markdown will be will be
Line one of the snippet
➙➙Line one of the snippet
Note that none of the tabs have been trimmed. ================================================ FILE: docs/max-width.md ================================================ # Max Width The Max Width setting is used to control the maximum characters per line of a snippet. If any snippet has a line that exceeds the maximum an error will be thrown. ## Usage ### Config File ``` { "MaxWidth": 80 } ``` ### Command Line ``` --max-width 80 ``` ### Code Api ```cs var processor = new DirectoryMarkdownProcessor( "targetDirectory", maxWidth: 80, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); ``` snippet source | anchor ## References https://en.wikipedia.org/wiki/Line_length#Electronic_text > Legibility research specific to digital text has shown that, like with printed text, line length can affect reading speed. If lines are too long it is difficult for the reader to quickly return to the start of the next line (saccade) whereas if lines are too short more scrolling or paging will be required. Researchers have suggested that longer lines are better for quick scanning, while shorter lines are better for accuracy. Longer lines would then be better suited for cases when the information will likely be scanned, while shorter lines would be appropriate when the information is meant to be read thoroughly. One proposal advanced that, in order for on-screen text to have the best compromise between reading speed and comprehension, about 55 cpl should be used. On the other hand, there have been studies indicating that digital text at 100 cpl can be read faster than text with lines of 25 characters, while retaining the same level of comprehension. ================================================ FILE: docs/mdsource/api.source.md ================================================ # Library Usage ## NuGet package https://nuget.org/packages/MarkdownSnippets/ [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.svg)](https://www.nuget.org/packages/MarkdownSnippets/) ## Reading snippets from files snippet: ReadingFilesSimple ## Ignored paths To change conventions manipulate lists `MarkdownSnippets.Exclusions.NoAcceptCommentsExtensions` and `MarkdownSnippets.Exclusions.BinaryFileExtensions`. ================================================ FILE: docs/mdsource/config-file.source.md ================================================ # Config File The [dotnet tool](/readme.md#installation) and the [MSBuild Task](msbuild.md) support a config file convention. Add a file named `mdsnippets.json` at the target directory with the following content: ## For [InPlaceOverwrite](https://github.com/SimonCropp/MarkdownSnippets#inplaceoverwrite) snippet: InPlaceOverwrite.json ## For [SourceTransform](https://github.com/SimonCropp/MarkdownSnippets#sourcetransform) snippet: SourceTransform.json ## All Settings snippet: allConfig.json ## JSON Schema Editor help is available by adding the `$schema` field to the `mdsnippets.json` file. ```json { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json" } ``` In the screenshot, [JetBrains Rider](https://jetbrains.com/rider), is able to offer code completion support. ![IDE schema code completion](/docs/code-completion.png) The schema also includes `enum` values for constrained value types. ![IDE schema code completion](/docs/code-completion-values.png) ## More Info * [ReadOnly: Mark resulting files as read only](/readme.md#mark-resulting-files-as-read-only) * [LinkFormat](/readme.md#linkformat). * [Convention](/readme.md#document-convention). * [TocLevel: Heading level](/docs/toc.md#heading-level). * [TocExcludes: Ignore headings](/docs/toc.md#ignore-headings). * [Exclude: Exclude directories from discovery](/docs/exclusion.md). * [WriteHeader: Disable Header](/docs/header.md#disable-header). * [UrlPrefix](/readme.md#urlprefix). * [UrlsAsSnippets: Urls to files to be included as snippets](/readme.md#urlsassnippets). * TreatMissingAsWarning: The default behavior for a missing snippet/include is to log an error (or throw an exception). To change that behavior to a warning set TreatMissingAsWarning to true. ================================================ FILE: docs/mdsource/doc-index.include.md ================================================ * Developer Information * [.net API](/docs/api.md) * [MsBuild Task](/docs/msbuild.md) * [Github Action](/docs/github-action.md) * Customisation * [Config file convention](/docs/config-file.md) * [Max Width](/docs/max-width.md) * [Includes](/docs/includes.md) * [Directory Exclusion](/docs/exclusion.md) * [Header](/docs/header.md) * Writing Documentation * [Indentation](/docs/indentation.md) * [Table of contents](/docs/toc.md) ================================================ FILE: docs/mdsource/exclusion.source.md ================================================ # Exclusions ## Exclude directories from snippet and markdown discovery To exclude directories use `-e` or `--exclude-directories`. For example the following will exclude any directory containing 'foo' or 'bar' ```ps mdsnippets -e foo:bar ``` ## Exclude snippets from directories To exclude directories from snippet discovery use `--exclude-snippet-directories`. For example the following will exclude any directory containing 'foo' or 'bar' ```ps mdsnippets --exclude-snippet-directories foo:bar ``` ## Exclude markdown from directories To exclude directories from markdown discovery use `--exclude-markdown-directories`. For example the following will exclude any directory containing 'foo' or 'bar' ```ps mdsnippets --exclude-markdown-directories foo:bar ``` ## Exclude files from snippet discovery To exclude specific files from snippet discovery, add an `ExcludeSnippetFiles` array to [`mdsnippets.json`](/docs/config-file.md). Each entry is a glob pattern matched (case-insensitively) against the file *name* — not the full path. Patterns support `*` (any sequence of characters) and `?` (a single character). For example, the following excludes all Verify snapshot files so that `` markers hand-added to a `*.verified.txt` are not picked up as snippet sources: ```json { "ExcludeSnippetFiles": [ "*.verified.txt", "*.received.txt" ] } ``` Matched files are still visible to the include/all-files enumeration — only snippet scanning skips them. ## Ignored paths ### Directory exclusion rules: snippet: DefaultDirectoryExclusions.cs ### File exclusion rules All binary files as defined by https://github.com/sindresorhus/binary-extensions/: snippet: BinaryFileExtensions ### No comment files Files that cannot contain comments are excluded. snippet: NoAcceptCommentsExtensions ================================================ FILE: docs/mdsource/github-action.source.md ================================================ # GitHub Actions Markdown snippets can be run inside a [GitHub Action](https://help.github.com/en/actions) by installing and using [MarkdownSnippets.Tool](/readme.md#installation). This can be useful to ensure md docs are in sync when .source files are edited online, and without needing to re-generate the docs locally. Add the following to `.github\workflows\on-push-do-doco.yml` in the target repository. snippet: on-push-do-docs.yml This action performs the following tasks: * Use the [Checkout Action](https://github.com/marketplace/actions/checkout) to pull down the source * Install the MarkdownSnippets dotnet tool * Run MarkdownSnippets against the current directory * Push any changes back to GitHub ## More Info * [Software installed on GitHub-hosted runners](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/software-installed-on-github-hosted-runners) ================================================ FILE: docs/mdsource/header.source.md ================================================ # Header When a .md file is written, a header is include. The default header is: snippet: HeaderWriterTests.DefaultHeader.verified.txt ## Disable Header To disable the header use `--write-header` ```ps mdsnippets --write-header false ``` ## Custom Header To apply a custom header use `--header`. `{relativePath}` will be replaced with the relative path of the `.source.md` file. ```ps mdsnippets --header "GENERATED FILE - Source File: {relativePath}" ``` ## Newlines in Header To insert a newline use `\n` ```ps mdsnippets --header "GENERATED FILE\nSource File: {relativePath}" ``` ================================================ FILE: docs/mdsource/includes.source.md ================================================ # Includes ## Including full code files When snippets are read all source files are stored in a list. When searching for a snippet with a specified key, and that key is not found, the list of files are used as a secondary lookup. The lookup is done by finding all files that have a suffix matching the key. This results in the ability to include full files as snippets using the following syntax:
snippet: directory/FileToInclude.txt
The path syntax uses forward slashes `/`. ## Including urls
snippet: http://myurl
## Including a snippet from an external URL To include a specific named snippet from a file using an external URL, use the `web-snippet:` keyword followed by the URL and the snippet key separated by a `#`:
web-snippet:https://raw.githubusercontent.com/owner/repo/branch/path/to/file.cs#snippetKey
This will fetch the file from the URL, extract the snippet with the given key, and embed it in your Markdown. ## Markdown includes Markdown includes are pulled into the document before passing the content through the snippet insertion. ### Defining an include Add a file anywhere in the target directory that is suffixed with `.include.md`. For example, the file might be named `theKey.include.md`. ### Using an include Add the following to the markdown: ``` include: theKey ``` ================================================ FILE: docs/mdsource/indentation.source.md ================================================ # Code indentation The code snippets will do smart trimming of snippet indentation. For example given this snippet:
••// begin-snippet MySnippetName
••Line one of the snippet
••••Line two of the snippet
••// end-snippet
The leading two spaces (••) will be trimmed and the result will be: ``` Line one of the snippet ••Line two of the snippet ``` The same behavior will apply to leading tabs. ## Do not mix tabs and spaces If tabs and spaces are mixed there is no way for the snippets to work out what to trim. So given this snippet:
••// begin-snippet MySnippetNamea
••Line one of the snippet
➙➙Line one of the snippet
••// end-snippet
Where ➙ is a tab. The resulting markdown will be will be
Line one of the snippet
➙➙Line one of the snippet
Note that none of the tabs have been trimmed. ================================================ FILE: docs/mdsource/max-width.source.md ================================================ # Max Width The Max Width setting is used to control the maximum characters per line of a snippet. If any snippet has a line that exceeds the maximum an error will be thrown. ## Usage ### Config File ``` { "MaxWidth": 80 } ``` ### Command Line ``` --max-width 80 ``` ### Code Api snippet: DirectoryMarkdownProcessorRunMaxWidth ## References https://en.wikipedia.org/wiki/Line_length#Electronic_text > Legibility research specific to digital text has shown that, like with printed text, line length can affect reading speed. If lines are too long it is difficult for the reader to quickly return to the start of the next line (saccade) whereas if lines are too short more scrolling or paging will be required. Researchers have suggested that longer lines are better for quick scanning, while shorter lines are better for accuracy. Longer lines would then be better suited for cases when the information will likely be scanned, while shorter lines would be appropriate when the information is meant to be read thoroughly. One proposal advanced that, in order for on-screen text to have the best compromise between reading speed and comprehension, about 55 cpl should be used. On the other hand, there have been studies indicating that digital text at 100 cpl can be read faster than text with lines of 25 characters, while retaining the same level of comprehension. ================================================ FILE: docs/mdsource/msbuild.source.md ================================================ # MsBuild An [MsBuild task](https://docs.microsoft.com/en-us/visualstudio/msbuild/msbuild-task) for merging snippets into markdown documents. MsBuild has a [convention](https://docs.microsoft.com/en-us/nuget/create-packages/creating-a-package#from-a-convention-based-working-directory) to automatically run build tasks from included NuGet packages. This package takes advantage of that hook to run markdownsnippets on build. This package only need to be included in one project of the solution. A logical choice is the test project. ## NuGet package https://nuget.org/packages/MarkdownSnippets.MsBuild/ [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.MsBuild.svg)](https://www.nuget.org/packages/MarkdownSnippets.MsBuild/) ## More Info * [Config file convention](/docs/config-file.md). ================================================ FILE: docs/mdsource/readme.source.md ================================================ # Documentation include: doc-index ================================================ FILE: docs/mdsource/toc/tocAfter.txt ================================================ # Title ## Contents * [Heading 1](#heading-1) * [Heading 2](#heading-2) ## Heading 1 Text1 ## Heading 2 Text2 ================================================ FILE: docs/mdsource/toc/tocBefore.txt ================================================ # Title toc ## Heading 1 Text1 ## Heading 1 Text2 ================================================ FILE: docs/mdsource/toc.source.md ================================================ # Table of contents If a line is `toc` it will be replaced with a table of contents So if a markdown document contains the following: snippet: tocBefore.txt The result will be rendered: snippet: tocAfter.txt ## Heading Level Headings with level 2 (`##`) or greater can be rendered. By default all level 2 and level 3 headings are included. To include more levels use the `--toc-level` argument. So for example to include headings levels 2 though level 6 use: ```ps mdsnippets --toc-level 5 ``` ## Ignore Headings To exclude headings use the `--toc-excludes` argument. So for example to exclude `heading1` and `heading2` use: ```ps mdsnippets --toc-excludes heading1:heading2 ``` ================================================ FILE: docs/msbuild.md ================================================ # MsBuild An [MsBuild task](https://docs.microsoft.com/en-us/visualstudio/msbuild/msbuild-task) for merging snippets into markdown documents. MsBuild has a [convention](https://docs.microsoft.com/en-us/nuget/create-packages/creating-a-package#from-a-convention-based-working-directory) to automatically run build tasks from included NuGet packages. This package takes advantage of that hook to run markdownsnippets on build. This package only need to be included in one project of the solution. A logical choice is the test project. ## NuGet package https://nuget.org/packages/MarkdownSnippets.MsBuild/ [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.MsBuild.svg)](https://www.nuget.org/packages/MarkdownSnippets.MsBuild/) ## More Info * [Config file convention](/docs/config-file.md). ================================================ FILE: docs/on-push-do-docs.yml ================================================ name: on-push-do-docs on: push: jobs: docs: runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Run MarkdownSnippets run: | dotnet tool install --global MarkdownSnippets.Tool mdsnippets ${GITHUB_WORKSPACE} shell: bash - name: Push changes run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git commit -m "Docs changes" -a || echo "nothing to commit" remote="https://${GITHUB_ACTOR}:${{secrets.GITHUB_TOKEN}}@github.com/${GITHUB_REPOSITORY}.git" branch="${GITHUB_REF:11}" git push "${remote}" ${branch} || echo "nothing to push" shell: bash ================================================ FILE: docs/readme.md ================================================ # Documentation * Developer Information * [.net API](/docs/api.md) * [MsBuild Task](/docs/msbuild.md) * [Github Action](/docs/github-action.md) * Customisation * [Config file convention](/docs/config-file.md) * [Max Width](/docs/max-width.md) * [Includes](/docs/includes.md) * [Directory Exclusion](/docs/exclusion.md) * [Header](/docs/header.md) * Writing Documentation * [Indentation](/docs/indentation.md) * [Table of contents](/docs/toc.md) ================================================ FILE: docs/toc.md ================================================ # Table of contents If a line is `toc` it will be replaced with a table of contents So if a markdown document contains the following: ```txt # Title toc ## Heading 1 Text1 ## Heading 1 Text2 ``` snippet source | anchor The result will be rendered: ```txt # Title ## Contents * [Heading 1](#heading-1) * [Heading 2](#heading-2) ## Heading 1 Text1 ## Heading 2 Text2 ``` snippet source | anchor ## Heading Level Headings with level 2 (`##`) or greater can be rendered. By default all level 2 and level 3 headings are included. To include more levels use the `--toc-level` argument. So for example to include headings levels 2 though level 6 use: ```ps mdsnippets --toc-level 5 ``` ## Ignore Headings To exclude headings use the `--toc-excludes` argument. So for example to exclude `heading1` and `heading2` use: ```ps mdsnippets --toc-excludes heading1:heading2 ``` ================================================ FILE: license.txt ================================================ The MIT License (MIT) Copyright (c) 2013 Simon Cropp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: readme.md ================================================ # MarkdownSnippets [![Build status](https://img.shields.io/appveyor/build/SimonCropp/MarkdownSnippets)](https://ci.appveyor.com/project/SimonCropp/MarkdownSnippets) [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.Tool.svg?label=dotnet%20tool)](https://www.nuget.org/packages/MarkdownSnippets.Tool/) [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.MsBuild.svg?label=MsBuild%20Task)](https://www.nuget.org/packages/MarkdownSnippets.MsBuild/) [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.svg?label=.net%20API)](https://www.nuget.org/packages/MarkdownSnippets/) A [dotnet tool](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools) or [MsBuild Task](/docs/msbuild.md) that extract snippets from code files and merges them into markdown documents. **See [Milestones](../../milestones?state=closed) for release notes.** **[.net 10](https://dotnet.microsoft.com/download/dotnet/10.0) or higher is required to run the dotnet tool.** ## Value Proposition Automatically extract snippets from code and injecting them into markdown documents has several benefits: * Snippets can be verified by a compiler or parser. * Tests can be run on snippets, or snippets can be pulled from existing tests. * Changes in code are automatically reflected in documentation. * Snippets are less likely to get out of sync with the main code-base. * Snippets in markdown is easier to create and maintain since any preferred editor can be used to edit them. ## Behavior * Recursively scan the target directory for code files containing snippets. (See [exclusion](/docs/exclusion.md)). * Recursively scan the target directory for markdown (`.md` or `mdx`) files. (See [Document Scanning](#document-convention)). * Merge the snippets into those markdown files. ## Installation Ensure [dotnet CLI is installed](https://docs.microsoft.com/en-us/dotnet/core/tools/). Install [MarkdownSnippets.Tool](https://nuget.org/packages/MarkdownSnippets.Tool/) ```ps dotnet tool install -g MarkdownSnippets.Tool ``` See also: [MsBuild Task usage](/docs/msbuild.md) ## Usage ```ps mdsnippets C:\Code\TargetDirectory ``` If no directory is passed the current directory will be used, but only if it exists with a git repository directory tree. If not an error is returned. ### Document Convention There are two approaches scanning and modifying markdown files. #### SourceTransform This is the default. ##### source.md file The file convention recursively scans the target directory for all `*.source.md` files. Once snippets are merged the `.source.md` to produce `.md` files. So for example `readme.source.md` would be merged with snippets to produce `readme.md`. Note that this process will overwrite any existing `.md` files that have matching `.source.md` files. #### mdsource directory There is a secondary convention that leverages the use of a directory named `mdsource`. Where `.source.md` files are placed in a `mdsource` sub-directory, the `mdsource` part of the file path will be removed when calculating the target path. This allows the `.source.md` to be grouped in a sub directory and avoid cluttering up the main documentation directory. When using the `mdsource` convention, all references to other files, such as links and images, should specify the full path from the root of the repository. This will allow those links to work correctly in both the source and generated markdown files. Relative paths cannot work for both the source and the target file. #### InPlaceOverwrite Recursively scans the target directory for all `*.md` files and merges snippets into those files. ##### Command line ```ps mdsnippets -c InPlaceOverwrite ``` ```ps mdsnippets --convention InPlaceOverwrite ``` ##### Config file Can be enabled in [mdsnippets.json config file](/docs/config-file.md). ```json { "Convention": "InPlaceOverwrite" } ``` #### Moving from SourceTransform to InPlaceOverwrite * Ensure `"WriteHeader": false` is used in `mdsnippets.json`. * Ensure `"ReadOnly": false` is used in `mdsnippets.json`. * Ensure using the current stable version and a docs generation has run. * Delete all `.source.md` files. * Modify `mdsnippets.json` to add `"Convention": "InPlaceOverwrite"`. * Run the docs generation. ### Mark resulting files as read only To mark the resulting documents files as read only use `-r` or `--read-only`. This can be helpful in preventing incorrectly editing the documents file instead of the `.source.` file conventions. ```ps mdsnippets -r true ``` ```ps mdsnippets --read-only true ``` ## Defining Snippets Any code wrapped in a convention based comment will be picked up. The comment needs to start with `begin-snippet:` which is followed by the key. The snippet is then terminated by `end-snippet`. ``` // begin-snippet: MySnippetName My Snippet Code // end-snippet ``` Named [C# regions](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-region) will also be picked up, with the name of the region used as the key. ``` #region MySnippetName My Snippet Code #endregion ``` To stop regions collapsing in Visual Studio [disable 'enter outlining mode when files open'](/docs/stop-regions-collapsing.png). See [Visual Studio outlining](https://docs.microsoft.com/en-us/visualstudio/ide/outlining). ### UrlsAsSnippets Urls to files to be included as snippets. Space ` ` separated for multiple values. Each url will be accessible using the file name as a key. Any snippets within the files will be extracted and accessible as individual keyed snippets. ```ps mdsnippets --urls-as-snippets "https://github.com/SimonCropp/MarkdownSnippets/snippet.cs" ``` ```ps mdsnippets -u "https://github.com/SimonCropp/MarkdownSnippets/snippet.cs" ``` ## Using Snippets The keyed snippets can be used in any documentation `.md` file by adding the text `snippet: KEY`. Then snippets with that key. For example
Some blurb about the below snippet
snippet: MySnippetName
The resulting markdown will be: Some blurb about the below snippet ``` My Snippet Code ``` snippet source | anchor Notes: * The vertical bar ( | ) is used to separate adjacent links as per web accessibility recommendations: https://webaim.org/techniques/hypertext/hypertext_links#groups * [H33: Supplementing link text with the title attribute](https://www.w3.org/TR/WCAG20-TECHS/H33.html) ### Including a snippet from the web Snippets that start with `http` will be downloaded and the contents rendered. For example: `snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/license.txt` Will render: ```txt The MIT License (MIT) ... ``` anchor Files are downloaded to `%temp%MarkdownSnippets` with a maximum of 100 files kept. `web-snippet:` can be used to reference remote content where a specific snippet is defined in that content. `web-snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt#snipPet` Will render: ```txt Some code ``` anchor You can optionally provide a second URL that will be used for the source link. This is useful when the raw content URL is different from the view URL. For example: `web-snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt#snipPet https://github.com/SimonCropp/MarkdownSnippets/blob/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt#snipPet` Will render: ```txt Some code ``` anchor ### Including a full file If no snippet is found matching the defined name. The target directory will be searched for a file matching that name. For example: `snippet: license.txt` Will render: ```txt The MIT License (MIT) Copyright (c) 2013 Simon Cropp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` snippet source | anchor ### LinkFormat Defines the format of `snippet source` links that appear under each snippet. #### Command line ```ps mdsnippets --link-format Bitbucket ``` ```ps mdsnippets -l Bitbucket ``` #### Values ```cs namespace MarkdownSnippets; public enum LinkFormat { GitHub, Tfs, Bitbucket, GitLab, DevOps, None } ``` snippet source | anchor Link format `None` will omit the source link but still keep the snippet anchor. ### OmitSnippetLinks The links below a snippet can be omitted. #### Command line ```ps mdsnippets --omit-snippet-links true ``` #### Config file ``` { "OmitSnippetLinks": true } ``` #### How links are constructed ```cs switch (linkFormat) { case LinkFormat.GitHub: Polyfill.Append(builder, $"{path}#L{snippet.StartLine}-L{snippet.EndLine}"); return; case LinkFormat.Tfs: Polyfill.Append(builder, $"{path}&line={snippet.StartLine}&lineEnd={snippet.EndLine}"); return; case LinkFormat.Bitbucket: Polyfill.Append(builder, $"{path}#lines={snippet.StartLine}:{snippet.EndLine}"); return; case LinkFormat.GitLab: Polyfill.Append(builder, $"{path}#L{snippet.StartLine}-{snippet.EndLine}"); return; case LinkFormat.DevOps: Polyfill.Append(builder, $"?path={path}&line={snippet.StartLine}&lineEnd={snippet.EndLine}&lineStartColumn=1&lineEndColumn=999"); return; case LinkFormat.None: throw new($"Unknown LinkFormat: {linkFormat}"); } ``` snippet source | anchor ### UrlPrefix UrlPrefix allows a string to be defined that will prefix all snippet links. This is helpful when the markdown file are being hosted on a site that is not co-located with the source code files. It can be defined in the [config file](/docs/config-file.md), the [MsBuild task](/docs/msbuild.md), and the dotnet tool. #### Command line ```ps mdsnippets --url-prefix "TheUrlPrefix" ``` #### Config file ``` { "UrlPrefix": "TheUrlPrefix" } ``` ## Add to Windows Explorer Use [src/context-menu.reg](context-menu.reg) to add MarkdownSnippets to the Windows Explorer context menu. ```reg Windows Registry Editor Version 5.00 [HKEY_CLASSES_ROOT\Directory\Shell] @="none" [HKEY_CLASSES_ROOT\Directory\shell\mdsnippets] "MUIVerb"="run mdsnippets" "Position"="bottom" [HKEY_CLASSES_ROOT\Directory\Background\shell\mdsnippets] "MUIVerb"="run mdsnippets" "Position"="bottom" [HKEY_CLASSES_ROOT\Directory\shell\mdsnippets\command] @="cmd.exe /c mdsnippets \"%V\"" [HKEY_CLASSES_ROOT\Directory\Background\shell\mdsnippets\command] @="cmd.exe /c mdsnippets \"%V\"" ``` snippet source | anchor ## Expressive Code / Snippet Metadata When defining snippets, additional metadata can be added at the source to the rendered snippet using the following syntax. ```csharp // begin-snippet: HelloWorld(title=Program.cs {1}) Console.WriteLine("Hello, World"); // end-snippet ``` Note the text within the parenthesis; this is metadata we want to add to the rendered Markdown block immediately after the language declaration. The metadata is stripped and the key remains `HelloWorld`. The feature produces the following output destination (will vary based on configuration): ````markdown <-- begin-snippet: HelloWorld --> ```csharp title=Program.cs {1} Console.WriteLine("Hello, World"); ``` <-- end-snippet --> ```` This syntax is known as [Expressive Code](https://expressive-code.com/) and is supported in documentation systems such as [Astro Starlight](https://github.com/withastro/starlight/) but can be installed in any Markdown-powered tool that supports [reHype](https://github.com/rehypejs/rehype). It is important to note, the metadata is not explicitly limited to Expressive code. Any text within the `()` will be rendered after the language block. This can be useful for adding additional information based on specific rendering engine. For example, if a presentation tool such as [Sli.dev](https://sli.dev/), then this feature to apply [magic-move animations](https://sli.dev/features/shiki-magic-move). ```csharp // begin-snippet: EncapsulateVariable({*|2}) Console.WriteLine("Hello, World"); // end-snippet ``` The above snippet will render as follows: ````markdown <-- begin-snippet: EncapsulateVariable --> ```csharp {*|2} Console.WriteLine("Hello, World"); ``` <-- end-snippet --> ```` ## Language Override By default the language of a rendered fenced code block is derived from the source file extension (e.g. a snippet extracted from a `.cs` file renders as `csharp`). The language can be overridden per snippet by adding a `lang=` token as the first item inside the parenthesised metadata: ```csharp // begin-snippet: SampleJson(lang=json) {"hello": "world"} // end-snippet ``` Renders as: ````markdown <-- begin-snippet: SampleJson --> ```json {"hello": "world"} ``` <-- end-snippet --> ```` `lang=` can be combined with Expressive Code metadata — the language token must come first, followed by a space, then the remaining metadata: ```csharp // begin-snippet: SampleJson(lang=json title=config.json) {"hello": "world"} // end-snippet ``` The value must be lowercase alphanumeric. ## More Documentation * Developer Information * [.net API](/docs/api.md) * [MsBuild Task](/docs/msbuild.md) * [Github Action](/docs/github-action.md) * Customisation * [Config file convention](/docs/config-file.md) * [Max Width](/docs/max-width.md) * [Includes](/docs/includes.md) * [Directory Exclusion](/docs/exclusion.md) * [Header](/docs/header.md) * Writing Documentation * [Indentation](/docs/indentation.md) * [Table of contents](/docs/toc.md) ## Credits Loosely based on some code from https://github.com/shiftkey/scribble. ## Icon [Down](https://thenounproject.com/AlfredoCreates/collection/arrows-5-glyph/) by [Alfredo Creates](https://thenounproject.com/AlfredoCreates) from [The Noun Project](https://thenounproject.com/). ================================================ FILE: readme.source.md ================================================ # MarkdownSnippets [![Build status](https://img.shields.io/appveyor/build/SimonCropp/MarkdownSnippets)](https://ci.appveyor.com/project/SimonCropp/MarkdownSnippets) [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.Tool.svg?label=dotnet%20tool)](https://www.nuget.org/packages/MarkdownSnippets.Tool/) [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.MsBuild.svg?label=MsBuild%20Task)](https://www.nuget.org/packages/MarkdownSnippets.MsBuild/) [![NuGet Status](https://img.shields.io/nuget/v/MarkdownSnippets.svg?label=.net%20API)](https://www.nuget.org/packages/MarkdownSnippets/) A [dotnet tool](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools) or [MsBuild Task](/docs/msbuild.md) that extract snippets from code files and merges them into markdown documents. **See [Milestones](../../milestones?state=closed) for release notes.** **[.net 10](https://dotnet.microsoft.com/download/dotnet/10.0) or higher is required to run the dotnet tool.** ## Value Proposition Automatically extract snippets from code and injecting them into markdown documents has several benefits: * Snippets can be verified by a compiler or parser. * Tests can be run on snippets, or snippets can be pulled from existing tests. * Changes in code are automatically reflected in documentation. * Snippets are less likely to get out of sync with the main code-base. * Snippets in markdown is easier to create and maintain since any preferred editor can be used to edit them. ## Behavior * Recursively scan the target directory for code files containing snippets. (See [exclusion](/docs/exclusion.md)). * Recursively scan the target directory for markdown (`.md` or `mdx`) files. (See [Document Scanning](#document-convention)). * Merge the snippets into those markdown files. ## Installation Ensure [dotnet CLI is installed](https://docs.microsoft.com/en-us/dotnet/core/tools/). Install [MarkdownSnippets.Tool](https://nuget.org/packages/MarkdownSnippets.Tool/) ```ps dotnet tool install -g MarkdownSnippets.Tool ``` See also: [MsBuild Task usage](/docs/msbuild.md) ## Usage ```ps mdsnippets C:\Code\TargetDirectory ``` If no directory is passed the current directory will be used, but only if it exists with a git repository directory tree. If not an error is returned. ### Document Convention There are two approaches scanning and modifying markdown files. #### SourceTransform This is the default. ##### source.md file The file convention recursively scans the target directory for all `*.source.md` files. Once snippets are merged the `.source.md` to produce `.md` files. So for example `readme.source.md` would be merged with snippets to produce `readme.md`. Note that this process will overwrite any existing `.md` files that have matching `.source.md` files. #### mdsource directory There is a secondary convention that leverages the use of a directory named `mdsource`. Where `.source.md` files are placed in a `mdsource` sub-directory, the `mdsource` part of the file path will be removed when calculating the target path. This allows the `.source.md` to be grouped in a sub directory and avoid cluttering up the main documentation directory. When using the `mdsource` convention, all references to other files, such as links and images, should specify the full path from the root of the repository. This will allow those links to work correctly in both the source and generated markdown files. Relative paths cannot work for both the source and the target file. #### InPlaceOverwrite Recursively scans the target directory for all `*.md` files and merges snippets into those files. ##### Command line ```ps mdsnippets -c InPlaceOverwrite ``` ```ps mdsnippets --convention InPlaceOverwrite ``` ##### Config file Can be enabled in [mdsnippets.json config file](/docs/config-file.md). ```json { "Convention": "InPlaceOverwrite" } ``` #### Moving from SourceTransform to InPlaceOverwrite * Ensure `"WriteHeader": false` is used in `mdsnippets.json`. * Ensure `"ReadOnly": false` is used in `mdsnippets.json`. * Ensure using the current stable version and a docs generation has run. * Delete all `.source.md` files. * Modify `mdsnippets.json` to add `"Convention": "InPlaceOverwrite"`. * Run the docs generation. ### Mark resulting files as read only To mark the resulting documents files as read only use `-r` or `--read-only`. This can be helpful in preventing incorrectly editing the documents file instead of the `.source.` file conventions. ```ps mdsnippets -r true ``` ```ps mdsnippets --read-only true ``` ## Defining Snippets Any code wrapped in a convention based comment will be picked up. The comment needs to start with `begin-snippet:` which is followed by the key. The snippet is then terminated by `end-snippet`. ``` // begin-snippet: MySnippetName My Snippet Code // end-snippet ``` Named [C# regions](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-region) will also be picked up, with the name of the region used as the key. ``` #region MySnippetName My Snippet Code #endregion ``` To stop regions collapsing in Visual Studio [disable 'enter outlining mode when files open'](/docs/stop-regions-collapsing.png). See [Visual Studio outlining](https://docs.microsoft.com/en-us/visualstudio/ide/outlining). ### UrlsAsSnippets Urls to files to be included as snippets. Space ` ` separated for multiple values. Each url will be accessible using the file name as a key. Any snippets within the files will be extracted and accessible as individual keyed snippets. ```ps mdsnippets --urls-as-snippets "https://github.com/SimonCropp/MarkdownSnippets/snippet.cs" ``` ```ps mdsnippets -u "https://github.com/SimonCropp/MarkdownSnippets/snippet.cs" ``` ## Using Snippets The keyed snippets can be used in any documentation `.md` file by adding the text `snippet: KEY`. Then snippets with that key. For example
Some blurb about the below snippet
snippet: MySnippetName
The resulting markdown will be: Some blurb about the below snippet ``` My Snippet Code ``` snippet source | anchor Notes: * The vertical bar ( | ) is used to separate adjacent links as per web accessibility recommendations: https://webaim.org/techniques/hypertext/hypertext_links#groups * [H33: Supplementing link text with the title attribute](https://www.w3.org/TR/WCAG20-TECHS/H33.html) ### Including a snippet from the web Snippets that start with `http` will be downloaded and the contents rendered. For example: `snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/license.txt` Will render: ```txt The MIT License (MIT) ... ``` anchor Files are downloaded to `%temp%MarkdownSnippets` with a maximum of 100 files kept. `web-snippet:` can be used to reference remote content where a specific snippet is defined in that content. `web-snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt#snipPet` Will render: ```txt Some code ``` anchor You can optionally provide a second URL that will be used for the source link. This is useful when the raw content URL is different from the view URL. For example: `web-snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt#snipPet https://github.com/SimonCropp/MarkdownSnippets/blob/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt#snipPet` Will render: ```txt Some code ``` anchor ### Including a full file If no snippet is found matching the defined name. The target directory will be searched for a file matching that name. For example: `snippet: license.txt` Will render: snippet: license.txt ### LinkFormat Defines the format of `snippet source` links that appear under each snippet. #### Command line ```ps mdsnippets --link-format Bitbucket ``` ```ps mdsnippets -l Bitbucket ``` #### Values snippet: LinkFormat.cs Link format `None` will omit the source link but still keep the snippet anchor. ### OmitSnippetLinks The links below a snippet can be omitted. #### Command line ```ps mdsnippets --omit-snippet-links true ``` #### Config file ``` { "OmitSnippetLinks": true } ``` #### How links are constructed snippet: BuildLink ### UrlPrefix UrlPrefix allows a string to be defined that will prefix all snippet links. This is helpful when the markdown file are being hosted on a site that is not co-located with the source code files. It can be defined in the [config file](/docs/config-file.md), the [MsBuild task](/docs/msbuild.md), and the dotnet tool. #### Command line ```ps mdsnippets --url-prefix "TheUrlPrefix" ``` #### Config file ``` { "UrlPrefix": "TheUrlPrefix" } ``` ## Add to Windows Explorer Use [src/context-menu.reg](context-menu.reg) to add MarkdownSnippets to the Windows Explorer context menu. snippet: context-menu.reg ## Expressive Code / Snippet Metadata When defining snippets, additional metadata can be added at the source to the rendered snippet using the following syntax. ```csharp // begin-snippet: HelloWorld(title=Program.cs {1}) Console.WriteLine("Hello, World"); // end-snippet ``` Note the text within the parenthesis; this is metadata we want to add to the rendered Markdown block immediately after the language declaration. The metadata is stripped and the key remains `HelloWorld`. The feature produces the following output destination (will vary based on configuration): ````markdown <-- begin-snippet: HelloWorld --> ```csharp title=Program.cs {1} Console.WriteLine("Hello, World"); ``` <-- end-snippet --> ```` This syntax is known as [Expressive Code](https://expressive-code.com/) and is supported in documentation systems such as [Astro Starlight](https://github.com/withastro/starlight/) but can be installed in any Markdown-powered tool that supports [reHype](https://github.com/rehypejs/rehype). It is important to note, the metadata is not explicitly limited to Expressive code. Any text within the `()` will be rendered after the language block. This can be useful for adding additional information based on specific rendering engine. For example, if a presentation tool such as [Sli.dev](https://sli.dev/), then this feature to apply [magic-move animations](https://sli.dev/features/shiki-magic-move). ```csharp // begin-snippet: EncapsulateVariable({*|2}) Console.WriteLine("Hello, World"); // end-snippet ``` The above snippet will render as follows: ````markdown <-- begin-snippet: EncapsulateVariable --> ```csharp {*|2} Console.WriteLine("Hello, World"); ``` <-- end-snippet --> ```` ## Language Override By default the language of a rendered fenced code block is derived from the source file extension (e.g. a snippet extracted from a `.cs` file renders as `csharp`). The language can be overridden per snippet by adding a `lang=` token as the first item inside the parenthesised metadata: ```csharp // begin-snippet: SampleJson(lang=json) {"hello": "world"} // end-snippet ``` Renders as: ````markdown <-- begin-snippet: SampleJson --> ```json {"hello": "world"} ``` <-- end-snippet --> ```` `lang=` can be combined with Expressive Code metadata — the language token must come first, followed by a space, then the remaining metadata: ```csharp // begin-snippet: SampleJson(lang=json title=config.json) {"hello": "world"} // end-snippet ``` The value must be lowercase alphanumeric. ## More Documentation include: doc-index ## Credits Loosely based on some code from https://github.com/shiftkey/scribble. ## Icon [Down](https://thenounproject.com/AlfredoCreates/collection/arrows-5-glyph/) by [Alfredo Creates](https://thenounproject.com/AlfredoCreates) from [The Noun Project](https://thenounproject.com/). ================================================ FILE: schema.json ================================================ { "definitions": {}, "$schema": "http://json-schema.org/draft-07/schema#", "$id": "MarkdownSnippets", "title": "Root", "type": "object", "required": [ ], "properties": { "OmitSnippetLinks": { "$id": "#root/OmitSnippetLinks", "title": "OmitSnippetLinks", "type": "boolean", "examples": [ true ], "default": false }, "ReadOnly": { "$id": "#root/ReadOnly", "title": "Readonly", "type": "boolean", "examples": [ true ], "default": false }, "LinkFormat": { "$id": "#root/LinkFormat", "title": "Linkformat", "type": "string", "default": "GitHub", "enum": [ "Tfs", "GitHub", "Bitbucket", "GitLab", "DevOps", "None" ], "pattern": "^.*$" }, "TocLevel": { "$id": "#root/TocLevel", "title": "Toclevel", "type": "integer", "examples": [ 3 ], "default": 1 }, "UrlsAsSnippets": { "$id": "#root/UrlsAsSnippets", "title": "Urlsassnippets", "type": "array", "default": [], "items": { "$id": "#root/UrlsAsSnippets/items", "title": "Items", "type": "string", "default": "", "examples": [ "https://github.com/SimonCropp/MarkdownSnippets/snippet.cs" ], "pattern": "^.*$" } }, "TocExcludes": { "$id": "#root/TocExcludes", "title": "Tocexcludes", "type": "array", "default": [], "items": { "$id": "#root/TocExcludes/items", "title": "Items", "type": "string", "default": "", "examples": [ "heading1", "heading2" ], "pattern": "^.*$" } }, "ExcludeMarkdownDirectories": { "$id": "#root/ExcludeMarkdownDirectories", "title": "ExcludeMarkdownDirectories", "type": "array", "default": [], "items": { "$id": "#root/ExcludeMarkdownDirectories/items", "title": "Items", "type": "string", "default": "", "examples": [ "directory1", "directory2" ], "pattern": "^.*$" } }, "ExcludeSnippetDirectories": { "$id": "#root/ExcludeSnippetDirectories", "title": "ExcludeSnippetDirectories", "type": "array", "default": [], "items": { "$id": "#root/ExcludeSnippetDirectories/items", "title": "Items", "type": "string", "default": "", "examples": [ "directory1", "directory1" ], "pattern": "^.*$" } }, "ExcludeSnippetFiles": { "$id": "#root/ExcludeSnippetFiles", "title": "ExcludeSnippetFiles", "type": "array", "default": [], "items": { "$id": "#root/ExcludeSnippetFiles/items", "title": "Items", "type": "string", "default": "", "examples": [ "*.verified.txt", "*.received.txt" ], "pattern": "^.*$" } }, "ExcludeDirectories": { "$id": "#root/ExcludeDirectories", "title": "ExcludeDirectories", "type": "array", "default": [], "items": { "$id": "#root/ExcludeDirectories/items", "title": "Items", "type": "string", "default": "", "examples": [ "directory1", "directory2" ], "pattern": "^.*$" } }, "DocumentExtensions": { "$id": "#root/DocumentExtensions", "title": "Documentextensions", "type": "array", "default": [], "items": { "$id": "#root/DocumentExtensions/items", "title": "Items", "type": "string", "default": "md", "examples": [ "md" ], "pattern": "^.*$" } }, "Convention": { "$id": "#root/Convention", "title": "Convention", "type": "string", "default": "InPlaceOverwrite", "enum": [ "InPlaceOverwrite", "SourceTransform" ], "pattern": "^.*$" }, "WriteHeader": { "$id": "#root/WriteHeader", "title": "Writeheader", "type": "boolean", "examples": [ false ], "default": true }, "MaxWidth": { "$id": "#root/MaxWidth", "title": "Maxwidth", "type": "integer", "examples": [ 80 ], "default": 100 }, "Header": { "$id": "#root/Header", "title": "Header", "type": "string", "default": "", "examples": [ "GENERATED FILE - Source File: {relativePath}" ], "pattern": "^.*$" }, "UrlPrefix": { "$id": "#root/UrlPrefix", "title": "UrlPrefix", "type": "string", "default": "", "examples": [ "TheUrlPrefix" ], "pattern": "^.*$" }, "TreatMissingAsWarning": { "$id": "#root/TreatMissingAsWarning", "title": "TreatMissingAsWarning", "type": "boolean", "examples": [ false ], "default": true }, "ValidateContent": { "$id": "#root/ValidateContent", "title": "ValidateContent", "type": "boolean", "examples": [ false ], "default": true } } } ================================================ FILE: src/Benchmarks/Benchmarks.csproj ================================================ net10.0 Exe ================================================ FILE: src/Benchmarks/FullRenderBenchmarks.cs ================================================ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using BenchmarkDotNet.Attributes; using MarkdownSnippets; [MemoryDiagnoser] public class FullRenderBenchmarks { string repoRoot = null!; Dictionary> snippetLookup = null!; string markdownInput = null!; static bool DirectoryFilter(string path) => !path.Contains("IncludeFileFinder") && !path.Contains("DirectoryMarkdownProcessor") && !DefaultDirectoryExclusions.ShouldExcludeDirectory(path); static bool SnippetDirectoryFilter(string path) => DirectoryFilter(path) && !path.Contains("badsnippets"); [GlobalSetup] public void Setup() { repoRoot = GitRepoDirectoryFinder.FindForFilePath(); // pre-load snippets for the in-memory MarkdownProcessor benchmark var processor = new DirectoryMarkdownProcessor( targetDirectory: repoRoot, directoryIncludes: DirectoryFilter, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: SnippetDirectoryFilter, scanForMdFiles: false, tocLevel: 1); snippetLookup = processor.Snippets .Where(s => !s.IsInError) .GroupBy(s => s.Key) .ToDictionary(g => g.Key, g => (IReadOnlyList) g.ToList()); // build a synthetic markdown doc that references real snippet keys var sb = new StringBuilder(); sb.AppendLine("# Benchmark Document"); sb.AppendLine(); foreach (var key in snippetLookup.Keys.Take(50)) { sb.AppendLine($"snippet: {key}"); sb.AppendLine(); } markdownInput = sb.ToString(); } /// /// End-to-end: file discovery, snippet extraction, markdown processing, and file writing. /// [Benchmark(Description = "Full repo render")] public void FullRepoRender() { var processor = new DirectoryMarkdownProcessor( targetDirectory: repoRoot, directoryIncludes: DirectoryFilter, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: SnippetDirectoryFilter, tocLevel: 1, tocExcludes: ["Icon", "Credits", "Release Notes"]); processor.Run(); } /// /// Constructor only: file discovery + snippet extraction (no markdown processing). /// [Benchmark(Description = "File discovery + snippet extraction")] public DirectoryMarkdownProcessor FileDiscoveryAndSnippetExtraction() => new( targetDirectory: repoRoot, directoryIncludes: DirectoryFilter, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: SnippetDirectoryFilter, scanForMdFiles: false, tocLevel: 1); /// /// In-memory markdown processing with pre-loaded snippets, no file I/O. /// [Benchmark(Description = "MarkdownProcessor.Apply (50 snippets)")] public string MarkdownProcessorApply() { var processor = new MarkdownProcessor( convention: DocumentConvention.SourceTransform, snippets: snippetLookup, includes: [], appendSnippets: SimpleSnippetMarkdownHandling.Append, snippetSourceFiles: [], tocLevel: 2, writeHeader: false, targetDirectory: repoRoot, validateContent: false, allFiles: []); return processor.Apply(markdownInput, "benchmark.source.md"); } } ================================================ FILE: src/Benchmarks/Program.cs ================================================ using BenchmarkDotNet.Running; BenchmarkRunner.Run(args: args); ================================================ FILE: src/ConfigReader/AssemblyInfo.cs ================================================ [assembly: InternalsVisibleTo("ConfigReader.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100e191859fcd1deee68b96927c170783ced0c9a471a6424a0a011cfd31156a49dd73c4ad4a88b995fb918c0b43e0c005ef5fb72d53a328a64bde825cb5f2e4c53d66f69fcbb87d6737128b98e677a42091974b5f56093123a2dd6bc738af751b101d41c4f7a996e217b61967a3aa1ae7bc791d19c1cbeef47f0cdd20d288dff1a3")] ================================================ FILE: src/ConfigReader/ConfigDefaults.cs ================================================ public static class ConfigDefaults { public static ConfigResult Convert(ConfigInput? fileConfig, ConfigInput otherConfig) { if (fileConfig == null) { return new() { ValidateContent = otherConfig.ValidateContent.GetValueOrDefault(), OmitSnippetLinks = otherConfig.OmitSnippetLinks.GetValueOrDefault(), ReadOnly = otherConfig.ReadOnly.GetValueOrDefault(), WriteHeader = otherConfig.WriteHeader, LinkFormat = otherConfig.LinkFormat.GetValueOrDefault(LinkFormat.GitHub), Convention = otherConfig.Convention.GetValueOrDefault(DocumentConvention.SourceTransform), ExcludeDirectories = otherConfig.ExcludeDirectories, ExcludeMarkdownDirectories = otherConfig.ExcludeMarkdownDirectories, ExcludeSnippetDirectories = otherConfig.ExcludeSnippetDirectories, ExcludeSnippetFiles = otherConfig.ExcludeSnippetFiles, Header = otherConfig.Header, UrlPrefix = otherConfig.UrlPrefix, TocExcludes = otherConfig.TocExcludes, UrlsAsSnippets = otherConfig.UrlsAsSnippets, TocLevel = otherConfig.TocLevel.GetValueOrDefault(2), MaxWidth = otherConfig.MaxWidth.GetValueOrDefault(int.MaxValue), TreatMissingAsWarning = otherConfig.TreatMissingAsWarning.GetValueOrDefault(), }; } return new() { ValidateContent = GetValueOrDefault("ValidateContent", otherConfig.ValidateContent, fileConfig.ValidateContent, false), OmitSnippetLinks = GetValueOrDefault("OmitSnippetLinks", otherConfig.OmitSnippetLinks, fileConfig.OmitSnippetLinks, false), ReadOnly = GetValueOrNull("ReadOnly", otherConfig.ReadOnly, fileConfig.ReadOnly), WriteHeader = GetValueOrNull("WriteHeader", otherConfig.WriteHeader, fileConfig.WriteHeader), LinkFormat = GetValueOrDefault("LinkFormat", otherConfig.LinkFormat, fileConfig.LinkFormat, LinkFormat.GitHub), Convention = GetValueOrDefault("Convention", otherConfig.Convention, fileConfig.Convention, DocumentConvention.SourceTransform), TocLevel = GetValueOrDefault("TocLevel", otherConfig.TocLevel, fileConfig.TocLevel, 2), MaxWidth = GetValueOrDefault("MaxWidth", otherConfig.MaxWidth, fileConfig.MaxWidth, int.MaxValue), Header = GetValueOrDefault("Header", otherConfig.Header, fileConfig.Header), UrlPrefix = GetValueOrDefault("UrlPrefix", otherConfig.UrlPrefix, fileConfig.UrlPrefix), ExcludeDirectories = JoinLists(fileConfig.ExcludeDirectories, otherConfig.ExcludeDirectories), ExcludeSnippetDirectories = JoinLists(fileConfig.ExcludeSnippetDirectories, otherConfig.ExcludeSnippetDirectories), ExcludeSnippetFiles = JoinLists(fileConfig.ExcludeSnippetFiles, otherConfig.ExcludeSnippetFiles), ExcludeMarkdownDirectories = JoinLists(fileConfig.ExcludeMarkdownDirectories, otherConfig.ExcludeMarkdownDirectories), TocExcludes = JoinLists(fileConfig.TocExcludes, otherConfig.TocExcludes), UrlsAsSnippets = JoinLists(fileConfig.UrlsAsSnippets, otherConfig.UrlsAsSnippets), TreatMissingAsWarning = GetValueOrDefault( "TreatMissingAsWarning", otherConfig.TreatMissingAsWarning, fileConfig.TreatMissingAsWarning, false), }; } static List JoinLists(List list1, List list2) => list1 .Concat(list2) .Distinct() .ToList(); static T GetValueOrDefault(string name, T? input, T? config, T defaultValue) where T : struct { if (input != null && config != null) { throw new ConfigurationException($"'{name}' cannot be defined in both mdsnippets.json and input"); } if (input != null) { return input.Value; } if (config != null) { return config.Value; } return defaultValue; } static T? GetValueOrNull(string name, T? input, T? config) where T : struct { if (input != null && config != null) { throw new ConfigurationException($"'{name}' cannot be defined in both mdsnippets.json and input"); } if (input != null) { return input.Value; } return config; } static string? GetValueOrDefault(string name, string? input, string? config) { if (input != null && config != null) { throw new ConfigurationException($"'{name}' cannot be defined in both mdsnippets.json and input"); } if (input != null) { return input; } return config; } } ================================================ FILE: src/ConfigReader/ConfigInput.cs ================================================ public class ConfigInput { public bool? ReadOnly { get; init; } public bool? ValidateContent { get; init; } public bool? OmitSnippetLinks { get; init; } public LinkFormat? LinkFormat { get; init; } public DocumentConvention? Convention { get; init; } public int? TocLevel { get; init; } public int? MaxWidth { get; init; } public List UrlsAsSnippets { get; init; } = []; public List ExcludeDirectories { get; init; } = []; public List ExcludeMarkdownDirectories { get; init; } = []; public List ExcludeSnippetDirectories { get; init; } = []; public List ExcludeSnippetFiles { get; init; } = []; public bool? WriteHeader { get; init; } public string? Header { get; init; } public string? UrlPrefix { get; init; } public List TocExcludes { get; init; } = []; public bool? TreatMissingAsWarning { get; init; } } ================================================ FILE: src/ConfigReader/ConfigReader.cs ================================================ public static class ConfigReader { public static (ConfigInput? config, string path) Read(string directory) { var exists = TryFindConfigFile(directory, out var path); if (!exists) { return (null, path); } return (Parse(File.ReadAllText(path), path), path); } static bool TryFindConfigFile(string directory, out string path) { path = Path.Combine(directory, "mdsnippets.json"); var exists = File.Exists(path); if (exists) { return true; } foreach (var subDirectory in Directory.EnumerateDirectories(directory)) { path = Path.Combine(subDirectory, "mdsnippets.json"); exists = File.Exists(path); if (exists) { return true; } } path = ""; return false; } public static ConfigInput Parse(string contents, string path) { var config = DeSerialize(contents); return new() { WriteHeader = config.WriteHeader, ReadOnly = config.ReadOnly, ValidateContent = config.ValidateContent, OmitSnippetLinks = config.OmitSnippetLinks, UrlsAsSnippets = config.UrlsAsSnippets, ExcludeDirectories = config.ExcludeDirectories, ExcludeMarkdownDirectories = config.ExcludeMarkdownDirectories, ExcludeSnippetDirectories = config.ExcludeSnippetDirectories, ExcludeSnippetFiles = config.ExcludeSnippetFiles, Header = config.Header, UrlPrefix = config.UrlPrefix, TocExcludes = config.TocExcludes, TocLevel = config.TocLevel, MaxWidth = config.MaxWidth, LinkFormat = GetLinkFormat(config.LinkFormat, path), Convention = GetConvention(config.Convention, path), TreatMissingAsWarning = config.TreatMissingAsWarning, }; } static ConfigSerialization DeSerialize(string contents) { using var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents)) { Position = 0 }; var serializer = new DataContractJsonSerializer(typeof(ConfigSerialization)); try { return (ConfigSerialization) serializer.ReadObject(stream)!; } catch (SerializationException exception) { throw new SnippetException( $""" Failed to deserialize configuration. Error: {exception.Message}. Content: {contents} """); } } static DocumentConvention? GetConvention(string? value, string path) { if (value == null) { return null; } if (Enum.TryParse(value, out var convention)) { return convention; } throw new ConfigurationException($"Failed to parse DocumentConvention: {convention}. FilePath: {path}."); } static LinkFormat? GetLinkFormat(string? value, string path) { if (value == null) { return null; } if (!Enum.TryParse(value, out var linkFormat)) { throw new ConfigurationException($"Failed to parse LinkFormat: {linkFormat}. FilePath: {path}."); } return linkFormat; } } ================================================ FILE: src/ConfigReader/ConfigReader.csproj ================================================ netstandard2.0;netstandard2.1;net48;net8.0;net9.0 false false ================================================ FILE: src/ConfigReader/ConfigResult.cs ================================================ public class ConfigResult { public bool? ReadOnly { get; init; } public bool ValidateContent { get; init; } public bool OmitSnippetLinks { get; init; } public LinkFormat LinkFormat { get; init; } public DocumentConvention Convention { get; init; } public int TocLevel { get; init; } public int MaxWidth { get; init; } public List? UrlsAsSnippets { get; init; } public List? ExcludeDirectories { get; init; } public List? ExcludeMarkdownDirectories { get; init; } public List? ExcludeSnippetDirectories { get; init; } public List? ExcludeSnippetFiles { get; init; } public bool? WriteHeader { get; init; } public string? Header { get; init; } public string? UrlPrefix { get; init; } public List? TocExcludes { get; init; } public bool TreatMissingAsWarning { get; init; } } ================================================ FILE: src/ConfigReader/ConfigSerialization.cs ================================================ public class ConfigSerialization { public bool? ReadOnly { get; set; } public bool? ValidateContent { get; set; } public bool? OmitSnippetLinks { get; set; } public string? LinkFormat { get; set; } public string? Convention { get; set; } public bool? WriteHeader { get; set; } public string? Header { get; set; } public string? UrlPrefix { get; set; } public int? TocLevel { get; set; } public int? MaxWidth { get; set; } public List UrlsAsSnippets { get; set; } = []; public List ExcludeDirectories { get; set; } = []; public List ExcludeMarkdownDirectories { get; set; } = []; public List ExcludeSnippetDirectories { get; set; } = []; public List ExcludeSnippetFiles { get; set; } = []; public List TocExcludes { get; set; } = []; public bool? TreatMissingAsWarning { get; set; } } ================================================ FILE: src/ConfigReader/ConfigurationException.cs ================================================ class ConfigurationException(string message) : Exception(message); ================================================ FILE: src/ConfigReader/ExcludeToFilterBuilder.cs ================================================ static class ExcludeToFilterBuilder { public static ShouldIncludeDirectory ExcludesToFilter(List? excludes) => path => { if (DefaultDirectoryExclusions.ShouldExcludeDirectory(path)) { return false; } if(excludes == null || excludes.Count == 0) { return true; } if (!excludes.Any(path.Contains)) { return true; } if (!excludes.Any(path.Replace('\\', '/').Contains)) { return true; } if (!excludes.Any(path.Replace('/', '\\').Contains)) { return true; } return false; }; public static ShouldIncludeFile? FileExcludesToFilter(List? excludes) { if (excludes == null || excludes.Count == 0) { return null; } var regexes = excludes .Select(GlobToRegex) .ToArray(); return path => { var name = Path.GetFileName(path); foreach (var regex in regexes) { if (regex.IsMatch(name)) { return false; } } return true; }; } static Regex GlobToRegex(string glob) { var builder = new StringBuilder("^"); foreach (var c in glob) { switch (c) { case '*': builder.Append(".*"); break; case '?': builder.Append('.'); break; default: builder.Append(Regex.Escape(c.ToString())); break; } } builder.Append('$'); return new(builder.ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } } ================================================ FILE: src/ConfigReader/LogBuilder.cs ================================================ static class LogBuilder { public static string BuildConfigLogMessage(string targetDirectory, ConfigResult config, string configFilePath) { var builder = new StringBuilder( $""" Config: TargetDirectory: {targetDirectory} UrlPrefix: {config.UrlPrefix} LinkFormat: {config.LinkFormat} Convention: {config.Convention} TocLevel: {config.TocLevel} ValidateContent: {config.ValidateContent} OmitSnippetLinks: {config.OmitSnippetLinks} TreatMissingAsWarning: {config.TreatMissingAsWarning} FileConfigPath: {configFilePath} (exists:{File.Exists(configFilePath)}) """); if (config.Convention == DocumentConvention.SourceTransform) { var header = GetHeader(config); Polyfill.AppendLine( builder, $""" ReadOnly: {config.ReadOnly} WriteHeader: {config.WriteHeader} Header: {header} """); } var maxWidth = config.MaxWidth; if (maxWidth != int.MaxValue && maxWidth != 0) { Polyfill.AppendLine( builder, $" MaxWidth: {maxWidth}"); } var excludeDirectories = config.ExcludeDirectories; if (excludeDirectories != null && excludeDirectories.Count != 0) { Polyfill.AppendLine( builder, $""" ExcludeDirectories: {string.Join("\r\n ", excludeDirectories)} """); } var excludeMarkdownDirectories = config.ExcludeMarkdownDirectories; if (excludeMarkdownDirectories != null && excludeMarkdownDirectories.Count != 0) { Polyfill.AppendLine( builder, $""" ExcludeMarkdownDirectories: {string.Join("\r\n ", excludeMarkdownDirectories)} """); } var excludeSnippetDirectories = config.ExcludeSnippetDirectories; if (excludeSnippetDirectories != null && excludeSnippetDirectories.Count != 0) { Polyfill.AppendLine( builder, $""" ExcludeSnippetDirectories: {string.Join("\r\n ", excludeSnippetDirectories)} """); } var excludeSnippetFiles = config.ExcludeSnippetFiles; if (excludeSnippetFiles != null && excludeSnippetFiles.Count != 0) { Polyfill.AppendLine( builder, $""" ExcludeSnippetFiles: {string.Join("\r\n ", excludeSnippetFiles)} """); } var tocExcludes = config.TocExcludes; if (tocExcludes != null && tocExcludes.Count != 0) { Polyfill.AppendLine( builder, $""" TocExcludes: {string.Join("\r\n ", tocExcludes)} """); } var urlsAsSnippets = config.UrlsAsSnippets; if (urlsAsSnippets != null && urlsAsSnippets.Count != 0) { Polyfill.AppendLine( builder, $""" UrlsAsSnippets: {string.Join("\r\n ", urlsAsSnippets)} """); } #if NET48 builder.AppendLine(" TargetFramework: net48"); #endif #if NET9_0 builder.AppendLine(" TargetFramework: net9.0"); #endif builder.TrimEnd(); return builder.ToString(); } static string? GetHeader(ConfigResult config) { var header = config.Header; if (header == null) { return null; } var newlineIndent = $"{Environment.NewLine} "; header = string.Join(newlineIndent, header.Lines()); header = header.Replace(@"\n", newlineIndent); return $""" {header} """; } } ================================================ FILE: src/ConfigReader/SharedGlobalUsings.cs ================================================ global using Polyfills; global using MarkdownSnippets; global using System.Runtime.Serialization; global using System.Runtime.Serialization.Json; global using System.Text; global using System.Text.RegularExpressions; ================================================ FILE: src/ConfigReader.Tests/ConfigReader.Tests.csproj ================================================ net9.0 Exe $(NoWarn);xUnit1051 testing PreserveNewest PreserveNewest PreserveNewest ================================================ FILE: src/ConfigReader.Tests/ConfigReaderTests.BadJson.verified.txt ================================================ { Type: SnippetException, Message: Failed to deserialize configuration. Error: There was an error deserializing the object of type ConfigSerialization. Encountered unexpected character '"'.. Content: { "ValidateContent": true "Convention": "InPlaceOverwrite" }, StackTrace: at ConfigReader.DeSerialize(String contents) at ConfigReader.Parse(String contents, String path) at ConfigReaderTests.<>c.b__1_0() } ================================================ FILE: src/ConfigReader.Tests/ConfigReaderTests.Empty.verified.txt ================================================ {} ================================================ FILE: src/ConfigReader.Tests/ConfigReaderTests.Values.verified.txt ================================================ { ReadOnly: false, ValidateContent: true, OmitSnippetLinks: true, LinkFormat: Tfs, Convention: InPlaceOverwrite, TocLevel: 3, MaxWidth: 80, UrlsAsSnippets: [ Url1, Url2 ], ExcludeDirectories: [ Dir1, Dir2 ], ExcludeMarkdownDirectories: [ Dir2, Dir3 ], ExcludeSnippetDirectories: [ Dir4, Dir5 ], ExcludeSnippetFiles: [ *.verified.txt, *.received.txt ], WriteHeader: true, Header: GENERATED FILE - Source File: {relativePath}, UrlPrefix: TheUrlPrefix, TocExcludes: [ Exclude Heading1, Exclude Heading2 ], TreatMissingAsWarning: true } ================================================ FILE: src/ConfigReader.Tests/ConfigReaderTests.cs ================================================ public class ConfigReaderTests { [Fact] public Task Empty() { var config = ConfigReader.Parse("{}", "filePath"); return Verify(config); } [Fact] public Task BadJson() => Throws(() => ConfigReader.Parse( """ { "ValidateContent": true "Convention": "InPlaceOverwrite" } """, "filePath")); [Fact] public Task Values() { var stream = File.ReadAllText("allConfig.json"); var config = ConfigReader.Parse(stream, "filePath"); return Verify(config); } [Fact] public void FileExcludesToFilter_NullOrEmpty_ReturnsNull() { Assert.Null(ExcludeToFilterBuilder.FileExcludesToFilter(null)); Assert.Null(ExcludeToFilterBuilder.FileExcludesToFilter([])); } [Fact] public void FileExcludesToFilter_GlobMatching() { var filter = ExcludeToFilterBuilder.FileExcludesToFilter( [ "*.verified.txt", "*.received.*", "ignore?.cs" ])!; Assert.False(filter("/a/b/Foo.verified.txt")); Assert.False(filter(@"C:\x\y\Foo.received.md")); Assert.False(filter("ignore1.cs")); Assert.True(filter("Foo.cs")); Assert.True(filter("Foo.txt")); Assert.True(filter("ignoreAB.cs")); } [Fact] public void FileExcludesToFilter_CaseInsensitive() { var filter = ExcludeToFilterBuilder.FileExcludesToFilter(["*.VERIFIED.txt"])!; Assert.False(filter("foo.verified.TXT")); } } ================================================ FILE: src/ConfigReader.Tests/GlobalUsings.cs ================================================ global using VerifyTests.DiffPlex; ================================================ FILE: src/ConfigReader.Tests/InPlaceOverwrite.json ================================================ { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json", "Convention": "InPlaceOverwrite" } ================================================ FILE: src/ConfigReader.Tests/ModuleInitializer.cs ================================================ public static class ModuleInitializer { [ModuleInitializer] public static void Initialize() => VerifyDiffPlex.Initialize(OutputType.Compact); } ================================================ FILE: src/ConfigReader.Tests/SourceTransform.json ================================================ { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json", "Convention": "SourceTransform" } ================================================ FILE: src/ConfigReader.Tests/allConfig.json ================================================ { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json", "ReadOnly": false, "LinkFormat": "Tfs", "TocLevel": 3, "ExcludeDirectories": [ "Dir1", "Dir2" ], "ExcludeMarkdownDirectories": [ "Dir2", "Dir3" ], "ExcludeSnippetDirectories": [ "Dir4", "Dir5" ], "ExcludeSnippetFiles": [ "*.verified.txt", "*.received.txt" ], "UrlsAsSnippets": [ "Url1", "Url2" ], "TocExcludes": [ "Exclude Heading1", "Exclude Heading2" ], "Convention": "InPlaceOverwrite", "WriteHeader": true, "MaxWidth": 80, "Header": "GENERATED FILE - Source File: {relativePath}", "UrlPrefix": "TheUrlPrefix", "TreatMissingAsWarning": true, "ValidateContent": true, "OmitSnippetLinks": true } ================================================ FILE: src/Directory.Build.props ================================================ CS1591;NU1608;NU1109 28.3.0 preview 1.0.0 Markdown, Snippets, mdsnippets, documentation, MarkdownSnippets Extracts snippets from code files and merges them into markdown documents. true true true true true readme.md ================================================ FILE: src/Directory.Packages.props ================================================ true true ================================================ FILE: src/MarkdownSnippets/AssemblyInfo.cs ================================================ [assembly: InternalsVisibleTo("Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100e191859fcd1deee68b96927c170783ced0c9a471a6424a0a011cfd31156a49dd73c4ad4a88b995fb918c0b43e0c005ef5fb72d53a328a64bde825cb5f2e4c53d66f69fcbb87d6737128b98e677a42091974b5f56093123a2dd6bc738af751b101d41c4f7a996e217b61967a3aa1ae7bc791d19c1cbeef47f0cdd20d288dff1a3")] [assembly: InternalsVisibleTo("mdsnippets, PublicKey=0024000004800000940000000602000000240000525341310004000001000100e191859fcd1deee68b96927c170783ced0c9a471a6424a0a011cfd31156a49dd73c4ad4a88b995fb918c0b43e0c005ef5fb72d53a328a64bde825cb5f2e4c53d66f69fcbb87d6737128b98e677a42091974b5f56093123a2dd6bc738af751b101d41c4f7a996e217b61967a3aa1ae7bc791d19c1cbeef47f0cdd20d288dff1a3")] [assembly: InternalsVisibleTo("MarkdownSnippets.MsBuild, PublicKey=0024000004800000940000000602000000240000525341310004000001000100e191859fcd1deee68b96927c170783ced0c9a471a6424a0a011cfd31156a49dd73c4ad4a88b995fb918c0b43e0c005ef5fb72d53a328a64bde825cb5f2e4c53d66f69fcbb87d6737128b98e677a42091974b5f56093123a2dd6bc738af751b101d41c4f7a996e217b61967a3aa1ae7bc791d19c1cbeef47f0cdd20d288dff1a3")] [assembly: InternalsVisibleTo("ConfigReader, PublicKey=0024000004800000940000000602000000240000525341310004000001000100e191859fcd1deee68b96927c170783ced0c9a471a6424a0a011cfd31156a49dd73c4ad4a88b995fb918c0b43e0c005ef5fb72d53a328a64bde825cb5f2e4c53d66f69fcbb87d6737128b98e677a42091974b5f56093123a2dd6bc738af751b101d41c4f7a996e217b61967a3aa1ae7bc791d19c1cbeef47f0cdd20d288dff1a3")] ================================================ FILE: src/MarkdownSnippets/ContentValidation.cs ================================================ static class ContentValidation { static FrozenDictionary phrases = FrozenDictionary.Create([ new("a majority of ", "most"), new("a number of", "some or many"), new("at an early date", "soon"), new("at the conclusion of", "after or following"), new("at the present time", "now"), new("at this point in time", "now"), new("based on the fact that", "because or since"), new("despite the fact that", "although"), new("due to the fact that", "because"), new("during the course of", "during"), new("during the time that", "during or while"), new("have the capability to", "can"), new("in connection with", "about"), new("in order to", "to"), new("in regard to ", "regarding or about"), new("in the event of", "if"), new("in view of the fact that", "because"), new("it is often the case that", "often"), new("make reference to ", "refer to"), new("of the opinion that", "think that "), new("on a daily basis", "daily"), new("on the grounds that", "because"), new("prior to", "before"), new("so as to", "to"), new("subsequent to", "after"), new("take into consideration", "consider"), new("until such time as", "until"), new("a lot", "many"), new("sort of", "similar or approximately"), new("kind of", "similar or approximately ") ]); static FrozenSet invalidWordSet = new[] { "you", "we", "our", "your", "us", "please", "yourself", "just", "simply", "simple", "easy", "feel", "think", "above-mentioned", "aforementioned", "foregoing", "henceforth", "hereafter", "heretofore", "herewith", "thereafter", "thereof", "therewith", "whatsoever", "whereat", "wherein", "whereof" }.ToFrozenSet(); static FrozenDictionary[]> phrasesByFirstWord = phrases .GroupBy(p => { var spaceIndex = p.Key.IndexOf(' '); return spaceIndex == -1 ? p.Key : p.Key[..spaceIndex]; }) .ToFrozenDictionary(g => g.Key, g => g.ToArray()); public static IEnumerable<(string error, int column)> Verify(string line) { if (line.StartsWith('>')) { yield break; } var cleanedLine = Clean(line); var message = "No exclamation marks. If a statement is important make it bold. https://www.technicalcommunicationcenter.com/2011/12/30/the-discipline-of-punctuation-in-technical-writing/. "; var exclamationIndex1 = cleanedLine.IndexOf("! "); if (exclamationIndex1 != -1) { yield return (message, exclamationIndex1); } // Tokenize words with positions var words = Tokenize(cleanedLine); // Check invalid words via set lookup (report first occurrence only) var seenWords = new HashSet(); foreach (var (word, start) in words) { if (invalidWordSet.Contains(word) && seenWords.Add(word)) { yield return ($"Invalid word detected: '{word}'", start - 1); } } // Check phrases via first-word lookup (report first occurrence only) var seenPhrases = new HashSet(); foreach (var (word, start) in words) { if (phrasesByFirstWord.TryGetValue(word, out var candidates)) { foreach (var candidate in candidates) { if (seenPhrases.Contains(candidate.Key)) { continue; } if (cleanedLine.AsSpan(start).StartsWith(candidate.Key.AsSpan(), StringComparison.Ordinal)) { seenPhrases.Add(candidate.Key); yield return ($"Invalid phrase detected: '{candidate.Key}'. Instead consider '{candidate.Value}'", start); } } } } } static List<(string word, int start)> Tokenize(string cleanedLine) { var words = new List<(string word, int start)>(); var span = cleanedLine.AsSpan(); var pos = 0; while (pos < span.Length) { if (span[pos] == ' ') { pos++; continue; } var wordStart = pos; while (pos < span.Length && span[pos] != ' ') { pos++; } words.Add((span[wordStart..pos].ToString(), wordStart)); } return words; } static string Clean(string input) { var length = input.Length + 2; // +2 for leading and trailing spaces return string.Create(length, input, (span, source) => { span[0] = ' '; var index = 1; foreach (var ch in source) { if (ch is '\'' or '?' or '.' or ',') { span[index++] = ' '; } else { span[index++] = char.ToLowerInvariant(ch); } } span[index] = ' '; }); } } ================================================ FILE: src/MarkdownSnippets/ContentValidationException.cs ================================================ namespace MarkdownSnippets; public class ContentValidationException(IReadOnlyList errors) : SnippetException(BuildMessage(errors)) { public IReadOnlyList Errors { get; } = errors; static string BuildMessage(IReadOnlyList errors) { var builder = new StringBuilder("Content validation errors:"); builder.AppendLine(); foreach (var error in errors) { if (error.File == null) { Polyfill.AppendLine( builder, $""" {error.Error} Line: {error.Line} Column: {error.Column} """); } Polyfill.AppendLine( builder, $""" {error.Error} File: {error.File} Line: {error.Line} Column: {error.Column} """); } return builder.ToString(); } public override string ToString() => Message; } ================================================ FILE: src/MarkdownSnippets/Downloader/Downloader.cs ================================================ static class Downloader { static string cache = Path.Combine(Path.GetTempPath(), "MarkdownSnippets"); static Downloader() { Directory.CreateDirectory(cache); foreach (var file in new DirectoryInfo(cache) .GetFiles() .OrderByDescending(_ => _.LastWriteTime) .Skip(100)) { file.Delete(); } } static HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; public static async Task<(bool success, string? path)> DownloadFile(string uri) { var file = Path.Combine(cache, FileNameFromUrl.ConvertToFileName(uri)); if (File.Exists(file)) { var fileTimestamp = Timestamp.GetTimestamp(file); if (fileTimestamp.Expiry > DateTime.UtcNow) { return (true, file); } } Timestamp webTimeStamp; using (var request = new HttpRequestMessage(HttpMethod.Head, uri)) { using var headResponse = await httpClient.SendAsync(request); if (headResponse.StatusCode != HttpStatusCode.OK) { return (false, null); } webTimeStamp = Timestamp.GetTimestamp(headResponse); if (File.Exists(file)) { var fileTimestamp = Timestamp.GetTimestamp(file); if (fileTimestamp.LastModified == webTimeStamp.LastModified) { return (true, file); } File.Delete(file); } } using var response = await httpClient.GetAsync(uri); using var httpStream = await response.Content.ReadAsStreamAsync(); using (var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None)) { await httpStream.CopyToAsync(fileStream); } webTimeStamp = Timestamp.GetTimestamp(response); Timestamp.SetTimestamp(file, webTimeStamp); return (true, file); } public static async Task<(bool success, string? content)> DownloadContent(string uri) { var (success, path) = await DownloadFile(uri); if (success) { return (true, await File.ReadAllTextAsync(path!)); } return (false, null); } } ================================================ FILE: src/MarkdownSnippets/Downloader/FileNameFromUrl.cs ================================================ static class FileNameFromUrl { static FrozenSet invalid = Path.GetInvalidFileNameChars().Concat(Path.GetInvalidPathChars()).ToFrozenSet(); public static string ConvertToFileName(string url) { var builder = StringBuilderCache.Acquire(url.Length); foreach (var ch in url) { if (invalid.Contains(ch)) { builder.Append('_'); continue; } builder.Append(ch); } return StringBuilderCache.GetStringAndRelease(builder); } } ================================================ FILE: src/MarkdownSnippets/Downloader/Timestamp.cs ================================================ class Timestamp { static DateTime minFileDate = DateTime.FromFileTimeUtc(0); public DateTime? Expiry; public DateTime? LastModified; public static Timestamp GetTimestamp(HttpResponseMessage headResponse) { var timestamp = new Timestamp(); var headers = headResponse.Content.Headers; if (headers.LastModified != null) { timestamp.LastModified = headers.LastModified.Value.UtcDateTime; } if (headers.Expires != null) { timestamp.Expiry = headers.Expires.Value.UtcDateTime; } return timestamp; } public static void SetTimestamp(string path, Timestamp timestamp) { File.SetCreationTimeUtc(path, timestamp.LastModified.GetValueOrDefault(DateTime.UtcNow)); File.SetLastWriteTimeUtc(path, timestamp.Expiry.GetValueOrDefault(minFileDate)); } public static Timestamp GetTimestamp(string path) { var timestamp = new Timestamp(); var creationTimeUtc = File.GetCreationTimeUtc(path); if (creationTimeUtc != minFileDate) { timestamp.LastModified = creationTimeUtc; } var lastWriteTimeUtc = File.GetLastWriteTimeUtc(path); if (lastWriteTimeUtc != minFileDate) { timestamp.Expiry = lastWriteTimeUtc; } return timestamp; } } ================================================ FILE: src/MarkdownSnippets/Extensions.cs ================================================ static class Extensions { public static bool TryFindNewline(this TextReader reader, [NotNullWhen(true)] out string? newline) { do { var c = reader.Read(); if (c == -1) { break; } if (c == '\r') { var peek = reader.Peek(); if (peek == -1) { newline = "\r"; return true; } if (peek == '\n') { newline = "\r\n"; return true; } newline = "\r"; return true; } if (c == '\n') { newline = "\n"; return true; } } while (true); newline = null; return false; } public static void TrimEnd(this StringBuilder builder) { var i = builder.Length - 1; for (; i >= 0; i--) { if (!char.IsWhiteSpace(builder[i])) { break; } } if (i < builder.Length - 1) { builder.Length = i + 1; } } public static IReadOnlyList ToReadonlyList(this IEnumerable value) => value.ToList(); public static int LineCount(this CharSpan input) { var count = 1; var len = input.Length; for (var i = 0; i != len; ++i) { switch (input[i]) { case '\r': ++count; if (i + 1 != len && input[i + 1] == '\n') { ++i; } break; case '\n': ++count; break; } } return count; } public static int LastIndexOfSequence(this CharSpan value, char c, int max) { var index = 0; while (true) { if (index == max) { return index; } if (index == value.Length) { return index; } var ch = value[index]; if (c != ch) { return index; } index++; } } public static CharSpan TrimBackCommentChars(this CharSpan input, int startIndex) { for (var index = input.Length - 1; index >= startIndex; index--) { var ch = input[index]; if (char.IsLetterOrDigit(ch) || ch is ']' or ' ' or ')') { return input[startIndex..(index + 1)]; } } return string.Empty; } public static string[] Lines(this string value) => value.Split(["\r\n", "\r", "\n"], StringSplitOptions.None); public static bool IsWhiteSpace(this CharSpan target) { for (var i = 0; i < target.Length; i++) { if (!char.IsWhiteSpace(target[i])) { return false; } } return true; } } ================================================ FILE: src/MarkdownSnippets/FileEx.cs ================================================ static class FileEx { public static string FixFileCapitalization(string file) { var fileName = Path.GetFileName(file); var directory = Path.GetDirectoryName(file); if (string.IsNullOrEmpty(directory)) { directory = "."; } var filePaths = Directory.GetFiles(directory, fileName, SearchOption.TopDirectoryOnly); if (filePaths.Length == 0) { throw new FileNotFoundException($"Could not find file: {file}"); } return filePaths[0]; } public static string GetRelativePath(string file, string directory) { var fileUri = new Uri(file); // Folders must end in a slash if (!directory.EndsWith(Path.DirectorySeparatorChar)) { directory += Path.DirectorySeparatorChar; } var directoryUri = new Uri(directory); return Uri.UnescapeDataString(directoryUri.MakeRelativeUri(fileUri).ToString().Replace('/', Path.DirectorySeparatorChar)); } public static string PrependSlash(string path) { if (path.StartsWith('/')) { return path; } return $"/{path}"; } public static void ClearReadOnly(string path) { if (!File.Exists(path)) { return; } var attributes = File.GetAttributes(path); if ((attributes & FileAttributes.ReadOnly) != 0) { File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); } } public static void MakeReadOnly(string path) { var attributes = File.GetAttributes(path); File.SetAttributes(path, attributes | FileAttributes.ReadOnly); } } ================================================ FILE: src/MarkdownSnippets/GitRepoDirectoryFinder.cs ================================================ namespace MarkdownSnippets; public static class GitRepoDirectoryFinder { public static string FindForFilePath([CallerFilePath] string sourceFilePath = "") { Guard.FileExists(sourceFilePath, nameof(sourceFilePath)); var directory = Path.GetDirectoryName(sourceFilePath)!; return FindForDirectory(directory); } public static string FindForDirectory(string directory) { Guard.DirectoryExists(directory, nameof(directory)); if (TryFind(directory, out var path)) { return path; } throw new("Could not find git repository directory"); } static bool TryFind(string directory, [NotNullWhen(true)] out string? path) { if (TryFind(directory, ".git", out var targetDirectory)) { path = targetDirectory; return true; } if (TryFind(directory, ".gitignore", out targetDirectory)) { path = targetDirectory; return true; } path = null; return false; } public static bool IsInGitRepository(string directory) { Guard.DirectoryExists(directory, nameof(directory)); return TryFind(directory, out _); } static bool TryFind(string directory, string suffix, [NotNullWhen(true)] out string? path) { Guard.DirectoryExists(directory, nameof(directory)); do { var combine = Path.Combine(directory, suffix); if (Directory.Exists(combine) || File.Exists(combine)) { path = directory; return true; } var parent = Directory.GetParent(directory); if (parent == null) { path = null; return false; } directory = parent.FullName; } while (true); } } ================================================ FILE: src/MarkdownSnippets/GlobalUsings.cs ================================================ global using System.Collections.Frozen; global using System.Diagnostics.CodeAnalysis; global using System.Net; global using System.Net.Http; global using System.Text.RegularExpressions; global using MarkdownSnippets; ================================================ FILE: src/MarkdownSnippets/Guard.cs ================================================ static class Guard { public static void AgainstUpperCase(string value, string argumentName) { foreach (var c in value) { if (char.IsUpper(c)) { throw new ArgumentException($"Cannot contain upper case. Value: {value}", argumentName); } } } public static void AgainstNegativeAndZero(int value, string argumentName) { if (value <= 0) { throw new ArgumentOutOfRangeException(argumentName,value, "Zero or less"); } } public static void AgainstNegative(int value, string argumentName) { if (value < 0) { throw new ArgumentOutOfRangeException(argumentName, value, "negative"); } } public static void AgainstNullAndEmpty(string? value, string argumentName) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentNullException(argumentName); } } public static void DirectoryExists(string path, string argumentName) { AgainstNullAndEmpty(path, argumentName); if (!Directory.Exists(path)) { throw new ArgumentException($"Directory does not exist: {path}", argumentName); } } public static void FileExists(string? path, string argumentName) { AgainstNullAndEmpty(path, argumentName); if (!File.Exists(path)) { throw new ArgumentException($"File does not exist: {path}", argumentName); } } public static void AgainstEmpty(string? value, string argumentName) { if (value == null) { return; } if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("Cannot be only whitespace.", argumentName); } } } ================================================ FILE: src/MarkdownSnippets/InterpretErrors.cs ================================================ namespace MarkdownSnippets; /// /// Extension method to convert various error cases. /// public static class InterpretErrors { /// /// Converts to a markdown string. /// public static string ErrorsAsMarkdown(this IReadOnlyList snippets) { if (snippets.Count == 0) { return ""; } var builder = StringBuilderCache.Acquire(); builder.AppendLine("## Snippet errors\r\n"); foreach (var error in snippets) { Polyfill.AppendLine( builder, $" * {error}"); } builder.AppendLine(); return StringBuilderCache.GetStringAndRelease(builder); } /// /// Converts to a markdown string. /// public static string ErrorsAsMarkdown(this ProcessResult processResult) { var builder = StringBuilderCache.Acquire(); var missingSnippets = processResult.MissingSnippets; if (missingSnippets.Count != 0) { builder.Append( """ ## Missing snippets """); foreach (var error in missingSnippets) { Polyfill.AppendLine( builder, $" * Key:'{error.Key}' Line:'{error.LineNumber}'"); } } //TODO: handle other errors builder.AppendLine(); return StringBuilderCache.GetStringAndRelease(builder); } } ================================================ FILE: src/MarkdownSnippets/KeyValidator.cs ================================================ static class KeyValidator { public static bool IsValidKey(CharSpan key) { if (key.Length == 0) { return false; } if (!char.IsLetterOrDigit(key[0])) { return false; } if (!char.IsLetterOrDigit(key[^1])) { return false; } return true; } } ================================================ FILE: src/MarkdownSnippets/MarkdownProcessingException.cs ================================================ namespace MarkdownSnippets; public class MarkdownProcessingException : SnippetException { public string? File { get; } public int LineNumber { get; } public MarkdownProcessingException(string message, string? file, int lineNumber) : base($"{message} File: {file}. LineNumber: {lineNumber}.") { Guard.AgainstNegativeAndZero(lineNumber, nameof(lineNumber)); Guard.AgainstEmpty(file, nameof(file)); File = file; LineNumber = lineNumber; } public override string ToString() => Message; } ================================================ FILE: src/MarkdownSnippets/MarkdownSnippets.csproj ================================================ netstandard2.0;netstandard2.1;net48;net8.0;net9.0;net10.0 ================================================ FILE: src/MarkdownSnippets/MissingIncludesException.cs ================================================ namespace MarkdownSnippets; public class MissingIncludesException(IReadOnlyList missing) : SnippetException($"Missing includes: {string.Join(", ", missing.Select(_ => _.Key))}") { public IReadOnlyList Missing { get; } = missing; public override string ToString() => Message; } ================================================ FILE: src/MarkdownSnippets/MissingSnippetsException.cs ================================================ namespace MarkdownSnippets; public class MissingSnippetsException(IReadOnlyList missing) : SnippetException($"Missing snippets:{Environment.NewLine} {Report(missing)}") { public IReadOnlyList Missing { get; } = missing; static string Report(IReadOnlyList missing) => string.Join( $"{Environment.NewLine} ", missing.GroupBy(_ => _.File ?? "file-unknown") .Select(_ => $"{_.Key}: {string.Join(',', _.Select(_ => _.Key))}")); public override string ToString() => Message; } ================================================ FILE: src/MarkdownSnippets/NewLineConfigReader.cs ================================================ static class NewLineConfigReader { public static string ReadNewLine(string directory, IEnumerable mdFiles) { var newLine = TryReadFromGitAttributes(directory); if (newLine != null) { return newLine; } newLine = TryReadFromEditorConfig(directory); if (newLine != null) { return newLine; } return DetectFromFiles(mdFiles); } static string DetectFromFiles(IEnumerable mdFiles) { foreach (var mdFile in mdFiles.OrderBy(_ => _.Length)) { using var reader = File.OpenText(mdFile); if (reader.TryFindNewline(out var detectedNewLine)) { return detectedNewLine; } } return Environment.NewLine; } static string? TryReadFromGitAttributes(string directory) { var gitAttributesPath = FindFileUpward(directory, ".gitattributes"); if (gitAttributesPath == null) { return null; } var lines = File.ReadAllLines(gitAttributesPath); return ParseGitAttributesEol(lines); } static string? ParseGitAttributesEol(string[] lines) { string? wildcardEol = null; string? extensionEol = null; foreach (var line in lines) { var trimmed = line.Trim(); if (trimmed.Length == 0 || trimmed.StartsWith('#')) { continue; } var eolValue = ExtractGitAttributeEol(trimmed); if (eolValue == null) { continue; } var pattern = GetGitAttributePattern(trimmed); if (pattern == "*") { wildcardEol = eolValue; } else if (pattern is "*.md" or "*.MD") { extensionEol = eolValue; } } // More specific pattern wins var eol = extensionEol ?? wildcardEol; return EolValueToNewLine(eol); } static string? ExtractGitAttributeEol(string line) { // Look for eol=lf or eol=crlf in the line var eolIndex = line.IndexOf("eol=", StringComparison.OrdinalIgnoreCase); if (eolIndex == -1) { return null; } var valueStart = eolIndex + 4; var valueEnd = valueStart; while (valueEnd < line.Length && !char.IsWhiteSpace(line[valueEnd])) { valueEnd++; } var value = line.AsSpan(valueStart, valueEnd - valueStart); if (value.Equals("lf", StringComparison.OrdinalIgnoreCase)) { return "lf"; } if (value.Equals("crlf", StringComparison.OrdinalIgnoreCase)) { return "crlf"; } if (value.Equals("cr", StringComparison.OrdinalIgnoreCase)) { return "cr"; } return null; } static string GetGitAttributePattern(string line) { // Pattern is the first whitespace-delimited token var end = 0; while (end < line.Length && !char.IsWhiteSpace(line[end])) { end++; } return line[..end]; } static string? TryReadFromEditorConfig(string directory) { var editorConfigPath = FindFileUpward(directory, ".editorconfig"); if (editorConfigPath == null) { return null; } var lines = File.ReadAllLines(editorConfigPath); return ParseEditorConfigEol(lines); } static string? ParseEditorConfigEol(string[] lines) { string? globalEol = null; string? extensionEol = null; var inWildcardSection = false; var inExtensionSection = false; foreach (var line in lines) { var trimmed = line.Trim(); if (trimmed.Length == 0 || trimmed.StartsWith('#') || trimmed.StartsWith(';')) { continue; } // Check for section headers if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) { var section = trimmed.AsSpan(1, trimmed.Length - 2); inWildcardSection = section.SequenceEqual("*"); inExtensionSection = EditorConfigSectionMatchesMd(section); continue; } // Parse key=value var equalsIndex = trimmed.IndexOf('='); if (equalsIndex == -1) { continue; } var key = trimmed[..equalsIndex].Trim().ToLowerInvariant(); var value = trimmed[(equalsIndex + 1)..].Trim().ToLowerInvariant(); if (key == "end_of_line") { if (inExtensionSection) { extensionEol = value; } else if (inWildcardSection) { globalEol = value; } } } // More specific section wins var eol = extensionEol ?? globalEol; return EolValueToNewLine(eol); } static bool EditorConfigSectionMatchesMd(CharSpan section) { // Handle patterns like *.md, *.{md,txt}, etc. if (section.Length == 0 || section[0] != '*') { return false; } var pattern = section[1..]; if (pattern.Equals(".md", StringComparison.OrdinalIgnoreCase)) { return true; } // Handle {md,txt} style patterns like *.{md,txt} if (pattern.Length > 0 && pattern[0] == '.') { var braceStart = pattern.IndexOf('{'); var braceEnd = pattern.IndexOf('}'); if (braceStart != -1 && braceEnd > braceStart) { var extensionsSpan = pattern.Slice(braceStart + 1, braceEnd - braceStart - 1); foreach (var range in extensionsSpan.Split(',')) { var ext = extensionsSpan[range].Trim(); if (ext.Equals("md", StringComparison.OrdinalIgnoreCase)) { return true; } } } } return false; } static string? EolValueToNewLine(string? eolValue) => eolValue switch { "lf" => "\n", "crlf" => "\r\n", "cr" => "\r", _ => null }; static string? FindFileUpward(string directory, string fileName) { var current = directory; while (current != null) { var filePath = Path.Combine(current, fileName); if (File.Exists(filePath)) { return filePath; } var parent = Directory.GetParent(current); current = parent?.FullName; } return null; } } ================================================ FILE: src/MarkdownSnippets/Paths.cs ================================================ static class Paths { public static bool IsMdFile(this string value) => value.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || value.EndsWith(".mdx", StringComparison.OrdinalIgnoreCase); public static bool IsSourceMdFile(this string value) => value.EndsWith(".source.md", StringComparison.OrdinalIgnoreCase) || value.EndsWith(".source.mdx", StringComparison.OrdinalIgnoreCase); public static bool IsIncludeMdFile(this string value) => value.EndsWith(".include.md", StringComparison.OrdinalIgnoreCase); } ================================================ FILE: src/MarkdownSnippets/Processing/AppendSnippetsToMarkdown.cs ================================================ namespace MarkdownSnippets; public delegate void AppendSnippetsToMarkdown(string key, IEnumerable snippets, Action appendLine); ================================================ FILE: src/MarkdownSnippets/Processing/DirectoryMarkdownProcessor.cs ================================================ namespace MarkdownSnippets; public class DirectoryMarkdownProcessor { DocumentConvention convention; bool writeHeader; bool validateContent; string? header; bool readOnly; int tocLevel; IEnumerable? tocExcludes; Action log; string targetDirectory; List mdFiles = []; List includes = []; List snippets = []; public IReadOnlyList Snippets => snippets; List snippetSourceFiles = []; AppendSnippetsToMarkdown appendSnippets; bool treatMissingAsWarning; string newLine; List allFiles; public DirectoryMarkdownProcessor( string targetDirectory, ShouldIncludeDirectory directoryIncludes, ShouldIncludeDirectory markdownDirectoryIncludes, ShouldIncludeDirectory snippetDirectoryIncludes, DocumentConvention convention = DocumentConvention.SourceTransform, bool scanForMdFiles = true, bool scanForSnippets = true, bool scanForIncludes = true, Action? log = null, bool? writeHeader = null, string? header = null, bool? readOnly = null, LinkFormat linkFormat = LinkFormat.GitHub, int tocLevel = 2, IEnumerable? tocExcludes = null, bool treatMissingAsWarning = false, int maxWidth = int.MaxValue, string? urlPrefix = null, bool validateContent = false, string? newLine = null, bool omitSnippetLinks = false, ShouldIncludeFile? snippetFileIncludes = null) : this( targetDirectory, new SnippetMarkdownHandling(targetDirectory, linkFormat, omitSnippetLinks, urlPrefix).Append, directoryIncludes, markdownDirectoryIncludes, snippetDirectoryIncludes, convention, scanForMdFiles: scanForMdFiles, scanForSnippets: scanForSnippets, scanForIncludes: scanForIncludes, log: log, writeHeader: writeHeader, header: header, readOnly: readOnly, tocLevel: tocLevel, tocExcludes: tocExcludes, treatMissingAsWarning: treatMissingAsWarning, maxWidth: maxWidth, validateContent: validateContent, newLine: newLine, snippetFileIncludes: snippetFileIncludes) { } public DirectoryMarkdownProcessor( string targetDirectory, AppendSnippetsToMarkdown appendSnippets, ShouldIncludeDirectory directoryIncludes, ShouldIncludeDirectory markdownDirectoryIncludes, ShouldIncludeDirectory snippetDirectoryIncludes, DocumentConvention convention = DocumentConvention.SourceTransform, bool scanForMdFiles = true, bool scanForSnippets = true, bool scanForIncludes = true, Action? log = null, bool? writeHeader = null, string? header = null, bool? readOnly = null, int tocLevel = 2, IEnumerable? tocExcludes = null, bool treatMissingAsWarning = false, int maxWidth = int.MaxValue, bool validateContent = false, string? newLine = null, ShouldIncludeFile? snippetFileIncludes = null) { this.appendSnippets = appendSnippets; this.convention = convention; this.writeHeader = writeHeader.GetValueOrDefault(convention == DocumentConvention.SourceTransform); this.readOnly = readOnly.GetValueOrDefault(false); this.validateContent = validateContent; this.header = header; this.tocLevel = tocLevel; this.tocExcludes = tocExcludes; this.newLine = newLine!; this.treatMissingAsWarning = treatMissingAsWarning; this.log = log ?? (_ => Trace.WriteLine(_)); Guard.DirectoryExists(targetDirectory, nameof(targetDirectory)); this.targetDirectory = Path.GetFullPath(targetDirectory); var fileFinder = new FileFinder(targetDirectory, convention, directoryIncludes, markdownDirectoryIncludes, snippetDirectoryIncludes, snippetFileIncludes); var (snippetFiles, mdFiles, includeFiles, allFiles) = fileFinder.FindFiles(); this.allFiles = allFiles; if (scanForMdFiles) { this.mdFiles.AddRange(mdFiles); } if (scanForSnippets) { InitNewLine(); if (snippetFiles.Count != 0) { snippetSourceFiles.AddRange(snippetFiles); this.log($"Found {snippetFiles.Count} files for snippets"); } var stopwatch = Stopwatch.StartNew(); var read = FileSnippetExtractor.Read(snippetFiles, maxWidth, this.newLine).ToList(); if (read.Count != 0) { snippets.AddRange(read); this.log($"Added {read.Count} snippets ({stopwatch.ElapsedMilliseconds}ms)"); } } if (scanForIncludes) { var includeKeys = new HashSet(); foreach (var file in includeFiles) { var key = Path.GetFileName(file).Replace(".include.md", ""); if (!includeKeys.Add(key)) { throw new($"Duplicate include: {key}"); } includes.Add(Include.Build(key, File.ReadAllLines(file), file)); } } } [MemberNotNull(nameof(newLine))] void InitNewLine() { if (newLine != null) { return; } newLine = NewLineConfigReader.ReadNewLine(targetDirectory, mdFiles); } public void AddSnippets(List snippets) { var files = snippets .Where(_ => _.Path != null) .Select(_ => _.Path!) .Distinct() .ToList(); if (files.Count != 0) { snippetSourceFiles.AddRange(files); } if (snippets.Count != 0) { this.snippets.AddRange(snippets); } } public void AddSnippets(params Snippet[] snippets) => AddSnippets(snippets.ToList()); public void AddMdFiles(params string[] files) { foreach (var file in files) { mdFiles.Add(file); } } public void Run() { if (mdFiles.Count == 0) { if (convention == DocumentConvention.InPlaceOverwrite) { throw new SnippetException("No markdown files found."); } throw new SnippetException( $$""" No markdown files found. This may be due to the DocumentConvention being SourceTransform. See https://github.com/SimonCropp/MarkdownSnippets#document-convention To move to InPlaceOverwrite add a file named `mdsnippets.json` in the target directory ({{targetDirectory}}) that contains: { "Convention": "InPlaceOverwrite" } """); } foreach (var group in snippets.GroupBy(_ => _.Path)) { if (group.Key == null) { log("Snippets added with no path"); } else { log($"Snippets extracted from {group.Key}"); } foreach (var snippet in group) { log($"\t{snippet.Key}"); } } var stopwatch = Stopwatch.StartNew(); var processor = new MarkdownProcessor( convention, Snippets.ToDictionary(), includes, appendSnippets, snippetSourceFiles, allFiles, tocLevel, writeHeader, targetDirectory, validateContent, header, tocExcludes, newLine); foreach (var sourceFile in mdFiles) { ProcessFile(sourceFile, processor); } log($"MarkdownProcessor Finished. {stopwatch.ElapsedMilliseconds}ms"); } void ProcessFile(string sourceFile, MarkdownProcessor markdownProcessor) { string targetFile; if (convention == DocumentConvention.SourceTransform) { targetFile = TargetFileForSourceTransform(sourceFile, targetDirectory); } else { targetFile = sourceFile; } var lines = ReadLines(sourceFile); FileEx.ClearReadOnly(targetFile); var relativeSource = sourceFile[targetDirectory.Length..] .Replace('\\', '/'); var result = markdownProcessor.Apply(lines, newLine, relativeSource); var missingSnippets = result.MissingSnippets; if (missingSnippets.Count != 0) { // If the config value is set to treat missing snippets as warnings, then don't throw if (treatMissingAsWarning) { foreach (var missing in missingSnippets) { log($"WARN: The source file:{missing.File} includes a key {missing.Key}, however the snippet is missing. Make sure that the snippet is defined."); } } else { throw new MissingSnippetsException(missingSnippets); } } var missingIncludes = result.MissingIncludes; if (missingIncludes.Count != 0) { // If the config value is set to treat missing include as warnings, then don't throw if (treatMissingAsWarning) { foreach (var missing in missingIncludes) { log($"WARN: The source file:{missing.File} includes a key {missing.Key}, however the include is missing. Make sure that the include is defined."); } } else { throw new MissingIncludesException(missingIncludes); } } var errors = result.ValidationErrors; if (errors.Count != 0) { throw new ContentValidationException(errors); } WriteLines(targetFile, lines); if (readOnly) { FileEx.MakeReadOnly(targetFile); } } void WriteLines(string target, List lines) { var directoryName = Path.GetDirectoryName(target)!; Directory.CreateDirectory(directoryName); using var writer = File.CreateText(target); writer.NewLine = newLine; foreach (var line in lines) { writer.WriteLine(line.Current); } } static List ReadLines(string sourceFile) { using var reader = File.OpenText(sourceFile); return Lines.ReadAllLines(reader, sourceFile).ToList(); } static string TargetFileForSourceTransform(string sourceFile, string targetDirectory) { var relativePath = FileEx.GetRelativePath(sourceFile, targetDirectory); var filtered = relativePath.Split(Path.DirectorySeparatorChar) .Where(_ => !string.Equals(_, "mdsource", StringComparison.OrdinalIgnoreCase)) .ToArray(); var sourceTrimmed = Path.Combine(filtered); var targetFile = Path.Combine(targetDirectory, sourceTrimmed); // remove ".md" from ".source.md" then change ".source" to ".md" targetFile = targetFile.Replace(".source.", "."); return targetFile; } } ================================================ FILE: src/MarkdownSnippets/Processing/DocumentConvention.cs ================================================ namespace MarkdownSnippets; public enum DocumentConvention { SourceTransform, InPlaceOverwrite } ================================================ FILE: src/MarkdownSnippets/Processing/HeaderWriter.cs ================================================ static class HeaderWriter { static string[] defaultHeaderLines; internal const string DefaultHeader = """ GENERATED FILE - DO NOT EDIT This file was generated by [MarkdownSnippets](https://github.com/SimonCropp/MarkdownSnippets). Source File: {relativePath} To change this file edit the source file and then run MarkdownSnippets. """; static HeaderWriter() => defaultHeaderLines = DefaultHeader.Lines(); public static string WriteHeader(string relativePath, string? header, string newline) { var lines = Header(header); var inner = string.Join(newline, lines) .Replace("{relativePath}", relativePath) .Replace(@"\n", newline); return $"{newline}"; } static string[] separator = ["\r\n", "\r", "\n", @"\n"]; static string[] Header(string? header) { if (header == null) { return defaultHeaderLines; } if (header.Contains("")) { throw new SnippetException("Header cannot contain ``."); } return header.Split(separator, StringSplitOptions.None); } } ================================================ FILE: src/MarkdownSnippets/Processing/IncludeProcessor.cs ================================================ class IncludeProcessor { DocumentConvention convention; Dictionary includesLookup; IReadOnlyDictionary> snippets; IReadOnlyList allFiles; string targetDirectory; public IncludeProcessor( DocumentConvention convention, IReadOnlyList includes, IReadOnlyDictionary> snippets, string targetDirectory, IReadOnlyList allFiles) { targetDirectory = Path.GetFullPath(targetDirectory); this.targetDirectory = targetDirectory.Replace('\\', '/'); this.convention = convention; includesLookup = includes.ToDictionary(_ => _.Key); this.snippets = snippets; this.allFiles = allFiles; } public bool TryProcessInclude(List lines, Line line, ICollection used, int index, List missing, string? relativePath) { var current = line.Current; if (current.StartsWith("include: ")) { var includeKey = line.Current[9..]; Inner(lines, line, used, index, missing, includeKey, relativePath); return true; } if (convention == DocumentConvention.SourceTransform) { return false; } static string GetIncludeKey(CharSpan substring) { var indexOfDotPath = substring.IndexOf(". path:", StringComparison.Ordinal); if (indexOfDotPath != -1) { return substring[..indexOfDotPath].ToString(); } return substring[..^4].ToString(); } var indexSingleLineInclude = current.IndexOf("", line.Path, line); Inner(lines, line, used, index, missing, includeKey, relativePath); return true; } var indexOfInclude = current.IndexOf("", line.Path, line); Inner(lines, line, used, index, missing, includeKey, relativePath); return true; } return false; } void Inner(List lines, Line line, ICollection used, int index, List missing, string includeKey, string? relativePath) { if (includesLookup.TryGetValue(includeKey, out var include)) { AddInclude(lines, line, used, index, include, true); return; } if (snippets.TryGetValue(includeKey, out var snippetsResult)) { if (snippetsResult.Count > 1) { throw new("Only one snippet may be used as an include"); } if (snippetsResult.Count == 1) { var snippet = snippetsResult[0]; var snippetInclude = Include.Build(snippet.Key, snippet.Value.Lines(), snippet.Path); AddInclude(lines, line, used, index, snippetInclude, true); return; } } if (includeKey.StartsWith("http")) { var (success, httpPath) = Downloader.DownloadFile(includeKey).GetAwaiter().GetResult(); if (success) { include = Include.Build(includeKey, File.ReadAllLines(httpPath!), null); AddInclude(lines, line, used, index, include, false); return; } } if (RelativeFile.Find(allFiles, targetDirectory, includeKey, relativePath, line.Path, out var path)) { include = Include.Build(includeKey, File.ReadAllLines(path), path); AddInclude(lines, line, used, index, include, false); return; } missing.Add(new(includeKey, index + 1, line.Path)); line.Current = $"** Could not find include '{includeKey}' ** "; } void AddInclude(List lines, Line line, ICollection used, int index, Include include, bool writePath) { used.Add(include); var linesToInject = BuildIncludes(line, include, writePath).ToList(); lines[index] = linesToInject[0]; if (linesToInject.Count > 1) { lines.InsertRange(index + 1, linesToInject.GetRange(1, linesToInject.Count - 1)); } } IEnumerable BuildIncludes(Line line, Include include, bool writePath) { var path = GetPath(include); var count = include.Lines.Count; if (count == 0) { return BuildEmpty(line, path, include, writePath); } if (count == 1) { return BuildSingle(line, path, include, writePath); } return BuildMultiple(line, path, include, writePath); } static IEnumerable BuildMultiple(Line line, string? path, Include include, bool writePath) { var lines = include.Lines; var count = lines.Count; var first = lines[0]; var key = include.Key; if (ShouldWriteIncludeOnDiffLine(first)) { if (writePath) { yield return line.WithCurrent($""); } else { yield return line.WithCurrent($""); } yield return new(first, path, 1); } else { if (writePath) { yield return line.WithCurrent($"{first}"); } else { yield return line.WithCurrent($"{first}"); } } for (var index = 1; index < lines.Count - 1; index++) { var includeLine = lines[index]; yield return new(includeLine, path, index); } var last = lines[^1]; if (ShouldWriteIncludeOnDiffLine(last)) { yield return new(last, path, count); yield return new("", path, count); } else { yield return new($"{last}", path, count); } } static bool ShouldWriteIncludeOnDiffLine(string line) => SnippetKey.IsSnippetLine(line) || line.StartsWith("") || line.EndsWith("```") || line.StartsWith('|') || line.EndsWith('|'); static IEnumerable BuildEmpty(Line line, string? path, Include include, bool writePath) { if (writePath) { yield return line.WithCurrent($""); } else { yield return line.WithCurrent($""); } } static IEnumerable BuildSingle(Line line, string? path, Include include, bool writePath) { var first = include.Lines[0]; var key = include.Key; if (ShouldWriteIncludeOnDiffLine(first)) { if (writePath) { yield return line.WithCurrent($""); } else { yield return line.WithCurrent($""); } yield return new(first, path, 1); yield return new("", path, 1); } else { if (writePath) { yield return line.WithCurrent($"{first}"); } else { yield return line.WithCurrent($"{first}"); } } } string? GetPath(IContent include) { if (include.Path == null) { return null; } var path = include.Path.Replace('\\', '/'); if (!path.StartsWith(targetDirectory)) { return path; } return path[targetDirectory.Length..]; } } ================================================ FILE: src/MarkdownSnippets/Processing/Line.cs ================================================ [DebuggerDisplay("Line={LineNumber}, Original={Original}, Current={Current}")] class Line { public Line(string original, string? path, int lineNumber) { Original = original; Current = original; Path = path; LineNumber = lineNumber; LeadingWhitespace = GetLeadingWhitespace(original); } public Line WithCurrent(string current) => new(Original, Path, LineNumber) { Current = current }; public readonly string Original; public override string ToString() => throw new(); public string Current { get; set { IsWhiteSpace = value.IsWhiteSpace(); Length = value.Length; field = value; } } = null!; public string? Path { get; } public int LineNumber { get; } public int Length { get; private set; } public bool IsWhiteSpace { get; private set; } public string LeadingWhitespace { get; } static string GetLeadingWhitespace(string text) { var length = 0; foreach (var c in text) { if (c is ' ' or '\t') { length++; } else { break; } } return text[..length]; } } ================================================ FILE: src/MarkdownSnippets/Processing/Lines.cs ================================================ static class Lines { public static void RemoveUntil( this List lines, int index, string match, string? path, Line startLine) { var endIndex = index; while (endIndex < lines.Count) { if (lines[endIndex].Current.Contains(match)) { lines.RemoveRange(index, endIndex - index + 1); return; } endIndex++; } throw new MarkdownProcessingException($"Expected to find `{match}`.", path, startLine.LineNumber); } public static IEnumerable ReadAllLines(TextReader textReader, string? path) { var index = 1; while (textReader.ReadLine() is { } line) { yield return new(line, path, index); index++; } } } ================================================ FILE: src/MarkdownSnippets/Processing/LinkFormat.cs ================================================ namespace MarkdownSnippets; public enum LinkFormat { GitHub, Tfs, Bitbucket, GitLab, DevOps, None } ================================================ FILE: src/MarkdownSnippets/Processing/Markdown.cs ================================================ static class Markdown { static Regex stripLinkRegex = new(@"\[(.*?)\][\[\(].*?[\]\)]", RegexOptions.Compiled); public static string StripMarkdown(string input) { var title = input.Replace("*",""); return stripLinkRegex.Replace(title, "$1"); } } ================================================ FILE: src/MarkdownSnippets/Processing/MarkdownProcessor.cs ================================================ namespace MarkdownSnippets; /// /// Merges s with an input file/text. /// public class MarkdownProcessor { DocumentConvention convention; IReadOnlyDictionary> snippets; AppendSnippetsToMarkdown appendSnippets; bool writeHeader; bool validateContent; string newLine; string? header; int tocLevel; List tocExcludes; List snippetSourceFiles; IncludeProcessor includeProcessor; static List validationExcludes = [ "code_of_conduct", ".github", "license" ]; string targetDirectory; IReadOnlyList allFiles; public MarkdownProcessor( DocumentConvention convention, IReadOnlyDictionary> snippets, IReadOnlyList includes, AppendSnippetsToMarkdown appendSnippets, IReadOnlyList snippetSourceFiles, IReadOnlyList allFiles, int tocLevel, bool writeHeader, string targetDirectory, bool validateContent, string? header = null, IEnumerable? tocExcludes = null, string newLine = "\n") { Guard.AgainstEmpty(header, nameof(header)); Guard.AgainstNegativeAndZero(tocLevel, nameof(tocLevel)); Guard.AgainstNullAndEmpty(targetDirectory, nameof(targetDirectory)); if (convention == DocumentConvention.InPlaceOverwrite && writeHeader) { throw new SnippetException("WriteHeader is not allowed with InPlaceOverwrite convention."); } this.targetDirectory = Path.GetFullPath(targetDirectory).Replace('\\', '/'); this.allFiles = allFiles .Select(_ => _.Replace('\\', '/')) .ToList(); this.convention = convention; this.snippets = snippets; this.appendSnippets = appendSnippets; this.writeHeader = writeHeader; this.validateContent = validateContent; this.newLine = newLine; this.header = header; this.tocLevel = tocLevel; if (tocExcludes == null) { this.tocExcludes = []; } else { this.tocExcludes = tocExcludes.ToList(); } this.snippetSourceFiles = snippetSourceFiles .Select(_ => _.Replace('\\', '/')) .ToList(); includeProcessor = new(convention, includes, snippets, targetDirectory, this.allFiles); } public string Apply(string input, string? file = null) { Guard.AgainstEmpty(file, nameof(file)); var builder = StringBuilderCache.Acquire(); try { using var reader = new StringReader(input); using var writer = new StringWriter(builder); var processResult = Apply(reader, writer, file); var missing = processResult.MissingSnippets; if (missing.Count != 0) { throw new MissingSnippetsException(missing); } return builder.ToString(); } finally { StringBuilderCache.Release(builder); } } /// /// Apply to . /// public ProcessResult Apply(TextReader textReader, TextWriter writer, string? file = null) { Guard.AgainstEmpty(file, nameof(file)); var lines = Lines.ReadAllLines(textReader, null).ToList(); writer.NewLine = newLine; var result = Apply(lines, newLine, file); for (var index = 0; index < lines.Count - 1; index++) { var line = lines[index]; writer.WriteLine(line.Current); } writer.Write(lines.Last().Current); return result; } internal ProcessResult Apply(List lines, string newLine, string? relativePath) { var missingSnippets = new List(); var validationErrors = new List(); var missingIncludes = new List(); var usedSnippets = new HashSet(); var usedIncludes = new HashSet(); var builder = new StringBuilder(); Line? tocLine = null; Action CreateIndentedAppendLine(string indent) => s => { var first = true; foreach (var line in s.AsSpan().EnumerateLines()) { if (!first) { builder.Append(newLine); } first = false; builder.Append(indent); builder.Append(line); } builder.Append(newLine); }; var headerLines = new List(); for (var index = 0; index < lines.Count; index++) { var line = lines[index]; if (ValidateContent(relativePath, line, validationErrors)) { continue; } if (includeProcessor.TryProcessInclude(lines, line, usedIncludes, index, missingIncludes, relativePath)) { continue; } if (line.Current.StartsWith('#')) { if (tocLine != null) { headerLines.Add(line); } continue; } if (line.Current.TrimStart() == "toc") { tocLine = line; continue; } void AppendSnippet(string key1) { builder.Clear(); var indentedAppendLine = CreateIndentedAppendLine(line.LeadingWhitespace); ProcessSnippetLine(indentedAppendLine, missingSnippets, usedSnippets, key1, relativePath, line); builder.TrimEnd(); line.Current = builder.ToString(); } void AppendWebSnippet(string url, string snippetKey, string? viewUrl = null) { builder.Clear(); var indentedAppendLine = CreateIndentedAppendLine(line.LeadingWhitespace); ProcessWebSnippetLine(indentedAppendLine, missingSnippets, usedSnippets, url, snippetKey, viewUrl, line); builder.TrimEnd(); line.Current = builder.ToString(); } if (SnippetKey.ExtractSnippet(line, out var key)) { AppendSnippet(key); continue; } if (SnippetKey.ExtractWebSnippet(line, out var url, out var snippetKey, out var viewUrl)) { AppendWebSnippet(url, snippetKey, viewUrl); continue; } if (convention == DocumentConvention.SourceTransform) { continue; } if (SnippetKey.ExtractStartCommentSnippet(line, out key)) { AppendSnippet(key); index++; lines.RemoveUntil( index, "", relativePath, line); continue; } if (SnippetKey.ExtractStartCommentWebSnippet(line, out url, out snippetKey, out viewUrl)) { AppendWebSnippet(url, snippetKey, viewUrl); index++; lines.RemoveUntil( index, "", relativePath, line); continue; } if (line.Current.TrimStart() == "") { tocLine = line; index++; lines.RemoveUntil(index, "", relativePath, line); } } if (writeHeader) { lines.Insert(0, new(HeaderWriter.WriteHeader(relativePath!, header, newLine), "", 0)); } tocLine?.Current = TocBuilder.BuildToc(headerLines, tocLevel, tocExcludes, newLine); return new( missingSnippets: missingSnippets, usedSnippets: usedSnippets.ToList(), usedIncludes: usedIncludes.ToList(), missingIncludes: missingIncludes, validationErrors: validationErrors); } bool ValidateContent(string? relativePath, Line line, List validationErrors) { if (!validateContent) { return false; } if (relativePath != null && validationExcludes.Any(relativePath.Contains)) { return false; } var found = false; foreach (var error in ContentValidation.Verify(line.Original)) { validationErrors.Add(new(error.error, line.LineNumber, error.column, line.Path)); found = true; } return found; } void ProcessSnippetLine(Action appendLine, List missings, HashSet used, string key, string? relativePath, Line line) { appendLine($""); if (TryGetSnippets(key, relativePath, line.Path, out var snippetsForKey)) { appendSnippets(key, snippetsForKey, appendLine); appendLine(""); used.UnionWith(snippetsForKey); return; } var missing = new MissingSnippet(key, line.LineNumber, line.Path); missings.Add(missing); appendLine("```"); appendLine($"** Could not find snippet '{key}' **"); appendLine("```"); appendLine(""); } void ProcessWebSnippetLine(Action appendLine, List missings, HashSet used, string url, string snippetKey, string? viewUrl, Line line) { var commentText = viewUrl == null ? $"" : $""; appendLine(commentText); // Download file content try { var (success, content) = Downloader.DownloadContent(url).GetAwaiter().GetResult(); if (!success || string.IsNullOrWhiteSpace(content)) { var missing = new MissingSnippet($"{url}#{snippetKey}", line.LineNumber, line.Path); missings.Add(missing); appendLine("```"); appendLine($"** Could not fetch or parse web-snippet '{url}#{snippetKey}' **"); appendLine("```"); appendLine(""); return; } // Extract snippets from content using var reader = new StringReader(content); var snippets = FileSnippetExtractor.Read(reader, url); var found = snippets.FirstOrDefault(_ => _.Key == snippetKey); if (found == null) { var missing = new MissingSnippet($"{url}#{snippetKey}", line.LineNumber, line.Path); missings.Add(missing); appendLine("```"); appendLine($"** Could not find snippet '{snippetKey}' in '{url}' **"); appendLine("```"); appendLine(""); return; } // Create new snippet with viewUrl if provided var snippetToAppend = viewUrl == null ? found : Snippet.Build( language: found.Language, startLine: found.StartLine, endLine: found.EndLine, value: found.Value, key: found.Key, path: found.Path, expressiveCode: found.ExpressiveCode, viewUrl: viewUrl); appendSnippets(snippetKey, [snippetToAppend], appendLine); appendLine(""); used.Add(snippetToAppend); } catch { var missing = new MissingSnippet($"{url}#{snippetKey}", line.LineNumber, line.Path); missings.Add(missing); appendLine("```"); appendLine($"** Could not fetch or parse web-snippet '{url}#{snippetKey}' **"); appendLine("```"); appendLine(""); } } bool TryGetSnippets( string key, string? relativePath, string? linePath, [NotNullWhen(true)] out IReadOnlyList? snippetsForKey) { if (snippets.TryGetValue(key, out snippetsForKey)) { return true; } if (key.StartsWith("http")) { return GetForHttp(key, out snippetsForKey); } return FilesToSnippets(key, relativePath, linePath, out snippetsForKey); } bool FilesToSnippets( string key, string? relativePath, string? linePath, [NotNullWhen(true)] out IReadOnlyList? snippetsForKey) { var keyWithDirChar = FileEx.PrependSlash(key); snippetsForKey = snippetSourceFiles .Where(file => file.EndsWith(keyWithDirChar, StringComparison.OrdinalIgnoreCase)) .Select(file => FileToSnippet(key, file, file)) .ToList(); if (snippetsForKey.Count != 0) { return true; } if (RelativeFile.Find(allFiles, targetDirectory, key, relativePath, linePath, out var path)) { snippetsForKey = SnippetsForFile(key, path); return true; } snippetsForKey = null!; return false; } List SnippetsForFile(string key, string relativeToRoot) => [FileToSnippet(key, relativeToRoot, null)]; bool GetForHttp(string key, out IReadOnlyList snippetsForKey) { var (success, path) = Downloader.DownloadFile(key).GetAwaiter().GetResult(); if (!success) { snippetsForKey = null!; return false; } snippetsForKey = SnippetsForFile(key, path!); return true; } Snippet FileToSnippet(string key, string file, string? path) { var (text, lineCount) = ReadNonStartEndLines(file); if (lineCount == 0) { lineCount++; } return Snippet.Build( startLine: 1, endLine: lineCount, value: text, key: key, language: FileSnippetExtractor.GetLanguageFromPath(file), path: path, expressiveCode: null); } (string text, int lineCount) ReadNonStartEndLines(string file) { var builder = StringBuilderCache.Acquire(); try { var lineCount = 0; foreach (var line in File.ReadLines(file)) { if (!StartEndTester.IsStartOrEnd(line.AsSpan().TrimStart())) { if (lineCount > 0) { builder.Append(newLine); } builder.Append(line); lineCount++; } } builder.TrimEnd(); var start = 0; while (start < builder.Length && char.IsWhiteSpace(builder[start])) { start++; } return (builder.ToString(start, builder.Length - start), lineCount); } finally { StringBuilderCache.Release(builder); } } } ================================================ FILE: src/MarkdownSnippets/Processing/MissingInclude.cs ================================================ namespace MarkdownSnippets; /// /// Part of . /// [DebuggerDisplay("Key={Key}, Line={LineNumber}")] public class MissingInclude { /// /// Initialise a new instance of . /// public MissingInclude(string key, int lineNumber, string? file) { Guard.AgainstNullAndEmpty(key, nameof(key)); Guard.AgainstNegativeAndZero(lineNumber, nameof(lineNumber)); Guard.AgainstEmpty(file, nameof(file)); Key = key; LineNumber = lineNumber; File = file; } /// /// The key of the missing include. /// public string Key { get; } /// /// The line number in the input text where the include was expected to be injected. /// public int LineNumber { get; } /// /// The File of the missing include. /// public string? File { get; } public override string ToString() { if (File == null) { return $""" MissingInclude. LineNumber: {LineNumber} Key: {Key} """; } return $""" MissingInclude. File: {File} LineNumber: {LineNumber} Key: {Key} """; } } ================================================ FILE: src/MarkdownSnippets/Processing/MissingSnippet.cs ================================================ namespace MarkdownSnippets; /// /// Part of . /// [DebuggerDisplay("Key={Key}, Line={LineNumber}")] public class MissingSnippet { /// /// Initialise a new instance of . /// public MissingSnippet(string key, int lineNumber, string? file) { Guard.AgainstNullAndEmpty(key, nameof(key)); Guard.AgainstNegativeAndZero(lineNumber, nameof(lineNumber)); Guard.AgainstEmpty(file, nameof(file)); Key = key; LineNumber = lineNumber; File = file; } /// /// The key of the missing snippet. /// public string Key { get; } /// /// The line number in the input text where the snippet was expected to be injected. /// public int LineNumber { get; } /// /// The File of the missing snippet. /// public string? File { get; } public override string ToString() { if (File == null) { return $""" MissingSnippet. LineNumber: {LineNumber} Key: {Key} """; } return $""" MissingSnippet. File: {File} LineNumber: {LineNumber} Key: {Key} """; } } ================================================ FILE: src/MarkdownSnippets/Processing/ProcessResult.cs ================================================ namespace MarkdownSnippets; /// /// The result of Apply methods. /// public class ProcessResult( IReadOnlyList usedSnippets, IReadOnlyList missingSnippets, IReadOnlyList usedIncludes, IReadOnlyList missingIncludes, IReadOnlyList validationErrors) : IEnumerable { /// /// List of all s that the markdown file used. /// public IReadOnlyList UsedSnippets { get; } = usedSnippets; /// /// List of all s that the markdown file used. /// public IReadOnlyList UsedIncludes { get; } = usedIncludes; /// /// Enumerates through the but will first throw an exception if there are any . /// public virtual IEnumerator GetEnumerator() { if (MissingSnippets.Count != 0) { throw new MissingSnippetsException(MissingSnippets); } if (MissingIncludes.Count != 0) { throw new MissingIncludesException(MissingIncludes); } if (ValidationErrors.Count != 0) { throw new ContentValidationException(ValidationErrors); } return UsedSnippets.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// /// List of all snippets that the markdown file expected but did not exist in the input snippets. /// public IReadOnlyList MissingSnippets { get; } = missingSnippets; /// /// List of all validation errors that the markdown file. /// public IReadOnlyList ValidationErrors { get; } = validationErrors; /// /// List of all includes that the markdown file expected but did not exist in the input includes. /// public IReadOnlyList MissingIncludes { get; } = missingIncludes; } ================================================ FILE: src/MarkdownSnippets/Processing/RelativeFile.cs ================================================ static class RelativeFile { static bool InnerFind(IReadOnlyList allFiles, string targetDirectory, string key, string? relativePath, string? linePath, out string path) { if (!key.Contains('.')) { path = null!; return false; } var relativeToRoot = Path.Combine(targetDirectory, key); if (File.Exists(relativeToRoot)) { path = relativeToRoot; return true; } var documentDirectory = Path.GetDirectoryName(relativePath); if (documentDirectory != null) { var relativeToDocument = Path.Combine(targetDirectory, documentDirectory.Trim('/', '\\'), key); if (File.Exists(relativeToDocument)) { path = relativeToDocument; return true; } } var lineDirectory = Path.GetDirectoryName(linePath); if (lineDirectory != null) { var relativeToLine = Path.Combine(lineDirectory, key); if (File.Exists(relativeToLine)) { path = relativeToLine; return true; } } if (File.Exists(key)) { path = Path.GetFullPath(key); return true; } var suffix = FileEx.PrependSlash(key); string? endWith = null; foreach (var file in allFiles) { if (file.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { endWith = file; break; } } if (endWith != null) { path = endWith; return true; } path = null!; return false; } public static bool Find( IReadOnlyList allFiles, string targetDirectory, string key, string? relativePath, string? linePath, [NotNullWhen(true)] out string? path) { if (!InnerFind(allFiles, targetDirectory, key, relativePath, linePath, out path)) { return false; } path = FileEx.FixFileCapitalization(path); return true; } } ================================================ FILE: src/MarkdownSnippets/Processing/SimpleSnippetMarkdownHandling.cs ================================================ namespace MarkdownSnippets; /// /// Simple markdown handling to be passed to . /// public static class SimpleSnippetMarkdownHandling { public static void Append(string key, IEnumerable snippets, Action appendLine) { foreach (var snippet in snippets) { WriteSnippet(appendLine, snippet); } } static void WriteSnippet(Action appendLine, Snippet snippet) { if (snippet.ExpressiveCode is null) { appendLine($"```{snippet.Language}"); } else { appendLine($"```{snippet.Language} {snippet.ExpressiveCode}"); } appendLine(snippet.Value); appendLine("```"); } } ================================================ FILE: src/MarkdownSnippets/Processing/SnippetKey.cs ================================================ static class SnippetKey { public static bool ExtractStartCommentSnippet(Line line, [NotNullWhen(true)] out string? key) { var lineCurrent = line.Current.AsSpan().TrimStart(); if (!IsStartCommentSnippetLine(lineCurrent)) { key = null; return false; } var substring = lineCurrent[14..]; var indexOf = substring.IndexOf("-->", StringComparison.Ordinal); if (indexOf < 0) { throw new SnippetException($"Could not find closing '-->' in: {line.Original}. Path: {line.Path}. Line: {line.LineNumber}"); } key = substring[..indexOf].Trim().ToString(); return true; } public static bool ExtractStartCommentWebSnippet(Line line, [NotNullWhen(true)] out string? url, [NotNullWhen(true)] out string? snippetKey) => ExtractStartCommentWebSnippet(line, out url, out snippetKey, out _); public static bool ExtractStartCommentWebSnippet(Line line, [NotNullWhen(true)] out string? url, [NotNullWhen(true)] out string? snippetKey, out string? viewUrl) { var lineCurrent = line.Current.AsSpan().TrimStart(); if (!IsStartCommentWebSnippetLine(lineCurrent)) { url = null; snippetKey = null; viewUrl = null; return false; } var substring = lineCurrent[18..]; // after "", StringComparison.Ordinal); if (indexOf < 0) { throw new SnippetException($"Could not find closing '-->' in: {line.Original}. Path: {line.Path}. Line: {line.LineNumber}"); } var value = substring[..indexOf].Trim(); // Check for optional second URL separated by whitespace var firstSpaceIndex = value.IndexOfAny([' ', '\t']); CharSpan firstPart; if (firstSpaceIndex >= 0) { firstPart = value[..firstSpaceIndex]; var secondPart = value[(firstSpaceIndex + 1)..].TrimStart(); var nextSpace = secondPart.IndexOfAny([' ', '\t']); viewUrl = (nextSpace >= 0 ? secondPart[..nextSpace] : secondPart).ToString(); } else { firstPart = value; viewUrl = null; } var hashIndex = firstPart.LastIndexOf('#'); if (hashIndex < 0 || hashIndex == firstPart.Length - 1) { url = null; snippetKey = null; viewUrl = null; return false; } url = firstPart[..hashIndex].ToString(); snippetKey = firstPart[(hashIndex + 1)..].ToString(); return true; } public static bool ExtractSnippet(Line line, [NotNullWhen(true)] out string? key) { var lineCurrent = line.Current.AsSpan().TrimStart(); if (!IsSnippetLine(lineCurrent)) { key = null; return false; } var keySpan = lineCurrent[8..].Trim(); if (keySpan.IsWhiteSpace()) { throw new SnippetException($"Could not parse snippet from: {line.Original}. Path: {line.Path}. Line: {line.LineNumber}"); } key = keySpan.ToString(); return true; } public static bool ExtractWebSnippet(Line line, [NotNullWhen(true)] out string? url, [NotNullWhen(true)] out string? snippetKey) => ExtractWebSnippet(line, out url, out snippetKey, out _); public static bool ExtractWebSnippet(Line line, [NotNullWhen(true)] out string? url, [NotNullWhen(true)] out string? snippetKey, out string? viewUrl) { var lineCurrent = line.Current.AsSpan().TrimStart(); if (!IsWebSnippetLine(lineCurrent)) { url = null; snippetKey = null; viewUrl = null; return false; } var value = lineCurrent[12..].Trim(); // after 'web-snippet:' // Check for optional second URL separated by whitespace var firstSpaceIndex = value.IndexOfAny([' ', '\t']); CharSpan firstPart; if (firstSpaceIndex >= 0) { firstPart = value[..firstSpaceIndex]; var secondPart = value[(firstSpaceIndex + 1)..].TrimStart(); var nextSpace = secondPart.IndexOfAny([' ', '\t']); viewUrl = (nextSpace >= 0 ? secondPart[..nextSpace] : secondPart).ToString(); } else { firstPart = value; viewUrl = null; } var hashIndex = firstPart.LastIndexOf('#'); if (hashIndex < 0 || hashIndex == firstPart.Length - 1) { url = null; snippetKey = null; viewUrl = null; return false; } url = firstPart[..hashIndex].ToString(); snippetKey = firstPart[(hashIndex + 1)..].ToString(); return true; } public static bool IsSnippetLine(string line) => IsSnippetLine(line.AsSpan()); public static bool IsSnippetLine(CharSpan line) => line.StartsWith("snippet:", StringComparison.OrdinalIgnoreCase); public static bool IsStartCommentSnippetLine(string line) => IsStartCommentSnippetLine(line.AsSpan()); public static bool IsStartCommentSnippetLine(CharSpan line) => line.StartsWith(""); builder.Append(newLine); builder.Append("## Contents"); builder.Append(newLine); builder.Append(newLine); const int startingLevel = 2; var headerDepth = level + startingLevel - 1; var headingCount = 0; foreach (var headerLine in headerLines) { var current = headerLine.Current; var trimmedHash = current.TrimStart('#'); if (!trimmedHash.StartsWith(' ')) { continue; } var headerLevel = current.Length - trimmedHash.Length; if (headerLevel == 1) { continue; } if (headerLevel > headerDepth) { continue; } var title = GetTitle(trimmedHash); if (excludesSet.Contains(title)) { continue; } headingCount++; builder.Append(' ', (headerLevel - 1) * 2); builder.Append("* ["); builder.Append(title); builder.Append("](#"); BuildLink(builder, processed, title); builder.Append(')'); builder.Append(newLine); } if (headingCount == 0) { return $"{newLine}"; } builder.TrimEnd(); builder.Append(""); return builder.ToString(); } static string GetTitle(string current) { var trim = current[1..].Trim(); return Markdown.StripMarkdown(trim); } static void BuildLink(StringBuilder builder, Dictionary processed, string title) { processed.TryGetValue(title, out var processedCount); processed[title] = processedCount + 1; SanitizeLink(builder, title); if (processedCount > 0) { builder.Append('-'); builder.Append(processedCount); } } internal static void SanitizeLink(StringBuilder builder, CharSpan title) { foreach (var ch in title) { if (char.IsLetterOrDigit(ch) || ch is '-' or '_') { builder.Append(char.ToLowerInvariant(ch)); } else if (char.IsWhiteSpace(ch)) { builder.Append('-'); } } } } ================================================ FILE: src/MarkdownSnippets/Processing/ValidationError.cs ================================================ namespace MarkdownSnippets; /// /// Part of . /// [DebuggerDisplay("Error={Error}, Line={Line}:{Column}")] public class ValidationError { /// /// Initialise a new instance of . /// public ValidationError(string error, int line, int column, string? file) { Guard.AgainstNullAndEmpty(error, nameof(error)); Guard.AgainstNegativeAndZero(line, nameof(line)); Guard.AgainstNegative(column, nameof(column)); Guard.AgainstEmpty(file, nameof(file)); Error = error; Line = line; Column = column; File = file; } /// /// The error. /// public string Error { get; } /// /// The line number in the input text. /// public int Line { get; } /// /// The column number in the line. /// public int Column { get; } /// /// The File. /// public string? File { get; } public override string ToString() { if (File == null) { return $""" ContentError. Line: {Line} Column: {Column} Error: {Error} """; } return $""" ContentError. File: {File} Line: {Line} Column: {Column} Error: {Error} """; } } ================================================ FILE: src/MarkdownSnippets/Reading/EndFunc.cs ================================================ delegate bool EndFunc(CharSpan line); ================================================ FILE: src/MarkdownSnippets/Reading/Exclusions/DefaultDirectoryExclusions.cs ================================================ namespace MarkdownSnippets; public static class DefaultDirectoryExclusions { public static bool ShouldExcludeDirectory(string path) { var suffix = Path .GetFileName(path) .ToLowerInvariant(); if (suffix is // source control ".git" or // ide temp files ".vs" or ".vscode" or ".idea" or // package cache "packages" or "node_modules" or // build output "dist" or ".angular" or "bin" or "obj") { return true; } var directory = new DirectoryInfo(path); return directory.Attributes.HasFlag(FileAttributes.Hidden); } } ================================================ FILE: src/MarkdownSnippets/Reading/Exclusions/SnippetFileExclusions.cs ================================================ namespace MarkdownSnippets; public static class SnippetFileExclusions { public static bool IsBinary(string extension) => binaryFileExtensionsFrozen.Contains(extension); public static bool CanContainCommentsExtension(string extension) => !noAcceptCommentsExtensionsFrozen.Contains(extension); static FrozenSet noAcceptCommentsExtensionsFrozen = FrozenSet.Create( StringComparer.OrdinalIgnoreCase, source: [ //files that dont accept comments hence cant contain snippets #region NoAcceptCommentsExtensions "DotSettings", "csv", "json", "geojson", "sln" #endregion ]); public static void AddNoAcceptCommentsExtensions(params string[] extensions) { var set = new HashSet(noAcceptCommentsExtensionsFrozen, StringComparer.OrdinalIgnoreCase); foreach (var extension in extensions) { set.Add(extension); } noAcceptCommentsExtensionsFrozen = set.ToFrozenSet(StringComparer.OrdinalIgnoreCase); } public static void RemoveNoAcceptCommentsExtensions(params string[] extensions) { var set = new HashSet(noAcceptCommentsExtensionsFrozen, StringComparer.OrdinalIgnoreCase); foreach (var extension in extensions) { set.Remove(extension); } noAcceptCommentsExtensionsFrozen = set.ToFrozenSet(StringComparer.OrdinalIgnoreCase); } static FrozenSet binaryFileExtensionsFrozen = FrozenSet.Create( StringComparer.OrdinalIgnoreCase, source: [ #region BinaryFileExtensions "user", // extra binary "mdb", "binlog", "shp", "dbf", "shx", "pbf", "map", "sbn", //from https://github.com/sindresorhus/binary-extensions/blob/master/binary-extensions.json "3dm", "3ds", "3g2", "3gp", "7z", "a", "aac", "adp", "ai", "aif", "aiff", "alz", "ape", "apk", "appimage", "ar", "arj", "asf", "au", "avi", "bak", "baml", "bh", "bin", "bk", "bmp", "btif", "bz2", "bzip2", "cab", "caf", "cgm", "class", "cmx", "cpio", "cr2", "cur", "dat", "dcm", "deb", "dex", "djvu", "dll", "dmg", "dng", "doc", "docm", "docx", "dot", "dotm", "dra", "DS_Store", "dsk", "dts", "dtshd", "dvb", "dwg", "dxf", "ecelp4800", "ecelp7470", "ecelp9600", "egg", "eol", "eot", "epub", "exe", "f4v", "fbs", "fh", "fla", "flac", "flatpak", "fli", "flv", "fpx", "fst", "fvt", "g3", "gh", "gif", "graffle", "gz", "gzip", "h261", "h263", "h264", "icns", "ico", "ief", "img", "ipa", "iso", "jar", "jpeg", "jpg", "jpgv", "jpm", "jxr", "key", "ktx", "lha", "lib", "lvp", "lz", "lzh", "lzma", "lzo", "m3u", "m4a", "m4v", "mar", "mdi", "mht", "mid", "midi", "mj2", "mka", "mkv", "mmr", "mng", "mobi", "mov", "movie", "mp3", "mp4", "mp4a", "mpeg", "mpg", "mpga", "mxu", "nef", "npx", "numbers", "nupkg", "o", "oga", "ogg", "ogv", "otf", "pages", "pbm", "pcx", "pdb", "pdf", "pea", "pgm", "pic", "png", "pnm", "pot", "potm", "potx", "ppa", "ppam", "ppm", "pps", "ppsm", "ppsx", "ppt", "pptm", "pptx", "psd", "pya", "pyc", "pyo", "pyv", "qt", "rar", "ras", "raw", "resources", "rgb", "rip", "rlc", "rmf", "rmvb", "rpm", "rtf", "rz", "s3m", "s7z", "scpt", "sgi", "shar", "snap", "sil", "sketch", "slk", "smv", "snk", "so", "stl", "suo", "sub", "swf", "tar", "tbz", "tbz2", "tga", "tgz", "thmx", "tif", "tiff", "tlz", "ttc", "ttf", "txz", "udf", "uvh", "uvi", "uvm", "uvp", "uvs", "uvu", "viv", "vob", "war", "wav", "wax", "wbmp", "wdp", "weba", "webm", "webp", "whl", "wim", "wm", "wma", "wmv", "wmx", "woff", "woff2", "wrm", "wvx", "xbm", "xif", "xla", "xlam", "xls", "xlsb", "xlsm", "xlsx", "xlt", "xltm", "xltx", "xm", "xmind", "xpi", "xpm", "xwd", "xz", "z", "zip", "zipx" #endregion ]); public static void AddBinaryFileExtensions(params string[] extensions) { var set = new HashSet(binaryFileExtensionsFrozen, StringComparer.OrdinalIgnoreCase); foreach (var extension in extensions) { set.Add(extension); } binaryFileExtensionsFrozen = set.ToFrozenSet(StringComparer.OrdinalIgnoreCase); } public static void RemoveBinaryFileExtensions(params string[] extensions) { var set = new HashSet(binaryFileExtensionsFrozen, StringComparer.OrdinalIgnoreCase); foreach (var extension in extensions) { set.Remove(extension); } binaryFileExtensionsFrozen = set.ToFrozenSet(StringComparer.OrdinalIgnoreCase); } } ================================================ FILE: src/MarkdownSnippets/Reading/FileFinder.cs ================================================ class FileFinder( string targetDirectory, DocumentConvention convention, ShouldIncludeDirectory directoryIncludes, ShouldIncludeDirectory markdownDirectoryIncludes, ShouldIncludeDirectory snippetDirectoryIncludes, ShouldIncludeFile? snippetFileIncludes = null) { List snippetFiles = []; List mdFiles = []; List allFiles = []; List includeFiles = []; public (List snippetFiles, List mdFiles, List includeFiles, List allFiles) FindFiles() { ProcessFiles(targetDirectory); foreach (var subDirectory in Directory.EnumerateDirectories(targetDirectory) .Where(path => directoryIncludes(path))) { FindFiles(subDirectory); } snippetFiles.Sort(StringComparer.Ordinal); mdFiles.Sort(StringComparer.Ordinal); includeFiles.Sort(StringComparer.Ordinal); allFiles.Sort(StringComparer.Ordinal); return (snippetFiles, mdFiles, includeFiles, allFiles); } void FindFiles(string directory) { ProcessFiles(directory); foreach (var subDirectory in Directory.EnumerateDirectories(directory) .Where(path => directoryIncludes(path))) { FindFiles(subDirectory); } } void ProcessFiles(string directory) { var scanForMarkdown = markdownDirectoryIncludes(directory); var scanForSnippets = snippetDirectoryIncludes(directory); if (scanForSnippets && scanForMarkdown) { foreach (var file in EnumerateFiles(directory)) { allFiles.Add(file); if (file.IsMdFile()) { ProcessMarkdown(file); continue; } if (IncludeAsSnippet(file)) { snippetFiles.Add(file); } } return; } if (scanForSnippets) { foreach (var file in EnumerateFiles(directory) .Where(_ => !_.IsMdFile())) { allFiles.Add(file); if (IncludeAsSnippet(file)) { snippetFiles.Add(file); } } return; } if (scanForMarkdown) { foreach (var file in EnumerateFiles(directory) .Where(Paths.IsMdFile)) { allFiles.Add(file); ProcessMarkdown(file); } } } bool IncludeAsSnippet(string file) => snippetFileIncludes == null || snippetFileIncludes(file); static IEnumerable EnumerateFiles(string directory) => Directory.EnumerateFiles(directory) .Where(ShouldInclude); void ProcessMarkdown(string file) { if (file.IsIncludeMdFile()) { includeFiles.Add(file); return; } if (convention != DocumentConvention.SourceTransform) { mdFiles.Add(file); return; } if (file.IsSourceMdFile()) { mdFiles.Add(file); } } static bool ShouldInclude(string file) { var extension = FileSnippetExtractor.GetLanguageFromPath(file); if (extension == string.Empty) { return false; } return !SnippetFileExclusions.IsBinary(extension); } } ================================================ FILE: src/MarkdownSnippets/Reading/FileSnippetExtractor.cs ================================================ namespace MarkdownSnippets; /// /// Extracts s from a given input. /// public static class FileSnippetExtractor { /// /// Each url will be accessible using the file name as a key. Any snippets within the files will be extracted and accessible as individual keyed snippets. /// public static Task AppendUrlAsSnippet(this ICollection snippets, string url) { Guard.AgainstNullAndEmpty(url, nameof(url)); return AppendUrlAsSnippet(snippets, url, Path.GetFileName(url).ToLowerInvariant()); } /// /// Each url will be accessible using the file name as a key. Any snippets within the files will be extracted and accessible as individual keyed snippets. /// public static Task AppendUrlsAsSnippets(this ICollection snippets, params string[] urls) => snippets.AppendUrlsAsSnippets((IEnumerable) urls); /// /// Each url will be accessible using the file name as a key. Any snippets within the files will be extracted and accessible as individual keyed snippets. /// public static async Task AppendUrlsAsSnippets(this ICollection snippets, IEnumerable urls) { foreach (var url in urls) { await snippets.AppendUrlAsSnippet(url); } } /// /// The url will be accessible using the file name as a key. Any snippets within the file will be extracted and accessible as individual keyed snippets. /// public static async Task AppendUrlAsSnippet(ICollection snippets, string url, string key) { Guard.AgainstNullAndEmpty(url, nameof(url)); var (success, content) = await Downloader.DownloadContent(url); if (!success) { throw new SnippetException($"Unable to get UrlAsSnippet: {url}"); } var snippet = Snippet.Build(1, content!.LineCount(), content!, key, GetLanguageFromPath(url), url, null); snippets.Add(snippet); using var reader = new StringReader(content!); foreach (var innerSnippet in Read(reader, url)) { snippets.Add(innerSnippet); } } public static void AppendFileAsSnippet(this ICollection snippets, string filePath) { Guard.FileExists(filePath, nameof(filePath)); AppendFileAsSnippet(snippets, filePath, Path.GetFileName(filePath).ToLowerInvariant()); } public static void AppendFilesAsSnippets(this ICollection snippets, params string[] filePaths) { foreach (var filePath in filePaths) { snippets.AppendFileAsSnippet(filePath); } } public static void AppendFileAsSnippet(ICollection snippets, string filePath, string key) { Guard.FileExists(filePath, nameof(filePath)); var text = File.ReadAllText(filePath); var snippet = Snippet.Build(1, text.LineCount(), text, key, GetLanguageFromPath(filePath), filePath, null); snippets.Add(snippet); } /// /// Read from paths. /// /// The paths to extract s from. /// Controls the maximum character width for snippets. Must be positive. /// The string to use as a line separator in snippets. public static IEnumerable Read(IEnumerable paths, int maxWidth = int.MaxValue, string newLine = "\n") => paths .Where(_ => SnippetFileExclusions.CanContainCommentsExtension(GetLanguageFromPath(_))) .SelectMany(path => Read(path, maxWidth, newLine)); /// /// Read from a path. /// /// The current path to extract s from. /// Controls the maximum character width for snippets. Must be positive. /// The string to use as a line separator in snippets. public static IEnumerable Read(string path, int maxWidth = int.MaxValue, string newLine = "\n") { Guard.AgainstNegativeAndZero(maxWidth, nameof(maxWidth)); Guard.AgainstNullAndEmpty(path, nameof(path)); if (!File.Exists(path)) { return []; } using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); return Read(reader, path, maxWidth, newLine).ToList(); } /// /// Read from a . /// /// The to read from. /// The current path being used to extract s from. Only used for logging purposes in this overload. /// Controls the maximum character width for snippets. Must be positive. /// The string to use as a line separator in snippets. public static IEnumerable Read(TextReader textReader, string path, int maxWidth = int.MaxValue, string newLine = "\n") { Guard.AgainstNegativeAndZero(maxWidth, nameof(maxWidth)); Guard.AgainstNullAndEmpty(path, nameof(path)); return GetSnippets(textReader, path, maxWidth, newLine); } public static string GetLanguageFromPath(string path) { var extension = Path.GetExtension(path); // ReSharper disable once ConstantConditionalAccessQualifier var s = extension?.TrimStart('.'); return s?.ToLowerInvariant() ?? string.Empty; } static IEnumerable GetSnippets(TextReader stringReader, string path, int maxWidth, string newLine) { var language = GetLanguageFromPath(path); var loopStack = new LoopStack(); var index = 0; while (true) { index++; var line = stringReader.ReadLine(); if (line == null) { if (loopStack.IsInSnippet) { var current = loopStack.Current; yield return Snippet.BuildError( error: "Snippet was not closed", path: path, lineNumberInError: current.StartLine + 1, key: current.Key); } break; } var trimmedLine = line.AsSpan().Trim(); if (StartEndTester.IsStart(trimmedLine, path, out var key, out var endFunc, out var expressive, out var languageOverride)) { loopStack.Push(endFunc, key, index, maxWidth, newLine, expressive, languageOverride); continue; } if (!loopStack.IsInSnippet) { continue; } if (!loopStack.Current.EndFunc(trimmedLine)) { Snippet? error = null; try { loopStack.AppendLine(line); } catch (LineTooLongException exception) { var current = loopStack.Current; error = Snippet.BuildError( error: "Line too long: " + exception.Line, path: path, lineNumberInError: current.StartLine + 1, key: current.Key); } if (error != null) { yield return error; break; } continue; } yield return BuildSnippet(path, loopStack, language, index); loopStack.Pop(); } } static Snippet BuildSnippet(string path, LoopStack loopStack, string language, int index) { var loopState = loopStack.Current; var value = loopState.GetLines(); return Snippet.Build( startLine: loopState.StartLine, endLine: index, key: loopState.Key, value: value, path: path, language: loopState.Language ?? language, expressiveCode: loopState.ExpressiveCode ); } } ================================================ FILE: src/MarkdownSnippets/Reading/IContent.cs ================================================ namespace MarkdownSnippets; public interface IContent { string? Path { get; } } ================================================ FILE: src/MarkdownSnippets/Reading/Include.cs ================================================ namespace MarkdownSnippets; [DebuggerDisplay("Key={Key}, Path={Path}, Error={Error}")] public class Include : IContent { /// /// Initialise a new instance of an in-error . /// public static Include BuildError(string key, int lineNumberInError, string path, string error) { Guard.AgainstNegativeAndZero(lineNumberInError, nameof(lineNumberInError)); Guard.AgainstNullAndEmpty(key, nameof(key)); Guard.AgainstNullAndEmpty(error, nameof(error)); return new() { Key = key, IsInError = true, Path = path, Error = error }; } /// /// Initialise a new instance of . /// public static Include Build(string key, IReadOnlyList lines, string? path) { Guard.AgainstNullAndEmpty(key, nameof(key)); Guard.AgainstEmpty(path, nameof(path)); return new() { lines = lines, Key = key, Path = path, Error = "" }; } public string Error { get; private init; } = null!; public bool IsInError { get; private init; } /// /// The key used to identify the snippet. /// public string Key { get; private init; } = null!; /// /// The path the snippet was read from. /// public string? Path { get; private init; } public IReadOnlyList Lines { get { ThrowIfIsInError(); return lines!; } } IReadOnlyList? lines; void ThrowIfIsInError() { if (IsInError) { throw new SnippetReadingException($"Cannot access when {nameof(IsInError)}. Key: {Key}. Path: {Path}. Error: {Error}"); } } public override string ToString() => $""" ReadInclude. Key: {Key} Path: {Path} Error: {Error} """; } ================================================ FILE: src/MarkdownSnippets/Reading/LineTooLongException.cs ================================================ class LineTooLongException(string line) : Exception { public string Line { get; } = line; } ================================================ FILE: src/MarkdownSnippets/Reading/LoopStack.cs ================================================ [DebuggerDisplay("Depth={stack.Count}, IsInSnippet={IsInSnippet}")] class LoopStack { public bool IsInSnippet => stack.Count > 0; public LoopState Current => stack.Peek(); public void AppendLine(string line) { foreach (var state in stack) { state.AppendLine(line); } } public void Pop() => stack.Pop(); public void Push(EndFunc endFunc, CharSpan key, int startLine, int maxWidth, string newLine, CharSpan expressiveCode, CharSpan language) { var expressiveCodeString = expressiveCode.Length == 0 ? null : expressiveCode.ToString(); var languageString = language.Length == 0 ? null : language.ToString(); var state = new LoopState(key.ToString(), endFunc, startLine, maxWidth, newLine, expressiveCodeString, languageString); stack.Push(state); } Stack stack = []; } ================================================ FILE: src/MarkdownSnippets/Reading/LoopState.cs ================================================ [DebuggerDisplay("Key={Key}")] class LoopState(string key, EndFunc endFunc, int startLine, int maxWidth, string newLine, string? expressiveCode = null, string? language = null) { public string GetLines() { if (builder == null) { return string.Empty; } try { builder.TrimEnd(); return builder.ToString(); } finally { StringBuilderCache.Release(builder); } } public void AppendLine(string line) { builder ??= StringBuilderCache.Acquire(); if (builder.Length == 0) { if (line.IsWhiteSpace()) { return; } CheckWhiteSpace(line, ' '); CheckWhiteSpace(line, '\t'); } else { if (newlineCount < 2) { builder.Append(newLine); } } var paddingToRemove = line.LastIndexOfSequence(paddingChar, paddingLength); var lineLength = line.Length - paddingToRemove; if (lineLength > maxWidth) { throw new LineTooLongException(line.AsSpan(paddingToRemove, lineLength).ToString()); } builder.Append(line, paddingToRemove, lineLength); if (line.Length == 0) { newlineCount++; } else { newlineCount = 0; } } void CheckWhiteSpace(CharSpan line, char whiteSpace) { var c = line[0]; if (c != whiteSpace) { return; } paddingChar = whiteSpace; for (var index = 1; index < line.Length; index++) { paddingLength++; var ch = line[index]; if (ch != whiteSpace) { break; } } } StringBuilder? builder; public string Key { get; } = key; char paddingChar; int paddingLength; public EndFunc EndFunc { get; } = endFunc; public int StartLine { get; } = startLine; int newlineCount; public string? ExpressiveCode { get; } = expressiveCode; public string? Language { get; } = language; } ================================================ FILE: src/MarkdownSnippets/Reading/ReadSnippets.cs ================================================ namespace MarkdownSnippets; [DebuggerDisplay("Count={Snippets.Count}")] public class ReadSnippets : IEnumerable { public IReadOnlyList Snippets { get; } public IReadOnlyList Files { get; } public IReadOnlyDictionary> Lookup { get; } public IReadOnlyList SnippetsInError { get; } public ReadSnippets(IReadOnlyList snippets, IReadOnlyList files) { Snippets = snippets; Files = files; SnippetsInError = Snippets.Where(_ => _.IsInError).Distinct().ToList(); Lookup = Snippets.ToDictionary(); } /// /// Enumerates through the but will first throw an exception if there are any . /// public virtual IEnumerator GetEnumerator() { if (SnippetsInError.Count != 0) { throw new SnippetReadingException($"SnippetsInError: {string.Join(", ", SnippetsInError.Select(_ => _.Key))}"); } return Snippets.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } ================================================ FILE: src/MarkdownSnippets/Reading/ShouldIncludeDirectory.cs ================================================ namespace MarkdownSnippets; public delegate bool ShouldIncludeDirectory(string directoryPath); ================================================ FILE: src/MarkdownSnippets/Reading/ShouldIncludeFile.cs ================================================ namespace MarkdownSnippets; public delegate bool ShouldIncludeFile(string filePath); ================================================ FILE: src/MarkdownSnippets/Reading/Snippet.cs ================================================ namespace MarkdownSnippets; [DebuggerDisplay("Key={Key}, FileLocation={FileLocation}, Error={Error}")] public class Snippet : IContent { /// /// Initialise a new instance of an in-error . /// public static Snippet BuildError(string key, int lineNumberInError, string path, string error) { Guard.AgainstNegativeAndZero(lineNumberInError, nameof(lineNumberInError)); Guard.AgainstNullAndEmpty(key, nameof(key)); Guard.AgainstNullAndEmpty(error, nameof(error)); return new() { Key = key, StartLine = lineNumberInError, EndLine = lineNumberInError, IsInError = true, Path = path, Error = error }; } /// /// Initialise a new instance of . /// public static Snippet Build(int startLine, int endLine, string value, string key, string language, string? path, string? expressiveCode) { Guard.AgainstNullAndEmpty(key, nameof(key)); Guard.AgainstEmpty(path, nameof(path)); Guard.AgainstEmpty(expressiveCode, nameof(expressiveCode)); Guard.AgainstUpperCase(language, nameof(language)); if (language.StartsWith('.')) { throw new ArgumentException("Language cannot start with '.'", nameof(language)); } Guard.AgainstNegativeAndZero(startLine, nameof(startLine)); Guard.AgainstNegativeAndZero(endLine, nameof(endLine)); return new() { StartLine = startLine, EndLine = endLine, value = value, Key = key, language = language, Path = path, ExpressiveCode = expressiveCode, Error = "", ViewUrl = null }; } /// /// Initialise a new instance of with an optional view URL. /// public static Snippet Build(int startLine, int endLine, string value, string key, string language, string? path, string? expressiveCode, string? viewUrl) { Guard.AgainstNullAndEmpty(key, nameof(key)); Guard.AgainstEmpty(path, nameof(path)); Guard.AgainstEmpty(expressiveCode, nameof(expressiveCode)); Guard.AgainstEmpty(viewUrl, nameof(viewUrl)); Guard.AgainstUpperCase(language, nameof(language)); if (language.StartsWith('.')) { throw new ArgumentException("Language cannot start with '.'", nameof(language)); } Guard.AgainstNegativeAndZero(startLine, nameof(startLine)); Guard.AgainstNegativeAndZero(endLine, nameof(endLine)); return new() { StartLine = startLine, EndLine = endLine, value = value, Key = key, language = language, Path = path, ExpressiveCode = expressiveCode, Error = "", ViewUrl = viewUrl }; } public string Error { get; private init; } = null!; public bool IsInError { get; private init; } /// /// The key used to identify the snippet. /// public string Key { get; private init; } = null!; /// /// An associated expressive code block with the snippet /// See https://expressive-code.com/ /// public string? ExpressiveCode { get; private init; } /// /// Optional URL to use for viewing the snippet source (for web-snippets). /// public string? ViewUrl { get; private init; } /// /// The language of the snippet, extracted from the file extension of the input file. /// public string Language { get { ThrowIfIsInError(); return language!; } } string? language; /// /// The path the snippet was read from. /// public string? Path { get; private init; } /// /// The line the snippets started on. /// public int StartLine { get; private init; } /// /// The line the snippet ended on. /// public int EndLine { get; private init; } /// /// The , , and concatenated. /// public string? FileLocation { get { if (Path == null) { return null; } return $"{Path}({StartLine}-{EndLine})"; } } public string Value { get { ThrowIfIsInError(); return value!; } } string? value; void ThrowIfIsInError() { if (IsInError) { throw new SnippetReadingException($"Cannot access when {nameof(IsInError)}. Key: {Key}. FileLocation: {FileLocation}. Error: {Error}"); } } public override string ToString() => $""" ReadSnippet. Key: {Key} FileLocation: {FileLocation} Error: {Error} """; } ================================================ FILE: src/MarkdownSnippets/Reading/StartEndTester.cs ================================================ static class StartEndTester { internal static bool IsStartOrEnd(CharSpan line) { var trimmedLine = line.Trim(); return IsBeginSnippet(trimmedLine) || IsEndSnippet(trimmedLine) || IsStartRegion(trimmedLine) || IsEndRegion(trimmedLine); } internal static bool IsStart( CharSpan trimmedLine, CharSpan path, out CharSpan currentKey, [NotNullWhen(true)] out EndFunc? endFunc, out CharSpan expressiveCode, out CharSpan language) { if (IsBeginSnippet(trimmedLine, path, out currentKey, out expressiveCode, out language)) { endFunc = IsEndSnippet; return true; } if (IsStartRegion(trimmedLine, out currentKey)) { endFunc = IsEndRegion; // not supported for regions expressiveCode = null; language = null; return true; } expressiveCode = null; language = null; endFunc = throwFunc; return false; } static EndFunc throwFunc = _ => throw new("Do not use out func"); static bool IsEndRegion(CharSpan line) => line.StartsWith("#endregion", StringComparison.Ordinal); static bool IsEndSnippet(CharSpan line) => IndexOf(line, "end-snippet") >= 0; static bool IsStartRegion(CharSpan line) => line.StartsWith("#region ", StringComparison.Ordinal); internal static bool IsStartRegion( CharSpan line, out CharSpan key) { if (!line.StartsWith("#region ", StringComparison.Ordinal)) { key = null; return false; } var substring = line[8..].Trim(); if (substring.Contains(' ') || !KeyValidator.IsValidKey(substring)) { key = null; return false; } key = substring.ToString(); return true; } static bool IsBeginSnippet(CharSpan line) { var startIndex = IndexOf(line, "begin-snippet: "); return startIndex != -1; } internal static bool IsBeginSnippet( CharSpan line, CharSpan path, out CharSpan key, out CharSpan expressiveCode) => IsBeginSnippet(line, path, out key, out expressiveCode, out _); internal static bool IsBeginSnippet( CharSpan line, CharSpan path, out CharSpan key, out CharSpan expressiveCode, out CharSpan language) { expressiveCode = null; language = null; var beginSnippetIndex = IndexOf(line, "begin-snippet: "); if (beginSnippetIndex == -1) { key = null; return false; } var startIndex = beginSnippetIndex + 15; var substring = line .TrimBackCommentChars(startIndex); var startArgs = substring.IndexOf('('); if (startArgs == -1) { key = substring.Trim(); } else { substring = substring.Trim(); key = substring[..startArgs].Trim(); if (!substring.EndsWith(')')) { throw new SnippetReadingException( $""" ExpressiveCode must end with ')`. Key: {key} Path: {path} Line: {line} """); } var args = substring[(startArgs + 1)..^1].Trim(); args = ExtractLanguage(args, key, path, line, out language); expressiveCode = args; } if (key.Length == 0) { throw new SnippetReadingException( $""" No Key could be derived. Path: {path} Line: '{line}' """); } if (KeyValidator.IsValidKey(key)) { return true; } throw new SnippetReadingException( $""" Key cannot contain whitespace or start/end with symbols. Key: {key} Path: {path} Line: {line} """); } static CharSpan ExtractLanguage(CharSpan args, scoped CharSpan key, scoped CharSpan path, scoped CharSpan line, out CharSpan language) { language = null; if (!args.StartsWith("lang=", StringComparison.Ordinal)) { return args; } var rest = args[5..]; var end = rest.IndexOf(' '); CharSpan value; CharSpan remainder; if (end == -1) { value = rest; remainder = null; } else { value = rest[..end]; remainder = rest[(end + 1)..].Trim(); } if (value.Length == 0) { throw new SnippetReadingException( $""" lang= must have a value. Key: {key} Path: {path} Line: {line} """); } foreach (var c in value) { if (c is (< 'a' or > 'z') and (< '0' or > '9')) { throw new SnippetReadingException( $""" lang value must be lowercase alphanumeric. Key: {key} Value: {value.ToString()} Path: {path} Line: {line} """); } } language = value; return remainder; } static int IndexOf(CharSpan line, CharSpan value) { if (value.Length > line.Length) { return -1; } var charactersToScan = Math.Min(line.Length, value.Length + 10); return line[..charactersToScan].IndexOf(value, StringComparison.Ordinal); } } ================================================ FILE: src/MarkdownSnippets/SnippetException.cs ================================================ namespace MarkdownSnippets; public class SnippetException(string message) : Exception(message); ================================================ FILE: src/MarkdownSnippets/SnippetExtensions.cs ================================================ static class SnippetExtensions { public static Dictionary> ToDictionary(this IEnumerable value) => value .GroupBy(_ => _.Key) .ToDictionary( keySelector: _ => _.Key, elementSelector: _ => _.OrderBy(ScrubPath).ToReadonlyList()); static string? ScrubPath(Snippet snippet) { var path = snippet.Path; if (path == null) { return null; } var count = path.Count(_ => _ is '/' or '\\'); if (count == 0) { return path; } return string.Create(path.Length - count, path, (span, source) => { var index = 0; foreach (var ch in source) { if (ch is not ('/' or '\\')) { span[index++] = ch; } } }); } } ================================================ FILE: src/MarkdownSnippets/SnippetReadingException.cs ================================================ namespace MarkdownSnippets; public class SnippetReadingException(string message) : SnippetException(message); ================================================ FILE: src/MarkdownSnippets/StringBuilderCache.cs ================================================ static class StringBuilderCache { const int MAX_BUILDER_SIZE = 360; [ThreadStatic] static StringBuilder? CachedInstance; public static StringBuilder Acquire(int capacity = 16) { if (capacity <= MAX_BUILDER_SIZE) { var builder = CachedInstance; // Avoid StringBuilder block fragmentation by getting a new StringBuilder // when the requested size is larger than the current capacity if (capacity <= builder?.Capacity) { CachedInstance = null; builder.Clear(); return builder; } } return new(capacity); } public static void Release(StringBuilder builder) { if (builder.Capacity <= MAX_BUILDER_SIZE) { CachedInstance = builder; } } public static string GetStringAndRelease(StringBuilder builder) { var result = builder.ToString(); Release(builder); return result; } } ================================================ FILE: src/MarkdownSnippets.MsBuild/DocoTask.cs ================================================ using Microsoft.Build.Framework; using Task = Microsoft.Build.Utilities.Task; namespace MarkdownSnippets; public class DocoTask : Task, ICancelableTask { [Required] public string ProjectDirectory { get; set; } = null!; public bool? ReadOnly { get; set; } public bool? ValidateContent { get; set; } public bool? WriteHeader { get; set; } public string? Header { get; set; } public string? UrlPrefix { get; set; } public int? TocLevel { get; set; } public int? MaxWidth { get; set; } public LinkFormat? LinkFormat { get; set; } public DocumentConvention? Convention { get; set; } public List ExcludeDirs { get; set; } = []; public List ExcludeMarkdownDirs { get; set; } = []; public List ExcludeSnippetDirs { get; set; } = []; public List TocExcludes { get; set; } = []; public List UrlsAsSnippets { get; set; } = []; public bool? TreatMissingAsWarning { get; set; } public bool? OmitSnippetLinks { get; set; } public string? PackageOutputPath { get; set; } public override bool Execute() { var stopwatch = Stopwatch.StartNew(); var root = GitRepoDirectoryFinder.FindForDirectory(ProjectDirectory); if (!string.IsNullOrWhiteSpace(PackageOutputPath)) { var resolved = Path.GetFullPath(Path.Combine(ProjectDirectory, PackageOutputPath)); ExcludeDirs.Add(resolved); } var (fileConfig, configFilePath) = ConfigReader.Read(root); var configResult = ConfigDefaults.Convert( fileConfig, new() { ReadOnly = ReadOnly, ValidateContent = ValidateContent, WriteHeader = WriteHeader, Header = Header, UrlPrefix = UrlPrefix, LinkFormat = LinkFormat, Convention = Convention, ExcludeDirectories = ExcludeDirs, ExcludeMarkdownDirectories = ExcludeMarkdownDirs, ExcludeSnippetDirectories = ExcludeSnippetDirs, TocExcludes = TocExcludes, TocLevel = TocLevel, MaxWidth = MaxWidth, UrlsAsSnippets = UrlsAsSnippets, TreatMissingAsWarning = TreatMissingAsWarning, OmitSnippetLinks = OmitSnippetLinks, }); var message = LogBuilder.BuildConfigLogMessage(root, configResult, configFilePath); Log.LogMessage(message); var processor = new DirectoryMarkdownProcessor( root, directoryIncludes: ExcludeToFilterBuilder.ExcludesToFilter(configResult.ExcludeDirectories), markdownDirectoryIncludes: ExcludeToFilterBuilder.ExcludesToFilter(configResult.ExcludeMarkdownDirectories), snippetDirectoryIncludes: ExcludeToFilterBuilder.ExcludesToFilter(configResult.ExcludeSnippetDirectories), convention: configResult.Convention, log: _ => Log.LogMessage(_), writeHeader: configResult.WriteHeader, header: configResult.Header, readOnly: configResult.ReadOnly, linkFormat: configResult.LinkFormat, tocLevel: configResult.TocLevel, tocExcludes: configResult.TocExcludes, treatMissingAsWarning: configResult.TreatMissingAsWarning, maxWidth: configResult.MaxWidth, urlPrefix: configResult.UrlPrefix, validateContent: configResult.ValidateContent, omitSnippetLinks: configResult.OmitSnippetLinks); try { var urlsAsSnippets = configResult.UrlsAsSnippets; if (urlsAsSnippets != null && urlsAsSnippets.Count != 0) { var snippets = new List(); snippets.AppendUrlsAsSnippets(urlsAsSnippets).GetAwaiter().GetResult(); processor.AddSnippets(snippets); } var snippetsInError = processor.Snippets.Where(_ => _.IsInError).ToList(); if (snippetsInError.Count != 0) { foreach (var snippet in snippetsInError) { Log.LogFileError($"Snippet error: {snippet.Error}. Key: {snippet.Key}", snippet.Path, snippet.StartLine, 0); } return false; } processor.Run(); return true; } catch (MissingSnippetsException exception) { foreach (var missing in exception.Missing) { if (configResult.TreatMissingAsWarning) { Log.LogWarning($"MarkdownSnippets: Missing snippet: {missing.Key}", missing.File, missing.LineNumber, 0); } else { Log.LogFileError($"MarkdownSnippets: Missing snippet: {missing.Key}", missing.File, missing.LineNumber, 0); } } return configResult.TreatMissingAsWarning; } catch (MissingIncludesException exception) { foreach (var missing in exception.Missing) { if (configResult.TreatMissingAsWarning) { Log.LogWarning($"MarkdownSnippets: Missing include: {missing.Key}", missing.File, missing.LineNumber); } else { Log.LogFileError($"MarkdownSnippets: Missing include: {missing.Key}", missing.File, missing.LineNumber, 0); } } return configResult.TreatMissingAsWarning; } catch (ContentValidationException exception) { foreach (var error in exception.Errors) { //TODO: add column Log.LogFileError($"MarkdownSnippets: Content validation: {error.Error}", error.File, error.Line, error.Column); } return configResult.TreatMissingAsWarning; } catch (MarkdownProcessingException exception) { Log.LogFileError($"MarkdownSnippets: {exception.Message}", exception.File, exception.LineNumber, 0); return false; } catch (SnippetException exception) { Log.LogError($"MarkdownSnippets: {exception}"); return false; } finally { Log.LogMessageFromText($"Finished MarkdownSnippets {stopwatch.ElapsedMilliseconds}ms", MessageImportance.Normal); } } public void Cancel() { } } ================================================ FILE: src/MarkdownSnippets.MsBuild/LoggingHelper.cs ================================================ using Microsoft.Build.Utilities; static class LoggingHelper { public static void LogFileError(this TaskLoggingHelper loggingHelper, string message, string? file, int line, int column) => loggingHelper.LogError(null, null, null, file, line, column, 0, 0, message); } ================================================ FILE: src/MarkdownSnippets.MsBuild/MarkdownSnippets.MsBuild.csproj ================================================ netstandard2.0;net10.0 Extract code snippets from any language to be used when building documentation. true false false true true $(MSBuildThisFileDirectory)..\key.snk true task\$(TargetFramework) true build ================================================ FILE: src/MarkdownSnippets.MsBuild/MarkdownSnippets.MsBuild.targets ================================================  $(MSBuildThisFileDirectory)..\task\net10.0\MarkdownSnippets.MsBuild.dll $(MSBuildThisFileDirectory)..\task\netstandard2.0\MarkdownSnippets.MsBuild.dll ================================================ FILE: src/MarkdownSnippets.Tool/AssemblyInfo.cs ================================================ [assembly: InternalsVisibleTo("MarkdownSnippets.Tool.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100e191859fcd1deee68b96927c170783ced0c9a471a6424a0a011cfd31156a49dd73c4ad4a88b995fb918c0b43e0c005ef5fb72d53a328a64bde825cb5f2e4c53d66f69fcbb87d6737128b98e677a42091974b5f56093123a2dd6bc738af751b101d41c4f7a996e217b61967a3aa1ae7bc791d19c1cbeef47f0cdd20d288dff1a3")] ================================================ FILE: src/MarkdownSnippets.Tool/CommandLineException.cs ================================================ class CommandLineException(string message) : Exception(message); ================================================ FILE: src/MarkdownSnippets.Tool/CommandRunner.cs ================================================ static class CommandRunner { public static Task RunCommand(Invoke invoke, params string[] args) { if (args.Length == 1) { var firstArg = args[0]; if (!firstArg.StartsWith('-')) { return invoke(firstArg, new()); } } return Parser.Default.ParseArguments(args) .WithParsedAsync( options => { ValidateAndApplyDefaults(options); var input = new ConfigInput { ReadOnly = options.ReadOnly, ValidateContent = options.ValidateContent, WriteHeader = options.WriteHeader, Header = options.Header, UrlPrefix = options.UrlPrefix, LinkFormat = options.LinkFormat, TocLevel = options.TocLevel, MaxWidth = options.MaxWidth, ExcludeDirectories = options.ExcludeDirectories.ToList(), ExcludeMarkdownDirectories = options.ExcludeMarkdownDirectories.ToList(), ExcludeSnippetDirectories = options.ExcludeSnippetDirectories.ToList(), TocExcludes = options.TocExcludes.ToList(), UrlsAsSnippets = options.UrlsAsSnippets.ToList(), TreatMissingAsWarning = options.TreatMissingAsWarning, Convention = options.Convention, OmitSnippetLinks = options.OmitSnippetLinks }; return invoke(options.TargetDirectory!, input); }); } static void ValidateAndApplyDefaults(Options options) { if (options.Header != null && string.IsNullOrWhiteSpace(options.Header)) { throw new CommandLineException("Empty Header is not allowed."); } if (options.UrlPrefix != null && string.IsNullOrWhiteSpace(options.UrlPrefix)) { throw new CommandLineException("Empty UrlPrefix is not allowed."); } if (options.TargetDirectory == null) { options.TargetDirectory = Environment.CurrentDirectory; if (!GitRepoDirectoryFinder.IsInGitRepository(options.TargetDirectory)) { throw new CommandLineException($"The current directory does no exist with a .git repository. Pass in a target directory instead. Current directory: {options.TargetDirectory}"); } } else { if (!Directory.Exists(options.TargetDirectory)) { throw new CommandLineException("target-directory does not exist."); } options.TargetDirectory = Path.GetFullPath(options.TargetDirectory); } if (options.TocLevel <= 0) { throw new CommandLineException("toc-level must be positive."); } if (options.MaxWidth <= 0) { throw new CommandLineException("max-width must be positive."); } ValidateItems("exclude", options.ExcludeDirectories); ValidateItems("exclude-markdown-directories", options.ExcludeMarkdownDirectories); ValidateItems("exclude-snippet-directories", options.ExcludeSnippetDirectories); ValidateItems("toc-excludes", options.TocExcludes); ValidateItems("urls-as-snippets", options.UrlsAsSnippets); } static void ValidateItems(string name, IList items) { if (items.Distinct().Count() != items.Count) { throw new CommandLineException($"duplicates found in {name}."); } if (items.Any(string.IsNullOrWhiteSpace)) { throw new CommandLineException($"Empty items found in `{name}`."); } } } ================================================ FILE: src/MarkdownSnippets.Tool/GlobalUsings.cs ================================================ global using CommandLine; global using MarkdownSnippets; global using Polyfills; ================================================ FILE: src/MarkdownSnippets.Tool/Invoke.cs ================================================ public delegate Task Invoke(string directory, ConfigInput config); ================================================ FILE: src/MarkdownSnippets.Tool/MarkdownSnippets.Tool.csproj ================================================ Exe net10.0 mdsnippets mdsnippets MarkdownSnippets.Tool True .NET Core Global Tool for merging code snippets with markdown documents true LatestMajor ================================================ FILE: src/MarkdownSnippets.Tool/Options.cs ================================================ public class Options { [Option('t', "target-directory", Required = false, HelpText = "The target directory to run against. Optional. If no directory is passed the current directory will be used, but only if it exists with a git repository directory tree. If not an error is returned.")] public string? TargetDirectory { get; set; } [Option('e', "exclude-directories", Separator = ':', Required = false, HelpText = "Directories to be excluded. Optional. Colon ':' separated for multiple values.")] public IList ExcludeDirectories { get; set; } = null!; [Option("exclude-markdown-directories", Separator = ':', Required = false, HelpText = "Directories to be excluded from markdown searching. Optional. Colon ':' separated for multiple values.")] public IList ExcludeMarkdownDirectories { get; set; } = null!; [Option("exclude-snippet-directories", Separator = ':', Required = false, HelpText = "Directories to be excluded from snippet searching. Optional. Colon ':' separated for multiple values.")] public IList ExcludeSnippetDirectories { get; set; } = null!; [Option("toc-excludes", Separator = ':', Required = false, HelpText = "Headings to be excluded from table of contents. Optional. Colon ':' separated for multiple values.")] public IList TocExcludes { get; set; } = null!; [Option('u', "urls-as-snippets", Separator = ' ', Required = false, HelpText = """ Urls to files to be included as snippets. Optional. Space ' ' separated for multiple values. Each url will be accessible using the file name as a key. Any snippets within the files will be extracted and accessible as individual keyed snippets. """)] public IList UrlsAsSnippets { get; set; } = null!; [Option('r', "read-only", Required = false, HelpText = "Set resultant md files as read-only. Optional. Defaults to false.")] public bool? ReadOnly { get; set; } [Option('v', "validate-content", Required = false, HelpText = "Validate the content. Optional. Defaults to false.")] public bool? ValidateContent { get; set; } [Option("write-header", Required = false, HelpText = "Write a header at the top of each resultant md file. Optional. Defaults to true")] public bool? WriteHeader { get; set; } [Option("missing-as-warning", Required = false, HelpText = "The default behavior for a missing snippet/include is to log an error (or throw an exception). To change that behavior to a warning set TreatMissingAsWarning to true. Optional. Defaults to false")] public bool? TreatMissingAsWarning { get; set; } [Option("omit-snippet-links", Required = false, HelpText = "The default behavior snippet links is to have both an anchor and a link to the snippet source. Optional. Defaults to false")] public bool? OmitSnippetLinks { get; set; } [Option("header", Required = false, HelpText = """ The header to write. `{relativePath}` is replaced with the current .source.md file. Optional. Defaults to: """ + HeaderWriter.DefaultHeader)] public string? Header { get; set; } [Option("url-prefix", Required = false, HelpText = "The prefix to add to all the snippet URLs. Optional. Defaults to: null")] public string? UrlPrefix { get; set; } [Option('l', "link-format", Required = false, HelpText = "Controls the format of the link under each snippet. Optional. Supported values: GitHub, Tfs. Defaults to GitHub.")] public LinkFormat? LinkFormat { get; set; } [Option('c', "convention", Required = false, HelpText = "Controls the target document convention. Optional. Supported values: SourceTransform, InPlaceOverwrite. Defaults to SourceTransform.")] public DocumentConvention? Convention { get; set; } [Option("toc-level", Required = false, HelpText = "Controls how many header levels to write in the table of contents. Optional. Defaults to 2. Must be positive.")] public int? TocLevel { get; set; } [Option("max-width", Required = false, HelpText = "Controls the maximum character width for snippets. Optional. Defaults to ignore. Must be positive.")] public int? MaxWidth { get; set; } } ================================================ FILE: src/MarkdownSnippets.Tool/Program.cs ================================================ var stopwatch = Stopwatch.StartNew(); try { await CommandRunner.RunCommand(Inner, args); } catch (CommandLineException exception) { Console.WriteLine($"Failed: {exception.Message}"); Environment.Exit(1); } catch (ConfigurationException exception) { Console.WriteLine($"Failed: {exception.Message}"); Environment.Exit(1); } catch (SnippetException exception) { Console.WriteLine($"Failed: {exception.Message}"); Environment.Exit(1); } finally { Console.WriteLine($"Finished {stopwatch.ElapsedMilliseconds}ms"); } static async Task Inner(string targetDirectory, ConfigInput configInput) { targetDirectory = Path.GetFullPath(targetDirectory); var (fileConfig, configFilePath) = ConfigReader.Read(targetDirectory); var configResult = ConfigDefaults.Convert(fileConfig, configInput); var message = LogBuilder.BuildConfigLogMessage(targetDirectory, configResult, configFilePath); Console.WriteLine(message); var processor = new DirectoryMarkdownProcessor( targetDirectory, directoryIncludes: ExcludeToFilterBuilder.ExcludesToFilter(configResult.ExcludeDirectories), markdownDirectoryIncludes: ExcludeToFilterBuilder.ExcludesToFilter(configResult.ExcludeMarkdownDirectories), snippetDirectoryIncludes: ExcludeToFilterBuilder.ExcludesToFilter(configResult.ExcludeSnippetDirectories), convention: configResult.Convention, log: Console.WriteLine, writeHeader: configResult.WriteHeader, header: configResult.Header, readOnly: configResult.ReadOnly, linkFormat: configResult.LinkFormat, tocLevel: configResult.TocLevel, tocExcludes: configResult.TocExcludes, treatMissingAsWarning: configResult.TreatMissingAsWarning, maxWidth: configResult.MaxWidth, urlPrefix: configResult.UrlPrefix, validateContent: configResult.ValidateContent, omitSnippetLinks: configResult.OmitSnippetLinks, snippetFileIncludes: ExcludeToFilterBuilder.FileExcludesToFilter(configResult.ExcludeSnippetFiles)); var urlsAsSnippets = configResult.UrlsAsSnippets; if (urlsAsSnippets != null && urlsAsSnippets.Count != 0) { var snippets = new List(); await snippets.AppendUrlsAsSnippets(urlsAsSnippets); processor.AddSnippets(snippets); } processor.Run(); } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ConventionLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { Convention: InPlaceOverwrite } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ConventionShort.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { Convention: InPlaceOverwrite } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.Empty.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: {} } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ExcludeLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ExcludeDirectories: [ dir ] } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ExcludeMarkdownDirectoriesLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ExcludeMarkdownDirectories: [ dir ] } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ExcludeMultiple.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ExcludeDirectories: [ dir1, dir2 ] } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ExcludeShort.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ExcludeDirectories: [ dir ] } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ExcludeSnippetDirectoriesLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ExcludeSnippetDirectories: [ dir ] } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.Header.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { Header: the header } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.LinkFormatLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { LinkFormat: Tfs } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.LinkFormatShort.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { LinkFormat: Tfs } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.MaxWidthLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { MaxWidth: 5 } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.OmitSnippetLinks.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { OmitSnippetLinks: true } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ReadOnlyLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ReadOnly: false } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ReadOnlyShort.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ReadOnly: false } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.SingleUnNamedArg.verified.txt ================================================ { targetDirectory: dir, configInput: {} } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.TargetDirectoryLong.verified.txt ================================================ { targetDirectory: {ProjectDirectory}bin/, configInput: {} } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.TargetDirectoryShort.verified.txt ================================================ { targetDirectory: {ProjectDirectory}bin/, configInput: {} } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.TocLevelLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { TocLevel: 5 } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.UrlPrefix.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { UrlPrefix: the prefix } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.UrlsAsSnippetsLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { UrlsAsSnippets: [ url ] } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.UrlsAsSnippetsMultiple.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { UrlsAsSnippets: [ url1, url2 ] } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.UrlsAsSnippetsShort.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { UrlsAsSnippets: [ url ] } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ValidateContentLong.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ValidateContent: false } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.ValidateContentShort.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { ValidateContent: false } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.VerifyContentLong.verified.txt ================================================ ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.VerifyContentShort.verified.txt ================================================ ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.WriteHeader.verified.txt ================================================ { targetDirectory: {CurrentDirectory}, configInput: { WriteHeader: false } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/CommandRunnerTests.cs ================================================ public class CommandRunnerTests { string? targetDirectory; ConfigInput? configInput; [Fact] public async Task Empty() { await CommandRunner.RunCommand(Capture); await VerifyResult(); } [Fact] public async Task SingleUnNamedArg() { await CommandRunner.RunCommand(Capture, "dir"); await VerifyResult(); } [Fact] public async Task Header() { await CommandRunner.RunCommand(Capture, "--header", "the header"); await VerifyResult(); } [Fact] public async Task UrlPrefix() { await CommandRunner.RunCommand(Capture, "--url-prefix", "the prefix"); await VerifyResult(); } [Fact] public async Task WriteHeader() { await CommandRunner.RunCommand(Capture, "--write-header", "false"); await VerifyResult(); } [Fact] public async Task OmitSnippetLinks() { await CommandRunner.RunCommand(Capture, "--omit-snippet-links", "true"); await VerifyResult(); } [Fact] public async Task ValidateContentShort() { await CommandRunner.RunCommand(Capture, "-v", "false"); await VerifyResult(); } [Fact] public async Task ValidateContentLong() { await CommandRunner.RunCommand(Capture, "--validate-content", "false"); await VerifyResult(); } [Fact] public async Task ConventionShort() { await CommandRunner.RunCommand(Capture, "-c", "InPlaceOverwrite"); await VerifyResult(); } [Fact] public async Task ConventionLong() { await CommandRunner.RunCommand(Capture, "--convention", "InPlaceOverwrite"); await VerifyResult(); } [Fact] public async Task ReadOnlyShort() { await CommandRunner.RunCommand(Capture, "-r", "false"); await VerifyResult(); } [Fact] public async Task ReadOnlyLong() { await CommandRunner.RunCommand(Capture, "--read-only", "false"); await VerifyResult(); } [Fact] public async Task LinkFormatShort() { await CommandRunner.RunCommand(Capture, "-l", "tfs"); await VerifyResult(); } [Fact] public async Task LinkFormatLong() { await CommandRunner.RunCommand(Capture, "--link-format", "tfs"); await VerifyResult(); } [Fact] public async Task TargetDirectoryShort() { await CommandRunner.RunCommand(Capture, "-t", "../../"); await VerifyResult(); } [Fact] public async Task TargetDirectoryLong() { await CommandRunner.RunCommand(Capture, "--target-directory", "../../"); await VerifyResult(); } [Fact] public async Task MaxWidthLong() { await CommandRunner.RunCommand(Capture, "--max-width", "5"); await VerifyResult(); } [Fact] public async Task TocLevelLong() { await CommandRunner.RunCommand(Capture, "--toc-level", "5"); await VerifyResult(); } [Fact] public async Task ExcludeShort() { await CommandRunner.RunCommand(Capture, "-e", "dir"); await VerifyResult(); } [Fact] public async Task ExcludeMultiple() { await CommandRunner.RunCommand(Capture, "-e", "dir1:dir2"); await VerifyResult(); } [Fact] public Task ExcludeDuplicates() => Assert.ThrowsAsync(() => CommandRunner.RunCommand(Capture, "-e", "dir:dir")); [Fact] public Task ExcludeWhitespace() => Assert.ThrowsAsync(() => CommandRunner.RunCommand(Capture, "-e", ": :")); [Fact] public async Task ExcludeLong() { await CommandRunner.RunCommand(Capture, "--exclude-directories", "dir"); await VerifyResult(); } [Fact] public async Task ExcludeMarkdownDirectoriesLong() { await CommandRunner.RunCommand(Capture, "--exclude-markdown-directories", "dir"); await VerifyResult(); } [Fact] public async Task ExcludeSnippetDirectoriesLong() { await CommandRunner.RunCommand(Capture, "--exclude-snippet-directories", "dir"); await VerifyResult(); } [Fact] public async Task UrlsAsSnippetsShort() { await CommandRunner.RunCommand(Capture, "-u", "url"); await VerifyResult(); } [Fact] public async Task UrlsAsSnippetsMultiple() { await CommandRunner.RunCommand(Capture, "-u", "url1 url2"); await VerifyResult(); } [Fact] public Task UrlsAsSnippetsDuplicates() => Assert.ThrowsAsync(() => CommandRunner.RunCommand(Capture, "-u", "url url")); [Fact] public Task UrlsAsSnippetsWhitespace() => Assert.ThrowsAsync(() => CommandRunner.RunCommand(Capture, "-u", ": :")); [Fact] public async Task UrlsAsSnippetsLong() { await CommandRunner.RunCommand(Capture, "--urls-as-snippets", "url"); await VerifyResult(); } Task Capture(string targetDirectory, ConfigInput configInput) { this.targetDirectory = targetDirectory; this.configInput = configInput; return Task.CompletedTask; } Task VerifyResult() => Verify( new { targetDirectory, configInput }); } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/GlobalUsings.cs ================================================ global using MarkdownSnippets; global using VerifyTests.DiffPlex; ================================================ FILE: src/MarkdownSnippets.Tool.Tests/LogBuilderTests.BuildConfigLogMessage.DotNet10_0.verified.txt ================================================ Config: TargetDirectory: theRoot UrlPrefix: LinkFormat: Tfs Convention: InPlaceOverwrite TocLevel: 5 ValidateContent: False OmitSnippetLinks: False TreatMissingAsWarning: False FileConfigPath: theConfigFilePath (exists:False) MaxWidth: 80 ExcludeDirectories: Dir1 Dir2 ExcludeMarkdownDirectories: Dir3 Dir4 ExcludeSnippetDirectories: Dir5 Dir6 UrlsAsSnippets: Url1 Url2 ================================================ FILE: src/MarkdownSnippets.Tool.Tests/LogBuilderTests.BuildConfigLogMessage.DotNet9_0.verified.txt ================================================ Config: TargetDirectory: theRoot UrlPrefix: LinkFormat: Tfs Convention: InPlaceOverwrite TocLevel: 5 ValidateContent: False OmitSnippetLinks: False TreatMissingAsWarning: False FileConfigPath: theConfigFilePath (exists:False) MaxWidth: 80 ExcludeDirectories: Dir1 Dir2 ExcludeMarkdownDirectories: Dir3 Dir4 ExcludeSnippetDirectories: Dir5 Dir6 UrlsAsSnippets: Url1 Url2 TargetFramework: net9.0 ================================================ FILE: src/MarkdownSnippets.Tool.Tests/LogBuilderTests.BuildConfigLogMessageMinimal.DotNet10_0.verified.txt ================================================ Config: TargetDirectory: theRoot UrlPrefix: LinkFormat: GitHub Convention: SourceTransform TocLevel: 0 ValidateContent: False OmitSnippetLinks: False TreatMissingAsWarning: False FileConfigPath: theConfigFilePath (exists:False) ReadOnly: WriteHeader: Header: ================================================ FILE: src/MarkdownSnippets.Tool.Tests/LogBuilderTests.BuildConfigLogMessageMinimal.DotNet9_0.verified.txt ================================================ Config: TargetDirectory: theRoot UrlPrefix: LinkFormat: GitHub Convention: SourceTransform TocLevel: 0 ValidateContent: False OmitSnippetLinks: False TreatMissingAsWarning: False FileConfigPath: theConfigFilePath (exists:False) ReadOnly: WriteHeader: Header: TargetFramework: net9.0 ================================================ FILE: src/MarkdownSnippets.Tool.Tests/LogBuilderTests.BuildConfigLogMessageSourceTransform.DotNet10_0.verified.txt ================================================ Config: TargetDirectory: theRoot UrlPrefix: LinkFormat: Tfs Convention: SourceTransform TocLevel: 5 ValidateContent: False OmitSnippetLinks: False TreatMissingAsWarning: False FileConfigPath: theConfigFilePath (exists:False) ReadOnly: True WriteHeader: True Header: line1 line2 MaxWidth: 80 ExcludeDirectories: Dir1 Dir2 ExcludeMarkdownDirectories: Dir3 Dir4 ExcludeSnippetDirectories: Dir5 Dir6 UrlsAsSnippets: Url1 Url2 ================================================ FILE: src/MarkdownSnippets.Tool.Tests/LogBuilderTests.BuildConfigLogMessageSourceTransform.DotNet9_0.verified.txt ================================================ Config: TargetDirectory: theRoot UrlPrefix: LinkFormat: Tfs Convention: SourceTransform TocLevel: 5 ValidateContent: False OmitSnippetLinks: False TreatMissingAsWarning: False FileConfigPath: theConfigFilePath (exists:False) ReadOnly: True WriteHeader: True Header: line1 line2 MaxWidth: 80 ExcludeDirectories: Dir1 Dir2 ExcludeMarkdownDirectories: Dir3 Dir4 ExcludeSnippetDirectories: Dir5 Dir6 UrlsAsSnippets: Url1 Url2 TargetFramework: net9.0 ================================================ FILE: src/MarkdownSnippets.Tool.Tests/LogBuilderTests.cs ================================================ public class LogBuilderTests { [Fact] public Task BuildConfigLogMessage() { var config = new ConfigResult { WriteHeader = true, Header = """ line1 line2 """, ExcludeDirectories = ["Dir1", "Dir2"], ExcludeMarkdownDirectories = ["Dir3", "Dir4"], ExcludeSnippetDirectories = ["Dir5", "Dir6"], ReadOnly = true, LinkFormat = LinkFormat.Tfs, UrlsAsSnippets = ["Url1", "Url2"], TocLevel = 5, MaxWidth = 80, Convention = DocumentConvention.InPlaceOverwrite, }; var message = LogBuilder.BuildConfigLogMessage("theRoot", config, "theConfigFilePath"); return Verify(message) .UniqueForTargetFrameworkAndVersion(); } [Fact] public Task BuildConfigLogMessageSourceTransform() { var config = new ConfigResult { WriteHeader = true, Header = """ line1 line2 """, ExcludeDirectories = ["Dir1", "Dir2"], ExcludeMarkdownDirectories = ["Dir3", "Dir4"], ExcludeSnippetDirectories = ["Dir5", "Dir6"], ReadOnly = true, LinkFormat = LinkFormat.Tfs, UrlsAsSnippets = ["Url1", "Url2"], TocLevel = 5, MaxWidth = 80, Convention = DocumentConvention.SourceTransform, }; var message = LogBuilder.BuildConfigLogMessage("theRoot", config, "theConfigFilePath"); return Verify(message) .UniqueForTargetFrameworkAndVersion(); } [Fact] public Task BuildConfigLogMessageMinimal() { var config = new ConfigResult(); var message = LogBuilder.BuildConfigLogMessage("theRoot", config, "theConfigFilePath"); return Verify(message) .UniqueForTargetFrameworkAndVersion(); } } ================================================ FILE: src/MarkdownSnippets.Tool.Tests/MarkdownSnippets.Tool.Tests.csproj ================================================ net10.0 Exe $(NoWarn);xUnit1051 ================================================ FILE: src/MarkdownSnippets.Tool.Tests/ModuleInitializer.cs ================================================ public static class ModuleInitializer { [ModuleInitializer] public static void Initialize() { VerifyDiffPlex.Initialize(OutputType.Compact); VerifierSettings.IgnoreStackTrace(); VerifierSettings.AddScrubber(_ => _.Replace('\\', '/')); } } ================================================ FILE: src/MarkdownSnippets.slnx ================================================ ================================================ FILE: src/MarkdownSnippets.slnx.DotSettings ================================================  ..\Shared.sln.DotSettings True True 1 ================================================ FILE: src/Shared.sln.DotSettings ================================================  DO_NOT_SHOW False False Quiet True True True DO_NOT_SHOW ERROR ERROR ERROR WARNING ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR DO_NOT_SHOW DO_NOT_SHOW ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR C90+,E79+,S14+ ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR DO_NOT_SHOW *.received.* *.verified.* ERROR ERROR DO_NOT_SHOW ECMAScript 2016 <?xml version="1.0" encoding="utf-16"?><Profile name="c# Cleanup"><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><CSCodeStyleAttributes ArrangeVarStyle="True" ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeCodeBodyStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JSStringLiteralQuotesDescriptor>True</JSStringLiteralQuotesDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsInsertSemicolon>True</JsInsertSemicolon><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><HtmlReformatCode>True</HtmlReformatCode><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><IDEA_SETTINGS>&lt;profile version="1.0"&gt; &lt;option name="myName" value="c# Cleanup" /&gt; &lt;/profile&gt;</IDEA_SETTINGS><RIDER_SETTINGS>&lt;profile&gt; &lt;Language id="EditorConfig"&gt; &lt;Reformat&gt;false&lt;/Reformat&gt; &lt;/Language&gt; &lt;Language id="HTML"&gt; &lt;OptimizeImports&gt;false&lt;/OptimizeImports&gt; &lt;Reformat&gt;false&lt;/Reformat&gt; &lt;Rearrange&gt;false&lt;/Rearrange&gt; &lt;/Language&gt; &lt;Language id="JSON"&gt; &lt;Reformat&gt;false&lt;/Reformat&gt; &lt;/Language&gt; &lt;Language id="RELAX-NG"&gt; &lt;Reformat&gt;false&lt;/Reformat&gt; &lt;/Language&gt; &lt;Language id="XML"&gt; &lt;OptimizeImports&gt;false&lt;/OptimizeImports&gt; &lt;Reformat&gt;false&lt;/Reformat&gt; &lt;Rearrange&gt;false&lt;/Rearrange&gt; &lt;/Language&gt; &lt;/profile&gt;</RIDER_SETTINGS></Profile> ExpressionBody ExpressionBody ExpressionBody False NEVER NEVER False False False True False CHOP_ALWAYS False False RemoveIndent RemoveIndent False True True True True True ERROR DoNothing ================================================ FILE: src/Tests/ContentValidationTest.CheckInvalidWord.verified.txt ================================================ [ { Item1: Invalid word detected: 'you', Item2: 1 } ] ================================================ FILE: src/Tests/ContentValidationTest.CheckInvalidWordIndicatesAllViolationsInTheExceptionMessage.verified.txt ================================================ [ { Item1: No exclamation marks. If a statement is important make it bold. https://www.technicalcommunicationcenter.com/2011/12/30/the-discipline-of-punctuation-in-technical-writing/. , Item2: 20 }, { Item1: Invalid word detected: 'you', Item2: 1 }, { Item1: Invalid word detected: 'yourself', Item2: 27 } ] ================================================ FILE: src/Tests/ContentValidationTest.CheckInvalidWordIndicatesAllViolationsInTheExceptionMessageIgnoringCase.verified.txt ================================================ [ { Item1: No exclamation marks. If a statement is important make it bold. https://www.technicalcommunicationcenter.com/2011/12/30/the-discipline-of-punctuation-in-technical-writing/. , Item2: 20 }, { Item1: Invalid word detected: 'you', Item2: 1 }, { Item1: Invalid word detected: 'yourself', Item2: 27 }, { Item1: Invalid word detected: 'us', Item2: 37 } ] ================================================ FILE: src/Tests/ContentValidationTest.CheckInvalidWordSentenceEnd.verified.txt ================================================ [ { Item1: Invalid word detected: 'you', Item2: 1 } ] ================================================ FILE: src/Tests/ContentValidationTest.CheckInvalidWordSentenceStart.verified.txt ================================================ [ { Item1: Invalid word detected: 'you' } ] ================================================ FILE: src/Tests/ContentValidationTest.CheckInvalidWordStringEnd.verified.txt ================================================ [ { Item1: Invalid word detected: 'you', Item2: 4 } ] ================================================ FILE: src/Tests/ContentValidationTest.CheckInvalidWordWithComma.verified.txt ================================================ [ { Item1: Invalid word detected: 'you', Item2: 1 } ] ================================================ FILE: src/Tests/ContentValidationTest.CheckInvalidWordWithQuestionMark.verified.txt ================================================ [ { Item1: Invalid word detected: 'you', Item2: 1 } ] ================================================ FILE: src/Tests/ContentValidationTest.cs ================================================ public class ContentValidationTest { [Fact] public Task CheckInvalidWord() => Verify(ContentValidation.Verify(" you ")); [Fact] public Task CheckInvalidWordIndicatesAllViolationsInTheExceptionMessage() => Verify(ContentValidation.Verify(" you, and you again! Still yourself? ")); [Fact] public Task CheckInvalidWordIndicatesAllViolationsInTheExceptionMessageIgnoringCase() => Verify(ContentValidation.Verify(" you, and you again! Still Yourself? Us")); [Fact] public Task CheckInvalidWordWithQuestionMark() => Verify(ContentValidation.Verify(" you? ")); [Fact] public Task CheckInvalidWordWithComma() => Verify(ContentValidation.Verify(" you, ")); [Fact] public Task CheckInvalidWordSentenceEnd() => Verify(ContentValidation.Verify(" you. ")); [Fact] public Task CheckInvalidWordSentenceStart() => Verify(ContentValidation.Verify("you ")); [Fact] public Task CheckInvalidWordStringEnd() => Verify(ContentValidation.Verify("the you")); [Fact] public void CheckInvalidWordDoesNotThrowWhenNoMatch() => Assert.Empty(ContentValidation.Verify(" some random content which doesn't contain invalid words. ")); [Fact] public void CheckInvalidWordDoesNotThrowWhenIsQuote() => Assert.Empty(ContentValidation.Verify("> you ")); [Fact] public void CheckInvalidWordInUrl() { Assert.Empty(ContentValidation.Verify("some random content containing links /us/allowed/")); Assert.Empty(ContentValidation.Verify("some random content containing links /yourself/us/")); Assert.Empty(ContentValidation.Verify(" /us/ ")); Assert.Empty(ContentValidation.Verify("/us-")); } } ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/BinaryFileSnippet/one.source.md ================================================ snippet: sourceFile.dot ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/BinaryFileSnippet/sourceFile.dot ================================================ From Source File ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/Convention/mdsource/two.source.md ================================================ snippet: snippet2 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/Convention/one.source.md ================================================ snippet: snippet1 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ConventionWithNestedDir/mdsource/Nested/one.source.md ================================================ snippet: snippet1 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileInclude/Nested/fileToInclude3.txt ================================================ The include text 3 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileInclude/Nested/fileToInclude4.txt ================================================ The include text 4 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileInclude/Nested/fileToInclude5.txt ================================================ The include text 5 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileInclude/Nested/fileToInclude6.txt ================================================ The include text 6 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileInclude/fileToInclude1.txt ================================================ The include text 1 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileInclude/fileToInclude2.txt ================================================ The include text 2 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileInclude/one.source.md ================================================ include: fileToInclude1.txt include: /fileToInclude2.txt include: fileToInclude3.txt include: /fileToInclude4.txt include: Nested/fileToInclude5.txt include: /Nested/fileToInclude6.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileIncludeWithMergedSnippet/fileToInclude.txt ================================================ The include text ```.cs the code from snippet1 ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileIncludeWithMergedSnippet/one.source.md ================================================ some content include: fileToInclude.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileIncludeWithSnippetAtEnd/fileToInclude.txt ================================================ The include text snippet: snippet1 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ExplicitFileIncludeWithSnippetAtEnd/one.source.md ================================================ include: fileToInclude.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/FileSnippet/one.source.md ================================================ Local snippet: sourceFile.txt Relative Local snippet: ./sourceFile.txt Rooted snippet: /sourceFile.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/FileSnippet/sourceFile.txt ================================================ From Source File ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/FileSnippetMissing/one.source.md ================================================ snippet: missing.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/FileSnippetWithHash/one.source.md ================================================ Local snippet: source#File.txt Relative Local snippet: ./source#File.txt Rooted snippet: /source#File.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/FileSnippetWithHash/source#File.txt ================================================ From Source File ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/FileSnippetWithWhiteSpace/one.source.md ================================================ Local snippet: sourceFile.txt Relative Local snippet: ./sourceFile.txt Rooted snippet: /sourceFile.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/FileSnippetWithWhiteSpace/sourceFile.txt ================================================ From Source File ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteExists/file.md ================================================ ```.cs the code from snippet1 ``` anchor Bad text Bad Line 1 Bad Line 2 ``` Bad Code ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteExists/fileToInclude.txt ================================================ The include text ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteExists/includeWithCode.txt ================================================ ``` The Code ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteExists/multiLineFileToInclude.txt ================================================ Line 1 Line 2 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteExistsMdx/file.mdx ================================================ ```.cs the code from snippet1 ``` anchor Bad text Bad Line 1 Bad Line 2 ``` Bad Code ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteExistsMdx/fileToInclude.txt ================================================ The include text ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteExistsMdx/includeWithCode.txt ================================================ ``` The Code ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteExistsMdx/multiLineFileToInclude.txt ================================================ Line 1 Line 2 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteNotExists/file.md ================================================ snippet: snippet1 include: fileToInclude.txt include: multiLineFileToInclude.txt include: includeWithCode.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteNotExists/fileToInclude.txt ================================================ The include text ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteNotExists/includeWithCode.txt ================================================ ``` The Code ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteNotExists/multiLineFileToInclude.txt ================================================ Line 1 Line 2 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteUrlInclude/one.md ================================================ BAD BAD BAD ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteUrlSnippet/one.md ================================================ BAD ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/InPlaceOverwriteWithFileSnippetMissing/file.md ================================================ ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/Mdx/one.source.mdx ================================================ Local snippet: sourceFile.txt Relative Local snippet: ./sourceFile.txt Rooted snippet: /sourceFile.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/Mdx/sourceFile.txt ================================================ From Source File ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/MissingInclude/one.md ================================================ ```txt this is some text to import ``` snippet source | anchor ```txt this is some text to import ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/MissingInclude/one.source.md ================================================ include: include1 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/MixedCaseInclude/fileToInclude.txt ================================================ The include text ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/MixedCaseInclude/one.source.md ================================================ include: fIletoinClude.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/NonMd/mdsource/two.source.txt ================================================ snippet: snippet2 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/NonMd/one.source.txt ================================================ snippet: snippet1 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ReadOnly/one.source.md ================================================ snippet: snippet1 ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/UrlInclude/one.source.md ================================================ include: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/license.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/UrlIncludeMissing/one.source.md ================================================ include: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/missing.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/UrlSnippet/one.source.md ================================================ snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/license.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/UrlSnippetMissing/one.source.md ================================================ snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/missing.txt ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ValidationErrors/one.md ================================================ ```txt this is some text to import ``` snippet source | anchor ```txt this is some text to import ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ```txt Some code ``` snippet source | anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessor/ValidationErrors/one.source.md ================================================ you we ! ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.BinaryFileSnippet.verified.txt ================================================  ```dot From Source File ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.Convention.verified.txt ================================================ /mdsource/two.source.md snippet: snippet2 /one.md ```cs the code from snippet1 ``` anchor /one.source.md snippet: snippet1 /two.md ```cs the code from snippet2 ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.ConventionWithNestedDir.verified.txt ================================================ /mdsource/Nested/one.source.md snippet: snippet1 /Nested/one.md ```cs the code from snippet1 ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.ExplicitFileInclude.verified.txt ================================================ The include text 1 The include text 2 The include text 3 The include text 4 The include text 5 The include text 6 ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.ExplicitFileIncludeWithMergedSnippet.verified.txt ================================================ some content The include text ```.cs the code from snippet1 ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.ExplicitFileIncludeWithSnippetAtEnd.verified.txt ================================================ The include text ```cs the code from snippet1 ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.FileSnippet.verified.txt ================================================ Local ```txt From Source File ``` snippet source | anchor Relative Local ```txt From Source File ``` anchor Rooted ```txt From Source File ``` snippet source | anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.FileSnippetExplicitIncludeBypassesExcludeSnippetFiles.verified.txt ================================================ Local ```txt From Source File ``` anchor Relative Local ```txt From Source File ``` anchor Rooted ```txt From Source File ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.FileSnippetMissing.verified.txt ================================================ { Type: MissingSnippetsException, Missing: [ { Key: missing.txt, LineNumber: 1, File: {CurrentDirectory}DirectoryMarkdownProcessor/FileSnippetMissing/one.source.md } ], Message: Missing snippets: {CurrentDirectory}DirectoryMarkdownProcessor/FileSnippetMissing/one.source.md: missing.txt } ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.FileSnippetWithHash.verified.txt ================================================ Local ```txt From Source File ``` snippet source | anchor Relative Local ```txt From Source File ``` anchor Rooted ```txt From Source File ``` snippet source | anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.FileSnippetWithWhiteSpace.verified.txt ================================================ Local ```txt From Source File ``` snippet source | anchor Relative Local ```txt From Source File ``` anchor Rooted ```txt From Source File ``` snippet source | anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.InPlaceOverwriteExists.verified.md ================================================  ```cs the code from snippet1 ``` anchor The include text Line 1 Line 2 ``` The Code ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.InPlaceOverwriteExistsMdx.verified.mdx ================================================  ```cs the code from snippet1 ``` anchor The include text Line 1 Line 2 ``` The Code ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.InPlaceOverwriteNotExists.verified.md ================================================  ```cs the code from snippet1 ``` anchor The include text Line 1 Line 2 ``` The Code ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.InPlaceOverwriteUrlInclude.verified.txt ================================================ The MIT License (MIT) Copyright (c) 2013 Simon Cropp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.InPlaceOverwriteUrlSnippet.verified.txt ================================================  ```txt The MIT License (MIT) Copyright (c) 2013 Simon Cropp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.InPlaceOverwriteWithFileSnippetMissing.verified.md ================================================  ``` ** Could not find snippet 'missing.txt' ** ``` ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.Mdx.verified.txt ================================================ Local ```txt From Source File ``` snippet source | anchor Relative Local ```txt From Source File ``` anchor Rooted ```txt From Source File ``` snippet source | anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.MixedCaseInclude.verified.txt ================================================ The include text ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.UrlInclude.verified.txt ================================================ The MIT License (MIT) Copyright (c) 2013 Simon Cropp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.UrlIncludeMissing.verified.txt ================================================ { Type: MissingIncludesException, Missing: [ { Key: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/missing.txt, LineNumber: 1, File: {CurrentDirectory}DirectoryMarkdownProcessor/UrlIncludeMissing/one.source.md } ], Message: Missing includes: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/missing.txt } ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.UrlSnippet.verified.txt ================================================  ```txt The MIT License (MIT) Copyright (c) 2013 Simon Cropp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` anchor ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.UrlSnippetMissing.verified.txt ================================================ { Type: MissingSnippetsException, Missing: [ { Key: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/missing.txt, LineNumber: 1, File: {CurrentDirectory}DirectoryMarkdownProcessor/UrlSnippetMissing/one.source.md } ], Message: Missing snippets: {CurrentDirectory}DirectoryMarkdownProcessor/UrlSnippetMissing/one.source.md: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/missing.txt } ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.ValidationErrors.verified.txt ================================================ { Type: ContentValidationException, Errors: [ { Error: Invalid word detected: 'you', Line: 1, File: {CurrentDirectory}DirectoryMarkdownProcessor/ValidationErrors/one.source.md }, { Error: Invalid word detected: 'we', Line: 2, Column: 1, File: {CurrentDirectory}DirectoryMarkdownProcessor/ValidationErrors/one.source.md }, { Error: No exclamation marks. If a statement is important make it bold. https://www.technicalcommunicationcenter.com/2011/12/30/the-discipline-of-punctuation-in-technical-writing/. , Line: 3, Column: 2, File: {CurrentDirectory}DirectoryMarkdownProcessor/ValidationErrors/one.source.md } ], Message: Content validation errors: Invalid word detected: 'you' File: {CurrentDirectory}DirectoryMarkdownProcessor/ValidationErrors/one.source.md Line: 1 Column: 0 Invalid word detected: 'we' File: {CurrentDirectory}DirectoryMarkdownProcessor/ValidationErrors/one.source.md Line: 2 Column: 1 No exclamation marks. If a statement is important make it bold. https://www.technicalcommunicationcenter.com/2011/12/30/the-discipline-of-punctuation-in-technical-writing/. File: {CurrentDirectory}DirectoryMarkdownProcessor/ValidationErrors/one.source.md Line: 3 Column: 2 } ================================================ FILE: src/Tests/DirectoryMarkdownProcessorTests.cs ================================================ public class DirectoryMarkdownProcessorTests { [Fact] public void Run() { var root = GitRepoDirectoryFinder.FindForFilePath(); var processor = new DirectoryMarkdownProcessor( targetDirectory: root, directoryIncludes: path => !path.Contains("IncludeFileFinder") && !path.Contains("DirectoryMarkdownProcessor") && !DefaultDirectoryExclusions.ShouldExcludeDirectory(path), markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true, tocLevel: 1, tocExcludes: new List { "Icon", "Credits", "Release Notes" }); processor.Run(); } [Fact] public Task InPlaceOverwriteExists() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/InPlaceOverwriteExists"); var processor = new DirectoryMarkdownProcessor( root, convention: DocumentConvention.InPlaceOverwrite, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.AddSnippets(SnippetBuild("snippet1", "thePath")); processor.Run(); var fileInfo = new FileInfo(Path.Combine(root, "file.md")); return VerifyFile(fileInfo); } [Fact] public Task InPlaceOverwriteExistsMdx() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/InPlaceOverwriteExistsMdx"); var processor = new DirectoryMarkdownProcessor( root, convention: DocumentConvention.InPlaceOverwrite, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.AddSnippets(SnippetBuild("snippet1", "thePath")); processor.Run(); var fileInfo = new FileInfo(Path.Combine(root, "file.mdx")); return VerifyFile(fileInfo); } [Fact] public Task InPlaceOverwriteNotExists() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/InPlaceOverwriteNotExists"); var processor = new DirectoryMarkdownProcessor( root, convention: DocumentConvention.InPlaceOverwrite, writeHeader: false, readOnly: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.AddSnippets(SnippetBuild("snippet1", "thePath")); processor.Run(); var fileInfo = new FileInfo(Path.Combine(root, "file.md")); return VerifyFile(fileInfo); } [Fact] public Task InPlaceOverwriteUrlSnippet() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/InPlaceOverwriteUrlSnippet"); var processor = new DirectoryMarkdownProcessor( root, convention: DocumentConvention.InPlaceOverwrite, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task InPlaceOverwriteUrlInclude() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/InPlaceOverwriteUrlInclude"); var processor = new DirectoryMarkdownProcessor( root, convention: DocumentConvention.InPlaceOverwrite, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task InPlaceOverwriteWithFileSnippetMissing() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/InPlaceOverwriteWithFileSnippetMissing"); var processor = new DirectoryMarkdownProcessor( root, convention: DocumentConvention.InPlaceOverwrite, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true, treatMissingAsWarning: true); processor.Run(); var fileInfo = new FileInfo(Path.Combine(root, "file.md")); return VerifyFile(fileInfo); } [Fact] public void ReadOnly() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/Readonly"); try { var processor = new DirectoryMarkdownProcessor(root, writeHeader: false, readOnly: true, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.AddSnippets( SnippetBuild("snippet1"), SnippetBuild("snippet2") ); processor.Run(); var fileInfo = new FileInfo(Path.Combine(root, "one.md")); Assert.True(fileInfo.IsReadOnly); } finally { foreach (var file in Directory.EnumerateFiles(root)) { FileEx.ClearReadOnly(file); } } } [Fact] public Task FileSnippetMissing() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/FileSnippetMissing"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\n", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); return Throws(() => processor.Run()); } [Fact] public Task UrlSnippetMissing() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/UrlSnippetMissing"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\n", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); return Throws(() => processor.Run()); } [Fact] public Task ValidationErrors() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/ValidationErrors"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, validateContent: true, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); return Throws(() => processor.Run()); } [Fact] public Task UrlIncludeMissing() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/UrlIncludeMissing"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\n", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); return Throws(() => processor.Run()); } [Fact] public Task UrlSnippet() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/UrlSnippet"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task BinaryFileSnippet() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/BinaryFileSnippet"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task FileSnippetWithWhiteSpace() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/FileSnippetWithWhiteSpace"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task Mdx() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/Mdx"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.mdx"); return Verify(File.ReadAllText(result)); } [Fact] public Task FileSnippet() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/FileSnippet"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task FileSnippetExplicitIncludeBypassesExcludeSnippetFiles() { // `ExcludeSnippetFiles` should stop MarkdownSnippets from scanning the file for // `begin-snippet`/`end-snippet` markers, but an explicit `snippet: sourceFile.txt` // in a markdown file must still resolve to the whole-file contents — that lookup // goes through allFiles, which remains unfiltered. var root = Path.GetFullPath("DirectoryMarkdownProcessor/FileSnippet"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true, snippetFileIncludes: path => Path.GetFileName(path) != "sourceFile.txt"); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task FileSnippetWithHash() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/FileSnippetWithHash"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task MixedCaseInclude() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/MixedCaseInclude"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task ExplicitFileInclude() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/ExplicitFileInclude"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task ExplicitFileIncludeWithMergedSnippet() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/ExplicitFileIncludeWithMergedSnippet"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.AddSnippets(SnippetBuild("snippet1")); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task ExplicitFileIncludeWithSnippetAtEnd() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/ExplicitFileIncludeWithSnippetAtEnd"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.AddSnippets(SnippetBuild("snippet1")); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task UrlInclude() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/UrlInclude"); var processor = new DirectoryMarkdownProcessor(root, writeHeader: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); var result = Path.Combine(root, "one.md"); return Verify(File.ReadAllText(result)); } [Fact] public Task Convention() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/Convention"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.AddSnippets( SnippetBuild("snippet1"), SnippetBuild("snippet2") ); processor.Run(); var builder = new StringBuilder(); foreach (var file in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories).OrderBy(_ => _)) { builder.AppendLineN(file.Replace(root, "")); builder.AppendLineN(File.ReadAllText(file)); builder.AppendLineN(); } return Verify(builder.ToString()); } [Fact] public Task ConventionWithNestedDir() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/ConventionWithNestedDir"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, newLine: "\r", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.AddSnippets(SnippetBuild("snippet1")); processor.Run(); var builder = new StringBuilder(); foreach (var file in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories).OrderBy(_ => _)) { builder.AppendLineN(file.Replace(root, "")); builder.AppendLineN(File.ReadAllText(file)); builder.AppendLineN(); } return Verify(builder.ToString()); } [Fact] public void MustErrorByDefaultWhenIncludesAreMissing() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/MissingInclude"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true, newLine: "\n"); Assert.Throws(() => processor.Run()); } [Fact] public void MustNotErrorForMissingIncludesIfConfigured() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/MissingInclude"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, treatMissingAsWarning: true, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true, newLine: "\n"); processor.Run(); } [Fact] public void MustErrorByDefaultWhenSnippetsAreMissing() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/Convention"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true, newLine: "\n"); Assert.Throws(() => processor.Run()); } [Fact] public void MustNotErrorForMissingSnippetsIfConfigured() { var root = Path.GetFullPath("DirectoryMarkdownProcessor/Convention"); var processor = new DirectoryMarkdownProcessor( root, writeHeader: false, treatMissingAsWarning: true, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true, newLine: "\n"); processor.Run(); } static Snippet SnippetBuild(string key, string? path = null) => Snippet.Build( language: "cs", startLine: 1, endLine: 2, value: "the code from " + key, key: key, path: path, expressiveCode: null); } ================================================ FILE: src/Tests/DirectorySnippetExtractor/Case/code1.txt ================================================ begin-snippet: snipPet Some code end-snippet ================================================ FILE: src/Tests/DirectorySnippetExtractor/Case/code2.txt ================================================ begin-snippet: Snippet Some code end-snippet ================================================ FILE: src/Tests/DirectorySnippetExtractor/Nested/nested/nested/code.txt ================================================ begin-snippet: nestedsnippet Some code end-snippet ================================================ FILE: src/Tests/DirectorySnippetExtractor/Simple/code1.txt ================================================ begin-snippet: snippet2 Some code end-snippet ================================================ FILE: src/Tests/DirectorySnippetExtractor/Simple/code2.txt ================================================ begin-snippet: snippet1 Some code end-snippet ================================================ FILE: src/Tests/DirectorySnippetExtractor/Simple/code3.txt ================================================ begin-snippet: snippet2 Some code end-snippet ================================================ FILE: src/Tests/DirectorySnippetExtractor/Simple/code4.txt ================================================ begin-snippet: snippet1 Some code end-snippet ================================================ FILE: src/Tests/DirectorySnippetExtractor/VerifyLambdasAreCalled/subpath/code4.txt ================================================ begin-snippet: snippet1 Some code end-snippet ================================================ FILE: src/Tests/DownloaderTests.Valid.verified.txt ================================================ { success: true, content: # Auto detect text files and normalize line endings to LF * text=auto eol=lf *.png binary *.snk binary *.verified.txt text eol=lf working-tree-encoding=UTF-8 *.verified.xml text eol=lf working-tree-encoding=UTF-8 *.verified.json text eol=lf working-tree-encoding=UTF-8 .editorconfig text eol=lf working-tree-encoding=UTF-8 *.sln.DotSettings text eol=lf working-tree-encoding=UTF-8 *.slnx.DotSettings text eol=lf working-tree-encoding=UTF-8 } ================================================ FILE: src/Tests/DownloaderTests.cs ================================================ public class DownloaderTests { [Fact] public async Task Valid() { var content = await Downloader.DownloadContent("https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/.gitattributes"); await Verify(new {content.success, content.content}); } [Fact] public async Task Missing() { var content = await Downloader.DownloadContent("https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/missing.txt"); Assert.False(content.success); Assert.Null(content.content); } } ================================================ FILE: src/Tests/FileExTests.cs ================================================ public class FileExTests { [Fact] public void MakeReadOnly_SetsReadOnlyAttribute() { var tempFile = Path.GetTempFileName(); try { FileEx.MakeReadOnly(tempFile); var attributes = File.GetAttributes(tempFile); Assert.True((attributes & FileAttributes.ReadOnly) != 0); } finally { File.SetAttributes(tempFile, FileAttributes.Normal); File.Delete(tempFile); } } [Fact] public void ClearReadOnly_RemovesReadOnlyAttribute() { var tempFile = Path.GetTempFileName(); try { File.SetAttributes(tempFile, File.GetAttributes(tempFile) | FileAttributes.ReadOnly); FileEx.ClearReadOnly(tempFile); var attributes = File.GetAttributes(tempFile); Assert.False((attributes & FileAttributes.ReadOnly) != 0); } finally { File.SetAttributes(tempFile, FileAttributes.Normal); File.Delete(tempFile); } } [Fact] public void ClearReadOnly_DoesNothingIfFileDoesNotExist() { var nonExistentFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); FileEx.ClearReadOnly(nonExistentFile); Assert.False(File.Exists(nonExistentFile)); } [Fact] public void MakeReadOnly_ThenClearReadOnly_RoundTrip() { var tempFile = Path.GetTempFileName(); try { FileEx.MakeReadOnly(tempFile); Assert.True((File.GetAttributes(tempFile) & FileAttributes.ReadOnly) != 0); FileEx.ClearReadOnly(tempFile); Assert.False((File.GetAttributes(tempFile) & FileAttributes.ReadOnly) != 0); } finally { File.SetAttributes(tempFile, FileAttributes.Normal); File.Delete(tempFile); } } [Fact] public void FixFileCapitalization_ReturnsActualCasing() { using var tempDir = new TempDirectory(); var actualPath = Path.Combine(tempDir, "TestFile.txt"); File.WriteAllText(actualPath, "test"); var inputPath = Path.Combine(tempDir, "testfile.txt"); var result = FileEx.FixFileCapitalization(inputPath); Assert.Equal(actualPath, result); } [Fact] public void FixFileCapitalization_WorksWhenCasingMatches() { using var tempFile = TempFile.Create(); var result = FileEx.FixFileCapitalization(tempFile); Assert.Equal(tempFile, result); } } ================================================ FILE: src/Tests/FileToUseAsSnippet.txt ================================================ The Content #region aaa #region indented From File ================================================ FILE: src/Tests/GirRepoDirectoryFinderTests.cs ================================================ public class GirRepoDirectoryFinderTests { [Fact] public void CanFindGirRepoDir() { var path = GitRepoDirectoryFinder.FindForFilePath(); Assert.True(Directory.Exists(path)); } } ================================================ FILE: src/Tests/GitDirs/NoRef/HEAD ================================================ 061e8aee1b5174df8ed7b201e0d8dd5a0d155418 ================================================ FILE: src/Tests/GitDirs/WithRef/HEAD ================================================ ref: refs/heads/master ================================================ FILE: src/Tests/GitDirs/WithRef/refs/heads/master ================================================ ad4901fc6e8bf4a253ee053327eba32fe31010ed ================================================ FILE: src/Tests/GlobalUsings.cs ================================================ global using Argon; global using MarkdownSnippets; global using Polyfills; global using VerifyTests.DiffPlex; ================================================ FILE: src/Tests/HeaderWriterTests.DefaultHeader.verified.txt ================================================ GENERATED FILE - DO NOT EDIT This file was generated by [MarkdownSnippets](https://github.com/SimonCropp/MarkdownSnippets). Source File: {relativePath} To change this file edit the source file and then run MarkdownSnippets. ================================================ FILE: src/Tests/HeaderWriterTests.WriteHeaderDefaultHeader.verified.txt ================================================  ================================================ FILE: src/Tests/HeaderWriterTests.WriteHeaderHeaderCustom.verified.txt ================================================  ================================================ FILE: src/Tests/HeaderWriterTests.cs ================================================ public class HeaderWriterTests { [Fact] public Task DefaultHeader() => Verify(HeaderWriter.DefaultHeader); [Fact] public Task WriteHeaderDefaultHeader() => Verify(HeaderWriter.WriteHeader("thePath", null, "\r\n")); [Fact] public Task WriteHeaderHeaderCustom() => Verify(HeaderWriter.WriteHeader("thePath", @"line1\nline2", "\r\n")); } ================================================ FILE: src/Tests/IncludeFileFinder/Nested/nested/nested/file.include.md ================================================ Nested ================================================ FILE: src/Tests/IncludeFileFinder/Nested/nested/nested/other.txt ================================================ begin-snippet: nestedsnippet Some code end-snippet ================================================ FILE: src/Tests/IncludeFileFinder/Simple/file1.include.md ================================================ Simple1 ================================================ FILE: src/Tests/IncludeFileFinder/Simple/file2.include.md ================================================ begin-snippet: nestedsnippet Some code end-snippet ================================================ FILE: src/Tests/IncludeFileFinder/Simple/other.txt ================================================ Simple2 ================================================ FILE: src/Tests/IncludeFileFinder/VerifyLambdasAreCalled/subpath/file.include.md ================================================ VerifyLambdasAreCalled ================================================ FILE: src/Tests/IncludeFinder/file.include.md ================================================ Simple1 ================================================ FILE: src/Tests/IndexReaderTests.cs ================================================ public class IndexReaderTests { [Theory] [InlineData("a\r", "\r")] [InlineData("a\n", "\n")] [InlineData("a\r\n", "\r\n")] [InlineData("", null)] [InlineData("a", null)] [InlineData("a\rb", "\r")] [InlineData("a\nb", "\n")] [InlineData("a\r\nb", "\r\n")] [InlineData("a\r\r", "\r")] [InlineData("a\r\r\nb", "\r")] public void NewLineDetection(string input, string? expected) { var fileName = Path.GetTempFileName(); try { File.WriteAllText(fileName, input); using var streamReader = File.OpenText(fileName); streamReader.TryFindNewline(out var newline); Assert.Equal(expected, newline); } finally { File.Delete(fileName); } } } ================================================ FILE: src/Tests/LoopState/LoopStateTests.ExcludeEmptyPaddingLines.verified.txt ================================================ Line2 ================================================ FILE: src/Tests/LoopState/LoopStateTests.TrimIndentation.verified.txt ================================================ Line1 Line2 Line2 ================================================ FILE: src/Tests/LoopState/LoopStateTests.TrimIndentation_no_initial_padding.verified.txt ================================================ Line1 Line2 Line2 ================================================ FILE: src/Tests/LoopState/LoopStateTests.TrimIndentation_with_mis_match.verified.txt ================================================ Line2 Line4 ================================================ FILE: src/Tests/LoopState/LoopStateTests.cs ================================================ public class LoopStateTests { [Fact] public Task TrimIndentation() { var loopState = new LoopState("key", _ => throw new(), 1, int.MaxValue, "\n"); loopState.AppendLine(" Line1"); loopState.AppendLine(" Line2"); loopState.AppendLine(" Line2"); return Verify(loopState.GetLines()); } [Fact] public Task ExcludeEmptyPaddingLines() { var loopState = new LoopState("key", _ => throw new(), 1, int.MaxValue, "\n"); loopState.AppendLine(" "); loopState.AppendLine(" Line2"); loopState.AppendLine(" "); return Verify(loopState.GetLines()); } [Fact] public Task TrimIndentation_with_mis_match() { var loopState = new LoopState("key", _ => throw new(), 1, int.MaxValue, "\n"); loopState.AppendLine(" Line2"); loopState.AppendLine(" "); loopState.AppendLine(" Line4"); return Verify(loopState.GetLines()); } [Fact] public void ExcludeEmptyPaddingLines_empty_list() { var loopState = new LoopState("key", _ => throw new(), 1, int.MaxValue, "\n"); Assert.Empty(loopState.GetLines()); } [Fact] public void ExcludeEmptyPaddingLines_whitespace_list() { var loopState = new LoopState("key", _ => throw new(), 1, int.MaxValue, "\n"); loopState.AppendLine(""); loopState.AppendLine(" "); Assert.Empty(loopState.GetLines()); } [Fact] public Task TrimIndentation_no_initial_padding() { var loopState = new LoopState("key", _ => throw new(), 1, int.MaxValue, "\n"); loopState.AppendLine("Line1"); loopState.AppendLine(" Line2"); loopState.AppendLine(" Line2"); return Verify(loopState.GetLines()); } } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Empty_snippet_key.verified.txt ================================================ { Type: SnippetException, Message: Could not parse snippet from: snippet:. Path: . Line: 2 } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.MissingInclude.verified.txt ================================================ { result: before ** Could not find include 'theKey' ** after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Missing_endInclude.verified.txt ================================================ { Type: MarkdownProcessingException, LineNumber: 2, Message: Expected to find ``. File: . LineNumber: 2. } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Missing_endToc.verified.txt ================================================ { Type: MarkdownProcessingException, File: sourceFile, LineNumber: 2, Message: Expected to find ``. File: sourceFile. LineNumber: 2. } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.MixedNewlinesInFile.verified.txt ================================================ { UsedSnippets: [ { Key: FileWithMixedNewLines.txt, Language: txt, Value: a b c d, Error: , FileLocation: null, IsInError: false } ], result: some other text ```txt a b c d ``` } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Simple.verified.txt ================================================ { UsedSnippets: [ { Key: snippet1, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false }, { Key: snippet2, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false }, { Key: FileToUseAsSnippet.txt, Language: txt, Value: The Content From File, Error: , FileLocation: {ProjectDirectory}FileToUseAsSnippet.txt(1-4), IsInError: false }, { Key: /FileToUseAsSnippet.txt, Language: txt, Value: The Content From File, Error: , FileLocation: {ProjectDirectory}FileToUseAsSnippet.txt(1-4), IsInError: false } ], result: ```cs Snippet ``` some text ```cs Snippet ``` some other text ```txt The Content From File ``` some other text ```txt The Content From File ``` } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Simple_Overwrite.verified.txt ================================================ { UsedSnippets: [ { Key: snippet1, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false }, { Key: snippet2, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false }, { Key: FileToUseAsSnippet.txt, Language: txt, Value: The Content From File, Error: , FileLocation: {ProjectDirectory}FileToUseAsSnippet.txt(1-4), IsInError: false }, { Key: /FileToUseAsSnippet.txt, Language: txt, Value: The Content From File, Error: , FileLocation: {ProjectDirectory}FileToUseAsSnippet.txt(1-4), IsInError: false } ], result: ```cs Snippet ``` some text ```cs Snippet ``` some other text ```txt The Content From File ``` some other text ```txt The Content From File ``` } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.SkipHeadingBeforeToc.verified.txt ================================================ { result: ## Heading 1 ## Contents * [Heading 2](#heading-2) Text1 ## Heading 2 Text2 } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.SnippetInInclude.verified.txt ================================================ { UsedSnippets: [ { Key: snippet1, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: some text ```cs Snippet ``` some other text } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.SnippetInIncludeLast.verified.txt ================================================ { UsedSnippets: [ { Key: snippet1, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: some text line1 ```cs Snippet ``` some other text } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.TableInInclude.verified.txt ================================================ { result: some text | Number of Parameters | Variations per Parameter | Total Combinations | Pairwise Combinations | | -------------------- | ----------------------- | ------------------ | --------------------- | |2|5|25|25| some other text } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Toc.verified.txt ================================================ { result: # Title ## Contents * [Heading 1](#heading-1) * [Heading 2](#heading-2) ## Heading 1 Text1 ## Heading 2 Text2 } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Toc1.verified.txt ================================================ { result: # Title toc1 ## Heading 1 Text1 ## Heading 2 Text2 } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.TocRetainedIfNoHeadingsInFile.verified.txt ================================================ { result: # Title This document has no headings. An empty toc section should be generated, in case any headings are added in future. } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Toc_Overwrite.verified.txt ================================================ { result: # Title ## Contents * [Heading 1](#heading-1) * [Heading 2](#heading-2) ## Heading 1 Text1 ## Heading 2 Text2 } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.Whitespace_snippet_key.verified.txt ================================================ { Type: SnippetException, Message: Could not parse snippet from: snippet:. Path: . Line: 2 } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithCommentWebSnippetUpdate.verified.txt ================================================ { UsedSnippets: [ { Key: snipPet, Language: txt, Value: Some code, Error: , FileLocation: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt(1-3), IsInError: false } ], result: before ```txt Some code ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithCommentWebSnippetWithViewUrl.verified.txt ================================================ { UsedSnippets: [ { Key: snipPet, Language: txt, Value: Some code, Error: , FileLocation: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt(1-3), IsInError: false } ], result: before ```txt Some code ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithDoubleInclude.verified.txt ================================================ { result: before theValue1 theValue2 after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithEmptyMultiLineInclude_Overwrite.verified.txt ================================================ { result: before one two after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithEmptyMultipleInclude.verified.txt ================================================ { result: before after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithIndentedCommentSnippet.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithIndentedMultiLineSnippet.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: the long Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs the long Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithIndentedSnippet.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithIndentedSnippetMultipleSpaces.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithIndentedWebSnippet.verified.txt ================================================ { MissingSnippets: [ { Key: http://example.com/file.cs#snippet1, LineNumber: 4 } ], result: before ``` ** Could not fetch or parse web-snippet 'http://example.com/file.cs#snippet1' ** ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithInlineWebSnippetWithViewUrl.verified.txt ================================================ { UsedSnippets: [ { Key: snipPet, Language: txt, Value: Some code, Error: , FileLocation: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt(1-3), IsInError: false } ], result: before ```txt Some code ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithMixedCaseInclude.verified.txt ================================================ { result: before theValue1 theValue2 after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithMixedCaseSnippet.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false }, { Key: TheKey, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs Snippet ``` ```cs Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithMultiLineInclude_Overwrite.verified.txt ================================================ { result: before theValue1 theValue2 after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithMultiLineSnippet.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: the long Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs the long Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithMultipleInclude.verified.txt ================================================ { result: before theValue1 theValue2 theValue3 after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithSingleInclude.verified.txt ================================================ { result: before theValue1 after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithSingleInclude_Overwrite.verified.txt ================================================ { result: before theValue1 after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithSingleSnippet.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithTabIndentedSnippet.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WithTwoLineSnippet.verified.txt ================================================ { UsedSnippets: [ { Key: theKey, Language: cs, Value: the Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], result: before ```cs the Snippet ``` after } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.WrongNewlineInSnippet.verified.txt ================================================ { MissingSnippets: [ { Key: 'snippet1', LineNumber: 2 } ], content: " ** Could not find snippet 'snippet1' ** some text " } ================================================ FILE: src/Tests/MarkdownProcessor/MarkdownProcessorTests.cs ================================================ public class MarkdownProcessorTests { [Fact] public Task Missing_endInclude() { var content = """ BAD """; return SnippetVerifier.VerifyThrows( DocumentConvention.InPlaceOverwrite, content, includes: [Include.Build("theKey", [], Path.GetFullPath("thePath"))]); } [Fact] public Task WithEmptyMultiLineInclude_Overwrite() { var content = """ before after """; var lines = new List { "one", "two" }; return SnippetVerifier.Verify( DocumentConvention.InPlaceOverwrite, content, includes: [Include.Build("theKey", lines, Path.GetFullPath("thePath"))]); } [Fact] public Task WithMultiLineInclude_Overwrite() { var content = """ before BAD BAD BAD after """; var lines = new List { "theValue1", "theValue2" }; return SnippetVerifier.Verify( DocumentConvention.InPlaceOverwrite, content, includes: [Include.Build("theKey", lines, Path.GetFullPath("thePath"))]); } [Fact] public Task WithSingleInclude_Overwrite() { var content = """ before BAD after """; var lines = new List { "theValue1" }; return SnippetVerifier.Verify( DocumentConvention.InPlaceOverwrite, content, includes: [Include.Build("theKey", lines, Path.GetFullPath("thePath"))]); } [Fact] public Task WithSingleInclude() { var content = """ before include: theKey after """; var lines = new List { "theValue1" }; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, includes: [Include.Build("theKey", lines, Path.GetFullPath("thePath"))]); } [Fact] public Task WithMixedCaseInclude() { var content = """ before include: theKey include: TheKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, includes: [ Include.Build("theKey", ["theValue1"], Path.GetFullPath("thePath")), Include.Build("TheKey", ["theValue2"], Path.GetFullPath("thePath")) ]); } [Fact] public Task WithSingleSnippet() { var content = """ before snippet: theKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [SnippetBuild("cs", "theKey")]); } [Fact] public Task WithMixedCaseSnippet() { var content = """ before snippet: theKey snippet: TheKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [ SnippetBuild("cs", "theKey"), SnippetBuild("cs", "TheKey"), ]); } [Fact] public Task WithTwoLineSnippet() { var content = """ before snippet: theKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [ Snippet.Build( language: "cs", startLine: 1, endLine: 2, value: """ the Snippet """, key: "theKey", path: "thePath", expressiveCode: null), ]); } [Fact] public Task WithMultiLineSnippet() { var content = """ before snippet: theKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [ Snippet.Build( language: "cs", startLine: 1, endLine: 2, value: """ the long Snippet """, key: "theKey", path: "thePath", expressiveCode: null) ]); } [Fact] public Task WithDoubleInclude() { var content = """ before include: theKey after """; var lines = new[] { "theValue1", "theValue2" }; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, includes: [ Include.Build("theKey", lines, Path.GetFullPath("thePath")) ]); } [Fact] public Task WithEmptyMultipleInclude() { var content = """ before include: theKey after """; var lines = new[] { "", "", "" }; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, includes: [Include.Build("theKey", lines, Path.GetFullPath("thePath"))]); } [Fact] public Task WithMultipleInclude() { var content = """ before include: theKey after """; var lines = new[] { "theValue1", "theValue2", "theValue3" }; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, includes: [Include.Build("theKey", lines, Path.GetFullPath("thePath"))]); } [Fact] public Task MissingInclude() { var content = """ before include: theKey after """; return SnippetVerifier.Verify(DocumentConvention.SourceTransform, content); } [Fact] public Task SkipHeadingBeforeToc() { var content = """ ## Heading 1 toc Text1 ## Heading 2 Text2 """; return SnippetVerifier.Verify(DocumentConvention.SourceTransform, content); } [Fact] public Task Toc1() { var content = """ # Title toc1 ## Heading 1 Text1 ## Heading 2 Text2 """; return SnippetVerifier.Verify(DocumentConvention.SourceTransform, content); } [Fact] public Task Toc() { var content = """ # Title toc ## Heading 1 Text1 ## Heading 2 Text2 """; return SnippetVerifier.Verify(DocumentConvention.SourceTransform, content); } [Fact] public Task TocRetainedIfNoHeadingsInFile() { var content = """ # Title toc This document has no headings. An empty toc section should be generated, in case any headings are added in future. """; return SnippetVerifier.Verify(DocumentConvention.SourceTransform, content); } [Fact] public Task Missing_endToc() { var content = """ Bad """; return SnippetVerifier.VerifyThrows(DocumentConvention.InPlaceOverwrite, content); } [Fact] public Task Empty_snippet_key() { var content = """ snippet: """; return SnippetVerifier.VerifyThrows(DocumentConvention.InPlaceOverwrite, content); } [Fact] public Task Whitespace_snippet_key() { var content = """ snippet: """; return SnippetVerifier.VerifyThrows(DocumentConvention.InPlaceOverwrite, content); } [Fact] public Task Toc_Overwrite() { var content = """ # Title Bad ## Heading 1 Text1 ## Heading 2 Text2 """; return SnippetVerifier.Verify(DocumentConvention.InPlaceOverwrite, content); } [Fact] public Task Simple_Overwrite() { var availableSnippets = new List { SnippetBuild("cs", "snippet1"), SnippetBuild("cs", "snippet2") }; var content = """ ```cs BAD ``` some text ```cs BAD ``` some other text ```txt BAD ``` some other text ```txt BAD ``` """; return SnippetVerifier.Verify( DocumentConvention.InPlaceOverwrite, content, availableSnippets, new List { Path.Combine(GitRepoDirectoryFinder.FindForFilePath(), "src/Tests/FileToUseAsSnippet.txt") }); } [Fact] public async Task MixedNewlinesInFile() { var file = "FileWithMixedNewLines.txt"; File.Delete(file); await File.WriteAllTextAsync(file, "a\rb\nc\r\nd"); var availableSnippets = new List(); var content = """ some other text snippet: FileWithMixedNewLines.txt """; var result = await SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, availableSnippets, new List { file }); Assert.DoesNotContain("\r\n", result); Assert.DoesNotContain("\r", result); } [Fact] public Task Simple() { var availableSnippets = new List { SnippetBuild("cs", "snippet1"), SnippetBuild("cs", "snippet2") }; var content = """ snippet: snippet1 some text snippet: snippet2 some other text snippet: FileToUseAsSnippet.txt some other text snippet: /FileToUseAsSnippet.txt """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, availableSnippets, new List { Path.Combine(GitRepoDirectoryFinder.FindForFilePath(), "src/Tests/FileToUseAsSnippet.txt") }); } [Fact] public Task SnippetInInclude() { var availableSnippets = new List { SnippetBuild("cs", "snippet1") }; var content = """ some text include: theKey some other text """; var lines = new List { "snippet: snippet1" }; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, availableSnippets, includes: [Include.Build("theKey", lines, "thePath")]); } [Fact] public Task TableInInclude() { var availableSnippets = new List(); var content = """ some text include: theKey some other text """; var lines = new List { """ | Number of Parameters | Variations per Parameter | Total Combinations | Pairwise Combinations | | -------------------- | ----------------------- | ------------------ | --------------------- | |2|5|25|25| """ }; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, availableSnippets, includes: [Include.Build("theKey", lines, "thePath")]); } [Fact] public Task SnippetInIncludeLast() { var availableSnippets = new List { SnippetBuild("cs", "snippet1") }; var content = """ some text include: theKey some other text """; var lines = new List { "line1", "snippet: snippet1" }; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, availableSnippets, includes: [Include.Build("theKey", lines, "thePath")]); } [Fact] public Task WithIndentedSnippet() { var content = """ before snippet: theKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [SnippetBuild("cs", "theKey")]); } [Fact] public Task WithIndentedSnippetMultipleSpaces() { var content = """ before snippet: theKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [SnippetBuild("cs", "theKey")]); } [Fact] public Task WithIndentedCommentSnippet() { var content = """ before bad content after """; return SnippetVerifier.Verify( DocumentConvention.InPlaceOverwrite, content, snippets: [SnippetBuild("cs", "theKey")]); } [Fact] public Task WithTabIndentedSnippet() { var content = $""" before {"\t"}snippet: theKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [SnippetBuild("cs", "theKey")]); } [Fact] public Task WithIndentedWebSnippet() { var content = """ before web-snippet: http://example.com/file.cs#snippet1 after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [SnippetBuild("cs", "snippet1")]); } [Fact] public Task WithIndentedMultiLineSnippet() { var content = """ before snippet: theKey after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content, snippets: [ Snippet.Build( language: "cs", startLine: 1, endLine: 2, value: """ the long Snippet """, key: "theKey", path: "thePath", expressiveCode: null) ]); } [Fact] public Task WithCommentWebSnippetUpdate() { var content = """ before OLD CONTENT THAT SHOULD BE REPLACED after """; return SnippetVerifier.Verify( DocumentConvention.InPlaceOverwrite, content); } [Fact] public Task WithCommentWebSnippetWithViewUrl() { var content = """ before OLD CONTENT THAT SHOULD BE REPLACED after """; return SnippetVerifier.Verify( DocumentConvention.InPlaceOverwrite, content); } [Fact] public Task WithInlineWebSnippetWithViewUrl() { var content = """ before web-snippet: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt#snipPet https://github.com/SimonCropp/MarkdownSnippets/blob/main/src/Tests/DirectorySnippetExtractor/Case/code1.txt after """; return SnippetVerifier.Verify( DocumentConvention.SourceTransform, content); } static Snippet SnippetBuild(string language, string key) => Snippet.Build( language: language, startLine: 1, endLine: 2, value: "Snippet", key: key, path: "thePath", expressiveCode: null); } ================================================ FILE: src/Tests/MarkdownProcessor/SnippetKey_ExtractStartCommentSnippet.cs ================================================ public class SnippetKey_ExtractStartCommentSnippet { [Fact] public void WithDashes() { Assert.True(SnippetKey.ExtractStartCommentSnippet(new("", "path", 1), out var key)); Assert.Equal("my-code-snippet", key); } [Fact] public void Simple() { Assert.True(SnippetKey.ExtractStartCommentSnippet(new("", "path", 1), out var key)); Assert.Equal("snippet", key); } [Fact] public void MissingClosingComment_Throws() { var line = new Line("", exception.Message); Assert.Contains("test.md", exception.Message); } } ================================================ FILE: src/Tests/MarkdownProcessor/SnippetKey_ExtractStartCommentWebSnippet.cs ================================================ public class SnippetKey_ExtractStartCommentWebSnippet { [Fact] public void Simple() { Assert.True(SnippetKey.ExtractStartCommentWebSnippet(new("", "path", 1), out var url, out var key)); Assert.Equal("https://example.com/file.cs", url); Assert.Equal("mysnippet", key); } [Fact] public void MissingClosingComment_Throws() { var line = new Line("", exception.Message); Assert.Contains("test.md", exception.Message); } } ================================================ FILE: src/Tests/MarkdownProcessor/SnippetKey_ExtractTransform.cs ================================================ public class SnippetKey_ExtractTransform { [Fact] public void MissingSpaces() { Assert.True( SnippetKey.ExtractSnippet(new("snippet:snippet", "path", 1), out var key)); Assert.Equal("snippet", key); } [Fact] public void WithDashes() { Assert.True(SnippetKey.ExtractSnippet(new("snippet: my-code-snippet", "path", 1), out var key)); Assert.Equal("my-code-snippet", key); } [Fact] public void Simple() { Assert.True(SnippetKey.ExtractSnippet(new("snippet: snippet", "path", 1), out var key)); Assert.Equal("snippet", key); } [Fact] public void ExtraSpace() { Assert.True(SnippetKey.ExtractSnippet(new("snippet: snippet ", "path", 1), out var key)); Assert.Equal("snippet", key); } } ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.Deep.verified.txt ================================================  ## Contents * [Heading1](#heading1) * [Heading2](#heading2) * [Heading3](#heading3) * [Heading4](#heading4) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.DuplicateNested.verified.txt ================================================  ## Contents * [Heading](#heading) * [Heading](#heading-1) * [Heading](#heading-2) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.Duplicates.verified.txt ================================================  ## Contents * [A](#a) * [A](#a-1) * [a](#a-2) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.EmptyHeading.verified.txt ================================================  ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.Exclude.verified.txt ================================================  ## Contents * [Heading1](#heading1) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.IgnoreTop.verified.txt ================================================  ## Contents * [Heading2](#heading2) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.Nested.verified.txt ================================================  ## Contents * [Heading1](#heading1) * [Heading2](#heading2) * [Heading3](#heading3) * [Heading4](#heading4) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.SanitizeLink.verified.txt ================================================ a_-b ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.Single.verified.txt ================================================  ## Contents * [Heading](#heading) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.StopAtLevel.verified.txt ================================================  ## Contents * [Heading1](#heading1) * [Heading2](#heading2) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.StripMarkdown.verified.txt ================================================  ## Contents * [bold italic Link](#bold-italic-link) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.WithSpaces.verified.txt ================================================  ## Contents * [A B](#a-b) ================================================ FILE: src/Tests/MarkdownProcessor/TocBuilderTests.cs ================================================ public class TocBuilderTests { [Fact] public Task EmptyHeading() { var lines = new List { new("##", "", 0) }; var buildToc = TocBuilder.BuildToc(lines, 1, [], "\r"); Assert.DoesNotContain("\r\n", buildToc); return Verify(buildToc); } [Fact] public Task IgnoreTop() { var lines = new List { new("# Heading1", "", 0), new("## Heading2", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 1, [], Environment.NewLine)); } [Fact] public Task SanitizeLink() { var builder = new StringBuilder(); TocBuilder.SanitizeLink(builder, "A!@#$%,^&*()_+-={};':\"<>?/b"); return Verify(builder); } [Fact] public Task StripMarkdown() { var lines = new List { new("## **bold** *italic* [Link](link)", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 1, [], Environment.NewLine)); } [Fact] public Task Exclude() { var lines = new List { new("## Heading1", "", 0), new("### Heading2", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 1, ["Heading2"], Environment.NewLine)); } [Fact] public Task Nested() { var lines = new List { new("## Heading1", "", 0), new("### Heading2", "", 0), new("## Heading3", "", 0), new("### Heading4", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 2, [], Environment.NewLine)); } [Fact] public Task Deep() { var lines = new List { new("## Heading1", "", 0), new("### Heading2", "", 0), new("#### Heading3", "", 0), new("##### Heading4", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 10, [], Environment.NewLine)); } [Fact] public Task StopAtLevel() { var lines = new List { new("## Heading1", "", 0), new("### Heading2", "", 0), new("#### Heading3", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 2, [], Environment.NewLine)); } [Fact] public Task Single() { var lines = new List { new("## Heading", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 1, [], Environment.NewLine)); } [Fact] public Task WithSpaces() { var lines = new List { new("## A B ", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 1, [], Environment.NewLine)); } [Fact] public Task DuplicateNested() { var lines = new List { new("## Heading", "", 0), new("### Heading", "", 0), new("#### Heading", "", 0) }; return Verify(TocBuilder.BuildToc(lines,4, [], Environment.NewLine)); } [Fact] public Task Duplicates() { var lines = new List { new("## A", "", 0), new("## A", "", 0), new("## a", "", 0) }; return Verify(TocBuilder.BuildToc(lines, 1, [], Environment.NewLine)); } } ================================================ FILE: src/Tests/ModuleInitializer.cs ================================================ public static class ModuleInitializer { [ModuleInitializer] public static void Initialize() { VerifyDiffPlex.Initialize(OutputType.Compact); VerifierSettings.IgnoreStackTrace(); VerifierSettings.AddExtraSettings(serializer => { var converters = serializer.Converters; converters.Add(new ProcessResultConverter()); converters.Add(new SnippetConverter()); }); VerifierSettings.AddScrubber(_ => _.Replace('\\', '/')); } } ================================================ FILE: src/Tests/MsBuildIntegrationTests.cs ================================================ // Integration tests for MSBuild task // // These tests verify that the MarkdownSnippets.MsBuild NuGet package works correctly // when consumed by a project using both .NET Core (dotnet build) and .NET Framework (msbuild.exe). // // How it works: // 1. Creates a temporary directory with a minimal test project // 2. Configures nuget.config to use the local nugets folder (C:\Code\MarkdownSnippets\nugets) // with a local packages cache to avoid global NuGet cache issues // 3. Creates a .csproj referencing MarkdownSnippets.MsBuild // 4. Creates a C# file with a code snippet and a markdown file referencing it // 5. Runs the build (dotnet or msbuild.exe) which triggers the MarkdownSnippets task // 6. Verifies the markdown was processed correctly // // The .NET Framework test (msbuild.exe) is particularly important because: // - MSBuild loads the netstandard2.0 version of the task DLL // - All dependencies (including System.Collections.Immutable with FrozenSet) are shaded // via PackageShader to avoid version conflicts with MSBuild's own dependencies // - Static field data (FieldRVA entries) must be correctly patched when shading // // These tests only run in RELEASE configuration because they depend on the // MarkdownSnippets.MsBuild.nupkg being built in the nugets folder. #if RELEASE public class MsBuildIntegrationTests { static string GetNugetsDir() { var solutionDir = ProjectFiles.SolutionDirectory.Path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // SolutionDirectory is C:\Code\MarkdownSnippets\src, nugets is at C:\Code\MarkdownSnippets\nugets var repoDir = Directory.GetParent(solutionDir)?.FullName ?? throw new InvalidOperationException($"Could not get parent of {solutionDir}"); return Path.Combine(repoDir, "nugets"); } [Fact] public async Task DotnetBuild_UsesNetCoreTask() { using var tempDir = new TempDirectory(); await SetupTestProject(tempDir); var result = await RunProcess("dotnet", $"build \"{tempDir}\" -c Release -nodeReuse:false", tempDir); Assert.True(result.ExitCode == 0, $"dotnet build failed:\n{result.Output}\n{result.Error}"); // Allow build processes to fully release file handles before cleanup await Task.Delay(2000); // Verify the markdown was processed (generated header indicates task ran) var outputMd = Path.Combine(tempDir, "docs", "readme.md"); Assert.True(File.Exists(outputMd), $"Output markdown should exist at {outputMd}"); var content = await File.ReadAllTextAsync(outputMd); Assert.Contains("GENERATED FILE", content); } [Fact] public async Task MsBuild_UsesNetFrameworkTask() { var msbuildPath = FindMsBuild(); if (msbuildPath == null) { // Skip if msbuild.exe not found return; } using var tempDir = new TempDirectory(); await SetupTestProject(tempDir); var result = await RunProcess(msbuildPath, $"\"{tempDir}\" /p:Configuration=Release /restore /nodeReuse:false -verbosity:minimal", tempDir); Assert.True(result.ExitCode == 0, $"msbuild failed:\n{result.Output}\n{result.Error}"); // Allow MSBuild processes to fully release file handles before cleanup await Task.Delay(2000); // Verify the markdown was processed (generated header indicates task ran) var outputMd = Path.Combine(tempDir, "docs", "readme.md"); Assert.True(File.Exists(outputMd), $"Output markdown should exist at {outputMd}"); var content = await File.ReadAllTextAsync(outputMd); Assert.Contains("GENERATED FILE", content); } static async Task SetupTestProject(TempDirectory tempDir) { // Find the latest nuget version var nugetVersion = GetLatestNugetVersion(); // Create nuget.config pointing to local nugets folder // Use a local packages folder to avoid global cache issues when testing local package changes var localPackagesFolder = Path.Combine(tempDir, "packages"); var nugetConfig = $""" """; await File.WriteAllTextAsync(Path.Combine(tempDir, "nuget.config"), nugetConfig); // Create a minimal csproj var csproj = $""" net8.0 Library """; await File.WriteAllTextAsync(Path.Combine(tempDir, "TestProject.csproj"), csproj); // Create a simple C# file with a snippet var csFile = """ using System; public class Sample { public void Method() { // begin-snippet: MySnippet var message = "Hello from snippet"; Console.WriteLine(message); // end-snippet } } """; await File.WriteAllTextAsync(Path.Combine(tempDir, "Sample.cs"), csFile); // Initialize git repo (required by MarkdownSnippets) await RunProcess("git", "init", tempDir); await RunProcess("git", "config user.email \"test@test.com\"", tempDir); await RunProcess("git", "config user.name \"Test\"", tempDir); // Create docs directory and source.md var docsDir = Path.Combine(tempDir, "docs"); Directory.CreateDirectory(docsDir); var sourceMd = """ Test Document """; await File.WriteAllTextAsync(Path.Combine(docsDir, "readme.source.md"), sourceMd); } static string GetLatestNugetVersion() { var packages = Directory.GetFiles(GetNugetsDir(), "MarkdownSnippets.MsBuild.*.nupkg") .Select(Path.GetFileNameWithoutExtension) .Where(_ => _ != null) .Select(_ => _!.Replace("MarkdownSnippets.MsBuild.", "")) .OrderByDescending(_ => _) .FirstOrDefault(); return packages ?? throw new InvalidOperationException("No MarkdownSnippets.MsBuild nuget found. Run Release build first."); } static string? FindMsBuild() { // Try to find msbuild.exe via vswhere var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); var vswherePath = Path.Combine(programFiles, "Microsoft Visual Studio", "Installer", "vswhere.exe"); if (!File.Exists(vswherePath)) { return null; } var process = Process.Start( new ProcessStartInfo { FileName = vswherePath, Arguments = @"-latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true })!; process.WaitForExit(); var msbuildPath = process.StandardOutput.ReadToEnd().Trim().Split('\n').FirstOrDefault()?.Trim(); if (string.IsNullOrEmpty(msbuildPath) || !File.Exists(msbuildPath)) { return null; } return msbuildPath; } static async Task RunProcess(string fileName, string arguments, string workingDirectory) { var psi = new ProcessStartInfo { FileName = fileName, Arguments = arguments, WorkingDirectory = workingDirectory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(psi)!; var output = await process.StandardOutput.ReadToEndAsync(); var error = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); return new(process.ExitCode, output, error); } record ProcessResult(int ExitCode, string Output, string Error); [Fact(Explicit = true)] public async Task MsBuild_AllLocalProjects_UsingMarkdownSnippets() { var msbuildPath = FindMsBuild(); if (msbuildPath == null) { // Skip if msbuild.exe not found return; } var codeRoots = new[] { @"C:\Code", @"D:\Code" } .Where(Directory.Exists) .ToList(); if (codeRoots.Count == 0) { // Skip if no code directories exist return; } var projectsUsingMdSnippets = codeRoots .SelectMany(FindProjectsUsingMarkdownSnippets) .ToList(); if (projectsUsingMdSnippets.Count == 0) { return; } var failures = new List<(string Directory, string Error)>(); foreach (var projectDir in projectsUsingMdSnippets) { // Skip MarkdownSnippets itself if (projectDir.Contains("MarkdownSnippets", StringComparison.OrdinalIgnoreCase) && projectDir.Contains(Path.Combine("Code", "MarkdownSnippets"), StringComparison.OrdinalIgnoreCase)) { continue; } var result = await RunMsBuildOnProject(msbuildPath, projectDir); if (result.ExitCode != 0) { // Extract just the error lines (lines containing "error") var errorLines = ExtractErrorLines(result.Output + "\n" + result.Error); failures.Add((projectDir, errorLines)); } } // Generate markdown report var reportPath = Path.Combine(GetNugetsDir(), "msbuild-test-report.md"); await GenerateMarkdownReport(reportPath, projectsUsingMdSnippets.Count, failures); // Output report location if (failures.Count > 0) { Assert.Fail($"MSBuild failed for {failures.Count} projects. Report written to: {reportPath}\n\n" + $"Summary:\n{string.Join("\n", failures.Select(f => $" - {f.Directory}"))}"); } } static List FindProjectsUsingMarkdownSnippets(string rootDir) { var results = new List(); try { // Find all Directory.Packages.props files var propsFiles = Directory.GetFiles(rootDir, "Directory.Packages.props", SearchOption.AllDirectories); foreach (var propsFile in propsFiles) { try { var content = File.ReadAllText(propsFile); if (content.Contains("MarkdownSnippets.MsBuild", StringComparison.OrdinalIgnoreCase)) { // Get the directory containing this props file (the repo root or src folder) var dir = Path.GetDirectoryName(propsFile)!; results.Add(dir); } } catch { // Ignore files we can't read } } } catch { // Ignore directories we can't access } return results; } static Task RunMsBuildOnProject(string msbuildPath, string projectDir) { // Find a solution file (.sln or .slnx) var slnFiles = Directory.GetFiles(projectDir, "*.sln"); var slnxFiles = Directory.GetFiles(projectDir, "*.slnx"); string targetFile; if (slnFiles.Length > 0) { targetFile = slnFiles[0]; } else if (slnxFiles.Length > 0) { targetFile = slnxFiles[0]; } else { // Try to find in subdirectories slnFiles = Directory.GetFiles(projectDir, "*.sln", SearchOption.AllDirectories); if (slnFiles.Length > 0) { targetFile = slnFiles[0]; } else { slnxFiles = Directory.GetFiles(projectDir, "*.slnx", SearchOption.AllDirectories); if (slnxFiles.Length > 0) { targetFile = slnxFiles[0]; } else { throw new InvalidOperationException($"No .sln or .slnx found in {projectDir}"); } } } var arguments = $"\"{targetFile}\" /p:Configuration=Release /restore /nodeReuse:false /t:Build -verbosity:minimal -maxcpucount:1"; return RunProcess(msbuildPath, arguments, projectDir); } static string ExtractErrorLines(string output) { var lines = output.Split('\n'); var errorLines = lines .Where(l => // Standard MSBuild error format: "path(line,col): error CODE: message" l.Contains(": error ", StringComparison.OrdinalIgnoreCase) || // Error prefix format l.Contains("error MSB", StringComparison.OrdinalIgnoreCase) || l.Contains("error CS", StringComparison.OrdinalIgnoreCase) || l.Contains("error FS", StringComparison.OrdinalIgnoreCase) || l.Contains("error NU", StringComparison.OrdinalIgnoreCase) || // MarkdownSnippets specific errors (l.Contains("MarkdownSnippets", StringComparison.OrdinalIgnoreCase) && l.Contains("error", StringComparison.OrdinalIgnoreCase)) || // MSBUILD : error format (no file path) l.TrimStart().StartsWith("MSBUILD : error", StringComparison.OrdinalIgnoreCase) || // Build failed line l.Contains("Build FAILED", StringComparison.OrdinalIgnoreCase) || // Custom error messages l.Contains("No .sln or .slnx found", StringComparison.OrdinalIgnoreCase)) .Select(l => l.Trim()) .Where(l => !string.IsNullOrWhiteSpace(l)) .Take(10) // Limit to first 10 error lines .ToList(); if (errorLines.Count > 0) { return string.Join("\n", errorLines); } // If no specific errors found, capture last 15 non-empty lines of output var lastLines = lines .Select(l => l.Trim()) .Where(l => !string.IsNullOrWhiteSpace(l)) .TakeLast(15) .ToList(); return lastLines.Count > 0 ? $"(Last {lastLines.Count} lines of output)\n{string.Join("\n", lastLines)}" : "Build failed (no output captured)"; } static Task GenerateMarkdownReport(string reportPath, int totalProjects, List<(string Directory, string Error)> failures) { var sb = new StringBuilder(); sb.AppendLine("# MSBuild Integration Test Report"); sb.AppendLine(); sb.AppendLine($"**Date:** {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine(); sb.AppendLine($"**Total Projects Tested:** {totalProjects}"); sb.AppendLine($"**Passed:** {totalProjects - failures.Count}"); sb.AppendLine($"**Failed:** {failures.Count}"); sb.AppendLine(); if (failures.Count == 0) { sb.AppendLine("## Result: All projects built successfully! ✓"); } else { sb.AppendLine("## Failures"); sb.AppendLine(); foreach (var (directory, error) in failures) { sb.AppendLine($"### {directory}"); sb.AppendLine(); sb.AppendLine("```"); sb.AppendLine(error); sb.AppendLine("```"); sb.AppendLine(); } } return File.WriteAllTextAsync(reportPath, sb.ToString()); } } #endif ================================================ FILE: src/Tests/NewLineConfigReaderTests.cs ================================================ public class NewLineConfigReaderTests { [Fact] public void GitAttributes_WildcardEolLf() { var directory = new TempDirectory(); File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=lf"); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void GitAttributes_WildcardEolCrlf() { var directory = new TempDirectory(); File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=crlf"); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\r\n", result); } [Fact] public void GitAttributes_MdSpecificEolLf() { var directory = new TempDirectory(); File.WriteAllText(Path.Combine(directory, ".gitattributes"), "*.md text eol=lf"); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void GitAttributes_MdSpecificOverridesWildcard() { var directory = new TempDirectory(); File.WriteAllText( Path.Combine(directory, ".gitattributes"), """ * text eol=crlf *.md text eol=lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void GitAttributes_NoEolSetting_FallsBackToEnvironmentNewLine() { var directory = new TempDirectory(); File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text"); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal(Environment.NewLine, result); } [Fact] public void GitAttributes_IgnoresComments() { var directory = new TempDirectory(); File.WriteAllText( Path.Combine(directory, ".gitattributes"), """ # comment eol=crlf * text eol=lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void GitAttributes_InParentDirectory() { var directory = new TempDirectory(); var childDir = Path.Combine(directory, "child"); Directory.CreateDirectory(childDir); File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=lf"); var result = NewLineConfigReader.ReadNewLine(childDir, []); Assert.Equal("\n", result); } [Fact] public void EditorConfig_WildcardEndOfLineLf() { var directory = new TempDirectory(); File.WriteAllText( Path.Combine(directory, ".editorconfig"), """ [*] end_of_line = lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void EditorConfig_WildcardEndOfLineCrlf() { var directory = new TempDirectory(); File.WriteAllText( Path.Combine(directory, ".editorconfig"), """ [*] end_of_line = crlf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\r\n", result); } [Fact] public void EditorConfig_MdSpecificEndOfLine() { var directory = new TempDirectory(); File.WriteAllText( Path.Combine(directory, ".editorconfig"), """ [*.md] end_of_line = lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void EditorConfig_MdSpecificOverridesWildcard() { var directory = new TempDirectory(); File.WriteAllText( Path.Combine(directory, ".editorconfig"), """ [*] end_of_line = crlf [*.md] end_of_line = lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void EditorConfig_BracePattern() { var directory = new TempDirectory(); File.WriteAllText( Path.Combine(directory, ".editorconfig"), """ [*.{md,txt}] end_of_line = lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void EditorConfig_IgnoresComments() { var directory = new TempDirectory(); File.WriteAllText( Path.Combine(directory, ".editorconfig"), """ # comment ; another comment [*] end_of_line = lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void GitAttributes_TakesPriorityOverEditorConfig() { var directory = new TempDirectory(); File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=crlf"); File.WriteAllText( Path.Combine(directory, ".editorconfig"), """ [*] end_of_line = lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\r\n", result); } [Fact] public void NoConfigFiles_FallsBackToEnvironmentNewLine() { var directory = new TempDirectory(); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal(Environment.NewLine, result); } [Fact] public void EditorConfig_FallbackWhenGitAttributesHasNoEol() { var directory = new TempDirectory(); File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text"); File.WriteAllText( Path.Combine(directory, ".editorconfig"), """ [*] end_of_line = lf """); var result = NewLineConfigReader.ReadNewLine(directory, []); Assert.Equal("\n", result); } [Fact] public void DetectsNewLineFromMdFiles_Lf() { var directory = new TempDirectory(); var mdFile = Path.Combine(directory, "test.md"); File.WriteAllText(mdFile, "line1\nline2\n"); var result = NewLineConfigReader.ReadNewLine(directory, [mdFile]); Assert.Equal("\n", result); } [Fact] public void DetectsNewLineFromMdFiles_Crlf() { var directory = new TempDirectory(); var mdFile = Path.Combine(directory, "test.md"); File.WriteAllText(mdFile, "line1\r\nline2\r\n"); var result = NewLineConfigReader.ReadNewLine(directory, [mdFile]); Assert.Equal("\r\n", result); } [Fact] public void ConfigTakesPriorityOverMdFileDetection() { var directory = new TempDirectory(); File.WriteAllText(Path.Combine(directory, ".gitattributes"), "* text eol=lf"); var mdFile = Path.Combine(directory, "test.md"); File.WriteAllText(mdFile, "line1\r\nline2\r\n"); var result = NewLineConfigReader.ReadNewLine(directory, [mdFile]); Assert.Equal("\n", result); } [Fact] public void MdFileDetection_PicksShortestFileFirst() { var directory = new TempDirectory(); var shortFile = Path.Combine(directory, "a.md"); var longFile = Path.Combine(directory, "longer-name.md"); File.WriteAllText(shortFile, "line1\nline2\n"); File.WriteAllText(longFile, "line1\r\nline2\r\n"); var result = NewLineConfigReader.ReadNewLine(directory, [longFile, shortFile]); Assert.Equal("\n", result); } [Fact] public void MdFileDetection_SkipsFilesWithNoNewlines() { var directory = new TempDirectory(); var noNewlineFile = Path.Combine(directory, "a.md"); var withNewlineFile = Path.Combine(directory, "bb.md"); File.WriteAllText(noNewlineFile, "no newlines here"); File.WriteAllText(withNewlineFile, "has\nnewlines"); var result = NewLineConfigReader.ReadNewLine(directory, [noNewlineFile, withNewlineFile]); Assert.Equal("\n", result); } } ================================================ FILE: src/Tests/PathsTests.cs ================================================ public class PathsTests { [Theory] [InlineData("file.md", true)] [InlineData("file.mdx", true)] [InlineData("file.MD", true)] [InlineData("file.MDX", true)] [InlineData("file.Md", true)] [InlineData("file.txt", false)] [InlineData("file.mdxx", false)] public void IsMdFile(string value, bool expected) => Assert.Equal(expected, value.IsMdFile()); [Theory] [InlineData("file.source.md", true)] [InlineData("file.source.mdx", true)] [InlineData("file.SOURCE.MD", true)] [InlineData("file.Source.Mdx", true)] [InlineData("file.md", false)] [InlineData("file.mdx", false)] public void IsSourceMdFile(string value, bool expected) => Assert.Equal(expected, value.IsSourceMdFile()); [Theory] [InlineData("file.include.md", true)] [InlineData("file.INCLUDE.MD", true)] [InlineData("file.Include.Md", true)] [InlineData("file.include.mdx", false)] [InlineData("file.md", false)] public void IsIncludeMdFile(string value, bool expected) => Assert.Equal(expected, value.IsIncludeMdFile()); } ================================================ FILE: src/Tests/ProcessResultConverter.cs ================================================ class ProcessResultConverter : JsonConverter { public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { var processResult = (ProcessResult)value; writer.WriteStartObject(); writer.WritePropertyName("missing"); serializer.Serialize(writer, processResult.MissingSnippets); writer.WritePropertyName("usedSnippets"); serializer.Serialize(writer, processResult.UsedSnippets); writer.WriteEndObject(); } public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) => throw new NotImplementedException(); public override bool CanConvert(Type objectType) => objectType == typeof(ProcessResult); } ================================================ FILE: src/Tests/SimpleSnippetMarkdownHandlingTests.Append.verified.txt ================================================ ```thelanguage theValue ``` ================================================ FILE: src/Tests/SimpleSnippetMarkdownHandlingTests.ExpressiveCode.verified.txt ================================================ ```cs title="HelloWorld.cs" {1} Console.WriteLine("Hello World"); ``` ================================================ FILE: src/Tests/SimpleSnippetMarkdownHandlingTests.cs ================================================ public class SimpleSnippetMarkdownHandlingTests { [Fact] public Task Append() { var builder = new StringBuilder(); var snippets = new List {Snippet.Build(1, 2, "theValue", "thekey", "thelanguage", "thePath", null)}; using (var writer = new StringWriter(builder)) { SimpleSnippetMarkdownHandling.Append("key1", snippets, writer.WriteLine); } return Verify(builder.ToString()); } [Fact] public Task ExpressiveCode() { var builder = new StringBuilder(); var snippets = new List {Snippet.Build(1, 2, """Console.WriteLine("Hello World");""", "thekey", "cs", "thePath", """title="HelloWorld.cs" {1}""")}; using (var writer = new StringWriter(builder)) { SimpleSnippetMarkdownHandling.Append("key1", snippets, writer.WriteLine); } return Verify(builder.ToString()); } } ================================================ FILE: src/Tests/SnippetConverter.cs ================================================ class SnippetConverter : JsonConverter { public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { var snippet = (Snippet)value; writer.WriteStartObject(); writer.WritePropertyName("Key"); serializer.Serialize(writer, snippet.Key); if (!snippet.IsInError) { writer.WritePropertyName("Language"); serializer.Serialize(writer, snippet.Language); writer.WritePropertyName("Value"); serializer.Serialize(writer, snippet.Value); } writer.WritePropertyName("Error"); serializer.Serialize(writer, snippet.Error); writer.WritePropertyName("FileLocation"); serializer.Serialize(writer, snippet.FileLocation); writer.WritePropertyName("IsInError"); serializer.Serialize(writer, snippet.IsInError); writer.WriteEndObject(); } public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) => throw new NotImplementedException(); public override bool CanConvert(Type objectType) => objectType == typeof(Snippet); } ================================================ FILE: src/Tests/SnippetExtensionsTests.ToDictionary.verified.txt ================================================ { snippet1: [ { Key: snippet1, Language: language, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ], snippet2: [ { Key: snippet2, Language: language, Value: Snippet, Error: , FileLocation: thePath(1-2), IsInError: false } ] } ================================================ FILE: src/Tests/SnippetExtensionsTests.ToDictionary_SameKey.verified.txt ================================================ { snippet1: [ { Key: snippet1, Language: language, Value: Snippet, Error: , FileLocation: null, IsInError: false }, { Key: snippet1, Language: language, Value: Snippet, Error: , FileLocation: thePath1(1-2), IsInError: false }, { Key: snippet1, Language: language, Value: Snippet, Error: , FileLocation: thePath2(1-2), IsInError: false } ] } ================================================ FILE: src/Tests/SnippetExtensionsTests.cs ================================================ public class SnippetExtensionsTests { [Fact] public Task ToDictionary() { var snippets = new List { SnippetBuild("snippet1", "thePath"), SnippetBuild("snippet2", "thePath") }; return Verify(snippets.ToDictionary()); } [Fact] public Task ToDictionary_SameKey() { var snippets = new List { SnippetBuild("snippet1", null), SnippetBuild("snippet1", "thePath2"), SnippetBuild("snippet1", "thePath1") }; return Verify(snippets.ToDictionary()); } static Snippet SnippetBuild(string key, string? path) => Snippet.Build( language: "language", startLine: 1, endLine: 2, value: "Snippet", key: key, path: path, expressiveCode: null); } ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.AppendFileAsSnippet.verified.txt ================================================ [ { Key: File.tmp, Language: tmp, Value: Foo, Error: , FileLocation: FilePath.txt(1-1), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.AppendUrlAsSnippet.verified.txt ================================================ [ { Key: appveyor.yml, Language: yml, Value: image: - Visual Studio 2022 #- macOS environment: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true build_script: - pwsh: | if ($isWindows) { Invoke-WebRequest "https://dot.net/v1/dotnet-install.ps1" -OutFile "./dotnet-install.ps1" ./dotnet-install.ps1 -JSonFile src/global.json -Architecture x64 -InstallDir 'C:/Program Files/dotnet' } else { Invoke-WebRequest "https://dot.net/v1/dotnet-install.sh" -OutFile "./dotnet-install.sh" sudo chmod u+x dotnet-install.sh sudo ./dotnet-install.sh --jsonfile src/global.json --architecture x64 --install-dir '/usr/local/share/dotnet' sudo ./dotnet-install.sh --version 9.0.306 --architecture x64 --install-dir '/usr/local/share/dotnet' } - dotnet build src --configuration Release - dotnet test src --configuration Release --no-build --no-restore test: off on_failure: - ps: Get-ChildItem *.received.* -recurse | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } artifacts: - path: nugets/*.nupkg, Error: , FileLocation: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/src/appveyor.yml(1-26), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.AppendUrlAsSnippetInline.verified.txt ================================================ [ { Key: usage.cs, Language: cs, Value: // ReSharper disable UnusedVariable class Usage { static void ReadingFiles() { var files = Directory.EnumerateFiles(@"C:/path", "*.cs", SearchOption.AllDirectories); var snippets = FileSnippetExtractor.Read(files); } static void DirectoryMarkdownProcessorRun() { var processor = new DirectoryMarkdownProcessor( "targetDirectory", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); } static void DirectoryMarkdownProcessorRunMaxWidth() { var processor = new DirectoryMarkdownProcessor( "targetDirectory", maxWidth: 80, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); } }, Error: , FileLocation: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/src/Tests/Snippets/Usage.cs(1-45), IsInError: false }, { Key: ReadingFilesSimple, Language: cs, Value: var files = Directory.EnumerateFiles(@"C:/path", "*.cs", SearchOption.AllDirectories); var snippets = FileSnippetExtractor.Read(files);, Error: , FileLocation: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/src/Tests/Snippets/Usage.cs(8-14), IsInError: false }, { Key: DirectoryMarkdownProcessorRun, Language: cs, Value: var processor = new DirectoryMarkdownProcessor( "targetDirectory", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run();, Error: , FileLocation: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/src/Tests/Snippets/Usage.cs(19-28), IsInError: false }, { Key: DirectoryMarkdownProcessorRunMaxWidth, Language: cs, Value: var processor = new DirectoryMarkdownProcessor( "targetDirectory", maxWidth: 80, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run();, Error: , FileLocation: https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/src/Tests/Snippets/Usage.cs(33-43), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.CanExtractFromRegion.verified.txt ================================================ [ { Key: CodeKey, Language: cs, Value: The Code, Error: , FileLocation: path.cs(2-4), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.CanExtractFromXml.verified.txt ================================================ [ { Key: CodeKey, Language: cs, Value: , Error: , FileLocation: path.cs(1-3), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.CanExtractWithExpressiveCode.verified.txt ================================================ [ { Key: CodeKey, Language: cs, Value: Console.WriteLine("Hello World");, Error: , FileLocation: path.cs(1-3), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.CanExtractWithInnerWhiteSpace.verified.txt ================================================ [ { Key: CodeKey, Language: cs, Value: BeforeWhiteSpace AfterWhiteSpace, Error: , FileLocation: path.cs(2-8), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.CanExtractWithMissingSpaces.verified.txt ================================================ [ { Key: CodeKey, Language: cs, Value: , Error: , FileLocation: path.cs(2-4), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.CanExtractWithNoTrailingCharacters.verified.txt ================================================ [ { Key: CodeKey, Language: cs, Value: the code, Error: , FileLocation: path.cs(2-4), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.CanExtractWithTrailingWhitespace.verified.txt ================================================ [ { Key: CodeKey, Language: cs, Value: the code, Error: , FileLocation: path.cs(2-4), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.CanReadFileWhileLockedByAnotherProcess.verified.txt ================================================ [ { Key: CodeKey, Language: cs, Value: The Code, Error: , FileLocation: LockedFile.cs(1-3), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.LanguageOverride.verified.txt ================================================ [ { Key: CodeKey, Language: json, Value: {"a": 1}, Error: , FileLocation: path.cs(1-3), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.LanguageOverrideWithExpressiveCode.verified.txt ================================================ [ { Key: CodeKey, Language: json, Value: {"a": 1}, Error: , FileLocation: path.cs(1-3), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.MixedNewLines.verified.txt ================================================ { Key: CodeKey, Language: cs, Value: A B C D, Error: , FileLocation: path.cs(1-6), IsInError: false } ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.NestedBroken.verified.txt ================================================ [ { Key: KeyChild, Language: cs, Value: b c, Error: , FileLocation: path.cs(4-7), IsInError: false }, { Key: KeyParent, Error: Snippet was not closed, FileLocation: path.cs(3-3), IsInError: true } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.NestedMixed1.verified.txt ================================================ [ { Key: KeyChild, Language: cs, Value: b, Error: , FileLocation: path.cs(3-5), IsInError: false }, { Key: KeyParent, Language: cs, Value: a b c, Error: , FileLocation: path.cs(1-7), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.NestedMixed2.verified.txt ================================================ [ { Key: KeyChild, Language: cs, Value: b, Error: , FileLocation: path.cs(3-5), IsInError: false }, { Key: KeyParent, Language: cs, Value: a b c, Error: , FileLocation: path.cs(1-7), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.NestedRegion.verified.txt ================================================ [ { Key: KeyChild, Language: cs, Value: b, Error: , FileLocation: path.cs(4-6), IsInError: false }, { Key: KeyParent, Language: cs, Value: a b c, Error: , FileLocation: path.cs(2-8), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.NestedStartCode.verified.txt ================================================ [ { Key: KeyChild, Language: cs, Value: b, Error: , FileLocation: path.cs(3-5), IsInError: false }, { Key: KeyParent, Language: cs, Value: a b c, Error: , FileLocation: path.cs(1-7), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.RemoveDuplicateNewlines.verified.txt ================================================ [ { Key: KeyChild, Language: cs, Value: b, Error: , FileLocation: path.cs(8-14), IsInError: false }, { Key: KeyParent, Language: cs, Value: a b c, Error: , FileLocation: path.cs(2-20), IsInError: false } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.TooWide.verified.txt ================================================ [ { Key: CodeKey, Error: Line too long: caaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab, FileLocation: path.cs(3-3), IsInError: true } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.UnClosedRegion.verified.txt ================================================ [ { Key: CodeKey, Error: Snippet was not closed, FileLocation: path.cs(3-3), IsInError: true } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.UnClosedSnippet.verified.txt ================================================ [ { Key: CodeKey, Error: Snippet was not closed, FileLocation: path.cs(2-2), IsInError: true } ] ================================================ FILE: src/Tests/SnippetExtractor/SnippetExtractorTests.cs ================================================ public class SnippetExtractorTests { [Fact] public async Task AppendUrlAsSnippet() { var snippets = new List(); await snippets.AppendUrlAsSnippet("https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/src/appveyor.yml"); await Verify(snippets); } [Fact] public async Task AppendUrlAsSnippetInline() { var snippets = new List(); await snippets.AppendUrlAsSnippet("https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/src/Tests/Snippets/Usage.cs"); await Verify(snippets).ScrubLinesContaining("#region", "#endregion"); } [Fact] public async Task AppendFileAsSnippet() { var temp = Path.GetTempFileName().ToLowerInvariant(); try { await File.WriteAllTextAsync(temp, "Foo"); var snippets = new List(); snippets.AppendFileAsSnippet(temp); await Verify(snippets) .AddScrubber(_ => { var nameWithoutExtension = Path.GetFileNameWithoutExtension(temp); _.Replace(temp, "FilePath.txt"); _.Replace(nameWithoutExtension, "File"); }); } finally { File.Delete(temp); } } [Fact] public Task CanReadFileWhileLockedByAnotherProcess() { var temp = Path.Combine(Path.GetTempPath(), "LockedSnippetFile.cs"); try { File.WriteAllText(temp, """ #region CodeKey The Code #endregion """); using var lockingStream = new FileStream(temp, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); var snippets = FileSnippetExtractor.Read(temp); return Verify(snippets) .AddScrubber(_ => _.Replace(temp, "LockedFile.cs")); } finally { File.Delete(temp); } } [Fact] public Task CanExtractWithInnerWhiteSpace() { var input = """ #region CodeKey BeforeWhiteSpace AfterWhiteSpace #endregion """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task NestedBroken() { var input = """ #region KeyParent a #region KeyChild b c #endregion """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task NestedRegion() { var input = """ #region KeyParent a #region KeyChild b #endregion c #endregion """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task NestedMixed2() { var input = """ #region KeyParent a b c #endregion """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task RemoveDuplicateNewlines() { var input = """ a b c """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task NestedStartCode() { var input = """ a b c """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task NestedMixed1() { var input = """ a #region KeyChild b #endregion c """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task CanExtractFromXml() { var input = """ """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task LanguageOverride() { var input = """ {"a": 1} """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task LanguageOverrideWithExpressiveCode() { var input = """ {"a": 1} """; var snippets = FromText(input); return Verify(snippets); } static List FromText(string contents) { using var reader = new StringReader(contents); return FileSnippetExtractor.Read(reader, "path.cs", 80).ToList(); } [Fact] public Task UnClosedSnippet() { var input = """ """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task UnClosedRegion() { var input = """ #region CodeKey """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task TooWide() { var input = """ #region CodeKey caaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab #endregion """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task MixedNewLines() { var input = "#region CodeKey\r A\r\n B\r C\n D\n #endregion"; var snippets = FromText(input); var single = snippets.Single(); var value = single.Value; Assert.DoesNotContain("\r\n", value); Assert.DoesNotContain("\r", value); return Verify(single); } [Fact] public Task CanExtractFromRegion() { var input = """ #region CodeKey The Code #endregion """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task CanExtractWithNoTrailingCharacters() { var input = """ // begin-snippet: CodeKey the code // end-snippet """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task CanExtractWithMissingSpaces() { var input = """ """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task CanExtractWithTrailingWhitespace() { var input = """ // begin-snippet: CodeKey the code // end-snippet """; var snippets = FromText(input); return Verify(snippets); } [Fact] public Task CanExtractWithExpressiveCode() { var input = """ Console.WriteLine("Hello World"); """; var snippets = FromText(input); return Verify(snippets); } } ================================================ FILE: src/Tests/SnippetFileFinder/Nested/nested/nested/code.txt ================================================ begin-snippet: nestedsnippet Some code end-snippet ================================================ FILE: src/Tests/SnippetFileFinder/Simple/code1.txt ================================================ begin-snippet: snippet2 Some code end-snippet ================================================ FILE: src/Tests/SnippetFileFinder/Simple/code2.txt ================================================ begin-snippet: snippet1 Some code end-snippet ================================================ FILE: src/Tests/SnippetFileFinder/Simple/code3.txt ================================================ begin-snippet: snippet2 Some code end-snippet ================================================ FILE: src/Tests/SnippetFileFinder/Simple/code4.txt ================================================ begin-snippet: snippet1 Some code end-snippet ================================================ FILE: src/Tests/SnippetFileFinder/VerifyLambdasAreCalled/subpath/code4.txt ================================================ begin-snippet: snippet1 Some code end-snippet ================================================ FILE: src/Tests/SnippetFileFinderTests.ExcludeSnippetFiles.verified.txt ================================================ [ {ProjectDirectory}SnippetFileFinder/Simple/code1.txt, {ProjectDirectory}SnippetFileFinder/Simple/code3.txt ] ================================================ FILE: src/Tests/SnippetFileFinderTests.Nested.verified.txt ================================================ [ {ProjectDirectory}SnippetFileFinder/Nested/nested/nested/code.txt ] ================================================ FILE: src/Tests/SnippetFileFinderTests.Simple.verified.txt ================================================ [ {ProjectDirectory}SnippetFileFinder/Simple/code1.txt, {ProjectDirectory}SnippetFileFinder/Simple/code2.txt, {ProjectDirectory}SnippetFileFinder/Simple/code3.txt, {ProjectDirectory}SnippetFileFinder/Simple/code4.txt ] ================================================ FILE: src/Tests/SnippetFileFinderTests.VerifyLambdasAreCalled.verified.txt ================================================ { directories: [ {ProjectDirectory}SnippetFileFinder/VerifyLambdasAreCalled/subpath ], markdownDirectories: [ {ProjectDirectory}SnippetFileFinder/VerifyLambdasAreCalled, {ProjectDirectory}SnippetFileFinder/VerifyLambdasAreCalled/subpath ], snippetDirectories: [ {ProjectDirectory}SnippetFileFinder/VerifyLambdasAreCalled, {ProjectDirectory}SnippetFileFinder/VerifyLambdasAreCalled/subpath ] } ================================================ FILE: src/Tests/SnippetFileFinderTests.cs ================================================ // ReSharper disable UnusedVariable public class SnippetFileFinderTests { [Fact] public void Hidden() { var path = Path.Combine(Path.GetTempPath(), "mdsnippetsHidden"); var directory = new DirectoryInfo(path); directory.Create(); directory.Attributes = FileAttributes.Directory | FileAttributes.Hidden; Assert.True(DefaultDirectoryExclusions.ShouldExcludeDirectory(path)); } [Fact] public Task Nested() { var directory = Path.Combine(ProjectFiles.ProjectDirectory, "SnippetFileFinder/Nested"); var finder = new FileFinder(directory, DocumentConvention.SourceTransform, _ => true, _ => true, _ => true); var files = finder.FindFiles(); return Verify(files.snippetFiles); } [Fact] public Task Simple() { var directory = Path.Combine(ProjectFiles.ProjectDirectory, "SnippetFileFinder/Simple"); var finder = new FileFinder(directory, DocumentConvention.SourceTransform, _ => true, _ => true, _ => true); var files = finder.FindFiles(); return Verify(files.snippetFiles); } [Fact] public Task ExcludeSnippetFiles() { var directory = Path.Combine(ProjectFiles.ProjectDirectory, "SnippetFileFinder/Simple"); var finder = new FileFinder( directory, DocumentConvention.SourceTransform, _ => true, _ => true, _ => true, snippetFileIncludes: path => { var name = Path.GetFileName(path); return name != "code2.txt" && name != "code4.txt"; }); var files = finder.FindFiles(); return Verify(files.snippetFiles); } [Fact] public Task VerifyLambdasAreCalled() { var directories = new ConcurrentBag(); var snippetDirectories = new ConcurrentBag(); var markdownDirectories = new ConcurrentBag(); var directory = Path.Combine(ProjectFiles.ProjectDirectory, "SnippetFileFinder/VerifyLambdasAreCalled"); var finder = new FileFinder( directory, DocumentConvention.SourceTransform, directoryIncludes: path => { directories.Add(path); return true; }, markdownDirectoryIncludes: path => { markdownDirectories.Add(path); return true; }, snippetDirectoryIncludes: path => { snippetDirectories.Add(path); return true; }); var files = finder.FindFiles(); return Verify(new { directories = directories.OrderBy(_ => _), markdownDirectories = markdownDirectories.OrderBy(_ => _), snippetDirectories = snippetDirectories.OrderBy(_ => _), }); } } ================================================ FILE: src/Tests/SnippetMarkdownHandlingTests.Append.verified.txt ================================================  ```thelanguage theValue ``` snippet source | anchor ================================================ FILE: src/Tests/SnippetMarkdownHandlingTests.AppendHashed.verified.txt ================================================  ```thelanguage theValue ``` snippet source | anchor ================================================ FILE: src/Tests/SnippetMarkdownHandlingTests.AppendOmitSnippetLinks.verified.txt ================================================ ```thelanguage theValue ``` ================================================ FILE: src/Tests/SnippetMarkdownHandlingTests.AppendOmitSourceLink.verified.txt ================================================  ```thelanguage theValue ``` anchor ================================================ FILE: src/Tests/SnippetMarkdownHandlingTests.AppendPrefixed.verified.txt ================================================  ```thelanguage theValue ``` snippet source | anchor ================================================ FILE: src/Tests/SnippetMarkdownHandlingTests.AppendWebSnippet.verified.txt ================================================  ```cs theValue ``` anchor ================================================ FILE: src/Tests/SnippetMarkdownHandlingTests.AppendWebSnippetWithViewUrl.verified.txt ================================================  ```cs theValue ``` anchor ================================================ FILE: src/Tests/SnippetMarkdownHandlingTests.cs ================================================ public class SnippetMarkdownHandlingTests { [Fact] public Task Append() { var builder = new StringBuilder(); var snippets = Snippets(); var markdownHandling = new SnippetMarkdownHandling(Environment.CurrentDirectory, LinkFormat.GitHub, false); using (var writer = new StringWriter(builder)) { markdownHandling.Append("key1", snippets, writer.WriteLine); } return Verify(builder.ToString()); } [Fact] public Task AppendOmitSourceLink() { var builder = new StringBuilder(); var snippets = Snippets(); var markdownHandling = new SnippetMarkdownHandling(Environment.CurrentDirectory, LinkFormat.None, false); using (var writer = new StringWriter(builder)) { markdownHandling.Append("key1", snippets, writer.WriteLine); } return Verify(builder.ToString()); } [Fact] public Task AppendOmitSnippetLinks() { var builder = new StringBuilder(); var snippets = Snippets(); var markdownHandling = new SnippetMarkdownHandling(Environment.CurrentDirectory, LinkFormat.GitHub, true); using (var writer = new StringWriter(builder)) { markdownHandling.Append("key1", snippets, writer.WriteLine); } return Verify(builder.ToString()); } [Fact] public Task AppendPrefixed() { var builder = new StringBuilder(); var snippets = Snippets(); var markdownHandling = new SnippetMarkdownHandling(Environment.CurrentDirectory, LinkFormat.GitHub, false, "prefix-"); using (var writer = new StringWriter(builder)) { markdownHandling.Append("key1", snippets, writer.WriteLine); } return Verify(builder.ToString()); } [Fact] public Task AppendHashed() { var builder = new StringBuilder(); var snippets = Snippets(); var markdownHandling = new SnippetMarkdownHandling(Environment.CurrentDirectory, LinkFormat.GitHub, false); using (var writer = new StringWriter(builder)) { markdownHandling.Append("key1", snippets, writer.WriteLine); } return Verify(builder.ToString()); } [Fact] public Task AppendWebSnippet() { var builder = new StringBuilder(); var webSnippet = Snippet.Build( startLine: 1, endLine: 2, value: "theValue", key: "mysnippet", language: "cs", path: "http://example.com/file.cs", expressiveCode: null); var markdownHandling = new SnippetMarkdownHandling(Environment.CurrentDirectory, LinkFormat.GitHub, false); using (var writer = new StringWriter(builder)) { markdownHandling.Append("key1", new List { webSnippet }, writer.WriteLine); } return Verify(builder.ToString()); } [Fact] public Task AppendWebSnippetWithViewUrl() { var builder = new StringBuilder(); var webSnippet = Snippet.Build( startLine: 5, endLine: 10, value: "theValue", key: "mysnippet", language: "cs", path: "http://example.com/raw/file.cs", expressiveCode: null, viewUrl: "https://github.com/user/repo/blob/main/file.cs"); var markdownHandling = new SnippetMarkdownHandling(Environment.CurrentDirectory, LinkFormat.GitHub, false); using (var writer = new StringWriter(builder)) { markdownHandling.Append("key1", new List { webSnippet }, writer.WriteLine); } return Verify(builder.ToString()); } static List Snippets() => [Snippet.Build(1, 2, "theValue", "thekey", "thelanguage", Environment.CurrentDirectory, expressiveCode: null)]; } ================================================ FILE: src/Tests/SnippetVerifier.cs ================================================ static class SnippetVerifier { public static Task VerifyThrows( DocumentConvention convention, string markdownContent, IReadOnlyList? snippets = null, IReadOnlyList? snippetSourceFiles = null, IReadOnlyList? includes = null, [CallerFilePath] string sourceFile = "") { var processor = BuildProcessor(convention, snippets, snippetSourceFiles, includes); var builder = new StringBuilder(); using var reader = new StringReader(markdownContent); using var writer = new StringWriter(builder); return Throws(() => processor.Apply(reader, writer, "sourceFile"), null, sourceFile); } static MarkdownProcessor BuildProcessor( DocumentConvention convention, IReadOnlyList? snippets, IReadOnlyList? snippetSourceFiles, IReadOnlyList? includes) { includes ??= []; snippets ??= []; snippetSourceFiles ??= []; return new( convention: convention, snippets: snippets.ToDictionary(), includes: includes, appendSnippets: SimpleSnippetMarkdownHandling.Append, snippetSourceFiles: snippetSourceFiles, tocLevel: 2, writeHeader: false, targetDirectory: "c:/root", validateContent: true, allFiles: new List()); } public static async Task Verify( DocumentConvention convention, string markdownContent, List? snippets = null, IReadOnlyList? snippetSourceFiles = null, IReadOnlyList? includes = null, [CallerFilePath] string sourceFile = "") { var markdownProcessor = BuildProcessor(convention, snippets, snippetSourceFiles, includes); var stringBuilder = new StringBuilder(); using var reader = new StringReader(markdownContent); using var writer = new StringWriter(stringBuilder); var processResult = markdownProcessor.Apply(reader, writer, "sourceFile"); var result = stringBuilder.ToString(); var output = new { processResult.MissingSnippets, processResult.UsedSnippets, result }; await Verifier.Verify(output, null, sourceFile); return result; } } ================================================ FILE: src/Tests/Snippets/Usage.cs ================================================  // ReSharper disable UnusedVariable class Usage { static void ReadingFiles() { #region ReadingFilesSimple var files = Directory.EnumerateFiles(@"C:\path", "*.cs", SearchOption.AllDirectories); var snippets = FileSnippetExtractor.Read(files); #endregion } static void DirectoryMarkdownProcessorRun() { #region DirectoryMarkdownProcessorRun var processor = new DirectoryMarkdownProcessor( "targetDirectory", directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); #endregion } static void DirectoryMarkdownProcessorRunMaxWidth() { #region DirectoryMarkdownProcessorRunMaxWidth var processor = new DirectoryMarkdownProcessor( "targetDirectory", maxWidth: 80, directoryIncludes: _ => true, markdownDirectoryIncludes: _ => true, snippetDirectoryIncludes: _ => true); processor.Run(); #endregion } } ================================================ FILE: src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForEmptyLanguageValue.verified.txt ================================================ { Type: SnippetReadingException, Message: lang= must have a value. Key: CodeKey Path: file Line: } ================================================ FILE: src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForInvalidLanguageValue.verified.txt ================================================ { Type: SnippetReadingException, Message: lang value must be lowercase alphanumeric. Key: CodeKey Value: C# Path: file Line: } ================================================ FILE: src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForKeyEndingWithSymbol.verified.txt ================================================ { Type: SnippetReadingException, Message: Key cannot contain whitespace or start/end with symbols. Key: key_ Path: file Line: } ================================================ FILE: src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForKeyStartingWithSymbol.verified.txt ================================================ { Type: SnippetReadingException, Message: Key cannot contain whitespace or start/end with symbols. Key: _key Path: file Line: } ================================================ FILE: src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForNoKey.verified.txt ================================================ { Type: SnippetReadingException, Message: No Key could be derived. Path: file Line: '' } ================================================ FILE: src/Tests/StartEndTester_IsBeginSnippetTests.cs ================================================ public class StartEndTester_IsBeginSnippetTests { [Fact] public void CanExtractFromXml() { var isBeginSnippet = StartEndTester.IsBeginSnippet("", "file", out var key, out _); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); } [Fact] public Task ShouldThrowForNoKey() => Throws(() => StartEndTester.IsBeginSnippet("", "file", out _, out _)); [Fact] public void ShouldNotThrowForNoKeyWithNoSpace() => StartEndTester.IsBeginSnippet("", "file", out _, out _); [Fact] public void CanExtractFromXmlWithMissingSpaces() { var isBeginSnippet = StartEndTester.IsBeginSnippet("", "file", out var key, out _); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); } [Fact] public void CanExtractFromXmlWithExtraSpaces() { var isBeginSnippet = StartEndTester.IsBeginSnippet("", "file", out var key, out _); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); } [Fact] public void CanExtractWithNoTrailingCharacters() { var isBeginSnippet = StartEndTester.IsBeginSnippet("", "file", out var key, out _); Assert.True(isBeginSnippet); Assert.Equal("Code_Key", key); } [Fact] public void CanExtractWithDashes() { var isBeginSnippet = StartEndTester.IsBeginSnippet("", "file", out var key, out _); Assert.True(isBeginSnippet); Assert.Equal("Code-Key", key); } [Fact] public Task ShouldThrowForKeyStartingWithSymbol() => Throws(() => StartEndTester.IsBeginSnippet("", "file", out _, out _)); [Fact] public Task ShouldThrowForKeyEndingWithSymbol() => Throws(() => StartEndTester.IsBeginSnippet("", "file", out _, out _)); [Fact] public void CanExtractWithDifferentEndComments() { var isBeginSnippet = StartEndTester.IsBeginSnippet("/* begin-snippet: CodeKey */", "file", out var key,out _); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); } [Fact] public void CanExtractWithDifferentEndCommentsAndNoSpaces() { var isBeginSnippet = StartEndTester.IsBeginSnippet("/*begin-snippet: CodeKey */", "file", out var key, out _); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); } [Fact] public void CanExtractWithExpressiveCodeWithHtmlSnippet() { var isBeginSnippet = StartEndTester.IsBeginSnippet("""""", "file", out var key, out var block); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); Assert.Equal("""title="Program.cs" {1-3}""", block); } [Fact] public void CanExtractWithExpressiveCodeWithCsharpComment() { var isBeginSnippet = StartEndTester.IsBeginSnippet("""/*begin-snippet: CodeKey(title="Program.cs" {1-3})*/""", "file", out var key, out var expressive); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); Assert.Equal("""title="Program.cs" {1-3}""", expressive); } [Fact] public void CanExtractWithExpressiveCodeWithHtmlSnippetTrailingWhitespace() { var isBeginSnippet = StartEndTester.IsBeginSnippet("""""", "file", out var key, out var block); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); Assert.Equal("""title="Program.cs" {1-3}""", block); } [Fact] public void CanExtractWithExpressiveCodeWithCsharpCommentTrailingWhitespace() { var isBeginSnippet = StartEndTester.IsBeginSnippet("""/*begin-snippet: CodeKey(title="Program.cs" {1-3}) */""", "file", out var key, out var expressive); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); Assert.Equal("""title="Program.cs" {1-3}""", expressive); } [Fact] public void CanExtractLanguageOverride() { var isBeginSnippet = StartEndTester.IsBeginSnippet("", "file", out var key, out var expressive, out var language); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); Assert.Equal("json", language.ToString()); Assert.Equal(0, expressive.Length); } [Fact] public void CanExtractLanguageOverrideWithExpressiveCode() { var isBeginSnippet = StartEndTester.IsBeginSnippet("""""", "file", out var key, out var expressive, out var language); Assert.True(isBeginSnippet); Assert.Equal("CodeKey", key); Assert.Equal("json", language.ToString()); Assert.Equal("""title="a.json" """.TrimEnd(), expressive.ToString()); } [Fact] public Task ShouldThrowForInvalidLanguageValue() => Throws(() => StartEndTester.IsBeginSnippet("", "file", out _, out _, out _)); [Fact] public Task ShouldThrowForEmptyLanguageValue() => Throws(() => StartEndTester.IsBeginSnippet("", "file", out _, out _, out _)); } ================================================ FILE: src/Tests/StartEndTester_IsStartRegionTests.cs ================================================ public class StartEndTester_IsStartRegionTests { [Fact] public void CanExtractFromXml() { StartEndTester.IsStartRegion("#region CodeKey", out var key); Assert.Equal("CodeKey", key); } [Fact] public void ShouldThrowForKeyStartingWithSymbol() => Assert.False(StartEndTester.IsStartRegion("#region _key", out _)); [Fact] public void WithSpaces() => Assert.False(StartEndTester.IsStartRegion("#region the text", out _)); [Fact] public void ShouldThrowForKeyEndingWithSymbol() => Assert.False(StartEndTester.IsStartRegion("#region key_ ", out _)); [Fact] public void ShouldIgnoreForNoKey() => Assert.False(StartEndTester.IsStartRegion("#region ", out _)); [Fact] public void CanExtractFromXmlWithExtraSpaces() { StartEndTester.IsStartRegion("#region CodeKey ", out var key); Assert.Equal("CodeKey", key); } [Fact] public void CanExtractWithNoTrailingCharacters() { StartEndTester.IsStartRegion("#region CodeKey", out var key); Assert.Equal("CodeKey", key); } [Fact] public void CanExtractWithUnderScores() { StartEndTester.IsStartRegion("#region Code_Key", out var key); Assert.Equal("Code_Key", key); } [Fact] public void CanExtractWithDashes() { StartEndTester.IsStartRegion("#region Code-Key", out var key); Assert.Equal("Code-Key", key); } } ================================================ FILE: src/Tests/Tests.csproj ================================================ net48 net10.0;$(TargetFrameworks) Exe $(NoWarn);xUnit1051 testing PreserveNewest PreserveNewest %(RecursiveDir)%(Filename)%(Extension) PreserveNewest %(RecursiveDir)%(Filename)%(Extension) PreserveNewest %(RecursiveDir)%(Filename)%(Extension) PreserveNewest %(RecursiveDir)%(Filename)%(Extension) PreserveNewest %(RecursiveDir)%(Filename)%(Extension) PreserveNewest %(RecursiveDir)%(Filename)%(Extension) Always ================================================ FILE: src/Tests/WebSnippetTests.cs ================================================ public class WebSnippetTests { [Fact] public void ExtractWebSnippet_ParsesCorrectly() { var line = new Line("web-snippet:https://example.com/file.cs#mysnippet", "", 1); Assert.True(SnippetKey.ExtractWebSnippet(line, out var url, out var key)); Assert.Equal("https://example.com/file.cs", url); Assert.Equal("mysnippet", key); } [Fact] public void ExtractWebSnippet_FailsWithoutHash() { var line = new Line("web-snippet:https://example.com/file.cs", "", 1); Assert.False(SnippetKey.ExtractWebSnippet(line, out var _, out var _)); } } ================================================ FILE: src/Tests/badsnippets/code.txt ================================================  begin-snippet: snippet1 this is some text to import end-snippet begin-snippet: snippet1 this is some text to import end-snippet ================================================ FILE: src/appveyor.yml ================================================ image: - Visual Studio 2022 #- macOS environment: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true build_script: - pwsh: | if ($isWindows) { Invoke-WebRequest "https://dot.net/v1/dotnet-install.ps1" -OutFile "./dotnet-install.ps1" ./dotnet-install.ps1 -JSonFile src/global.json -Architecture x64 -InstallDir 'C:\Program Files\dotnet' } else { Invoke-WebRequest "https://dot.net/v1/dotnet-install.sh" -OutFile "./dotnet-install.sh" sudo chmod u+x dotnet-install.sh sudo ./dotnet-install.sh --jsonfile src/global.json --architecture x64 --install-dir '/usr/local/share/dotnet' sudo ./dotnet-install.sh --version 9.0.306 --architecture x64 --install-dir '/usr/local/share/dotnet' } - dotnet build src --configuration Release - dotnet test src --configuration Release --no-build --no-restore test: off on_failure: - ps: | $root = (Get-Location).Path Get-ChildItem *.received.* -Recurse | % { $rel = $_.FullName.Substring($root.Length + 1) Push-AppveyorArtifact $_.FullName -FileName $rel } artifacts: - path: nugets\*.nupkg ================================================ FILE: src/context-menu.reg ================================================ Windows Registry Editor Version 5.00 [HKEY_CLASSES_ROOT\Directory\Shell] @="none" [HKEY_CLASSES_ROOT\Directory\shell\mdsnippets] "MUIVerb"="run mdsnippets" "Position"="bottom" [HKEY_CLASSES_ROOT\Directory\Background\shell\mdsnippets] "MUIVerb"="run mdsnippets" "Position"="bottom" [HKEY_CLASSES_ROOT\Directory\shell\mdsnippets\command] @="cmd.exe /c mdsnippets \"%V\"" [HKEY_CLASSES_ROOT\Directory\Background\shell\mdsnippets\command] @="cmd.exe /c mdsnippets \"%V\"" ================================================ FILE: src/global.json ================================================ { "msbuild-sdks": { "MSBuild.Sdk.Extras": "3.0.44" }, "sdk": { "version": "10.0.203", "allowPrerelease": true, "rollForward": "latestFeature" } } ================================================ FILE: src/mdsnippets.json ================================================ { "$schema": "https://raw.githubusercontent.com/SimonCropp/MarkdownSnippets/master/schema.json", "TocLevel": 1, "TocExcludes": [ "Credits", "Release Notes", "Icon" ], "MaxWidth": 80, "ValidateContent": true } ================================================ FILE: src/nuget-readme.md ================================================ # MarkdownSnippets A [dotnet tool](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools) or [MsBuild Task](https://github.com/SimonCropp/MarkdownSnippets/blob/main/docs/msbuild.md) that extract snippets from code files and merges them into markdown documents. See https://github.com/SimonCropp/MarkdownSnippets for full documentation. ## Behavior * Recursively scan the target directory for code files containing snippets. * Recursively scan the target directory for markdown (`.md` or `mdx`) files. * Merge the snippets into those markdown files. ## Installation Ensure [dotnet CLI is installed](https://docs.microsoft.com/en-us/dotnet/core/tools/). Install [MarkdownSnippets.Tool](https://nuget.org/packages/MarkdownSnippets.Tool/) ``` dotnet tool install -g MarkdownSnippets.Tool ``` ## Usage ``` mdsnippets C:\Code\TargetDirectory ``` If no directory is passed the current directory will be used, but only if it exists with a git repository directory tree. If not an error is returned. ## Defining Snippets Any code wrapped in a convention based comment will be picked up. The comment needs to start with `begin-snippet:` which is followed by the key. The snippet is then terminated by `end-snippet`. ``` // begin-snippet: MySnippetName My Snippet Code // end-snippet ``` Named [C# regions](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-region) will also be picked up, with the name of the region used as the key. ``` #region MySnippetName My Snippet Code #endregion ``` ## Using Snippets in Markdown The raw snippet key can be used in any markdown document by subsequent surrounding it with `snippet:`: ``` ``` ================================================ FILE: src/nuget.config ================================================