@writedocs/generator 0.1.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.
Files changed (95) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +17 -0
  3. package/astro.config.mjs +419 -0
  4. package/bin/writedocs.js +73 -0
  5. package/package.json +79 -0
  6. package/src/assets/wd_watermark.png +0 -0
  7. package/src/assets/wd_watermark_dark.png +0 -0
  8. package/src/cli/build-auth.js +53 -0
  9. package/src/cli/build.js +40 -0
  10. package/src/cli/dev.js +12 -0
  11. package/src/cli/generate-api-pages.js +359 -0
  12. package/src/cli/init.js +81 -0
  13. package/src/cli/preflight.js +40 -0
  14. package/src/cli/run-astro.js +57 -0
  15. package/src/cli/run-pagefind.js +66 -0
  16. package/src/cli/write-redirects-file.js +80 -0
  17. package/src/components/Accordion.astro +164 -0
  18. package/src/components/AccordionGroup.astro +40 -0
  19. package/src/components/ApiLangSelect.astro +168 -0
  20. package/src/components/ApiPlayground.astro +281 -0
  21. package/src/components/ApiReferencePanel.astro +1754 -0
  22. package/src/components/ApiSchemaField.astro +54 -0
  23. package/src/components/AppIcon.astro +32 -0
  24. package/src/components/Badge.astro +128 -0
  25. package/src/components/Callout.astro +168 -0
  26. package/src/components/Card.astro +136 -0
  27. package/src/components/CardGroup.astro +20 -0
  28. package/src/components/CodeGroup.astro +184 -0
  29. package/src/components/CopyPageMenu.astro +246 -0
  30. package/src/components/Danger.astro +12 -0
  31. package/src/components/Expandable.astro +126 -0
  32. package/src/components/Frame.astro +102 -0
  33. package/src/components/Hint.astro +99 -0
  34. package/src/components/Icon.astro +70 -0
  35. package/src/components/Image.astro +147 -0
  36. package/src/components/Info.astro +12 -0
  37. package/src/components/Note.astro +12 -0
  38. package/src/components/Parameter.astro +119 -0
  39. package/src/components/RequestExample.astro +33 -0
  40. package/src/components/ResponseExample.astro +19 -0
  41. package/src/components/Searchbar.astro +117 -0
  42. package/src/components/Step.astro +10 -0
  43. package/src/components/Steps.astro +32 -0
  44. package/src/components/Tab.astro +9 -0
  45. package/src/components/Tabs.astro +52 -0
  46. package/src/components/Tip.astro +12 -0
  47. package/src/components/Video.astro +135 -0
  48. package/src/components/Warning.astro +12 -0
  49. package/src/components/index.ts +48 -0
  50. package/src/content.config.ts +223 -0
  51. package/src/layout/BaseLayout.astro +750 -0
  52. package/src/layout/components/AnalyticsScripts.astro +77 -0
  53. package/src/layout/components/AskAiWidget.astro +37 -0
  54. package/src/layout/components/Breadcrumbs.astro +97 -0
  55. package/src/layout/components/ImageZoom.astro +19 -0
  56. package/src/layout/components/MobileMenu.astro +200 -0
  57. package/src/layout/components/NavTree.astro +351 -0
  58. package/src/layout/components/SearchModal.astro +42 -0
  59. package/src/layout/components/Sidebar.astro +122 -0
  60. package/src/layout/components/SiteFooter.astro +85 -0
  61. package/src/layout/components/TableOfContents.astro +117 -0
  62. package/src/layout/components/TopBar.astro +311 -0
  63. package/src/layout/styles/banner.css +44 -0
  64. package/src/layout/styles/base.css +234 -0
  65. package/src/layout/styles/dropdown.css +133 -0
  66. package/src/layout/styles/footer.css +108 -0
  67. package/src/layout/styles/image-zoom.css +50 -0
  68. package/src/layout/styles/mobile-menu.css +258 -0
  69. package/src/layout/styles/search-modal.css +122 -0
  70. package/src/layout/styles/topbar.css +437 -0
  71. package/src/lib/config.ts +2131 -0
  72. package/src/lib/mdx-auto-hydrate.js +70 -0
  73. package/src/lib/mdx-inject-builtins.js +87 -0
  74. package/src/lib/mdx-substitute-variables.js +66 -0
  75. package/src/lib/mdx-title-anchor-ids.js +84 -0
  76. package/src/lib/mermaid-rehype.js +72 -0
  77. package/src/lib/openapi-render.ts +479 -0
  78. package/src/lib/shiki-code-block.js +102 -0
  79. package/src/lib/shiki-copy-button.js +45 -0
  80. package/src/lib/styles-asset-integration.js +210 -0
  81. package/src/lib/writedocs-temp-dir.js +93 -0
  82. package/src/pages/404.astro +62 -0
  83. package/src/pages/[...slug].astro +1270 -0
  84. package/src/pages/[...slug].md.ts +78 -0
  85. package/src/pages/llms-full.txt.ts +71 -0
  86. package/src/pages/llms.txt.ts +141 -0
  87. package/src/scripts/banner.ts +20 -0
  88. package/src/scripts/dropdowns.ts +61 -0
  89. package/src/scripts/image-zoom.ts +66 -0
  90. package/src/scripts/mobile-menu.ts +55 -0
  91. package/src/scripts/search.ts +155 -0
  92. package/src/scripts/sidebar-scroll.ts +65 -0
  93. package/src/scripts/theme-toggle.ts +35 -0
  94. package/src/scripts/topbar-offset.ts +141 -0
  95. package/src/styles/global.css +18 -0
@@ -0,0 +1,77 @@
1
+ ---
2
+ // writedocs.json's `integrations` (integrationsSchema in lib/config.ts) - the
3
+ // six analytics providers. Each provider's inline-JS half (the ones that
4
+ // need one; Fathom/Plausible/Umami are pure <script src> tags with data-*
5
+ // attributes, rendered directly in the template below with no inline half
6
+ // to build here) is assembled as a plain string, interpolating the site's
7
+ // own config values via JSON.stringify (not raw string interpolation) so
8
+ // a value containing a quote or backslash can never break out of the
9
+ // generated script.
10
+ //
11
+ // Rendered by BaseLayout.astro in <head>, right after the meta tags and
12
+ // before even the theme-detection script - the usual placement every one
13
+ // of these providers' own install instructions ask for, so pageview
14
+ // timing is captured as close to actual page load as it can be. Every
15
+ // snippet below was sourced from that provider's own current official
16
+ // docs (fetched directly, not recalled from training data) while building
17
+ // this feature - see docs/dev/docs/integrations.mdx for links and dates.
18
+ // Every provider field is optional and independent - a site can turn on
19
+ // any subset (including all of them at once, for a migration period) or
20
+ // none.
21
+ //
22
+ // AskAiWidget.astro is the seventh integration (DocsBot) - kept as its
23
+ // own separate component rather than folded in here, since it renders at
24
+ // the *end* of <body>, not in <head> (it's a UI widget, not a pageview
25
+ // beacon - see that component's own comment).
26
+ //
27
+ // Split out of BaseLayout.astro, which had grown to ~2200 lines covering
28
+ // several unrelated concerns in one file - see that component's own
29
+ // comment for the fuller rationale.
30
+ import type { DocsConfig } from '../../lib/config';
31
+
32
+ interface Props {
33
+ config: DocsConfig;
34
+ }
35
+ const { config } = Astro.props as Props;
36
+
37
+ // Google Tag Manager's snippet is copied verbatim from Google's own
38
+ // long-stable install snippet (unchanged in substance for years) - the
39
+ // self-invoking function that pushes the initial 'gtm.js' event and
40
+ // injects the real gtm.js loader script.
41
+ const gtmScript = config.integrations.googleTagManager
42
+ ? `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer',${JSON.stringify(config.integrations.googleTagManager.containerId)});`
43
+ : null;
44
+ // GA4's own gtag.js config call - paired with the separate `<script
45
+ // async src="...gtag/js?id=...">` loader tag rendered directly below,
46
+ // both gated on the same config.integrations.ga4 check.
47
+ const ga4ConfigScript = config.integrations.ga4
48
+ ? `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${JSON.stringify(config.integrations.ga4.measurementId)});`
49
+ : null;
50
+ // PostHog's own "array.js" bootstrap loader - copied verbatim from
51
+ // posthog.com/docs/libraries/js (fetched directly while building this
52
+ // feature, not recalled from training data - PostHog revises this
53
+ // snippet's minified body occasionally, so re-fetch and diff before
54
+ // assuming it's still current if this ever needs touching again). The
55
+ // `defaults` date is PostHog's own versioned configuration-snapshot pin
56
+ // (their docs describe it as "a date... for a configuration snapshot used
57
+ // as defaults") - current as of the fetch above; bump it if PostHog's own
58
+ // docs have moved on to a newer one.
59
+ const posthogScript = config.integrations.posthog
60
+ ? `!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagResult isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);posthog.init(${JSON.stringify(config.integrations.posthog.apiKey)},{api_host:${JSON.stringify(config.integrations.posthog.apiHost)},defaults:'2026-05-30'});`
61
+ : null;
62
+ ---
63
+ {config.integrations.ga4 && (
64
+ <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${config.integrations.ga4.measurementId}`}></script>
65
+ )}
66
+ {ga4ConfigScript && <script is:inline set:html={ga4ConfigScript} />}
67
+ {gtmScript && <script is:inline set:html={gtmScript} />}
68
+ {config.integrations.plausible && (
69
+ <script is:inline defer data-domain={config.integrations.plausible.domain} src={config.integrations.plausible.src}></script>
70
+ )}
71
+ {config.integrations.fathom && (
72
+ <script is:inline src="https://cdn.usefathom.com/script.js" data-site={config.integrations.fathom.siteId} defer></script>
73
+ )}
74
+ {posthogScript && <script is:inline set:html={posthogScript} />}
75
+ {config.integrations.umami && (
76
+ <script is:inline defer src={config.integrations.umami.src} data-website-id={config.integrations.umami.websiteId}></script>
77
+ )}
@@ -0,0 +1,37 @@
1
+ ---
2
+ // writedocs.json's `integrations.askAi` (askAiIntegrationSchema in
3
+ // lib/config.ts) - DocsBot's own embeddable-chat-widget loader, copied
4
+ // verbatim from docsbot.ai/documentation/developer/embeddable-chat-widget
5
+ // (fetched directly while building this feature, not recalled from
6
+ // training data). Unlike AnalyticsScripts.astro's six providers, this
7
+ // isn't rendered in <head> - it's a UI widget (a floating chat bubble),
8
+ // not a pageview beacon, so there's no reason to load it before everything
9
+ // else the way the analytics scripts need to for accurate timing;
10
+ // BaseLayout.astro renders this at the very end of <body> instead, after
11
+ // even the site's own `scripts.body`/root-JS. The loader itself never
12
+ // changes per-site (it's DocsBot's fixed bootstrap, not something a
13
+ // config value gets interpolated into), so it's a plain constant rather
14
+ // than a template string - only the second, actual init call needs the
15
+ // site's own bot id.
16
+ //
17
+ // Split out of BaseLayout.astro, which had grown to ~2200 lines covering
18
+ // several unrelated concerns in one file - see that component's own
19
+ // comment for the fuller rationale.
20
+ import type { DocsConfig } from '../../lib/config';
21
+
22
+ interface Props {
23
+ config: DocsConfig;
24
+ }
25
+ const { config } = Astro.props as Props;
26
+
27
+ const askAiLoaderScript = `window.DocsBotAI=window.DocsBotAI||{},DocsBotAI.init=function(e){return new Promise((t,r)=>{var n=document.createElement("script");n.type="text/javascript",n.async=!0,n.src="https://widget.docsbot.ai/chat.js";let o=document.getElementsByTagName("script")[0];o.parentNode.insertBefore(n,o),n.addEventListener("load",()=>{let n;Promise.all([new Promise((t,r)=>{window.DocsBotAI.mount(Object.assign({}, e)).then(t).catch(r)}),(n=function e(t){return new Promise(e=>{if(document.querySelector(t))return e(document.querySelector(t));let r=new MutationObserver(n=>{if(document.querySelector(t))return e(document.querySelector(t)),r.disconnect()});r.observe(document.body,{childList:!0,subtree:!0})})})("#docsbotai-root"),]).then(()=>t()).catch(r)}),n.addEventListener("error",e=>{r(e.message)})})};`;
28
+ const askAiInitScript = config.integrations.askAi
29
+ ? `DocsBotAI.init(${JSON.stringify({ id: config.integrations.askAi.id })});`
30
+ : null;
31
+ ---
32
+ {config.integrations.askAi && (
33
+ <>
34
+ <script is:inline set:html={askAiLoaderScript} />
35
+ {askAiInitScript && <script is:inline set:html={askAiInitScript} />}
36
+ </>
37
+ )}
@@ -0,0 +1,97 @@
1
+ ---
2
+ // The ancestor-group trail above the auto-rendered <h1> ("Learn the
3
+ // Basics > Authentication Flows") - `items` is ancestorGroupsForSlug()'s
4
+ // output (lib/config.ts): the chain of group labels (root first) the
5
+ // current page is nested under within its active Section's sidebar tree,
6
+ // each linking to that group's own attached page when it has one
7
+ // (`href`), or rendered as plain unlinked text when it doesn't.
8
+ // Deliberately does NOT include the current page itself as a trailing
9
+ // crumb - that's already the <h1> right below this, and repeating it
10
+ // here would just be noise. [...slug].astro only renders this component
11
+ // at all when `items` is non-empty (a page with no enclosing group has
12
+ // no ancestor trail to show, and a home-icon-only bar isn't worth
13
+ // rendering on its own).
14
+ //
15
+ // The home icon is always the first crumb, on every page this component
16
+ // renders on, regardless of `items` - a fixed entry point back to `/`,
17
+ // not itself part of the ancestor chain. Rendered via astro-icon (same
18
+ // system every other writedocs.json `icon` field/AppIcon.astro uses - a real
19
+ // build-time <svg>, no client JS/React needed) - fa6-solid:house is the
20
+ // Iconify id for the exact same Font Awesome 6 glyph as react-icons'
21
+ // `FaHouse`, without pulling React into an otherwise-static nav element.
22
+ import { Icon } from 'astro-icon/components';
23
+
24
+ export interface BreadcrumbItem {
25
+ label: string;
26
+ href: string | null;
27
+ }
28
+
29
+ interface Props {
30
+ items: BreadcrumbItem[];
31
+ }
32
+
33
+ const { items } = Astro.props as Props;
34
+ ---
35
+ <nav class="wd-breadcrumbs" aria-label="Breadcrumb" data-pagefind-ignore>
36
+ <ol class="wd-breadcrumb-list">
37
+ <li class="wd-breadcrumb-item">
38
+ <a href="/" class="wd-breadcrumb-home" aria-label="Home">
39
+ <Icon name="fa6-solid:house" class="wd-breadcrumb-home-icon" />
40
+ </a>
41
+ <svg class="wd-breadcrumb-sep" width="10" height="10" viewBox="0 0 18 18" fill="none" aria-hidden="true">
42
+ <path d="M6.5 2.75L12.75 9L6.5 15.25" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
43
+ </svg>
44
+ </li>
45
+ {items.map((item, i) => (
46
+ <li class="wd-breadcrumb-item">
47
+ {item.href ? <a href={item.href}>{item.label}</a> : <span>{item.label}</span>}
48
+ {i < items.length - 1 && (
49
+ <svg class="wd-breadcrumb-sep" width="10" height="10" viewBox="0 0 18 18" fill="none" aria-hidden="true">
50
+ <path d="M6.5 2.75L12.75 9L6.5 15.25" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
51
+ </svg>
52
+ )}
53
+ </li>
54
+ ))}
55
+ </ol>
56
+ </nav>
57
+
58
+ <style>
59
+ .wd-breadcrumbs {
60
+ margin-bottom: 0.6rem;
61
+ }
62
+ .wd-breadcrumb-list {
63
+ display: flex;
64
+ flex-wrap: wrap;
65
+ align-items: center;
66
+ gap: 0.35rem;
67
+ list-style: none;
68
+ margin: 0;
69
+ padding: 0;
70
+ font-size: 0.85rem;
71
+ color: var(--wd-text-muted);
72
+ }
73
+ .wd-breadcrumb-item {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 0.35rem;
77
+ }
78
+ .wd-breadcrumb-item a {
79
+ display: flex;
80
+ align-items: center;
81
+ color: inherit;
82
+ text-decoration: none;
83
+ }
84
+ .wd-breadcrumb-item a:hover {
85
+ color: var(--wd-text);
86
+ }
87
+ .wd-breadcrumb-home-icon {
88
+ width: 0.85em;
89
+ height: 0.85em;
90
+ flex-shrink: 0;
91
+ }
92
+ .wd-breadcrumb-sep {
93
+ flex-shrink: 0;
94
+ color: var(--wd-text-muted);
95
+ opacity: 0.6;
96
+ }
97
+ </style>
@@ -0,0 +1,19 @@
1
+ ---
2
+ // The fullscreen lightbox overlay opened by clicking any zoomable content
3
+ // image (src/scripts/image-zoom.ts). Mounted once in BaseLayout.astro
4
+ // alongside SearchModal, same reasoning as that component's own comment:
5
+ // self-contained, no writedocs.json-derived props, and needs to exist in the
6
+ // DOM regardless of page `mode` since its own script queries the whole
7
+ // document for zoomable images rather than being scoped to a slot.
8
+ import '../styles/image-zoom.css';
9
+ ---
10
+ <div class="wd-zoom-overlay" id="wd-zoom-overlay" hidden>
11
+ <button type="button" class="wd-zoom-close" aria-label="Close">&times;</button>
12
+ <img class="wd-zoom-image" id="wd-zoom-image" alt="" />
13
+ </div>
14
+ <script>
15
+ import { initImageZoom } from '../../scripts/image-zoom';
16
+
17
+ initImageZoom(document);
18
+ document.addEventListener('astro:page-load', () => initImageZoom(document));
19
+ </script>
@@ -0,0 +1,200 @@
1
+ ---
2
+ // The <= 860px overlay drawer that TopBar.astro's own hamburger button
3
+ // opens - everything that's a side-by-side control on desktop (the
4
+ // brand-row switchers, the tabs bar, the always-visible global dropdowns)
5
+ // plus the sidebar itself collapses into this one panel below 860px.
6
+ // Reuses the exact same `selectors`/`globalDropdowns` props TopBar.astro
7
+ // renders its own desktop switchers/tabs from (see its own
8
+ // switcherSelectors/tabSelectors) rather than a filtered subset - unlike
9
+ // TopBar's switcherSelectors (which deliberately *drops* a dropdown
10
+ // selector nested directly under a tab, since desktop folds it into that
11
+ // tab's own dropdown-trigger button), this renders every entry of
12
+ // `selectors` in root-to-leaf order as its own accordion row. That's what
13
+ // makes "a tab with its own dropdowns gets a second selector right below
14
+ // it" fall out for free: when the active page is under such a tab,
15
+ // `selectors` already contains that nested dropdown as its own array
16
+ // entry immediately following the tab's entry (see buildSelectors() in
17
+ // lib/config.ts, which walks the active PathSegment[] in that exact
18
+ // order) - no tab-specific branching needed here at all.
19
+ //
20
+ // The sidebar itself is the `sidebar` slot, forwarded straight through
21
+ // from whatever BaseLayout.astro received - rendered a second time
22
+ // (Astro renders the same passed-in slot content at every place it's
23
+ // referenced, not just once); the first copy is in BaseLayout's own
24
+ // `.wd-sidebar-col`, for desktop. Both copies exist in the DOM on every
25
+ // load; only one is ever visible at a time (mobile-menu.css hides this
26
+ // whole panel above 860px, base.css hides .wd-sidebar-col at/below it),
27
+ // so there's no duplicate-content flash either way.
28
+ //
29
+ // Split out of BaseLayout.astro, which had grown to ~2200 lines covering
30
+ // several unrelated concerns in one file - see that component's own
31
+ // comment for the fuller rationale.
32
+ import { isExternalHref, type DocsConfig, type Selector, type GlobalDropdownView } from '../../lib/config';
33
+ import AppIcon from '../../components/AppIcon.astro';
34
+ import '../styles/dropdown.css';
35
+ import '../styles/topbar.css';
36
+ import '../styles/mobile-menu.css';
37
+
38
+ interface Props {
39
+ config: DocsConfig;
40
+ selectors: Selector[];
41
+ globalDropdowns: GlobalDropdownView[];
42
+ // Whether this page would show a sidebar at all (mode === 'default' or
43
+ // 'wide') - this whole component renders nothing otherwise, same
44
+ // condition TopBar.astro gates its own hamburger toggle on.
45
+ showSidebarCol: boolean;
46
+ logoLight?: string;
47
+ logoDark?: string;
48
+ brandLabel?: string;
49
+ }
50
+ const { config, selectors, globalDropdowns, showSidebarCol, logoLight, logoDark, brandLabel } = Astro.props as Props;
51
+ ---
52
+ {showSidebarCol && (
53
+ <div class="wd-mobile-menu" id="wd-mobile-menu" inert>
54
+ <div class="wd-mobile-menu-backdrop" data-mobile-menu-backdrop></div>
55
+ <div class="wd-mobile-menu-panel" role="dialog" aria-modal="true" aria-label="Navigation">
56
+ {/* The panel reaches all the way to the top of the viewport (see
57
+ mobile-menu.css) rather than stopping below the real topbar -
58
+ the real topbar's own height varies (one row, or two once
59
+ tabs/global dropdowns/a wrapped search box are involved), which
60
+ made lining this panel up under it a fragile approximation.
61
+ Instead this header replicates the topbar's own brand/close/
62
+ theme row *inside* the panel, at a fixed height the panel fully
63
+ controls, so it reads as "the same navbar" occupying the same
64
+ spot rather than a separate floating piece underneath one of
65
+ variable size. The real topbar is still there underneath, just
66
+ no longer relied on for anything while the menu is open - it's
67
+ covered by this panel's own opaque background (or dimmed by the
68
+ backdrop, past the panel's own width). */}
69
+ <div class="wd-mobile-menu-header">
70
+ <button
71
+ type="button"
72
+ class="wd-mobile-menu-close"
73
+ aria-label="Close menu"
74
+ data-mobile-menu-close
75
+ >
76
+ <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
77
+ <path d="M4 4L14 14M14 4L4 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
78
+ </svg>
79
+ </button>
80
+ <a class="wd-topbar-brand" href="/">
81
+ {logoLight && <img src={logoLight} alt={config.name} height="24" class="wd-logo-light" />}
82
+ {logoDark && <img src={logoDark} alt={config.name} height="24" class="wd-logo-dark" />}
83
+ {brandLabel && <span>{brandLabel}</span>}
84
+ </a>
85
+ {/* Same class as the real topbar's own theme button -
86
+ initThemeToggle() (src/scripts/theme-toggle.ts, wired below)
87
+ wires up every `.wd-theme-toggle` it finds (not just the
88
+ first), and both stay in sync for free since [data-theme] on
89
+ <html> - not per-button JS state - is what actually drives
90
+ which icon shows. */}
91
+ <button type="button" class="wd-theme-toggle" aria-label="Toggle color theme">
92
+ <AppIcon icon="sun" class="wd-theme-icon wd-theme-icon-sun" />
93
+ <AppIcon icon="moon" class="wd-theme-icon wd-theme-icon-moon" />
94
+ </button>
95
+ </div>
96
+ <div class="wd-mobile-menu-scroll">
97
+ {(selectors.length > 0 || globalDropdowns.length > 0) && (
98
+ <div class="wd-mobile-menu-selectors">
99
+ {selectors.map((sel) => {
100
+ const current = sel.options.find((o) => o.active) ?? sel.options[0];
101
+ return (
102
+ <details class="wd-mobile-accordion" name="wd-mobile-accordion">
103
+ <summary class="wd-mobile-accordion-trigger">
104
+ <AppIcon icon={current.icon} class="wd-tab-icon" />
105
+ <span class="wd-mobile-accordion-label">{current.label}</span>
106
+ {current.tag && <span class="wd-selector-tag">{current.tag}</span>}
107
+ <svg class="wd-mobile-accordion-caret" width="12" height="12" viewBox="0 0 10 10" aria-hidden="true">
108
+ <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
109
+ </svg>
110
+ </summary>
111
+ <div class="wd-mobile-accordion-options">
112
+ <div class="wd-mobile-accordion-options-panel">
113
+ {sel.options.map((opt) => (
114
+ <a
115
+ href={opt.href}
116
+ class={opt.active ? 'active' : ''}
117
+ target={isExternalHref(opt.href) ? '_blank' : undefined}
118
+ rel={isExternalHref(opt.href) ? 'noopener noreferrer' : undefined}
119
+ >
120
+ <AppIcon icon={opt.icon} class="wd-tab-icon" />
121
+ <span class="wd-mobile-accordion-option-label">{opt.label}</span>
122
+ {opt.tag && <span class="wd-selector-tag">{opt.tag}</span>}
123
+ {opt.active && (
124
+ <svg class="wd-mobile-accordion-check" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
125
+ <path d="M3 7.5L6 10.5L11 4.5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round" />
126
+ </svg>
127
+ )}
128
+ </a>
129
+ ))}
130
+ </div>
131
+ </div>
132
+ </details>
133
+ );
134
+ })}
135
+ {globalDropdowns.map((d) =>
136
+ d.href !== null ? (
137
+ <a
138
+ href={d.href}
139
+ class="wd-mobile-menu-link"
140
+ target={isExternalHref(d.href) ? '_blank' : undefined}
141
+ rel={isExternalHref(d.href) ? 'noopener noreferrer' : undefined}
142
+ >
143
+ <AppIcon icon={d.icon} class="wd-tab-icon" />
144
+ <span class="wd-mobile-accordion-option-label">{d.label}</span>
145
+ </a>
146
+ ) : (
147
+ <details class="wd-mobile-accordion" name="wd-mobile-accordion">
148
+ <summary class="wd-mobile-accordion-trigger">
149
+ <AppIcon icon={d.icon} class="wd-tab-icon" />
150
+ <span class="wd-mobile-accordion-label">{d.label}</span>
151
+ <svg class="wd-mobile-accordion-caret" width="12" height="12" viewBox="0 0 10 10" aria-hidden="true">
152
+ <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
153
+ </svg>
154
+ </summary>
155
+ <div class="wd-mobile-accordion-options">
156
+ <div class="wd-mobile-accordion-options-panel">
157
+ {d.items.map((item) => (
158
+ <a href={item.href} class={item.active ? 'active' : ''}>
159
+ <span class="wd-mobile-accordion-option-label">{item.label}</span>
160
+ {item.active && (
161
+ <svg class="wd-mobile-accordion-check" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
162
+ <path d="M3 7.5L6 10.5L11 4.5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round" />
163
+ </svg>
164
+ )}
165
+ </a>
166
+ ))}
167
+ </div>
168
+ </div>
169
+ </details>
170
+ )
171
+ )}
172
+ </div>
173
+ )}
174
+ <div class="wd-mobile-menu-sidebar">
175
+ <slot name="sidebar" />
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ )}
181
+ <script>
182
+ import { initMobileMenu } from '../../scripts/mobile-menu';
183
+ import { initThemeToggle } from '../../scripts/theme-toggle';
184
+ // Not required by any markup this component currently renders (its own
185
+ // accordion rows are plain <details>, not .wd-dropdown), but imported
186
+ // and called anyway so both this file and TopBar.astro consistently own
187
+ // wiring up whatever interactive primitives their own markup might use
188
+ // - safe/idempotent regardless of how many components call it, see
189
+ // src/scripts/dropdowns.ts's own comment.
190
+ import { initDropdowns } from '../../scripts/dropdowns';
191
+
192
+ initMobileMenu(document);
193
+ initThemeToggle(document);
194
+ initDropdowns(document);
195
+ document.addEventListener('astro:page-load', () => {
196
+ initMobileMenu(document);
197
+ initThemeToggle(document);
198
+ initDropdowns(document);
199
+ });
200
+ </script>