@minidocs/astro 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -2
- package/dist/behaviors.d.mts +114 -0
- package/dist/behaviors.mjs +635 -0
- package/dist/index.d.mts +23 -0
- package/dist/index.mjs +275 -0
- package/dist/links-Dp7d5R-O.mjs +242 -0
- package/dist/loaders.d.mts +25 -0
- package/dist/loaders.mjs +88 -0
- package/dist/routing-D28b92w8.mjs +60 -0
- package/dist/runtime.d.mts +202 -0
- package/dist/runtime.mjs +327 -0
- package/package.json +80 -3
- package/src/components/Prose.astro +9 -0
- package/src/components/callout/Root.astro +14 -0
- package/src/components/callout/Title.astro +9 -0
- package/src/components/callout/index.ts +2 -0
- package/src/components/index.ts +7 -0
- package/src/components/layout/Aside.astro +29 -0
- package/src/components/layout/Header.astro +18 -0
- package/src/components/layout/Main.astro +9 -0
- package/src/components/layout/Root.astro +15 -0
- package/src/components/layout/Sidebar.astro +9 -0
- package/src/components/layout/Title.astro +10 -0
- package/src/components/layout/index.ts +6 -0
- package/src/components/pager/Label.astro +9 -0
- package/src/components/pager/Link.astro +12 -0
- package/src/components/pager/Root.astro +12 -0
- package/src/components/pager/Title.astro +9 -0
- package/src/components/pager/index.ts +4 -0
- package/src/components/search/Count.astro +10 -0
- package/src/components/search/Dialog.astro +14 -0
- package/src/components/search/Empty.astro +10 -0
- package/src/components/search/Error.astro +10 -0
- package/src/components/search/Found.astro +10 -0
- package/src/components/search/Idle.astro +10 -0
- package/src/components/search/Input.astro +26 -0
- package/src/components/search/Loading.astro +10 -0
- package/src/components/search/Query.astro +10 -0
- package/src/components/search/Results.astro +20 -0
- package/src/components/search/Root.astro +15 -0
- package/src/components/search/Shortcut.astro +14 -0
- package/src/components/search/Trigger.astro +28 -0
- package/src/components/search/index.ts +13 -0
- package/src/components/sidebar/Group.astro +45 -0
- package/src/components/sidebar/Item.astro +9 -0
- package/src/components/sidebar/Label.astro +9 -0
- package/src/components/sidebar/Link.astro +23 -0
- package/src/components/sidebar/List.astro +11 -0
- package/src/components/sidebar/Root.astro +24 -0
- package/src/components/sidebar/Trigger.astro +29 -0
- package/src/components/sidebar/index.ts +7 -0
- package/src/components/toc/Item.astro +9 -0
- package/src/components/toc/Link.astro +12 -0
- package/src/components/toc/List.astro +11 -0
- package/src/components/toc/Root.astro +20 -0
- package/src/components/toc/index.ts +4 -0
- package/src/styles/base.css +42 -0
- package/src/styles/callout.css +58 -0
- package/src/styles/layout.css +406 -0
- package/src/styles/pager.css +56 -0
- package/src/styles/prose.css +218 -0
- package/src/styles/search.css +207 -0
- package/src/styles/sidebar.css +162 -0
- package/src/styles/toc.css +79 -0
- package/src/styles.css +9 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
//#region src/behaviors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Restores a sidebar to how the reader left it on the previous page, so navigating changes nothing they can see.
|
|
4
|
+
* Groups containing the current page are always open. Without a snapshot, the current page is scrolled into view.
|
|
5
|
+
*
|
|
6
|
+
* Runs inline right after the sidebar markup, before the first paint, so it must not reference anything outside itself.
|
|
7
|
+
*/
|
|
8
|
+
function restoreSidebar(root) {
|
|
9
|
+
const groups = [...root.querySelectorAll("[data-md-sidebar-group]")];
|
|
10
|
+
const titleOf = (group) => group.querySelector(":scope > :is(summary, a)")?.textContent?.trim() ?? "";
|
|
11
|
+
for (const group of groups) {
|
|
12
|
+
const titles = [];
|
|
13
|
+
for (let el = group; el && root.contains(el); el = el.parentElement.closest("[data-md-sidebar-group]")) titles.unshift(titleOf(el));
|
|
14
|
+
group.dataset.mdKey = titles.join("/");
|
|
15
|
+
}
|
|
16
|
+
let scroller = root;
|
|
17
|
+
while (scroller && scroller !== document.body && getComputedStyle(scroller).overflowY === "visible") scroller = scroller.parentElement;
|
|
18
|
+
if (scroller === document.body) scroller = null;
|
|
19
|
+
let snapshot = null;
|
|
20
|
+
try {
|
|
21
|
+
const key = `minidocs:sidebar:${root.id}:${document.documentElement.lang}`;
|
|
22
|
+
snapshot = JSON.parse(sessionStorage.getItem(key) ?? "null");
|
|
23
|
+
} catch {}
|
|
24
|
+
for (const group of groups) {
|
|
25
|
+
const stored = snapshot?.open[group.dataset.mdKey];
|
|
26
|
+
const open = group.querySelector("[aria-current=\"page\"]") ? true : stored;
|
|
27
|
+
if (open === void 0) continue;
|
|
28
|
+
group.dataset.state = open ? "open" : "closed";
|
|
29
|
+
if (group instanceof HTMLDetailsElement) group.open = open;
|
|
30
|
+
else {
|
|
31
|
+
const content = group.querySelector(":scope > [data-md-sidebar-group-content]");
|
|
32
|
+
if (content) content.hidden = !open;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!scroller) return;
|
|
36
|
+
if (snapshot) {
|
|
37
|
+
scroller.scrollTop = snapshot.scroll;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const current = root.querySelector("[aria-current=\"page\"]");
|
|
41
|
+
if (!current) return;
|
|
42
|
+
const top = current.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
43
|
+
if (top < 0 || top + current.offsetHeight > scroller.clientHeight) scroller.scrollTop += top - (scroller.clientHeight - current.offsetHeight) / 2;
|
|
44
|
+
}
|
|
45
|
+
/** Mirrors native collapsing into `data-state`, and remembers group and scroll state when the reader leaves the page. */
|
|
46
|
+
function attachSidebar(root) {
|
|
47
|
+
const onToggle = (event) => {
|
|
48
|
+
const details = event.target;
|
|
49
|
+
if (details instanceof HTMLDetailsElement && details.hasAttribute("data-md-sidebar-group")) details.dataset.state = details.open ? "open" : "closed";
|
|
50
|
+
};
|
|
51
|
+
const onVisibilityChange = () => {
|
|
52
|
+
if (document.visibilityState !== "hidden") return;
|
|
53
|
+
const open = {};
|
|
54
|
+
for (const group of root.querySelectorAll("[data-md-key]")) open[group.dataset.mdKey] = group.dataset.state === "open";
|
|
55
|
+
let scroller = root;
|
|
56
|
+
while (scroller && scroller !== document.body && getComputedStyle(scroller).overflowY === "visible") scroller = scroller.parentElement;
|
|
57
|
+
const scroll = scroller && scroller !== document.body ? scroller.scrollTop : 0;
|
|
58
|
+
try {
|
|
59
|
+
const key = `minidocs:sidebar:${root.id}:${document.documentElement.lang}`;
|
|
60
|
+
sessionStorage.setItem(key, JSON.stringify({
|
|
61
|
+
open,
|
|
62
|
+
scroll
|
|
63
|
+
}));
|
|
64
|
+
} catch {}
|
|
65
|
+
};
|
|
66
|
+
root.addEventListener("toggle", onToggle, true);
|
|
67
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
68
|
+
return () => {
|
|
69
|
+
root.removeEventListener("toggle", onToggle, true);
|
|
70
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Turns a button into a toggle for the element named by its `aria-controls`. */
|
|
74
|
+
function attachTrigger(button) {
|
|
75
|
+
const id = button.getAttribute("aria-controls");
|
|
76
|
+
const target = id ? document.getElementById(id) : null;
|
|
77
|
+
if (!target) {
|
|
78
|
+
console.warn(`[minidocs] Sidebar trigger points to "${id}", which is not on the page.`);
|
|
79
|
+
return () => {};
|
|
80
|
+
}
|
|
81
|
+
const setOpen = (open) => {
|
|
82
|
+
target.dataset.state = open ? "open" : "closed";
|
|
83
|
+
target.dispatchEvent(new CustomEvent("md:sidebar-toggle", {
|
|
84
|
+
detail: { open },
|
|
85
|
+
bubbles: true
|
|
86
|
+
}));
|
|
87
|
+
};
|
|
88
|
+
const sync = () => button.setAttribute("aria-expanded", String(target.dataset.state === "open"));
|
|
89
|
+
sync();
|
|
90
|
+
const onClick = () => setOpen(target.dataset.state !== "open");
|
|
91
|
+
const onKeydown = (event) => {
|
|
92
|
+
if (event.key !== "Escape" || target.dataset.state !== "open") return;
|
|
93
|
+
setOpen(false);
|
|
94
|
+
button.focus();
|
|
95
|
+
};
|
|
96
|
+
const onPointerDown = (event) => {
|
|
97
|
+
if (target.dataset.state !== "open") return;
|
|
98
|
+
const clicked = event.target;
|
|
99
|
+
if (target.contains(clicked) || clicked.closest?.(`[aria-controls="${CSS.escape(id)}"]`)) return;
|
|
100
|
+
setOpen(false);
|
|
101
|
+
};
|
|
102
|
+
button.addEventListener("click", onClick);
|
|
103
|
+
target.addEventListener("md:sidebar-toggle", sync);
|
|
104
|
+
document.addEventListener("keydown", onKeydown);
|
|
105
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
106
|
+
return () => {
|
|
107
|
+
button.removeEventListener("click", onClick);
|
|
108
|
+
target.removeEventListener("md:sidebar-toggle", sync);
|
|
109
|
+
document.removeEventListener("keydown", onKeydown);
|
|
110
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Marks the link of the heading being read with `aria-current="true"`. One link is always current.
|
|
115
|
+
* Each change dispatches a bubbling `md:toc-change` event with the link.
|
|
116
|
+
* A heading is passed once its top crosses its own `scroll-margin-top`, so anchor jumps and highlighting line up.
|
|
117
|
+
*/
|
|
118
|
+
function attachToc(root) {
|
|
119
|
+
const items = [...root.querySelectorAll("a[href^=\"#\"]")].flatMap((link) => {
|
|
120
|
+
const heading = document.getElementById(decodeURIComponent(link.getAttribute("href").slice(1)));
|
|
121
|
+
return heading ? [{
|
|
122
|
+
link,
|
|
123
|
+
heading,
|
|
124
|
+
margin: 0
|
|
125
|
+
}] : [];
|
|
126
|
+
});
|
|
127
|
+
if (items.length === 0) return () => {};
|
|
128
|
+
const measure = () => {
|
|
129
|
+
for (const item of items) item.margin = Number.parseFloat(getComputedStyle(item.heading).scrollMarginTop) || 0;
|
|
130
|
+
};
|
|
131
|
+
let current;
|
|
132
|
+
let placed = false;
|
|
133
|
+
const placeMarker = (slide = false) => {
|
|
134
|
+
if (!current) return;
|
|
135
|
+
const instant = !slide || !placed;
|
|
136
|
+
if (instant) root.setAttribute("data-md-toc-instant", "");
|
|
137
|
+
const top = current.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop;
|
|
138
|
+
root.style.setProperty("--md-toc-marker-top", `${top}px`);
|
|
139
|
+
root.style.setProperty("--md-toc-marker-height", `${current.offsetHeight}px`);
|
|
140
|
+
if (!instant) return;
|
|
141
|
+
getComputedStyle(root, "::after").transform;
|
|
142
|
+
root.removeAttribute("data-md-toc-instant");
|
|
143
|
+
placed = true;
|
|
144
|
+
};
|
|
145
|
+
const setCurrent = (link) => {
|
|
146
|
+
if (link === current) return;
|
|
147
|
+
current?.removeAttribute("aria-current");
|
|
148
|
+
link.setAttribute("aria-current", "true");
|
|
149
|
+
current = link;
|
|
150
|
+
placeMarker(true);
|
|
151
|
+
revealInScroller(link);
|
|
152
|
+
root.dispatchEvent(new CustomEvent("md:toc-change", {
|
|
153
|
+
detail: { link },
|
|
154
|
+
bubbles: true
|
|
155
|
+
}));
|
|
156
|
+
};
|
|
157
|
+
const update = () => {
|
|
158
|
+
const page = document.documentElement;
|
|
159
|
+
if (page.scrollHeight > innerHeight && Math.ceil(scrollY + innerHeight) >= page.scrollHeight - 1) {
|
|
160
|
+
setCurrent(items.at(-1).link);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let passed = items[0].link;
|
|
164
|
+
for (const { link, heading, margin } of items) {
|
|
165
|
+
if (heading.getBoundingClientRect().top - margin > 1) break;
|
|
166
|
+
passed = link;
|
|
167
|
+
}
|
|
168
|
+
setCurrent(passed);
|
|
169
|
+
};
|
|
170
|
+
let locked;
|
|
171
|
+
const lock = () => {
|
|
172
|
+
clearTimeout(locked);
|
|
173
|
+
locked = setTimeout(() => locked = void 0, 150);
|
|
174
|
+
};
|
|
175
|
+
let frame = 0;
|
|
176
|
+
const onScroll = () => {
|
|
177
|
+
if (locked) return lock();
|
|
178
|
+
cancelAnimationFrame(frame);
|
|
179
|
+
frame = requestAnimationFrame(update);
|
|
180
|
+
};
|
|
181
|
+
const onResize = () => {
|
|
182
|
+
measure();
|
|
183
|
+
onScroll();
|
|
184
|
+
};
|
|
185
|
+
const onClick = (event) => {
|
|
186
|
+
const link = event.target.closest("a[href^=\"#\"]");
|
|
187
|
+
const item = items.find((candidate) => candidate.link === link);
|
|
188
|
+
if (!item) return;
|
|
189
|
+
setCurrent(item.link);
|
|
190
|
+
lock();
|
|
191
|
+
};
|
|
192
|
+
const resizeObserver = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(() => placeMarker());
|
|
193
|
+
resizeObserver?.observe(root);
|
|
194
|
+
measure();
|
|
195
|
+
update();
|
|
196
|
+
addEventListener("scroll", onScroll, { passive: true });
|
|
197
|
+
addEventListener("resize", onResize);
|
|
198
|
+
addEventListener("load", onResize);
|
|
199
|
+
root.addEventListener("click", onClick);
|
|
200
|
+
return () => {
|
|
201
|
+
resizeObserver?.disconnect();
|
|
202
|
+
clearTimeout(locked);
|
|
203
|
+
cancelAnimationFrame(frame);
|
|
204
|
+
removeEventListener("scroll", onScroll);
|
|
205
|
+
removeEventListener("resize", onResize);
|
|
206
|
+
removeEventListener("load", onResize);
|
|
207
|
+
root.removeEventListener("click", onClick);
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/** Scrolls the closest scrollable ancestor of `element` just enough to show it, without scrolling the page. */
|
|
211
|
+
function revealInScroller(element) {
|
|
212
|
+
let scroller = element.parentElement;
|
|
213
|
+
while (scroller && scroller !== document.body) {
|
|
214
|
+
if (scroller.scrollHeight > scroller.clientHeight && getComputedStyle(scroller).overflowY !== "visible") break;
|
|
215
|
+
scroller = scroller.parentElement;
|
|
216
|
+
}
|
|
217
|
+
if (!scroller || scroller === document.body) return;
|
|
218
|
+
const top = element.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
219
|
+
if (top < 0) scroller.scrollTop += top;
|
|
220
|
+
else if (top + element.offsetHeight > scroller.clientHeight) scroller.scrollTop += top + element.offsetHeight - scroller.clientHeight;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Marks a Layout.Root with `data-md-resizing` while the viewport is resized.
|
|
224
|
+
* Crossing a breakpoint changes the layout, not the state, so styles turn off transitions meant for opening and closing, e.g. the drawer sliding out.
|
|
225
|
+
*/
|
|
226
|
+
function attachLayout(root) {
|
|
227
|
+
let timer;
|
|
228
|
+
let width = innerWidth;
|
|
229
|
+
const onResize = () => {
|
|
230
|
+
if (innerWidth === width) return;
|
|
231
|
+
width = innerWidth;
|
|
232
|
+
root.setAttribute("data-md-resizing", "");
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
timer = setTimeout(() => root.removeAttribute("data-md-resizing"), 150);
|
|
235
|
+
};
|
|
236
|
+
addEventListener("resize", onResize);
|
|
237
|
+
return () => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
root.removeAttribute("data-md-resizing");
|
|
240
|
+
removeEventListener("resize", onResize);
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Collapses a Layout.Aside that the styles show in its collapsible form, so it starts closed above the content.
|
|
245
|
+
* Runs inline right after the aside, before the first paint, so it must not reference anything outside itself.
|
|
246
|
+
*/
|
|
247
|
+
function restoreAside(aside) {
|
|
248
|
+
if (getComputedStyle(aside).getPropertyValue("--md-layout-aside-collapsible").trim() === "1") aside.open = false;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Keeps a Layout.Aside open while the styles show it as a column, and closes it after a link inside is followed.
|
|
252
|
+
* The styles decide which form applies through --md-layout-aside-collapsible, so breakpoints live only in CSS.
|
|
253
|
+
* Its title shows the heading being read, taken from md:toc-change events.
|
|
254
|
+
*/
|
|
255
|
+
function attachAside(aside) {
|
|
256
|
+
const summary = aside.querySelector(":scope > summary");
|
|
257
|
+
const currentText = aside.querySelector("[data-md-aside-current]");
|
|
258
|
+
const collapsible = () => getComputedStyle(aside).getPropertyValue("--md-layout-aside-collapsible").trim() === "1";
|
|
259
|
+
const showCurrent = (link) => {
|
|
260
|
+
if (currentText) currentText.textContent = link?.textContent?.trim() ?? "";
|
|
261
|
+
};
|
|
262
|
+
showCurrent(aside.querySelector("[aria-current=\"true\"]"));
|
|
263
|
+
const onTocChange = (event) => showCurrent(event.detail.link);
|
|
264
|
+
const onPointerDown = (event) => {
|
|
265
|
+
if (aside.open && collapsible() && !aside.contains(event.target)) aside.open = false;
|
|
266
|
+
};
|
|
267
|
+
const onKeydown = (event) => {
|
|
268
|
+
if (event.key !== "Escape" || !aside.open || !collapsible()) return;
|
|
269
|
+
aside.open = false;
|
|
270
|
+
summary?.focus();
|
|
271
|
+
};
|
|
272
|
+
let wasCollapsible = collapsible();
|
|
273
|
+
const sync = () => {
|
|
274
|
+
const canCollapse = collapsible();
|
|
275
|
+
if (!canCollapse) aside.open = true;
|
|
276
|
+
else if (!wasCollapsible) aside.open = false;
|
|
277
|
+
wasCollapsible = canCollapse;
|
|
278
|
+
if (summary) summary.tabIndex = canCollapse ? 0 : -1;
|
|
279
|
+
};
|
|
280
|
+
const onClick = (event) => {
|
|
281
|
+
const target = event.target;
|
|
282
|
+
if (!collapsible()) {
|
|
283
|
+
if (summary?.contains(target)) event.preventDefault();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (target.closest("a[href]") && !summary?.contains(target)) aside.open = false;
|
|
287
|
+
};
|
|
288
|
+
let frame = 0;
|
|
289
|
+
const progress = () => {
|
|
290
|
+
cancelAnimationFrame(frame);
|
|
291
|
+
frame = requestAnimationFrame(() => {
|
|
292
|
+
const scrollable = document.documentElement.scrollHeight - innerHeight;
|
|
293
|
+
const value = scrollable > 0 ? Math.min(1, Math.max(0, scrollY / scrollable)) : 1;
|
|
294
|
+
aside.style.setProperty("--md-reading-progress", value.toFixed(3));
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
sync();
|
|
298
|
+
progress();
|
|
299
|
+
addEventListener("scroll", progress, { passive: true });
|
|
300
|
+
addEventListener("resize", progress);
|
|
301
|
+
addEventListener("resize", sync);
|
|
302
|
+
aside.addEventListener("click", onClick);
|
|
303
|
+
aside.addEventListener("md:toc-change", onTocChange);
|
|
304
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
305
|
+
document.addEventListener("keydown", onKeydown);
|
|
306
|
+
return () => {
|
|
307
|
+
cancelAnimationFrame(frame);
|
|
308
|
+
removeEventListener("scroll", progress);
|
|
309
|
+
removeEventListener("resize", progress);
|
|
310
|
+
removeEventListener("resize", sync);
|
|
311
|
+
aside.removeEventListener("click", onClick);
|
|
312
|
+
aside.removeEventListener("md:toc-change", onTocChange);
|
|
313
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
314
|
+
document.removeEventListener("keydown", onKeydown);
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** Looks for Apple platforms, where Mod in a shortcut means ⌘ instead of Ctrl. */
|
|
318
|
+
const APPLE_PLATFORM = /Mac|iPhone|iPad|iPod/;
|
|
319
|
+
/**
|
|
320
|
+
* Fills a Search.Shortcut with the shortcut of the trigger around it, one nested `<kbd>` per key as HTML suggests for key combinations.
|
|
321
|
+
* Keys follow the platform: ⌘ and other symbols on Apple platforms, Ctrl and other names elsewhere. Each carries `data-key` for styling.
|
|
322
|
+
* Without a shortcut it hides. Runs inline right after it, before the first paint, so it must not reference anything outside itself.
|
|
323
|
+
*/
|
|
324
|
+
function restoreShortcut(kbd) {
|
|
325
|
+
const keys = kbd.closest("[data-md-search-keys]")?.getAttribute("data-md-search-keys")?.split(" ")[0];
|
|
326
|
+
if (!keys) {
|
|
327
|
+
kbd.hidden = true;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const apple = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
|
331
|
+
kbd.dataset.platform = apple ? "apple" : "other";
|
|
332
|
+
const names = apple ? {
|
|
333
|
+
Mod: "⌘",
|
|
334
|
+
Meta: "⌘",
|
|
335
|
+
Control: "⌃",
|
|
336
|
+
Alt: "⌥",
|
|
337
|
+
Shift: "⇧"
|
|
338
|
+
} : {
|
|
339
|
+
Mod: "Ctrl",
|
|
340
|
+
Meta: "Meta",
|
|
341
|
+
Control: "Ctrl",
|
|
342
|
+
Alt: "Alt",
|
|
343
|
+
Shift: "Shift"
|
|
344
|
+
};
|
|
345
|
+
kbd.replaceChildren(...keys.split("+").map((key) => {
|
|
346
|
+
const part = document.createElement("kbd");
|
|
347
|
+
part.dataset.key = key;
|
|
348
|
+
part.textContent = names[key] ?? (key.length === 1 ? key.toUpperCase() : key);
|
|
349
|
+
return part;
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
/** Reads shortcuts written as in aria-keyshortcuts, e.g. "Mod+K /", with Mod standing for the platform's command key. */
|
|
353
|
+
function parseShortcuts(value, apple) {
|
|
354
|
+
return value.split(" ").filter(Boolean).map((combo) => {
|
|
355
|
+
const parts = combo.split("+");
|
|
356
|
+
const modifiers = new Set(parts.slice(0, -1).map((part) => part === "Mod" ? apple ? "Meta" : "Control" : part));
|
|
357
|
+
return {
|
|
358
|
+
key: parts.at(-1),
|
|
359
|
+
ctrl: modifiers.has("Control"),
|
|
360
|
+
meta: modifiers.has("Meta"),
|
|
361
|
+
alt: modifiers.has("Alt"),
|
|
362
|
+
shift: modifiers.has("Shift")
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/** The shortcut in aria-keyshortcuts form, with Mod resolved for this platform. */
|
|
367
|
+
const ariaShortcut = ({ key, ctrl, meta, alt, shift }) => [
|
|
368
|
+
ctrl && "Control",
|
|
369
|
+
meta && "Meta",
|
|
370
|
+
alt && "Alt",
|
|
371
|
+
shift && "Shift",
|
|
372
|
+
key.length === 1 ? key.toUpperCase() : key
|
|
373
|
+
].filter(Boolean).join("+");
|
|
374
|
+
function matchesShortcut(event, shortcut) {
|
|
375
|
+
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) return false;
|
|
376
|
+
if (event.ctrlKey !== shortcut.ctrl || event.metaKey !== shortcut.meta || event.altKey !== shortcut.alt) return false;
|
|
377
|
+
return !/^[a-z]$/i.test(shortcut.key) || event.shiftKey === shortcut.shift;
|
|
378
|
+
}
|
|
379
|
+
/** Whether the reader is typing somewhere, where a shortcut without Ctrl, ⌘ or Alt is just text. */
|
|
380
|
+
const isTyping = (target) => target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
|
|
381
|
+
let optionCount = 0;
|
|
382
|
+
/**
|
|
383
|
+
* Runs the search inside a Search.Root: queries, states, keyboard navigation, the dialog and the triggers that point to it.
|
|
384
|
+
* It never changes what `render` puts in the results container; it only reads the options there.
|
|
385
|
+
* The root dispatches bubbling `md:search-open`, `md:search-close`, `md:search-select` and `md:search-error` events.
|
|
386
|
+
*/
|
|
387
|
+
function attachSearch(root, { source, render, debounce = 100 }) {
|
|
388
|
+
const input = root.querySelector("[data-md-search-input]");
|
|
389
|
+
const results = root.querySelector("[data-md-search-results]");
|
|
390
|
+
const dialog = root.querySelector("[data-md-search-dialog]");
|
|
391
|
+
if (!input || !results || !dialog || !root.id) {
|
|
392
|
+
console.warn("[minidocs] Search.Root needs an id, and a Search.Dialog with a Search.Input and a Search.Results inside.");
|
|
393
|
+
return () => {};
|
|
394
|
+
}
|
|
395
|
+
const triggers = [...document.querySelectorAll(`[data-md-search-trigger][aria-controls="${CSS.escape(root.id)}"]`)];
|
|
396
|
+
const apple = APPLE_PLATFORM.test(navigator.platform);
|
|
397
|
+
results.id ||= `md-search-results-${++optionCount}`;
|
|
398
|
+
input.setAttribute("aria-controls", results.id);
|
|
399
|
+
const bindings = triggers.flatMap((trigger) => {
|
|
400
|
+
const value = trigger.dataset.mdSearchKeys;
|
|
401
|
+
if (!value) return [];
|
|
402
|
+
const shortcuts = parseShortcuts(value, apple);
|
|
403
|
+
trigger.setAttribute("aria-keyshortcuts", shortcuts.map(ariaShortcut).join(" "));
|
|
404
|
+
return shortcuts;
|
|
405
|
+
});
|
|
406
|
+
const emit = (name, detail = null) => root.dispatchEvent(new CustomEvent(`md:search-${name}`, {
|
|
407
|
+
detail,
|
|
408
|
+
bubbles: true
|
|
409
|
+
}));
|
|
410
|
+
const announcer = document.createElement("div");
|
|
411
|
+
announcer.setAttribute("role", "status");
|
|
412
|
+
announcer.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
|
|
413
|
+
dialog.append(announcer);
|
|
414
|
+
const show = (status, query, count = 0) => {
|
|
415
|
+
root.dataset.status = status;
|
|
416
|
+
for (const part of root.querySelectorAll("[data-md-search-state]")) if (part.dataset.mdSearchState !== "loading") part.hidden = part.dataset.mdSearchState !== status;
|
|
417
|
+
for (const part of root.querySelectorAll("[data-md-search-query]")) part.textContent = query;
|
|
418
|
+
for (const part of root.querySelectorAll("[data-md-search-count]")) part.textContent = String(count);
|
|
419
|
+
results.hidden = status !== "found";
|
|
420
|
+
input.setAttribute("aria-expanded", String(status === "found"));
|
|
421
|
+
if (status === "found" || status === "empty" || status === "error") {
|
|
422
|
+
const message = root.querySelector(`[data-md-search-state="${status}"]`);
|
|
423
|
+
announcer.textContent = message?.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
const setBusy = (busy) => {
|
|
427
|
+
root.toggleAttribute("aria-busy", busy);
|
|
428
|
+
for (const part of root.querySelectorAll("[data-md-search-state=\"loading\"]")) part.hidden = !busy;
|
|
429
|
+
};
|
|
430
|
+
const params = () => {
|
|
431
|
+
const values = new URLSearchParams();
|
|
432
|
+
for (const control of root.querySelectorAll("input[name], select[name], textarea[name]")) {
|
|
433
|
+
if (control.disabled || results.contains(control)) continue;
|
|
434
|
+
if (control instanceof HTMLSelectElement && control.multiple) {
|
|
435
|
+
for (const option of control.options) if (option.selected) values.append(control.name, option.value);
|
|
436
|
+
} else if (!(control instanceof HTMLInputElement && /^(checkbox|radio)$/.test(control.type) && !control.checked)) values.append(control.name, control.value);
|
|
437
|
+
}
|
|
438
|
+
return values;
|
|
439
|
+
};
|
|
440
|
+
let active;
|
|
441
|
+
let resetPending = false;
|
|
442
|
+
const options = () => [...results.querySelectorAll("[role=\"option\"]")];
|
|
443
|
+
const setActive = (option, reveal) => {
|
|
444
|
+
if (option === active) return;
|
|
445
|
+
active?.removeAttribute("aria-selected");
|
|
446
|
+
active = option;
|
|
447
|
+
if (!option) {
|
|
448
|
+
input.removeAttribute("aria-activedescendant");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
option.id ||= `md-search-option-${++optionCount}`;
|
|
452
|
+
option.setAttribute("aria-selected", "true");
|
|
453
|
+
input.setAttribute("aria-activedescendant", option.id);
|
|
454
|
+
if (reveal) option.scrollIntoView?.({ block: "nearest" });
|
|
455
|
+
};
|
|
456
|
+
const observer = new MutationObserver(() => {
|
|
457
|
+
if (!resetPending && active && results.contains(active)) return;
|
|
458
|
+
resetPending = false;
|
|
459
|
+
setActive(options()[0], false);
|
|
460
|
+
});
|
|
461
|
+
observer.observe(results, {
|
|
462
|
+
childList: true,
|
|
463
|
+
subtree: true
|
|
464
|
+
});
|
|
465
|
+
let timer;
|
|
466
|
+
let controller;
|
|
467
|
+
const query = async (text) => {
|
|
468
|
+
const current = new AbortController();
|
|
469
|
+
controller = current;
|
|
470
|
+
setBusy(true);
|
|
471
|
+
if (text && root.dataset.status === "idle") show("loading", text);
|
|
472
|
+
try {
|
|
473
|
+
const found = await source(text, {
|
|
474
|
+
signal: current.signal,
|
|
475
|
+
params: params()
|
|
476
|
+
});
|
|
477
|
+
if (current.signal.aborted) return;
|
|
478
|
+
render(found, results, { query: text });
|
|
479
|
+
resetPending = true;
|
|
480
|
+
setActive(options()[0], false);
|
|
481
|
+
show(found.length > 0 ? "found" : text ? "empty" : "idle", text, found.length);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (current.signal.aborted) return;
|
|
484
|
+
console.error("[minidocs] Search failed:", error);
|
|
485
|
+
render([], results, { query: text });
|
|
486
|
+
setActive(void 0, false);
|
|
487
|
+
show("error", text);
|
|
488
|
+
emit("error", {
|
|
489
|
+
error,
|
|
490
|
+
query: text
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
setBusy(false);
|
|
494
|
+
};
|
|
495
|
+
const run = () => {
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
controller?.abort();
|
|
498
|
+
const text = input.value.trim();
|
|
499
|
+
if (text) timer = setTimeout(() => query(text), debounce);
|
|
500
|
+
else query(text);
|
|
501
|
+
};
|
|
502
|
+
const onInput = (event) => {
|
|
503
|
+
if (event.isComposing) return;
|
|
504
|
+
const target = event.target;
|
|
505
|
+
if (target === input || target.hasAttribute("name") && !results.contains(target)) run();
|
|
506
|
+
};
|
|
507
|
+
const move = (step) => {
|
|
508
|
+
const list = options();
|
|
509
|
+
if (list.length === 0) return;
|
|
510
|
+
const index = active ? list.indexOf(active) : -1;
|
|
511
|
+
setActive(list[(index + step + list.length) % list.length], true);
|
|
512
|
+
resetPending = false;
|
|
513
|
+
};
|
|
514
|
+
const onKeydown = (event) => {
|
|
515
|
+
if (event.isComposing || event.keyCode === 229 || results.hidden) return;
|
|
516
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
move(event.key === "ArrowDown" ? 1 : -1);
|
|
519
|
+
} else if (event.key === "Enter" && active && results.contains(active)) {
|
|
520
|
+
event.preventDefault();
|
|
521
|
+
const { ctrlKey, metaKey, shiftKey, altKey } = event;
|
|
522
|
+
active.dispatchEvent(new MouseEvent("click", {
|
|
523
|
+
bubbles: true,
|
|
524
|
+
cancelable: true,
|
|
525
|
+
view: window,
|
|
526
|
+
ctrlKey,
|
|
527
|
+
metaKey,
|
|
528
|
+
shiftKey,
|
|
529
|
+
altKey
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
const onPointerMove = (event) => {
|
|
534
|
+
const option = event.target.closest("[role=\"option\"]");
|
|
535
|
+
if (option && results.contains(option)) {
|
|
536
|
+
setActive(option, false);
|
|
537
|
+
resetPending = false;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
let returnTo;
|
|
541
|
+
let returnRing = false;
|
|
542
|
+
const open = () => {
|
|
543
|
+
if (!dialog.open) {
|
|
544
|
+
returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : void 0;
|
|
545
|
+
returnRing = returnTo?.matches(":focus-visible") ?? false;
|
|
546
|
+
dialog.showModal();
|
|
547
|
+
emit("open");
|
|
548
|
+
if (!input.value.trim()) run();
|
|
549
|
+
}
|
|
550
|
+
input.focus();
|
|
551
|
+
input.select();
|
|
552
|
+
};
|
|
553
|
+
const onClose = () => {
|
|
554
|
+
emit("close");
|
|
555
|
+
if (!returnTo || returnRing || document.activeElement !== returnTo) return;
|
|
556
|
+
returnTo.blur();
|
|
557
|
+
returnTo.focus({
|
|
558
|
+
focusVisible: false,
|
|
559
|
+
preventScroll: true
|
|
560
|
+
});
|
|
561
|
+
};
|
|
562
|
+
const onShortcut = (event) => {
|
|
563
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
564
|
+
const typing = isTyping(event.target);
|
|
565
|
+
if (!bindings.some((shortcut) => matchesShortcut(event, shortcut) && !(typing && !shortcut.ctrl && !shortcut.meta && !shortcut.alt))) return;
|
|
566
|
+
event.preventDefault();
|
|
567
|
+
if (dialog.open) dialog.close();
|
|
568
|
+
else open();
|
|
569
|
+
};
|
|
570
|
+
const onResultClick = (event) => {
|
|
571
|
+
const option = event.target.closest("[role=\"option\"]");
|
|
572
|
+
if (!option || !results.contains(option)) return;
|
|
573
|
+
emit("select", {
|
|
574
|
+
option,
|
|
575
|
+
query: input.value.trim()
|
|
576
|
+
});
|
|
577
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
|
578
|
+
dialog.close();
|
|
579
|
+
};
|
|
580
|
+
const onDialogClick = (event) => {
|
|
581
|
+
if (event.target !== dialog) return;
|
|
582
|
+
const box = dialog.getBoundingClientRect();
|
|
583
|
+
if (!(event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom)) dialog.close();
|
|
584
|
+
};
|
|
585
|
+
root.addEventListener("input", onInput);
|
|
586
|
+
input.addEventListener("compositionend", run);
|
|
587
|
+
input.addEventListener("keydown", onKeydown);
|
|
588
|
+
results.addEventListener("pointermove", onPointerMove);
|
|
589
|
+
results.addEventListener("click", onResultClick);
|
|
590
|
+
dialog.addEventListener("click", onDialogClick);
|
|
591
|
+
dialog.addEventListener("close", onClose);
|
|
592
|
+
for (const trigger of triggers) trigger.addEventListener("click", open);
|
|
593
|
+
document.addEventListener("keydown", onShortcut);
|
|
594
|
+
return () => {
|
|
595
|
+
clearTimeout(timer);
|
|
596
|
+
controller?.abort();
|
|
597
|
+
observer.disconnect();
|
|
598
|
+
announcer.remove();
|
|
599
|
+
root.removeEventListener("input", onInput);
|
|
600
|
+
input.removeEventListener("compositionend", run);
|
|
601
|
+
input.removeEventListener("keydown", onKeydown);
|
|
602
|
+
results.removeEventListener("pointermove", onPointerMove);
|
|
603
|
+
results.removeEventListener("click", onResultClick);
|
|
604
|
+
dialog.removeEventListener("click", onDialogClick);
|
|
605
|
+
dialog.removeEventListener("close", onClose);
|
|
606
|
+
for (const trigger of triggers) trigger.removeEventListener("click", open);
|
|
607
|
+
document.removeEventListener("keydown", onShortcut);
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Splits HTML such as an excerpt from Pagefind into plain and `<mark>`ed pieces, so any renderer can show it safely.
|
|
612
|
+
* Every other tag is dropped and only its text is kept.
|
|
613
|
+
*/
|
|
614
|
+
function splitMarks(html) {
|
|
615
|
+
const template = document.createElement("template");
|
|
616
|
+
template.innerHTML = html;
|
|
617
|
+
const pieces = [];
|
|
618
|
+
const push = (text, marked) => {
|
|
619
|
+
const last = pieces.at(-1);
|
|
620
|
+
if (last?.marked === marked) last.text += text;
|
|
621
|
+
else if (text) pieces.push({
|
|
622
|
+
text,
|
|
623
|
+
marked
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
const walk = (node) => {
|
|
627
|
+
for (const child of node.childNodes) if (child.nodeType === Node.TEXT_NODE) push(child.textContent ?? "", false);
|
|
628
|
+
else if (child.nodeName === "MARK") push(child.textContent ?? "", true);
|
|
629
|
+
else walk(child);
|
|
630
|
+
};
|
|
631
|
+
walk(template.content);
|
|
632
|
+
return pieces;
|
|
633
|
+
}
|
|
634
|
+
//#endregion
|
|
635
|
+
export { attachAside, attachLayout, attachSearch, attachSidebar, attachToc, attachTrigger, restoreAside, restoreShortcut, restoreSidebar, splitMarks };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AstroIntegration } from "astro";
|
|
2
|
+
//#region src/settings.d.ts
|
|
3
|
+
interface MinidocsOptions {
|
|
4
|
+
/** Locales to try, in order, when a page is not translated. Locales without an entry never fall back. Requires `i18n`. */
|
|
5
|
+
fallback?: Record<string, string | string[]>;
|
|
6
|
+
/** Markdown syntax minidocs adds on top of the configured processor. */
|
|
7
|
+
markdown?: {
|
|
8
|
+
/** `:::tip[Title]` callout blocks. Defaults to `true`. */
|
|
9
|
+
directives?: boolean;
|
|
10
|
+
/** GitHub's `> [!TIP]` alerts, rendered as callouts. Defaults to `true`. */
|
|
11
|
+
alerts?: boolean;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/callouts.d.ts
|
|
16
|
+
/** Callout types, shared by `:::type` directives, `> [!TYPE]` alerts and the `Callout` component. */
|
|
17
|
+
declare const CALLOUT_TYPES: readonly ['note', 'tip', 'important', 'warning', 'caution'];
|
|
18
|
+
type CalloutType = (typeof CALLOUT_TYPES)[number];
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/index.d.ts
|
|
21
|
+
export default function minidocs(options?: MinidocsOptions): AstroIntegration;
|
|
22
|
+
//#endregion
|
|
23
|
+
export type { CalloutType, MinidocsOptions };
|