Skip to content

Commit c44080f

Browse files
authored
Add htmx to dynamically load content (#545)
* Add htmx to dynamicaly load content * Fix copy button * Add HtmxLinkeInlineRenderer * scroll position and hljs warning * cleanup * Fix tests
1 parent 77adba4 commit c44080f

File tree

18 files changed

+2962
-383
lines changed

18 files changed

+2962
-383
lines changed

src/Elastic.Markdown/Assets/copybutton.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,13 @@ const addCopyButtonToCodeCells = () => {
145145
// happens because we load ClipboardJS asynchronously.
146146

147147
// Add copybuttons to all of our code cells
148-
const COPYBUTTON_SELECTOR = 'div.highlight pre';
148+
const COPYBUTTON_SELECTOR = '.markdown-content div.highlight pre';
149149
const codeCells = document.querySelectorAll(COPYBUTTON_SELECTOR)
150150
codeCells.forEach((codeCell, index) => {
151+
if (codeCell.id) {
152+
return
153+
}
154+
151155
const id = codeCellId(index)
152156
codeCell.setAttribute('id', id)
153157

@@ -256,6 +260,5 @@ var copyTargetText = (trigger) => {
256260
}
257261

258262
export function initCopyButton() {
259-
console.log("initCopyButton");
260-
runWhenDOMLoaded(addCopyButtonToCodeCells)
263+
addCopyButtonToCodeCells();
261264
}

src/Elastic.Markdown/Assets/hljs.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ hljs.registerLanguage('esql', function() {
164164

165165
hljs.addPlugin(mergeHTMLPlugin);
166166
export function initHighlight() {
167-
168-
hljs.highlightAll();
167+
document.querySelectorAll('#markdown-content pre code:not(.hljs)').forEach((block) => {
168+
hljs.highlightElement(block);
169+
});
169170
}
Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,34 @@
1+
import "htmx.org"
2+
import "htmx-ext-preload"
13
import {initTocNav} from "./toc-nav";
24
import {initHighlight} from "./hljs";
35
import {initTabs} from "./tabs";
46
import {initCopyButton} from "./copybutton";
7+
import {initNav} from "./pages-nav";
8+
import {$$} from "select-dom"
59

6-
initTocNav();
7-
initHighlight();
8-
initCopyButton();
9-
initTabs();
10+
document.addEventListener('htmx:load', function() {
11+
initTocNav();
12+
initHighlight();
13+
initCopyButton();
14+
initTabs();
15+
initNav();
16+
});
17+
18+
document.body.addEventListener('htmx:oobAfterSwap', function(event) {
19+
if (event.target.id === 'markdown-content') {
20+
window.scrollTo(0, 0);
21+
}
22+
});
23+
24+
document.body.addEventListener('htmx:pushedIntoHistory', function(event) {
25+
const currentNavItem = $$('.current');
26+
currentNavItem.forEach(el => {
27+
el.classList.remove('current');
28+
})
29+
// @ts-ignore
30+
const navItems = $$('a[href="' + event.detail.path + '"]');
31+
navItems.forEach(navItem => {
32+
navItem.classList.add('current');
33+
});
34+
});
Lines changed: 16 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,21 @@
11
import {$, $$} from "select-dom";
22

3-
type NavExpandState = {
4-
current:string,
5-
selected: Record<string, boolean>
6-
};
7-
const PAGE_NAV_EXPAND_STATE_KEY = 'pagesNavState';
8-
9-
// Initialize the nav state from the session storage
10-
// Return a function to keep the nav state in the session storage that should be called before the page is unloaded
11-
function keepNavState(nav: HTMLElement): () => void {
12-
13-
const currentNavigation = nav.dataset.currentNavigation;
14-
const currentPageId = nav.dataset.currentPageId;
15-
16-
let navState = JSON.parse(sessionStorage.getItem(PAGE_NAV_EXPAND_STATE_KEY) ?? "{}") as NavExpandState
17-
if (navState.current !== currentNavigation)
18-
{
19-
sessionStorage.removeItem(PAGE_NAV_EXPAND_STATE_KEY);
20-
navState = { current: currentNavigation } as NavExpandState;
21-
}
22-
if (currentPageId)
23-
{
24-
const currentPageLink = $('a[id="page-' + currentPageId + '"]', nav);
25-
currentPageLink.classList.add('current');
26-
currentPageLink.classList.add('pointer-events-none');
27-
currentPageLink.classList.add('text-blue-elastic!');
28-
currentPageLink.classList.add('font-semibold');
29-
30-
const parentIds = nav.dataset.currentPageParentIds?.split(',') ?? [];
31-
for (const parentId of parentIds)
32-
{
33-
const input = $('input[type="checkbox"][id=\"'+parentId+'\"]', nav) as HTMLInputElement;
34-
if (input) {
35-
input.checked = true;
36-
const link = input.nextElementSibling as HTMLAnchorElement;
37-
link.classList.add('font-semibold');
38-
}
3+
function expandAllParents(navItem: HTMLElement) {
4+
let parent = navItem?.closest('li');
5+
while (parent) {
6+
const input = parent.querySelector('input');
7+
if (input) {
8+
(input as HTMLInputElement).checked = true;
399
}
40-
}
41-
42-
// expand items previously selected
43-
for (const groupId in navState.selected)
44-
{
45-
const input = $('input[type="checkbox"][id=\"'+groupId+'\"]', nav) as HTMLInputElement;
46-
input.checked = navState.selected[groupId];
47-
}
48-
49-
return () => {
50-
// store all expanded groups
51-
const inputs = $$('input[type="checkbox"]:checked', nav);
52-
const selectedMap: Record<string, boolean> = inputs
53-
.filter(input => input.checked)
54-
.reduce((state: Record<string, boolean>, input) => {
55-
const key = input.id;
56-
const value = input.checked;
57-
return { ...state, [key]: value};
58-
}, {});
59-
const state = { current: currentNavigation, selected: selectedMap };
60-
sessionStorage.setItem(PAGE_NAV_EXPAND_STATE_KEY, JSON.stringify(state));
61-
}
62-
}
63-
64-
type NavScrollPosition = number;
65-
const PAGE_NAV_SCROLL_POSITION_KEY = 'pagesNavScrollPosition';
66-
const pagesNavScrollPosition: NavScrollPosition = parseInt(
67-
sessionStorage.getItem(PAGE_NAV_SCROLL_POSITION_KEY) ?? '0'
68-
);
69-
70-
71-
// Initialize the nav scroll position from the session storage
72-
// Return a function to keep the nav scroll position in the session storage that should be called before the page is unloaded
73-
function keepNavPosition(nav: HTMLElement): () => void {
74-
if (pagesNavScrollPosition) {
75-
nav.scrollTop = pagesNavScrollPosition;
76-
}
77-
return () => {
78-
sessionStorage.setItem(PAGE_NAV_SCROLL_POSITION_KEY, nav.scrollTop.toString());
10+
parent = parent.parentElement?.closest('li');
7911
}
8012
}
8113

8214
function scrollCurrentNaviItemIntoView(nav: HTMLElement, delay: number) {
15+
const currentNavItem = $('.current', nav);
16+
expandAllParents(currentNavItem);
8317
setTimeout(() => {
84-
const currentNavItem = $('.current', nav);
18+
8519
if (currentNavItem && !isElementInViewport(currentNavItem)) {
8620
currentNavItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
8721
}
@@ -102,13 +36,12 @@ export function initNav() {
10236
if (!pagesNav) {
10337
return;
10438
}
105-
const keepNavStateCallback = keepNavState(pagesNav);
106-
const keepNavPositionCallback = keepNavPosition(pagesNav);
39+
const navItems = $$('a[href="' + window.location.pathname + '"]', pagesNav);
40+
navItems.forEach(el => {
41+
el.classList.add('current');
42+
});
10743
scrollCurrentNaviItemIntoView(pagesNav, 100);
108-
window.addEventListener('beforeunload', () => {
109-
keepNavStateCallback();
110-
keepNavPositionCallback();
111-
}, true);
11244
}
11345

114-
initNav();
46+
47+
// initNav();

src/Elastic.Markdown/Assets/styles.css

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
}
8888

8989
.content-container {
90-
@apply w-full max-w-[80ch] lg:w-[80ch];
90+
@apply w-full max-w-[80ch];
9191
}
9292

9393
.applies {
@@ -140,6 +140,38 @@
140140
outline: none;
141141
}
142142

143+
.htmx-indicator {
144+
display:none;
145+
}
146+
.htmx-request .htmx-indicator,
147+
.htmx-request.htmx-indicator{
148+
display:block;
149+
z-index: 9999;
150+
}
151+
152+
.progress {
153+
animation: progress 1s infinite linear;
154+
}
155+
156+
.left-right {
157+
transform-origin: 0% 50%;
158+
}
159+
@keyframes progress {
160+
0% {
161+
transform: translateX(0) scaleX(0);
162+
}
163+
40% {
164+
transform: translateX(0) scaleX(0.4);
165+
}
166+
100% {
167+
transform: translateX(100%) scaleX(0.5);
168+
}
169+
}
170+
171+
#pages-nav .current {
172+
@apply text-blue-elastic!;
173+
}
174+
143175
.markdown-content {
144176
@apply font-body;
145177
}

src/Elastic.Markdown/Assets/tabs.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,6 @@ function ready() {
5757
group
5858
);
5959
if (tabParam) {
60-
console.log(
61-
"sphinx-design: Selecting tab id for group '" +
62-
group +
63-
"' from URL parameter: " +
64-
tabParam
65-
);
6660
window.sessionStorage.setItem(storageKeyPrefix + group, tabParam);
6761
}
6862
}
@@ -72,9 +66,6 @@ function ready() {
7266
storageKeyPrefix + group
7367
);
7468
if (previousId === id) {
75-
// console.log(
76-
// "sphinx-design: Selecting tab from session storage: " + id
77-
// );
7869
// @ts-ignore
7970
label.previousElementSibling.checked = true;
8071
}
@@ -101,6 +92,5 @@ function onSDLabelClick() {
10192
}
10293

10394
export function initTabs() {
104-
console.log("inittabs");
105-
document.addEventListener("DOMContentLoaded", ready, false);
95+
ready();
10696
}

src/Elastic.Markdown/Myst/MarkdownParser.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using Elastic.Markdown.Myst.Directives;
1313
using Elastic.Markdown.Myst.FrontMatter;
1414
using Elastic.Markdown.Myst.InlineParsers;
15+
using Elastic.Markdown.Myst.Renderers;
1516
using Elastic.Markdown.Myst.Substitution;
1617
using Markdig;
1718
using Markdig.Extensions.EmphasisExtras;
@@ -80,6 +81,7 @@ public static MarkdownPipeline Pipeline
8081
.UseDirectives()
8182
.UseDefinitionLists()
8283
.UseEnhancedCodeBlocks()
84+
.UseHtmxLinkInlineRenderer()
8385
.DisableHtml()
8486
.UseHardBreaks();
8587
_ = builder.BlockParsers.TryRemove<IndentedCodeBlockParser>();
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Markdig;
6+
using Markdig.Renderers;
7+
using Markdig.Renderers.Html.Inlines;
8+
using Markdig.Syntax.Inlines;
9+
10+
namespace Elastic.Markdown.Myst.Renderers;
11+
12+
public class HtmxLinkInlineRenderer : LinkInlineRenderer
13+
{
14+
protected override void Write(HtmlRenderer renderer, LinkInline link)
15+
{
16+
if (renderer.EnableHtmlForInline && !link.IsImage && link.Url?.StartsWith('/') == true)
17+
{
18+
_ = renderer.Write("<a href=\"");
19+
_ = renderer.WriteEscapeUrl(link.GetDynamicUrl != null ? link.GetDynamicUrl() ?? link.Url : link.Url);
20+
_ = renderer.Write('"');
21+
_ = renderer.WriteAttributes(link);
22+
_ = renderer.Write(" hx-select-oob=\"#markdown-content,#toc-nav,#prev-next-nav\"");
23+
_ = renderer.Write(" hx-swap=\"none\"");
24+
_ = renderer.Write(" hx-push-url=\"true\"");
25+
_ = renderer.Write(" hx-indicator=\"#htmx-indicator\"");
26+
_ = renderer.Write(" preload=\"mouseover\"");
27+
28+
if (!string.IsNullOrEmpty(link.Title))
29+
{
30+
_ = renderer.Write(" title=\"");
31+
_ = renderer.WriteEscape(link.Title);
32+
_ = renderer.Write('"');
33+
}
34+
if (!string.IsNullOrWhiteSpace(Rel))
35+
{
36+
_ = renderer.Write(" rel=\"");
37+
_ = renderer.Write(Rel);
38+
_ = renderer.Write('"');
39+
}
40+
41+
_ = renderer.Write('>');
42+
renderer.WriteChildren(link);
43+
44+
_ = renderer.Write("</a>");
45+
}
46+
else
47+
{
48+
base.Write(renderer, link);
49+
}
50+
}
51+
}
52+
53+
public static class CustomLinkInlineRendererExtensions
54+
{
55+
public static MarkdownPipelineBuilder UseHtmxLinkInlineRenderer(this MarkdownPipelineBuilder pipeline)
56+
{
57+
pipeline.Extensions.AddIfNotAlready<HtmxLinkInlineRendererExtension>();
58+
return pipeline;
59+
}
60+
}
61+
62+
public class HtmxLinkInlineRendererExtension : IMarkdownExtension
63+
{
64+
public void Setup(MarkdownPipelineBuilder pipeline)
65+
{
66+
// No setup required for the pipeline
67+
}
68+
69+
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
70+
{
71+
if (renderer is HtmlRenderer htmlRenderer)
72+
{
73+
_ = htmlRenderer.ObjectRenderers.RemoveAll(x => x is LinkInlineRenderer);
74+
htmlRenderer.ObjectRenderers.Add(new HtmxLinkInlineRenderer());
75+
}
76+
}
77+
}
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
@inherits RazorSlice<LayoutViewModel>
2-
<aside class="sidebar hidden lg:block order-1 w-100 border-r-1 border-r-gray-200">
3-
<nav id="pages-nav" class="sidebar-nav pr-6"
2+
<aside class="sidebar hidden lg:block order-1 w-100 border-r-1 border-r-gray-200"
3+
>
4+
<nav
5+
id="pages-nav"
6+
class="sidebar-nav pr-6"
47
data-current-page-id="@Model.CurrentDocument.Id"
58
data-current-page-parent-ids="@(string.Join(",",Model.ParentIds))"
69
@* used to invalidate session storage *@
710
data-current-navigation="@LayoutViewModel.CurrentNavigationId">
8-
911
@(new HtmlString(Model.NavigationHtml))
1012
</nav>
11-
<script src="@Model.Static("pages-nav.js")"></script>
1213
</aside>

src/Elastic.Markdown/Slices/Layout/_TocTree.cshtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@inherits RazorSlice<NavigationViewModel>
22
<div class="pt-6 pb-20">
33
<div class="font-bold">Elastic Docs</div>
4-
<ul class="block w-full">
4+
<ul class="block">
55
@await RenderPartialAsync(_TocTreeNav.Create(new NavigationTreeItem
66
{
77
Level = Model.Tree.Depth,

0 commit comments

Comments
 (0)