Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
548541b
Add deeplink anchors
theletterf Aug 22, 2025
126e90f
Add anchors
theletterf Aug 22, 2025
9a92523
Edit docs
theletterf Aug 22, 2025
fa2ea6b
Format file
theletterf Aug 22, 2025
26c8fa5
Merge branch 'main' into add-deeplink-anchors-dropdown
theletterf Aug 26, 2025
bda669a
Instant and autogenerate anchors
theletterf Aug 27, 2025
035b3b5
Prettify
theletterf Aug 27, 2025
5a0d5f6
Merge branch 'main' into add-deeplink-anchors-dropdown
theletterf Aug 27, 2025
e5dfcf9
Remove test file
theletterf Aug 27, 2025
d597bec
Merge branch 'add-deeplink-anchors-dropdown' of github.com:elastic/do…
theletterf Aug 27, 2025
0fd16ed
Address peer edits
theletterf Aug 27, 2025
1008fd9
Merge branch 'main' into add-deeplink-anchors-dropdown
theletterf Aug 27, 2025
8a8418b
Update src/Elastic.Markdown/Myst/ParserContext.cs
theletterf Aug 27, 2025
b9bdaf9
Add tests
theletterf Aug 27, 2025
0c3ab5c
Merge branch 'add-deeplink-anchors-dropdown' of github.com:elastic/do…
theletterf Aug 27, 2025
29607ec
Prettify
theletterf Aug 27, 2025
520ade3
Merge branch 'main' into add-deeplink-anchors-dropdown
theletterf Aug 27, 2025
08e8136
Simplify dupe detection
theletterf Oct 2, 2025
f03502f
Merge branch 'main' into add-deeplink-anchors-dropdown
theletterf Oct 2, 2025
6568772
Docs
theletterf Oct 2, 2025
60d2b6a
Reconcile changes from main
theletterf Oct 2, 2025
e925652
Add hints
theletterf Oct 2, 2025
dbffa7d
Fix line numbers in hints
theletterf Oct 2, 2025
f078aea
Merge remote-tracking branch 'origin/main' into add-deeplink-anchors-…
theletterf Oct 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions docs/syntax/dropdowns.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@

::::{tab-item} Output

:::{dropdown} Dropdown Title
:::{dropdown} Dropdown Title 1
Dropdown content
:::

::::

::::{tab-item} Markdown
```markdown
:::{dropdown} Dropdown Title
:::{dropdown} Dropdown Title 1
Dropdown content
:::
```
Expand All @@ -33,7 +33,7 @@

::::{tab-item} Output

:::{dropdown} Dropdown Title
:::{dropdown} Dropdown Title 2
:open:
Dropdown content
:::
Expand All @@ -59,7 +59,7 @@

::::{tab-item} Output

:::{dropdown} Dropdown Title

Check notice on line 62 in docs/syntax/dropdowns.md

View workflow job for this annotation

GitHub Actions / build

Duplicate anchor 'dropdown-title' found in dropdown. Multiple elements with the same anchor may cause linking issues.
:applies_to: stack: ga 9.0
Dropdown content for Stack GA 9.0
:::
Expand All @@ -85,7 +85,7 @@

::::{tab-item} Output

:::{dropdown} Dropdown Title

Check notice on line 88 in docs/syntax/dropdowns.md

View workflow job for this annotation

GitHub Actions / build

Duplicate anchor 'dropdown-title' found in dropdown. Multiple elements with the same anchor may cause linking issues.
:applies_to: { ece:, ess: }
Dropdown content for ECE and ECH
:::
Expand All @@ -102,3 +102,39 @@
::::

:::::

## Anchors and deep linking

Dropdowns automatically generate anchors from their titles, allowing you to link directly to them. The anchor is created by converting the title to lowercase and replacing spaces with hyphens (slugify).

For example, a dropdown with title "Installation Guide" will have the anchor `#installation-guide`.

### Custom anchors

You can specify a custom anchor using the `:name:` option:

:::::{tab-set}

::::{tab-item} Output

:::{dropdown} My Dropdown
:name: custom-anchor
Content that can be linked to via #custom-anchor
:::

::::

::::{tab-item} Markdown
```markdown
:::{dropdown} My Dropdown
:name: custom-anchor
Content that can be linked to via #custom-anchor
:::
```
::::

:::::

### Duplicate anchors

If multiple elements (dropdowns or headings) in the same document have the same anchor, the build will emit a hint warning. While this doesn't fail the build, it may cause linking issues. Ensure each dropdown has a unique title or use the `:name:` option to specify unique anchors.
7 changes: 7 additions & 0 deletions docs/testing/nested/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ The files in this directory are used for testing purposes. Do not edit these fil
## Injecting a {{x}} is supported in headers.

This should show up in the file's table of contents too.

:::{dropdown} Dropdown Title
:name: dropdown-title

Text.

:::
4 changes: 2 additions & 2 deletions src/Elastic.Documentation.Site/Assets/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { initCopyButton } from './copybutton'
import { initHighlight } from './hljs'
import { initImageCarousel } from './image-carousel'
import './markdown/applies-to'
import { openDetailsWithAnchor } from './open-details-with-anchor'
import { initOpenDetailsWithAnchor } from './open-details-with-anchor'
import { initNav } from './pages-nav'
import { initSmoothScroll } from './smooth-scroll'
import { initTabs } from './tabs'
Expand Down Expand Up @@ -33,7 +33,7 @@ document.addEventListener('htmx:load', function (event) {
initNav()
}
initSmoothScroll()
openDetailsWithAnchor()
initOpenDetailsWithAnchor()
initImageCarousel()

const urlParams = new URLSearchParams(window.location.search)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,72 @@ import { UAParser } from 'ua-parser-js'

const { browser } = UAParser()

// This is a fix for anchors in details elements in non-Chrome browsers.
// Opens details elements (dropdowns) when navigating to an anchor link within them
// This enables deeplinking to collapsed dropdown content
export function openDetailsWithAnchor() {
if (window.location.hash) {
const target = document.querySelector(window.location.hash)
if (target) {
const closestDetails = target.closest('details')
if (closestDetails) {
if (browser.name !== 'Chrome') {
if (closestDetails && !closestDetails.open) {
// Only open if it's not already open by default
if (!closestDetails.dataset.openDefault) {
closestDetails.open = true
target.scrollIntoView({
behavior: 'instant',
block: 'start',
})
}
}

// Chrome automatically ensures parent content is visible, scroll immediately
// Other browsers need manual scroll handling
if (browser.name !== 'Chrome') {
target.scrollIntoView({
behavior: 'instant',
block: 'start',
})
}
}
}
}

// Initialize the anchor handling functionality
export function initOpenDetailsWithAnchor() {
// Handle initial page load
openDetailsWithAnchor()

// Handle hash changes within the same page (e.g., clicking anchor links)
window.addEventListener('hashchange', openDetailsWithAnchor)

// Handle dropdown URL updates
document.addEventListener(
'click',
(event) => {
const target = event.target as HTMLElement
const dropdown = target.closest(
'details.dropdown'
) as HTMLDetailsElement
if (dropdown) {
const initialState = dropdown.open

// Check state after toggle completes
setTimeout(() => {
const finalState = dropdown.open
const stateChanged = initialState !== finalState

// If dropdown opened and doesn't have open-default flag, push URL
if (
stateChanged &&
finalState &&
!dropdown.dataset.openDefault
) {
window.history.pushState(null, '', `#${dropdown.id}`)
}

// Remove open-default flag after first interaction
if (dropdown.dataset.openDefault === 'true') {
delete dropdown.dataset.openDefault
}
}, 10)
}
},
true
)
}
64 changes: 64 additions & 0 deletions src/Elastic.Markdown/IO/MarkdownFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Elastic.Markdown.Helpers;
using Elastic.Markdown.Myst;
using Elastic.Markdown.Myst.Directives;
using Elastic.Markdown.Myst.Directives.Admonition;
using Elastic.Markdown.Myst.Directives.Include;
using Elastic.Markdown.Myst.Directives.Stepper;
using Elastic.Markdown.Myst.FrontMatter;
Expand Down Expand Up @@ -178,6 +179,7 @@ public async Task<MarkdownDocument> ParseFullAsync(Cancel ctx)
_ = await MinimalParseAsync(ctx);

var document = await GetParseDocumentAsync(ctx);
ValidateDuplicateAnchors(document);
return document;
}

Expand All @@ -194,6 +196,68 @@ private IReadOnlyDictionary<string, string> GetSubstitutions()
return allProperties;
}

private void ValidateDuplicateAnchors(MarkdownDocument document)
{
// Collect all anchors with their source blocks
var anchorSources = new List<(string Anchor, int Line, int Column, int Length, string Type)>();

// Collect dropdown anchors
foreach (var dropdown in document.Descendants<DropdownBlock>())
{
if (!string.IsNullOrEmpty(dropdown.CrossReferenceName))
{
anchorSources.Add((
dropdown.CrossReferenceName,
dropdown.Line + 1,
dropdown.Column + 1, // Column is 0-indexed, add 1
dropdown.OpeningLength,
"dropdown"
));
}
}

// Collect heading anchors
foreach (var heading in document.Descendants<HeadingBlock>())
{
var header = heading.GetData("header") as string;
var anchor = heading.GetData("anchor") as string;
var slugTarget = (anchor ?? header) ?? string.Empty;
if (!string.IsNullOrEmpty(slugTarget))
{
var slug = slugTarget.Slugify();
anchorSources.Add((
slug,
heading.Line + 1,
heading.Column + 1, // Column is 0-indexed, add 1
1, // heading length
"heading"
));
}
}

// Group by anchor and find duplicates
var duplicateGroups = anchorSources
.GroupBy(a => a.Anchor, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1);

foreach (var group in duplicateGroups)
{
var anchor = group.Key;
foreach (var (_, line, column, length, type) in group)
{
Collector.Write(new Diagnostic
{
Severity = Severity.Hint,
File = SourceFile.FullName,
Line = line,
Column = column,
Length = length,
Message = $"Duplicate anchor '{anchor}' found in {type}. Multiple elements with the same anchor may cause linking issues."
});
}
}
}

protected void ReadDocumentInstructions(MarkdownDocument document)
{
Title ??= document
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ public override void FinalizeAndValidate(ParserContext context)
else if (!string.IsNullOrEmpty(Arguments))
Title += $" {Arguments}";
Title = Title.ReplaceSubstitutions(context);

// Auto-generate CrossReferenceName for dropdowns without explicit name, same as headings
if (string.IsNullOrEmpty(CrossReferenceName) && (Admonition == "dropdown" || Classes == "dropdown"))
CrossReferenceName = Title.Slugify();
}

private ApplicableTo? ParseApplicableTo(string yaml)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
@using Elastic.Markdown.Myst.Components
@inherits RazorSlice<Elastic.Markdown.Myst.Directives.Admonition.AdmonitionViewModel>
@if (!string.IsNullOrEmpty(Model.Open))
{
<details class="dropdown @Model.Classes" id="@Model.CrossReferenceName" open="@Model.Open" data-open-default="true">
<summary class="dropdown-title">
<span class="sd-summary-text">@Model.Title</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 mr-1 shrink -rotate-90 group-has-checked/label:rotate-0 cursor-pointer text-ink">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/>
</svg>
</summary>
<div class="dropdown-content">
@Model.RenderBlock()
</div>
</details>
}
else
{
<details class="dropdown @Model.Classes" id="@Model.CrossReferenceName" open="@Model.Open">
<summary class="dropdown-title">
<div class="dropdown-title__container">
Expand Down Expand Up @@ -35,3 +57,4 @@
@Model.RenderBlock()
</div>
</details>
}
1 change: 1 addition & 0 deletions src/Elastic.Markdown/Myst/ParserContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Elastic.Documentation.Configuration.Builder;
using Elastic.Documentation.Links.CrossLinks;
using Elastic.Markdown.Diagnostics;
using Elastic.Markdown.Helpers;
using Elastic.Markdown.IO;
using Elastic.Markdown.Myst.FrontMatter;
using Markdig;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,27 @@ namespace Documentation.Builder.Diagnostics.LiveMode;
public class LiveModeDiagnosticsCollector(ILoggerFactory logFactory)
: DiagnosticsCollector([new Log(logFactory.CreateLogger<Log>())])
{
protected override void HandleItem(Diagnostic diagnostic) { }
private readonly List<Diagnostic> _errors = [];
private readonly List<Diagnostic> _warnings = [];
private readonly List<Diagnostic> _hints = [];

public override async Task StopAsync(Cancel cancellationToken) => await Task.CompletedTask;
protected override void HandleItem(Diagnostic diagnostic)
{
if (diagnostic.Severity == Severity.Error)
_errors.Add(diagnostic);
else if (diagnostic.Severity == Severity.Warning)
_warnings.Add(diagnostic);
else
_hints.Add(diagnostic);
}

public override async Task StopAsync(Cancel cancellationToken)
{
if (_errors.Count > 0 || _warnings.Count > 0 || _hints.Count > 0)
{
var repository = new Elastic.Documentation.Tooling.Diagnostics.Console.ErrataFileSourceRepository();
repository.WriteDiagnosticsToConsole(_errors, _warnings, _hints);
}
await Task.CompletedTask;
}
}
Loading
Loading