diff --git a/docs/_docset.yml b/docs/_docset.yml index bcc68e978..fd757a739 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -97,6 +97,7 @@ toc: - file: definition-lists.md - file: example_blocks.md - file: file_inclusion.md + - file: footnotes.md - file: frontmatter.md - file: icons.md - file: images.md diff --git a/docs/syntax/footnotes.md b/docs/syntax/footnotes.md new file mode 100644 index 000000000..a070c748a --- /dev/null +++ b/docs/syntax/footnotes.md @@ -0,0 +1,191 @@ +# Footnotes + +Footnotes allow you to add notes and references without cluttering the main text. They're automatically numbered and linked, providing an elegant way to include supplementary information, citations, or explanations. + +## Plain paragraph test + +This is a plain footnote test.[^plain] No directives involved. + +[^plain]: This footnote is in a plain paragraph, outside any directive. + +## Basic footnotes + +::::{tab-set} + +:::{tab-item} Output + +Here's a simple footnote[^1] and another one.[^2] + +You can also use named identifiers[^my-note] which can be more descriptive in your source files. + +::: + +:::{tab-item} Markdown + +```markdown +Here's a simple footnote[^1] and another one.[^2] + +You can also use named identifiers[^my-note] which can be more descriptive in your source files. + +[^1]: This is the first footnote. +[^2]: This is the second footnote. +[^my-note]: This footnote uses a named identifier instead of a number. +``` + +::: + +:::: + +[^1]: This is the first footnote. +[^2]: This is the second footnote. +[^my-note]: This footnote uses a named identifier instead of a number. + +## Multiple references + +You can reference the same footnote multiple times throughout your document. + +::::{tab-set} + +:::{tab-item} Output + +First reference to the concept.[^concept] Some more text here. + +... + +Second reference to the same concept.[^concept] + +::: + +:::{tab-item} Markdown + +```markdown +First reference to the concept.[^concept] Some more text here. + +... + +Second reference to the same concept.[^concept] + +[^concept]: This explains an important concept that's referenced multiple times. +``` + +::: + +:::: + +[^concept]: This explains an important concept that's referenced multiple times. + +## Complex footnotes + +Footnotes can contain multiple paragraphs, lists, blockquotes, and code blocks. Subsequent content must be indented to be included in the footnote. + +::::{tab-set} + +:::{tab-item} Output + +This has a complex footnote.[^complex] + +::: + +:::{tab-item} Markdown + +```markdown +This has a complex footnote.[^complex] + +[^complex]: This footnote has multiple elements. + + It has multiple paragraphs with detailed explanations. + + > This is a blockquote inside the footnote. + > It can span multiple lines. + + - List item one + - List item two + - List item three + + You can even include code: + + ```python + def example(): + return "Hello from footnote" + ``` +``` + +::: + +:::: + +[^complex]: This footnote has multiple elements. + + It has multiple paragraphs with detailed explanations. + + > This is a blockquote inside the footnote. + > It can span multiple lines. + + - List item one + - List item two + - List item three + + You can even include code: + + ```python + def example(): + return "Hello from footnote" + ``` + +## Footnote placement + +Footnote definitions should be placed at the document level (not inside directives like tab-sets, admonitions, or other containers). Footnote references can be used anywhere in your document, including inside directives. The footnote content will always be rendered at the bottom of the page. + +::::{tab-set} + +:::{tab-item} Output + +Here's text with a footnote.[^early] + +More content here, and another footnote.[^late] + +Even more content in between. + +::: + +:::{tab-item} Markdown + +```markdown +Here's text with a footnote.[^early] + +[^early]: This footnote is defined right after the reference. + +More content here, and another footnote.[^late] + +Even more content in between. + +[^late]: This footnote is defined later in the document. +``` + +::: + +:::: + +[^early]: This footnote is defined right after the reference. +[^late]: This footnote is defined later in the document. + +## Best practices + +### Use descriptive identifiers + +While you can use simple numbers like `[^1]`, descriptive identifiers like `[^api-note]` make your source more maintainable. + +### Keep footnotes focused + +Each footnote should contain a single, focused piece of information. If you find yourself writing very long footnotes, consider whether that content belongs in the main text. + +### Consider alternatives + +Before adding footnotes, consider whether: +- The information is important enough to be in the main text. +- A link to external documentation would be more appropriate. +- An admonition (note, warning, etc.) would be clearer. + +### Numbering + +Footnotes are automatically numbered in order of first reference, regardless of the identifier you use in your source. This means `[^zebra]` appearing before `[^apple]` will be numbered as footnote 1. \ No newline at end of file diff --git a/docs/syntax/frontmatter.md b/docs/syntax/frontmatter.md index 43da89801..67e8e00d8 100644 --- a/docs/syntax/frontmatter.md +++ b/docs/syntax/frontmatter.md @@ -8,8 +8,8 @@ In the frontmatter block, you can define the following fields: ```yaml --- -navigation_title: This is the navigation title <1> -description: This is a description of the page <2> +navigation_title: This is the navigation title. <1> +description: This is a description of the page. <2> applies_to: <3> serverless: all products: <4> diff --git a/src/Elastic.Documentation.Site/Assets/markdown/footnotes.css b/src/Elastic.Documentation.Site/Assets/markdown/footnotes.css new file mode 100644 index 000000000..169376fe2 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/footnotes.css @@ -0,0 +1,53 @@ +/* Footnotes styling */ + +.footnotes h4 { + @apply mt-8 mb-10; +} + +.footnotes ol { + /* Extra top margin so the gap after the "Footnotes" heading + is more prominent than the gap between individual items. */ + @apply mt-4 list-decimal pl-6; +} + +.footnotes li { + /* Tighter spacing between items than between heading and list. */ + @apply text-ink-light mt-2 text-sm leading-relaxed; +} + +.footnotes li p { + @apply inline; +} + +.footnotes li p:not(:last-child) { + @apply mb-3; +} + +/* Footnote reference (superscript link in text) */ +.footnote-ref { + @apply text-blue-elastic hover:text-blue-elastic-100 font-semibold no-underline; + text-decoration: none !important; + display: inline-block; + padding: 0.15em 0.2em; + margin: -0.15em 0; + border-radius: 0.15em; +} + +.footnote-ref sup { + @apply font-sans; + text-decoration: none !important; +} + +/* Back reference (return arrow in footnote) */ +.footnote-back-ref { + @apply text-blue-elastic hover:text-blue-elastic-100 no-underline; + font-family: monospace; + text-decoration: none !important; + vertical-align: super; + font-size: x-small; +} + +/* Multiple back references */ +.footnote-back-ref + .footnote-back-ref { + @apply ml-1; +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index fb3bbf8a6..425c988bc 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -16,6 +16,7 @@ @import './markdown/dropdown.css'; @import './markdown/table.css'; @import './markdown/definition-list.css'; +@import './markdown/footnotes.css'; @import './markdown/images.css'; @import './markdown/math.css'; @import './markdown/image-carousel.css'; diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index 07f7bf7d0..b11197891 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -367,6 +367,27 @@ public static string CreateHtml(MarkdownDocument document) var h1 = document.Descendants().FirstOrDefault(h => h.Level == 1); if (h1 is not null) _ = document.Remove(h1); - return document.ToHtml(MarkdownParser.Pipeline); + + var html = document.ToHtml(MarkdownParser.Pipeline); + return InsertFootnotesHeading(html); + } + + private static string InsertFootnotesHeading(string html) + { + const string footnotesContainer = "
"; + + var containerIndex = html.IndexOf(footnotesContainer, StringComparison.Ordinal); + if (containerIndex < 0) + return html; + + var hrIndex = html.IndexOf("', hrIndex); + if (endOfHr < 0) + return html; + + return html.Insert(endOfHr + 1, "\n

Footnotes

"); } } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs index a07570d77..dd3a85952 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs @@ -35,6 +35,7 @@ public void Setup(MarkdownPipelineBuilder pipeline) // Insert the parser before any other parsers _ = pipeline.BlockParsers.InsertBefore(new DirectiveBlockParser()); } + _ = pipeline.BlockParsers.Replace(new DirectiveParagraphParser()); // Plug the inline parser for CustomContainerInline diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 1aab91540..cf121086a 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -132,6 +132,7 @@ private static MarkdownPipeline MinimalPipeline return field; var builder = new MarkdownPipelineBuilder() .UseYamlFrontMatter() + .UseFootnotes() // Must match Pipeline to avoid inconsistent footnote handling .UseInlineAnchors() .UseHeadingsWithSlugs() .UseDirectives(); @@ -153,6 +154,7 @@ public static MarkdownPipeline Pipeline var builder = new MarkdownPipelineBuilder() .UseInlineAnchors() .UsePreciseSourceLocation() + .UseFootnotes() // Must be before UseDiagnosticLinks to ensure FootnoteLinkParser is inserted correctly .UseDiagnosticLinks() .UseHeadingsWithSlugs() .UseEmphasisExtras(EmphasisExtraOptions.Default) @@ -165,10 +167,10 @@ public static MarkdownPipeline Pipeline .UseYamlFrontMatter() .UseGridTables() .UsePipeTables() - .UseDirectives() - .UseDefinitionLists() - .UseEnhancedCodeBlocks() - .UseHtmxLinkInlineRenderer() + .UseDirectives() + .UseDefinitionLists() + .UseEnhancedCodeBlocks() + .UseHtmxLinkInlineRenderer() .DisableHtml() .UseSpaceNormalizer() .UseHardBreaks(); diff --git a/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs b/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs new file mode 100644 index 000000000..09f5d30fb --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs @@ -0,0 +1,377 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using FluentAssertions; +using JetBrains.Annotations; + +namespace Elastic.Markdown.Tests.Inline; + +public class FootnotesBasicTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + Here's a simple footnote[^1] and another[^2]. + + [^1]: This is the first footnote. + [^2]: This is the second footnote. + """) +{ + [Fact] + public void ContainsFootnoteReferences() + { + Html.Should().Contain("footnote-ref"); + Html.Should().Contain("href=\"#fn:1\""); + Html.Should().Contain("href=\"#fn:2\""); + } + + [Fact] + public void ContainsFootnoteContainer() + { + Html.Should().Contain("class=\"footnotes\""); + } + + [Fact] + public void ContainsFootnoteDefinitions() + { + Html.Should().Contain("id=\"fn:1\""); + Html.Should().Contain("id=\"fn:2\""); + Html.Should().Contain("This is the first footnote."); + Html.Should().Contain("This is the second footnote."); + } + + [Fact] + public void ContainsBackReferences() + { + Html.Should().Contain("footnote-back-ref"); + Html.Should().Contain("href=\"#fnref:1\""); + Html.Should().Contain("href=\"#fnref:2\""); + } + + [Fact] + public void RendersFootnotesHeading() + { + Html.Should().Contain("

Footnotes

"); + } + + [Fact] + public void FootnotesHeadingPrecedesFootnoteContainer() + { + var headingIndex = Html.IndexOf("

Footnotes

", StringComparison.Ordinal); + var footnotesIndex = Html.IndexOf("
", StringComparison.Ordinal); + + headingIndex.Should().BeGreaterThanOrEqualTo(0); + footnotesIndex.Should().BeGreaterThanOrEqualTo(0); + footnotesIndex.Should().BeLessThan(headingIndex); + + var hrIndex = Html.IndexOf(" And even a blockquote. + + - List item 1 + - List item 2 + """) +{ + [Fact] + public void ContainsComplexFootnoteStructure() + { + Html.Should().Contain("href=\"#fn:1\""); + Html.Should().Contain("This footnote has multiple elements."); + Html.Should().Contain("It has multiple paragraphs."); + } + + [Fact] + public void ContainsBlockquoteInFootnote() + { + Html.Should().Contain("blockquote"); + Html.Should().Contain("And even a blockquote."); + } + + [Fact] + public void ContainsListInFootnote() + { + Html.Should().Contain("List item 1"); + Html.Should().Contain("List item 2"); + } +} + +public class FootnotesWithCodeTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + See the code example[^code]. + + [^code]: Example code: + + ```python + def hello(): + print("Hello, world!") + ``` + """) +{ + [Fact] + public void ContainsCodeBlockInFootnote() + { + Html.Should().Contain("Example code:"); + Html.Should().Contain("hello"); + } +} + +public class FootnotesConsecutiveDefinitionsTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + First[^1], second[^2], third[^3]. + + [^1]: First footnote. + [^2]: Second footnote. + [^3]: Third footnote. + """) +{ + [Fact] + public void HandlesConsecutiveFootnoteDefinitions() + { + Html.Should().Contain("First footnote."); + Html.Should().Contain("Second footnote."); + Html.Should().Contain("Third footnote."); + } + + [Fact] + public void AllFootnoteReferencesAreLinked() + { + Html.Should().Contain("href=\"#fn:1\""); + Html.Should().Contain("href=\"#fn:2\""); + Html.Should().Contain("href=\"#fn:3\""); + } +} + +public class FootnotesInListTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + - Item one + - Item two with footnote[^list] + + [^list]: Footnote from list item. + """) +{ + [Fact] + public void FootnoteWorksInListItem() + { + Html.Should().Contain("href=\"#fn:1\""); + Html.Should().Contain("Footnote from list item."); + } +} + +public class FootnotesWithNamedReferencesTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + Named reference[^my-footnote]. + + [^my-footnote]: This uses a named identifier. + """) +{ + [Fact] + public void HandlesNamedFootnoteIdentifiers() + { + Html.Should().Contain("footnote-ref"); + Html.Should().Contain("This uses a named identifier."); + } +} + +public partial class FootnotesInlineCodeNotParsedTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + Real reference[^1]. Inline code example: `[^1]` should not be parsed. + + [^1]: This is the footnote. + """) +{ + [Fact] + public void InlineCodeFootnoteSyntaxNotParsed() + { + // The inline code `[^1]` should render as code, not as a footnote reference + Html.Should().Contain("[^1]"); + } + + [Fact] + public void OnlyOneBackReference() + { + // Should have only ONE back-reference (inline code shouldn't create a reference) + var count = BackRefRegex().Count(Html); + count.Should().Be(1, "Inline code should not be parsed as footnote references"); + } + + [System.Text.RegularExpressions.GeneratedRegex("footnote-back-ref")] + private static partial System.Text.RegularExpressions.Regex BackRefRegex(); +} + +public partial class FootnotesCodeBlockNotParsedTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + Real reference[^1]. + + ```markdown + Code block with [^1] reference. + ``` + + [^1]: This is the footnote. + """) +{ + [Fact] + public void CodeBlockRendered() + { + Html.Should().Contain("language-markdown"); + } + + [Fact] + public void OnlyOneBackReference() + { + // At document level, code blocks work correctly - only 1 back-reference + var count = BackRefRegex().Count(Html); + count.Should().Be(1, "Code block content should not be parsed as footnote references"); + } + + [System.Text.RegularExpressions.GeneratedRegex("footnote-back-ref")] + private static partial System.Text.RegularExpressions.Regex BackRefRegex(); +} + +public partial class FootnotesCodeBlockInDirectiveTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + ::::{tab-set} + + :::{tab-item} Output + + Here's a simple footnote[^1] and another one[^2]. + + ::: + + :::{tab-item} Markdown + + ```markdown + Here's a simple footnote[^1] and another one[^2]. + + [^1]: This is the first footnote. + [^2]: This is the second footnote. + ``` + + ::: + + :::: + + [^1]: This is the first footnote. + [^2]: This is the second footnote. + """) +{ + [Fact] + public void CodeBlockRendered() + { + Html.Should().Contain("language-markdown"); + } + + [Fact] + public void CorrectBackReferenceCount() + { + // Should have exactly 2 back-references (one for [^1] and one for [^2]) + // If code block content is being parsed, we'd see 4 back-references + var count = BackRefRegex().Count(Html); + output.WriteLine("=== HTML ==="); + output.WriteLine(Html); + output.WriteLine("=== END ==="); + count.Should().Be(2, $"Expected 2 back-refs (one per footnote), got {count}. Code block content may be parsed incorrectly."); + } + + [System.Text.RegularExpressions.GeneratedRegex("footnote-back-ref")] + private static partial System.Text.RegularExpressions.Regex BackRefRegex(); +} + +public class FootnotesInsideDirectiveTests(ITestOutputHelper output) : InlineTest(output, + // language=markdown + """ + ::::{tab-set} + + :::{tab-item} Output + + Here's a **bold** and a [link](https://example.com) and footnote[^1]. + + ::: + + :::{tab-item} Markdown + + ```markdown + Here's footnote[^1]. + + [^1]: Example definition in code block. + ``` + + ::: + + :::: + + [^1]: Footnote definitions must be at the document level, not inside directives. + """) +{ + [Fact] + public void OtherInlineElementsWorkInsideDirectives() + { + // Do other inline elements work? + Html.Should().Contain("bold"); + Html.Should().Contain("href=\"https://example.com\""); + } + + [Fact] + public void FootnoteReferencesWorkInsideDirectives() + { + // Footnote REFERENCES work inside directives + Html.Should().Contain("footnote-ref"); + Html.Should().Contain("href=\"#fn:1\""); + } + + [Fact] + public void FootnoteDefinitionsAreAtDocumentLevel() + { + // Footnote DEFINITIONS are rendered at the document level + Html.Should().Contain("
"); + Html.Should().Contain("Footnote definitions must be at the document level"); + } +} +