@writedocs/generator 0.8.1 → 0.9.1
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/astro.config.mjs +2 -1
- package/bin/writedocs.js +10 -7
- package/package.json +1 -1
- package/src/cli/build.js +21 -0
- package/src/cli/check-built-styles.js +33 -0
- package/src/cli/dev.js +4 -0
- package/src/cli/rewrite-redirect-pages.js +84 -0
- package/src/cli/run-astro.js +4 -0
- package/src/components/ApiLangSelect.astro +1 -2
- package/src/components/CopyPageMenu.astro +122 -34
- package/src/layout/BaseLayout.astro +2 -0
- package/src/layout/components/MobileMenu.astro +10 -4
- package/src/layout/components/Sidebar.astro +110 -2
- package/src/layout/components/TopBar.astro +127 -103
- package/src/layout/styles/mobile-menu.css +6 -0
- package/src/layout/styles/search-modal.css +27 -0
- package/src/layout/styles/topbar.css +113 -1
- package/src/lib/canonical-path.js +11 -0
- package/src/lib/config-schema.js +99 -6
- package/src/lib/config-schema.ts +120 -7
- package/src/lib/config.ts +4 -0
- package/src/lib/json-schema-descriptions.js +1 -1
- package/src/lib/mcp-links.js +30 -0
- package/src/lib/mintlify-convert.js +5 -5
- package/src/lib/search-scope.js +44 -0
- package/src/lib/selector-placement.js +30 -0
- package/src/lib/tabs-strip-offset.js +31 -0
- package/src/lib/writedocs-temp-dir.js +3 -1
- package/src/pages/[...slug].astro +72 -5
- package/src/scripts/dropdowns.ts +70 -35
- package/src/scripts/search.ts +58 -4
- package/src/scripts/tabs-strip.ts +169 -0
- package/writedocs.schema.json +6 -3
|
@@ -69,6 +69,8 @@ import Visibility from "../components/Visibility.astro";
|
|
|
69
69
|
import { Tree, FileTree, Color, GitHub } from "../components/compound";
|
|
70
70
|
import ApiPlayground from "../components/ApiPlayground.astro";
|
|
71
71
|
import ApiReferencePanel from "../components/ApiReferencePanel.astro";
|
|
72
|
+
import { searchScope, sectionTrail, ALL_SCOPES } from "../lib/search-scope.js";
|
|
73
|
+
import { selectorPlacements } from "../lib/selector-placement.js";
|
|
72
74
|
|
|
73
75
|
// A page is hand-written (the `pages` collection, sourced from anywhere
|
|
74
76
|
// in the project - docs/ has no special status, see findAllPages() in
|
|
@@ -373,6 +375,11 @@ const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosi
|
|
|
373
375
|
// <html lang>: the `language` of the navigation level this page sits under,
|
|
374
376
|
// if any. A hidden page has no level of its own (activeSection is only a
|
|
375
377
|
// fallback for its chrome), so it keeps the default.
|
|
378
|
+
// For search: the section line under this page's result (and words it can
|
|
379
|
+
// be found by), and the version/language/product it belongs to - search
|
|
380
|
+
// shows the reader's own first. A hidden page belongs to no section.
|
|
381
|
+
const pageSearchScope = isHidden ? { key: ALL_SCOPES, label: "" } : searchScope(activeSection.path);
|
|
382
|
+
const pageSectionTrail = isHidden ? "" : sectionTrail(activeSection.path, breadcrumbs);
|
|
376
383
|
const languageSegment = isHidden ? undefined : activeSection.path.find((segment) => segment.kind === "language");
|
|
377
384
|
const pageLang = languageSegment ? (languageSegment.items[languageSegment.index] as { language: string }).language : undefined;
|
|
378
385
|
const globalDropdowns = buildGlobalDropdowns(
|
|
@@ -407,6 +414,10 @@ const currentPath = hrefForSlug(currentFileId);
|
|
|
407
414
|
// it set one explicitly, still wins.
|
|
408
415
|
const pageMode = isHidden && entry.data.mode === "default" ? "frame" : entry.data.mode;
|
|
409
416
|
const showSidebar = pageMode === "default" || pageMode === "wide";
|
|
417
|
+
// Products inside a tab or dropdown switch at the top of the sidebar, not in
|
|
418
|
+
// the top bar (lib/selector-placement.js - TopBar.astro applies the same rule).
|
|
419
|
+
const sidebarPlacements = selectorPlacements(selectors, { sidebar: showSidebar });
|
|
420
|
+
const sidebarSwitchers = selectors.filter((_, i) => sidebarPlacements[i] === "sidebar");
|
|
410
421
|
const showToc = pageMode === "default";
|
|
411
422
|
// 'custom' and 'blank' both get the bare wd-canvas treatment below (no
|
|
412
423
|
// auto <h1>, no prev/next, no prose width/padding) - they only differ in
|
|
@@ -425,7 +436,7 @@ const isCanvasMode = pageMode === "custom" || pageMode === "blank";
|
|
|
425
436
|
// spec rather than prose - see [...slug].md.ts's own comment on why
|
|
426
437
|
// those are excluded from the .md route this menu links to in the first
|
|
427
438
|
// place, which this mirrors on the UI side.
|
|
428
|
-
const contextMenuItems = contextMenuOptions(config.contextMenu);
|
|
439
|
+
const contextMenuItems = contextMenuOptions(config.contextMenu, { mcp: config.mcp });
|
|
429
440
|
const showCopyPageMenu = contextMenuItems.length > 0 && !isCanvasMode && !entry.data.openapi;
|
|
430
441
|
const siteUrl = resolveSiteUrl(config);
|
|
431
442
|
|
|
@@ -487,7 +498,7 @@ const components = {
|
|
|
487
498
|
mode={pageMode}
|
|
488
499
|
lang={pageLang}
|
|
489
500
|
>
|
|
490
|
-
{showSidebar && <Sidebar slot="sidebar" navTree={navTree} hrefForSlug={hrefForSlug} currentSlug={currentFileId} />}
|
|
501
|
+
{showSidebar && <Sidebar slot="sidebar" navTree={navTree} hrefForSlug={hrefForSlug} currentSlug={currentFileId} switchers={sidebarSwitchers} />}
|
|
491
502
|
{
|
|
492
503
|
isCanvasMode ? (
|
|
493
504
|
// 'custom' and 'blank' both get this treatment: no auto <h1>, no
|
|
@@ -498,6 +509,11 @@ const components = {
|
|
|
498
509
|
<div class="wd-canvas" data-pagefind-body>
|
|
499
510
|
<Content components={components} />
|
|
500
511
|
{entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
|
|
512
|
+
{/* After the content: a result's excerpt shows the section only when
|
|
513
|
+
that's what matched. */}
|
|
514
|
+
<div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
|
|
515
|
+
{pageSectionTrail && <span data-pagefind-meta="section">{pageSectionTrail}</span>}
|
|
516
|
+
</div>
|
|
501
517
|
</div>
|
|
502
518
|
) : (
|
|
503
519
|
<article class={`wd-article ${pageMode === "wide" ? "wd-article-wide" : ""}`} data-pagefind-body>
|
|
@@ -516,11 +532,14 @@ const components = {
|
|
|
516
532
|
)
|
|
517
533
|
}
|
|
518
534
|
{showCopyPageMenu && (
|
|
519
|
-
<CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} options={contextMenuItems} />
|
|
535
|
+
<CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} siteName={config.name} options={contextMenuItems} />
|
|
520
536
|
)}
|
|
521
537
|
</div>
|
|
522
538
|
<Content components={components} />
|
|
523
539
|
{entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
|
|
540
|
+
<div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
|
|
541
|
+
{pageSectionTrail && <span data-pagefind-meta="section">{pageSectionTrail}</span>}
|
|
542
|
+
</div>
|
|
524
543
|
{!entry.data.hideFooterPagination && (
|
|
525
544
|
<nav class="wd-prevnext" data-pagefind-ignore>
|
|
526
545
|
{prev && (
|
|
@@ -585,6 +604,8 @@ const components = {
|
|
|
585
604
|
</BaseLayout>
|
|
586
605
|
|
|
587
606
|
<script>
|
|
607
|
+
import { mcpServerUrl, cursorInstallLink, vscodeInstallLink } from "../lib/mcp-links.js";
|
|
608
|
+
|
|
588
609
|
// Astro assigns every h2-h4 an id automatically, but doesn't add a
|
|
589
610
|
// clickable anchor next to it. rehype-autolink-headings can't be used
|
|
590
611
|
// for this directly: Astro's own heading-id rehype plugin always runs
|
|
@@ -648,7 +669,7 @@ const components = {
|
|
|
648
669
|
// isn't otherwise present in the DOM anywhere (the rendered HTML
|
|
649
670
|
// content is not the same text).
|
|
650
671
|
function initCopyPageMenu(root: ParentNode) {
|
|
651
|
-
root.querySelectorAll<HTMLButtonElement>("
|
|
672
|
+
root.querySelectorAll<HTMLButtonElement>("button[data-copy-page]").forEach((btn) => {
|
|
652
673
|
if (btn.dataset.wdInit) return;
|
|
653
674
|
btn.dataset.wdInit = "true";
|
|
654
675
|
const label = btn.querySelector<HTMLElement>(".wd-copy-page-primary-label");
|
|
@@ -688,6 +709,40 @@ const components = {
|
|
|
688
709
|
initAskLinks(document);
|
|
689
710
|
document.addEventListener("astro:page-load", () => initAskLinks(document));
|
|
690
711
|
|
|
712
|
+
// The menu's MCP options (CopyPageMenu.astro). "Copy MCP server URL"
|
|
713
|
+
// copies it and says so in its own label - inside the menu, which stays
|
|
714
|
+
// open to show it. "Connect to Cursor / VS Code" on a site with no
|
|
715
|
+
// `domain` get their install links here, for the address the page is
|
|
716
|
+
// served from.
|
|
717
|
+
function initMcpMenu(root: ParentNode) {
|
|
718
|
+
const serverUrl = (el: HTMLElement) => el.dataset.copyMcp || mcpServerUrl(window.location.origin);
|
|
719
|
+
root.querySelectorAll<HTMLButtonElement>("button[data-copy-mcp]").forEach((btn) => {
|
|
720
|
+
if (btn.dataset.wdInit) return;
|
|
721
|
+
btn.dataset.wdInit = "true";
|
|
722
|
+
const label = btn.querySelector<HTMLElement>(".wd-copy-page-item-title, .wd-copy-page-primary-label");
|
|
723
|
+
const originalLabel = label?.textContent ?? "";
|
|
724
|
+
btn.addEventListener("click", async (e) => {
|
|
725
|
+
e.stopPropagation();
|
|
726
|
+
try {
|
|
727
|
+
await navigator.clipboard.writeText(serverUrl(btn));
|
|
728
|
+
} catch {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (label) label.textContent = "Copied!";
|
|
732
|
+
window.setTimeout(() => {
|
|
733
|
+
if (label) label.textContent = originalLabel;
|
|
734
|
+
}, 1500);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
root.querySelectorAll<HTMLAnchorElement>("a[data-mcp-install]").forEach((link) => {
|
|
738
|
+
const url = mcpServerUrl(window.location.origin);
|
|
739
|
+
const name = link.dataset.mcpName ?? document.title;
|
|
740
|
+
link.href = link.dataset.mcpInstall === "cursor" ? cursorInstallLink(name, url) : vscodeInstallLink(name, url);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
initMcpMenu(document);
|
|
744
|
+
document.addEventListener("astro:page-load", () => initMcpMenu(document));
|
|
745
|
+
|
|
691
746
|
// Show more/less toggle for ```js expandable code blocks - same
|
|
692
747
|
// build-time-emitted-button + client-wired-click pattern as the copy
|
|
693
748
|
// button above; codeBlockTransformer only adds this button when the
|
|
@@ -873,13 +928,25 @@ const components = {
|
|
|
873
928
|
direct .wd-article child. */
|
|
874
929
|
.wd-article-header {
|
|
875
930
|
display: flex;
|
|
931
|
+
flex-wrap: wrap;
|
|
876
932
|
align-items: center;
|
|
877
933
|
justify-content: space-between;
|
|
878
|
-
gap: 1rem;
|
|
934
|
+
gap: 0.75rem 1rem;
|
|
879
935
|
margin: 0 0 1rem;
|
|
880
936
|
}
|
|
881
937
|
.wd-article-header h1 {
|
|
882
938
|
margin: 0;
|
|
939
|
+
/* A word longer than the whole line (a long product name on a phone)
|
|
940
|
+
breaks rather than pushing the page wider than the screen. */
|
|
941
|
+
overflow-wrap: break-word;
|
|
942
|
+
}
|
|
943
|
+
/* The title takes the room the menu leaves. Its basis is 0, so the row
|
|
944
|
+
only wraps when its longest word and the menu can't share a line - on
|
|
945
|
+
a desktop, a long title still wraps beside the menu; on a phone, a long
|
|
946
|
+
word sends the menu below the title instead of off the screen. */
|
|
947
|
+
.wd-article-header > h1,
|
|
948
|
+
.wd-article-header > .wd-article-title {
|
|
949
|
+
flex: 1 1 0;
|
|
883
950
|
}
|
|
884
951
|
/* Frontmatter `deprecated: true` (Mintlify's) - same amber as the
|
|
885
952
|
sidebar's own deprecated tag (NavTree.astro) and Parameter's
|
package/src/scripts/dropdowns.ts
CHANGED
|
@@ -4,56 +4,91 @@
|
|
|
4
4
|
// written for the topbar's own switcher/tab dropdowns, but the same
|
|
5
5
|
// `.wd-dropdown`/`.wd-dropdown-trigger`/`.wd-dropdown-menu`/
|
|
6
6
|
// `.wd-dropdown-menu-panel` shape is now reused by TopBar.astro (switchers,
|
|
7
|
-
// tabs, the small-screen topbar.links ellipsis)
|
|
8
|
-
// exported from here so any
|
|
9
|
-
//
|
|
10
|
-
// and call it too without needing to know whether some other component
|
|
11
|
-
// already has.
|
|
7
|
+
// tabs, the small-screen topbar.links ellipsis), CopyPageMenu.astro and
|
|
8
|
+
// ApiLangSelect.astro, and exported from here so any of them can call it
|
|
9
|
+
// without needing to know whether some other component already has.
|
|
12
10
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
11
|
+
// These are show/hide buttons for a list of links (the W3C's "disclosure"
|
|
12
|
+
// pattern for navigation), not ARIA menus: the button says whether it's
|
|
13
|
+
// open (aria-expanded), the options are ordinary links and buttons that
|
|
14
|
+
// Tab reaches. For the keyboard, on top of that: Down/Up open the list and
|
|
15
|
+
// move between its options (Home/End jump to the ends); Escape closes it
|
|
16
|
+
// and puts focus back on its button; tabbing out of it closes it.
|
|
17
|
+
//
|
|
18
|
+
// The document-level listeners are bound once per page load, and act on
|
|
19
|
+
// whichever dropdowns are open at the time - so they keep working for the
|
|
20
|
+
// ones a client-side navigation brings in.
|
|
22
21
|
let globalListenersBound = false;
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const triggerOf = (dropdown: Element) => dropdown.querySelector<HTMLElement>('.wd-dropdown-trigger');
|
|
24
|
+
const optionsOf = (dropdown: Element) =>
|
|
25
|
+
Array.from(dropdown.querySelectorAll<HTMLElement>('.wd-dropdown-menu a[href], .wd-dropdown-menu button'));
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
};
|
|
27
|
+
function open(dropdown: Element) {
|
|
28
|
+
closeAll(dropdown);
|
|
29
|
+
dropdown.classList.add('open');
|
|
30
|
+
triggerOf(dropdown)?.setAttribute('aria-expanded', 'true');
|
|
31
|
+
}
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
function close(dropdown: Element) {
|
|
34
|
+
dropdown.classList.remove('open');
|
|
35
|
+
triggerOf(dropdown)?.setAttribute('aria-expanded', 'false');
|
|
36
|
+
// Focus on an option that just got hidden would drop to the top of the
|
|
37
|
+
// page - it goes back to the button instead.
|
|
38
|
+
const focused = document.activeElement;
|
|
39
|
+
if (focused && focused !== triggerOf(dropdown) && dropdown.contains(focused)) triggerOf(dropdown)?.focus();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function closeAll(except?: Element) {
|
|
43
|
+
document.querySelectorAll('.wd-dropdown.open').forEach((d) => {
|
|
44
|
+
if (d !== except) close(d);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function initDropdowns(root: ParentNode) {
|
|
49
|
+
root.querySelectorAll<HTMLElement>('.wd-dropdown').forEach((dropdown) => {
|
|
50
|
+
const trigger = triggerOf(dropdown);
|
|
37
51
|
if (!trigger || trigger.dataset.wdInit) return;
|
|
38
52
|
trigger.dataset.wdInit = 'true';
|
|
53
|
+
|
|
39
54
|
trigger.addEventListener('click', (e) => {
|
|
40
55
|
e.stopPropagation();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
if (dropdown.classList.contains('open')) close(dropdown);
|
|
57
|
+
else open(dropdown);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
dropdown.addEventListener('keydown', (e) => {
|
|
61
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
62
|
+
const options = optionsOf(dropdown);
|
|
63
|
+
if (options.length === 0) return;
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
if (!dropdown.classList.contains('open')) open(dropdown);
|
|
66
|
+
const at = options.indexOf(document.activeElement as HTMLElement);
|
|
67
|
+
let next: number;
|
|
68
|
+
if (e.key === 'Home') next = 0;
|
|
69
|
+
else if (e.key === 'End') next = options.length - 1;
|
|
70
|
+
else if (at === -1) next = e.key === 'ArrowUp' ? options.length - 1 : 0;
|
|
71
|
+
else next = Math.min(Math.max(at + (e.key === 'ArrowDown' ? 1 : -1), 0), options.length - 1);
|
|
72
|
+
options[next].focus();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Focus moving somewhere outside the dropdown (Tab, Shift+Tab) closes
|
|
76
|
+
// it. No `relatedTarget` means focus went nowhere in particular - a
|
|
77
|
+
// click on the page, which the click-outside listener already handles.
|
|
78
|
+
dropdown.addEventListener('focusout', (e) => {
|
|
79
|
+
const to = e.relatedTarget as Node | null;
|
|
80
|
+
if (to && !dropdown.contains(to) && dropdown.classList.contains('open')) {
|
|
81
|
+
dropdown.classList.remove('open');
|
|
82
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
46
83
|
}
|
|
47
84
|
});
|
|
48
85
|
});
|
|
49
86
|
|
|
50
87
|
if (!globalListenersBound) {
|
|
51
88
|
globalListenersBound = true;
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
// scoped any narrower or bound more than once.
|
|
56
|
-
document.addEventListener('click', closeAll);
|
|
89
|
+
// A click anywhere closes what's open - including a click on one of its
|
|
90
|
+
// options, which has done its job by then.
|
|
91
|
+
document.addEventListener('click', () => closeAll());
|
|
57
92
|
document.addEventListener('keydown', (e) => {
|
|
58
93
|
if (e.key === 'Escape') closeAll();
|
|
59
94
|
});
|
package/src/scripts/search.ts
CHANGED
|
@@ -72,15 +72,52 @@ export function initSearch(root: ParentNode) {
|
|
|
72
72
|
let currentResults: HTMLElement[] = [];
|
|
73
73
|
let activeIndex = -1;
|
|
74
74
|
|
|
75
|
+
// Down on the last result (or Up on the first) stays there - Enter always
|
|
76
|
+
// has a result to open.
|
|
75
77
|
function setActive(index: number) {
|
|
76
78
|
currentResults.forEach((el) => el.classList.remove('wd-search-result-active'));
|
|
77
|
-
activeIndex =
|
|
79
|
+
activeIndex = currentResults.length ? Math.min(Math.max(index, 0), currentResults.length - 1) : -1;
|
|
78
80
|
if (activeIndex >= 0) {
|
|
79
81
|
currentResults[activeIndex].classList.add('wd-search-result-active');
|
|
80
82
|
currentResults[activeIndex].scrollIntoView({ block: 'nearest' });
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Search looks in the version, language and product of the page the reader
|
|
87
|
+
// is on (plus pages outside any of them), with a switch to search
|
|
88
|
+
// everything. [...slug].astro marks each page's scope for Pagefind and
|
|
89
|
+
// for this - see lib/search-scope.js.
|
|
90
|
+
let everything = false;
|
|
91
|
+
const pageScope = () => {
|
|
92
|
+
const el = document.querySelector<HTMLElement>('[data-wd-search-scope]');
|
|
93
|
+
const key = el?.dataset.wdSearchScope ?? 'all';
|
|
94
|
+
return key === 'all' ? null : { key, label: el?.dataset.wdSearchScopeLabel || key };
|
|
95
|
+
};
|
|
96
|
+
function scopeNote(scope: { label: string } | null, found: boolean) {
|
|
97
|
+
if (!scope) return null;
|
|
98
|
+
const note = document.createElement('div');
|
|
99
|
+
note.className = 'wd-search-scope';
|
|
100
|
+
const text = document.createElement('span');
|
|
101
|
+
const button = document.createElement('button');
|
|
102
|
+
button.type = 'button';
|
|
103
|
+
button.className = 'wd-search-scope-toggle';
|
|
104
|
+
if (everything) {
|
|
105
|
+
text.textContent = 'Showing results from the whole site.';
|
|
106
|
+
button.textContent = `Only ${scope.label}`;
|
|
107
|
+
} else {
|
|
108
|
+
text.textContent = found ? `Showing results in ${scope.label}.` : `No results in ${scope.label}.`;
|
|
109
|
+
button.textContent = 'Search everything';
|
|
110
|
+
}
|
|
111
|
+
button.addEventListener('click', (e) => {
|
|
112
|
+
e.stopPropagation();
|
|
113
|
+
everything = !everything;
|
|
114
|
+
input.focus();
|
|
115
|
+
runSearch(input.value);
|
|
116
|
+
});
|
|
117
|
+
note.append(text, ' ', button);
|
|
118
|
+
return note;
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
async function runSearch(term: string) {
|
|
85
122
|
if (!term.trim()) {
|
|
86
123
|
resultsEl.innerHTML = '<div class="wd-search-hint">Start typing to search...</div>';
|
|
@@ -94,10 +131,14 @@ export function initSearch(root: ParentNode) {
|
|
|
94
131
|
// superseded this call - pagefind's own debounce, on top of (not
|
|
95
132
|
// instead of) the plain `input` listener below, so a fast typist never
|
|
96
133
|
// races two searches rendering out of order.
|
|
97
|
-
const
|
|
134
|
+
const scope = pageScope();
|
|
135
|
+
const options = scope && !everything ? { filters: { any: [{ scope: scope.key }, { scope: 'all' }] } } : {};
|
|
136
|
+
const search = await pf.debouncedSearch(term, options, 150);
|
|
98
137
|
if (search === null) return;
|
|
99
138
|
if (search.results.length === 0) {
|
|
100
139
|
resultsEl.innerHTML = '<div class="wd-search-empty">No results.</div>';
|
|
140
|
+
const note = scopeNote(scope, false);
|
|
141
|
+
if (note) resultsEl.replaceChildren(note);
|
|
101
142
|
currentResults = [];
|
|
102
143
|
activeIndex = -1;
|
|
103
144
|
return;
|
|
@@ -111,15 +152,27 @@ export function initSearch(root: ParentNode) {
|
|
|
111
152
|
const title = document.createElement('div');
|
|
112
153
|
title.className = 'wd-search-result-title';
|
|
113
154
|
title.textContent = data.meta?.title ?? data.url;
|
|
155
|
+
// Where the page sits: "API Reference › Webhooks".
|
|
156
|
+
const section = data.meta?.section ? document.createElement('div') : null;
|
|
157
|
+
if (section) {
|
|
158
|
+
section.className = 'wd-search-result-section';
|
|
159
|
+
section.textContent = data.meta.section;
|
|
160
|
+
}
|
|
114
161
|
const excerpt = document.createElement('div');
|
|
115
162
|
excerpt.className = 'wd-search-result-excerpt';
|
|
116
163
|
// innerHTML (not textContent) is deliberate: pagefind returns this
|
|
117
164
|
// pre-sanitized with <mark> wrapping the matched terms, which is the
|
|
118
165
|
// whole point - a plain-text excerpt couldn't highlight anything.
|
|
119
|
-
|
|
120
|
-
|
|
166
|
+
// The section is indexed as the page's last words, so a short page's
|
|
167
|
+
// excerpt ends with it - shown once, on its own line, unless it's what
|
|
168
|
+
// matched (then the excerpt keeps it, highlighted).
|
|
169
|
+
const trailing = data.meta?.section ? ` ${data.meta.section}.` : null;
|
|
170
|
+
excerpt.innerHTML = trailing && data.excerpt.endsWith(trailing) ? data.excerpt.slice(0, -trailing.length) : data.excerpt;
|
|
171
|
+
a.append(title, ...(section ? [section] : []), excerpt);
|
|
121
172
|
resultsEl.appendChild(a);
|
|
122
173
|
}
|
|
174
|
+
const note = scopeNote(scope, true);
|
|
175
|
+
if (note) resultsEl.appendChild(note);
|
|
123
176
|
currentResults = Array.from(resultsEl.querySelectorAll('.wd-search-result'));
|
|
124
177
|
setActive(0);
|
|
125
178
|
}
|
|
@@ -128,6 +181,7 @@ export function initSearch(root: ParentNode) {
|
|
|
128
181
|
overlay.hidden = false;
|
|
129
182
|
document.body.style.overflow = 'hidden';
|
|
130
183
|
input.value = '';
|
|
184
|
+
everything = false;
|
|
131
185
|
resultsEl.innerHTML = '<div class="wd-search-hint">Start typing to search...</div>';
|
|
132
186
|
currentResults = [];
|
|
133
187
|
activeIndex = -1;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// A tab row with more tabs than fit slides sideways instead of wrapping:
|
|
2
|
+
// an arrow shows on each side that has more, the active tab starts in
|
|
3
|
+
// view, and keyboard focus, a horizontal wheel/trackpad swipe and a touch
|
|
4
|
+
// swipe move the row too. TopBar.astro renders the markup:
|
|
5
|
+
// [data-tabs-strip] > arrow, .wd-tabs-viewport > .wd-topbar-tabs, arrow.
|
|
6
|
+
//
|
|
7
|
+
// The row is moved with a transform inside an `overflow-x: clip` viewport,
|
|
8
|
+
// not scrolled: a scroll container clips both directions, and would cut
|
|
9
|
+
// off the menus of the tabs and global dropdowns that open below the row.
|
|
10
|
+
// `clip` on one axis leaves the other visible. Browsers without it keep
|
|
11
|
+
// the row wrapping (topbar.css), and this does nothing.
|
|
12
|
+
import { maxOffset, clampOffset, stepOffset, revealOffset } from '../lib/tabs-strip-offset.js';
|
|
13
|
+
|
|
14
|
+
export function initTabsStrips(root: ParentNode) {
|
|
15
|
+
if (!CSS.supports('overflow-x', 'clip')) return;
|
|
16
|
+
root.querySelectorAll<HTMLElement>('[data-tabs-strip]').forEach(initStrip);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function initStrip(strip: HTMLElement) {
|
|
20
|
+
if (strip.dataset.wdInit) return;
|
|
21
|
+
strip.dataset.wdInit = 'true';
|
|
22
|
+
const viewport = strip.querySelector<HTMLElement>('.wd-tabs-viewport');
|
|
23
|
+
const track = strip.querySelector<HTMLElement>('.wd-topbar-tabs');
|
|
24
|
+
const prev = strip.querySelector<HTMLElement>('[data-tabs-prev]');
|
|
25
|
+
const next = strip.querySelector<HTMLElement>('[data-tabs-next]');
|
|
26
|
+
if (!viewport || !track || !prev || !next) return;
|
|
27
|
+
|
|
28
|
+
let offset = 0;
|
|
29
|
+
const geometry = () => {
|
|
30
|
+
const view = viewport.clientWidth;
|
|
31
|
+
return { view, max: maxOffset(track.scrollWidth, view), edge: prev.offsetWidth || 36 };
|
|
32
|
+
};
|
|
33
|
+
const apply = () => {
|
|
34
|
+
const { max } = geometry();
|
|
35
|
+
offset = clampOffset(offset, max);
|
|
36
|
+
track.style.transform = offset ? `translateX(${-offset}px)` : '';
|
|
37
|
+
strip.classList.toggle('wd-can-prev', offset > 0);
|
|
38
|
+
strip.classList.toggle('wd-can-next', offset < max);
|
|
39
|
+
};
|
|
40
|
+
// The tab (or dropdown) a node belongs to - a direct child of the row.
|
|
41
|
+
const itemOf = (node: Element | null) => {
|
|
42
|
+
while (node && node.parentElement !== track) node = node.parentElement;
|
|
43
|
+
return node as HTMLElement | null;
|
|
44
|
+
};
|
|
45
|
+
const reveal = (item: HTMLElement | null) => {
|
|
46
|
+
if (!item) return;
|
|
47
|
+
offset = revealOffset(offset, item.offsetLeft, item.offsetLeft + item.offsetWidth, geometry());
|
|
48
|
+
apply();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Where the page starts: the active tab in view, without sliding to it.
|
|
52
|
+
// Tabs widen when the web font arrives, after this first placement - until
|
|
53
|
+
// the reader moves the row, each resize puts the active tab back in view.
|
|
54
|
+
let readerMoved = false;
|
|
55
|
+
const activeItem = itemOf(track.querySelector('.wd-tab-link.active'));
|
|
56
|
+
const settle = () => {
|
|
57
|
+
strip.classList.remove('wd-tabs-animate');
|
|
58
|
+
reveal(activeItem);
|
|
59
|
+
requestAnimationFrame(() => strip.classList.add('wd-tabs-animate'));
|
|
60
|
+
};
|
|
61
|
+
settle();
|
|
62
|
+
|
|
63
|
+
prev.addEventListener('click', (e) => {
|
|
64
|
+
e.stopPropagation();
|
|
65
|
+
readerMoved = true;
|
|
66
|
+
offset = stepOffset(offset, -1, geometry());
|
|
67
|
+
apply();
|
|
68
|
+
});
|
|
69
|
+
next.addEventListener('click', (e) => {
|
|
70
|
+
e.stopPropagation();
|
|
71
|
+
readerMoved = true;
|
|
72
|
+
offset = stepOffset(offset, 1, geometry());
|
|
73
|
+
apply();
|
|
74
|
+
});
|
|
75
|
+
track.addEventListener('focusin', (e) => reveal(itemOf(e.target as Element)));
|
|
76
|
+
|
|
77
|
+
// A sideways swipe on a trackpad, or Shift + wheel. A plain vertical
|
|
78
|
+
// wheel still scrolls the page.
|
|
79
|
+
viewport.addEventListener(
|
|
80
|
+
'wheel',
|
|
81
|
+
(e) => {
|
|
82
|
+
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.shiftKey ? e.deltaY : 0;
|
|
83
|
+
if (!delta || geometry().max === 0) return;
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
readerMoved = true;
|
|
86
|
+
strip.classList.remove('wd-tabs-animate');
|
|
87
|
+
offset += delta;
|
|
88
|
+
apply();
|
|
89
|
+
requestAnimationFrame(() => strip.classList.add('wd-tabs-animate'));
|
|
90
|
+
},
|
|
91
|
+
{ passive: false }
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Touch: a sideways swipe drags the row, and a quick one carries on a
|
|
95
|
+
// little after the finger lifts. `touch-action: pan-y` (topbar.css) leaves
|
|
96
|
+
// sideways moves to this and up/down ones to the page, which scrolls as
|
|
97
|
+
// usual. A swipe that ends on a tab doesn't open it.
|
|
98
|
+
let drag: { id: number; x: number; from: number; moving: boolean; last: [number, number][] } | null = null;
|
|
99
|
+
let swallowClick = false;
|
|
100
|
+
viewport.addEventListener('pointerdown', (e) => {
|
|
101
|
+
if (e.pointerType !== 'touch' || geometry().max === 0) return;
|
|
102
|
+
drag = { id: e.pointerId, x: e.clientX, from: offset, moving: false, last: [[e.clientX, e.timeStamp]] };
|
|
103
|
+
});
|
|
104
|
+
viewport.addEventListener('pointermove', (e) => {
|
|
105
|
+
if (!drag || e.pointerId !== drag.id) return;
|
|
106
|
+
const dx = e.clientX - drag.x;
|
|
107
|
+
if (!drag.moving) {
|
|
108
|
+
if (Math.abs(dx) < 8) return;
|
|
109
|
+
drag.moving = true;
|
|
110
|
+
readerMoved = true;
|
|
111
|
+
strip.classList.remove('wd-tabs-animate');
|
|
112
|
+
viewport.setPointerCapture(e.pointerId);
|
|
113
|
+
}
|
|
114
|
+
drag.last = [...drag.last.slice(-1), [e.clientX, e.timeStamp]];
|
|
115
|
+
offset = drag.from - dx;
|
|
116
|
+
apply();
|
|
117
|
+
});
|
|
118
|
+
const endDrag = (e: PointerEvent) => {
|
|
119
|
+
if (!drag || e.pointerId !== drag.id) return;
|
|
120
|
+
if (drag.moving && e.type === 'pointerup') {
|
|
121
|
+
const [[x0, t0], [x1, t1]] = drag.last.length > 1 ? drag.last : [drag.last[0], drag.last[0]];
|
|
122
|
+
const velocity = t1 > t0 ? (x1 - x0) / (t1 - t0) : 0; // px per ms
|
|
123
|
+
strip.classList.add('wd-tabs-animate');
|
|
124
|
+
offset -= velocity * 180;
|
|
125
|
+
apply();
|
|
126
|
+
swallowClick = true;
|
|
127
|
+
setTimeout(() => (swallowClick = false), 400);
|
|
128
|
+
} else {
|
|
129
|
+
requestAnimationFrame(() => strip.classList.add('wd-tabs-animate'));
|
|
130
|
+
}
|
|
131
|
+
drag = null;
|
|
132
|
+
};
|
|
133
|
+
viewport.addEventListener('pointerup', endDrag);
|
|
134
|
+
viewport.addEventListener('pointercancel', endDrag);
|
|
135
|
+
viewport.addEventListener(
|
|
136
|
+
'click',
|
|
137
|
+
(e) => {
|
|
138
|
+
if (!swallowClick) return;
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
swallowClick = false;
|
|
142
|
+
},
|
|
143
|
+
true
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// A menu that opens under a tab near the right end would be cut off
|
|
147
|
+
// there - it opens to the left of its tab instead.
|
|
148
|
+
track.querySelectorAll<HTMLElement>('.wd-dropdown').forEach((dropdown) => {
|
|
149
|
+
const menu = dropdown.querySelector<HTMLElement>('.wd-dropdown-menu');
|
|
150
|
+
if (!menu) return;
|
|
151
|
+
const place = () => {
|
|
152
|
+
const shown = menu.style.display;
|
|
153
|
+
menu.style.visibility = 'hidden';
|
|
154
|
+
menu.style.display = 'block';
|
|
155
|
+
const width = menu.offsetWidth;
|
|
156
|
+
menu.style.display = shown;
|
|
157
|
+
menu.style.visibility = '';
|
|
158
|
+
const right = dropdown.getBoundingClientRect().left + width;
|
|
159
|
+
dropdown.classList.toggle('wd-dropdown-align-end', right > viewport.getBoundingClientRect().right);
|
|
160
|
+
};
|
|
161
|
+
dropdown.addEventListener('pointerenter', place);
|
|
162
|
+
dropdown.addEventListener('focusin', place);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Tabs change width as fonts load, and the space changes with the window.
|
|
166
|
+
const resized = () => (readerMoved ? apply() : settle());
|
|
167
|
+
new ResizeObserver(resized).observe(viewport);
|
|
168
|
+
new ResizeObserver(resized).observe(track);
|
|
169
|
+
}
|
package/writedocs.schema.json
CHANGED
|
@@ -760,7 +760,10 @@
|
|
|
760
760
|
"view",
|
|
761
761
|
"chatgpt",
|
|
762
762
|
"claude",
|
|
763
|
-
"perplexity"
|
|
763
|
+
"perplexity",
|
|
764
|
+
"mcp",
|
|
765
|
+
"cursor",
|
|
766
|
+
"vscode"
|
|
764
767
|
]
|
|
765
768
|
}
|
|
766
769
|
},
|
|
@@ -784,8 +787,8 @@
|
|
|
784
787
|
"additionalProperties": false
|
|
785
788
|
}
|
|
786
789
|
],
|
|
787
|
-
"description": "The \"Copy page\" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity - and a Markdown copy of each page at its address + \".md\". On by default with every option. A list picks the options (\"copy\", \"view\", \"chatgpt\", \"claude\", \"perplexity\"); false turns it all off.",
|
|
788
|
-
"markdownDescription": "The \"Copy page\" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity - and a Markdown copy of each page at its address + \".md\". On by default with every option. A list picks the options (\"copy\", \"view\", \"chatgpt\", \"claude\", \"perplexity\"); false turns it all off."
|
|
790
|
+
"description": "The \"Copy page\" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity, copy the MCP server URL, connect to Cursor or VS Code - and a Markdown copy of each page at its address + \".md\". On by default with every option (the MCP ones only while `mcp` is on). A list picks the options (\"copy\", \"view\", \"chatgpt\", \"claude\", \"perplexity\", \"mcp\", \"cursor\", \"vscode\"); false turns it all off.",
|
|
791
|
+
"markdownDescription": "The \"Copy page\" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity, copy the MCP server URL, connect to Cursor or VS Code - and a Markdown copy of each page at its address + \".md\". On by default with every option (the MCP ones only while `mcp` is on). A list picks the options (\"copy\", \"view\", \"chatgpt\", \"claude\", \"perplexity\", \"mcp\", \"cursor\", \"vscode\"); false turns it all off."
|
|
789
792
|
},
|
|
790
793
|
"mcp": {
|
|
791
794
|
"default": true,
|