@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,78 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import type { APIRoute } from 'astro';
5
+ import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
6
+ import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
+
8
+ // The raw-Markdown twin of [...slug].astro: every content page is also
9
+ // reachable at the exact same slug with a literal ".md" suffix (e.g.
10
+ // /guides/foo/ -> /guides/foo.md), serving its untouched MDX/Markdown
11
+ // source instead of rendered HTML - what the "Copy page"/"View as
12
+ // Markdown" menu (CopyPageMenu.astro, wired in from [...slug].astro)
13
+ // links to, and what an LLM fetching that URL directly gets. Gated
14
+ // entirely behind writedocs.json's `contextMenu` field being present (see
15
+ // contextMenuSchema in lib/config.ts) - a site that hasn't opted in gets
16
+ // no .md routes at all, not just a hidden menu.
17
+ //
18
+ // A literal ".md" filename suffix rather than a `[...slug]` capture
19
+ // covering it: naming the file `[...slug].md.ts` makes Astro treat ".md"
20
+ // as a static part of the route, not part of the `...slug` rest
21
+ // parameter - requesting /guides/foo.md resolves with `slug ===
22
+ // "guides/foo"`, exactly like the HTML router's own `entry.id`-derived
23
+ // slug, just without the trailing-slash convention HTML pages use.
24
+ //
25
+ // getStaticPaths here is intentionally self-contained rather than
26
+ // sharing a module-level helper with [...slug].astro's own
27
+ // getStaticPaths - see that file's own extensive comment on Astro's
28
+ // getStaticPaths bundling extraction silently dropping top-level
29
+ // helper functions that are only referenced from inside it.
30
+ type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
31
+
32
+ export async function getStaticPaths() {
33
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
34
+ const config = loadDocsConfig(contentDir);
35
+ // No writedocs.json `contextMenu` - feature is off entirely, including these
36
+ // routes (not just the menu UI - see the file-level comment above).
37
+ if (!config.contextMenu) return [];
38
+
39
+ const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
40
+ const hasPages = findAllPages(contentDir).length > 0;
41
+ const [pagesEntries, generatedDocsEntries] = await Promise.all([
42
+ hasPages ? getCollection('pages') : Promise.resolve([]),
43
+ hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
44
+ ]);
45
+ const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
46
+
47
+ return entries
48
+ // OpenAPI operation pages (generated stubs, or hand-written pages that
49
+ // set `openapi:` frontmatter) render almost entirely from the spec at
50
+ // request time (see ApiPlayground.astro) rather than from prose in
51
+ // entry.body - a raw .md dump of one would be near-empty and useless
52
+ // as an LLM-facing "view this page as text" export, so they're left
53
+ // out of this route entirely rather than serving something misleading.
54
+ .filter((entry) => !entry.data.openapi)
55
+ .map((entry) => ({
56
+ params: { slug: normalizeEntryId(entry.id) },
57
+ props: { entry },
58
+ }));
59
+ }
60
+
61
+ interface Props {
62
+ entry: DocsEntry;
63
+ }
64
+
65
+ export const GET: APIRoute = ({ props }) => {
66
+ const { entry } = props as Props;
67
+ // entry.body is the raw, frontmatter-stripped MDX/Markdown source text
68
+ // Astro's glob() loader already read off disk and stashed on every
69
+ // entry by default (see astro/dist/content/loaders/glob.js) - reusing
70
+ // it here means this route needs no separate file read/parse of its
71
+ // own, and always matches exactly what [...slug].astro rendered from
72
+ // (same entry, same collection query).
73
+ const body = entry.body ?? '';
74
+ const markdown = `# ${entry.data.title}\n\n${body}`;
75
+ return new Response(markdown, {
76
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
77
+ });
78
+ };
@@ -0,0 +1,71 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import type { APIRoute } from 'astro';
5
+ import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
6
+ import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
+
8
+ // The "everything, concatenated" half of the llms.txt pair - see
9
+ // llms.txt.ts (right next to this file) for the lightweight index half,
10
+ // and that file's own top-of-file comment for the shared design notes
11
+ // (fixed non-dynamic route, unconditional/always generated, custom
12
+ // project-root override file support).
13
+ //
14
+ // Unlike llms.txt.ts, there's no `contextMenu`-dependent branch here:
15
+ // this route doesn't link to anything, it *is* the raw content, sourced
16
+ // the same way [...slug].md.ts sources a single page's raw content
17
+ // (`entry.body`, already read off disk by the glob() loader - no
18
+ // separate file read needed).
19
+ type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
20
+
21
+ export const GET: APIRoute = async () => {
22
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
23
+
24
+ const customPath = path.join(contentDir, 'llms-full.txt');
25
+ if (fs.existsSync(customPath)) {
26
+ return new Response(fs.readFileSync(customPath, 'utf-8'), {
27
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
28
+ });
29
+ }
30
+
31
+ const config = loadDocsConfig(contentDir);
32
+
33
+ const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
34
+ const hasPages = findAllPages(contentDir).length > 0;
35
+ const [pagesEntries, generatedDocsEntries] = await Promise.all([
36
+ hasPages ? getCollection('pages') : Promise.resolve([]),
37
+ hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
38
+ ]);
39
+ const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
40
+
41
+ const sections = entries
42
+ // Same exclusion [...slug].md.ts applies to its own per-page raw
43
+ // Markdown route, for the same reason: an OpenAPI operation page
44
+ // (generated stub, or a hand-written page that opts into rendering
45
+ // <ApiPlayground /> via `openapi:` frontmatter) renders almost
46
+ // entirely from the spec at request time, so entry.body alone is
47
+ // near-empty and not a meaningful "full content" contribution here.
48
+ .filter((entry) => !entry.data.openapi)
49
+ // Same noindex exclusion llms.txt.ts applies to its own listing -
50
+ // see that file's comment.
51
+ .filter((entry) => !entry.data.seo?.noindex)
52
+ .map((entry: DocsEntry) => {
53
+ const slug = normalizeEntryId(entry.id);
54
+ const body = entry.body ?? '';
55
+ return { slug, title: entry.data.title, body };
56
+ })
57
+ .sort((a, b) => a.slug.localeCompare(b.slug));
58
+
59
+ const lines: string[] = [`# ${config.name}`, ''];
60
+ if (config.description) lines.push(`> ${config.description}`, '');
61
+
62
+ for (const section of sections) {
63
+ lines.push('---', '', `# ${section.title}`, '', section.body.trim(), '');
64
+ }
65
+
66
+ const content = lines.join('\n') + '\n';
67
+
68
+ return new Response(content, {
69
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
70
+ });
71
+ };
@@ -0,0 +1,141 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import type { APIRoute } from 'astro';
5
+ import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl } from '../lib/config';
6
+ import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
+
8
+ // A lightweight, LLM-facing index of every page on the site - the
9
+ // llms.txt convention (see https://llmstxt.org), also adopted by Mintlify
10
+ // and others: a plain-text file at the project root listing every page
11
+ // with a one-line description, so an AI tool can decide what's worth
12
+ // fetching without downloading the whole site. This is the "index" half
13
+ // of the pair; llms-full.txt (llms-full.txt.ts, right next to this file)
14
+ // is the "everything, concatenated" half.
15
+ //
16
+ // Fixed, non-dynamic route (no getStaticPaths/params needed) - Astro
17
+ // prerenders this once at the literal path /llms.txt, same as any other
18
+ // static-output route (this project's astro.config.mjs sets
19
+ // `output: 'static'`), and re-evaluates it per request in `writedocs dev`
20
+ // - see [...slug].md.ts for the same fixed-route-vs-catchall distinction
21
+ // applied to per-page raw Markdown.
22
+ //
23
+ // Unconditional - unlike the per-page .md routes in [...slug].md.ts,
24
+ // this doesn't depend on writedocs.json's `contextMenu` field. There's no UI
25
+ // footprint to gate (it's an extra static file, not a visible menu), so
26
+ // it's always generated. `contextMenu` only affects which URL each entry
27
+ // links to below (a real fetchable .md URL if the per-page raw-Markdown
28
+ // routes exist, the ordinary HTML page URL otherwise).
29
+ type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
30
+
31
+ // Mirrors Mintlify's own llms.txt behavior: truncate a page's frontmatter
32
+ // `description` at the first line break (a multi-paragraph description
33
+ // would blow out a one-line list entry) and at 300 characters (an
34
+ // arbitrary but reasonable cap - keeps every entry scannable regardless
35
+ // of how verbose an individual page's description happens to be).
36
+ const DESCRIPTION_MAX_CHARS = 300;
37
+ function truncateDescription(description: string | undefined): string | undefined {
38
+ if (!description) return undefined;
39
+ const firstLine = description.split('\n')[0].trim();
40
+ if (!firstLine) return undefined;
41
+ return firstLine.length > DESCRIPTION_MAX_CHARS
42
+ ? firstLine.slice(0, DESCRIPTION_MAX_CHARS).trimEnd() + '…'
43
+ : firstLine;
44
+ }
45
+
46
+ // Same cap Mintlify documents for its own auto-generated llms.txt - this
47
+ // file is meant to stay a lightweight index (one line per page), not
48
+ // balloon into something llms-full.txt-sized. Realistically unlikely to
49
+ // matter for most Writedocs sites (a site would need on the order of a
50
+ // thousand-plus pages with full-length descriptions to hit this), but
51
+ // cheap to guard against regardless.
52
+ const MAX_CHARS = 100_000;
53
+
54
+ export const GET: APIRoute = async () => {
55
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
56
+
57
+ // A hand-authored llms.txt at the project root (next to writedocs.json)
58
+ // always wins outright over the generated one below - same override
59
+ // convention Mintlify documents for its own llms.txt. Lets a site
60
+ // author hand-curate this file (different ordering, editorial
61
+ // descriptions, extra sections) without losing the ability to fall
62
+ // back to the auto-generated version by just deleting the override.
63
+ const customPath = path.join(contentDir, 'llms.txt');
64
+ if (fs.existsSync(customPath)) {
65
+ return new Response(fs.readFileSync(customPath, 'utf-8'), {
66
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
67
+ });
68
+ }
69
+
70
+ const config = loadDocsConfig(contentDir);
71
+ const siteUrl = resolveSiteUrl(config); // absolute origin if `domain` is set, else null
72
+
73
+ const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
74
+ const hasPages = findAllPages(contentDir).length > 0;
75
+ const [pagesEntries, generatedDocsEntries] = await Promise.all([
76
+ hasPages ? getCollection('pages') : Promise.resolve([]),
77
+ hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
78
+ ]);
79
+ const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
80
+
81
+ const items = entries
82
+ // A page that opted out of search-engine indexing (frontmatter
83
+ // `seo.noindex: true`, already excluded from sitemap.xml - see
84
+ // astro.config.mjs's collectNoindexIds()) is excluded here for the
85
+ // same reason: llms.txt exists to help external tools discover
86
+ // pages, exactly what `noindex` asked not to happen.
87
+ .filter((entry) => !entry.data.seo?.noindex)
88
+ .map((entry: DocsEntry) => {
89
+ const slug = normalizeEntryId(entry.id);
90
+ // Same URL Astro's own router resolves this entry to - see
91
+ // hrefForSlug() in [...slug].astro for the HTML form, and
92
+ // [...slug].md.ts for the .md form (no trailing slash, "index.md"
93
+ // for the home page rather than the HTML convention's bare "/").
94
+ const path_ = config.contextMenu ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`;
95
+ const href = (siteUrl ?? '') + path_;
96
+ let description = truncateDescription(entry.data.description);
97
+ // Mirrors Mintlify's own behavior: an OpenAPI operation page's
98
+ // description gets its "METHOD /path" appended, since the page
99
+ // itself renders almost entirely from the spec at request time
100
+ // (see ApiPlayground.astro) rather than from frontmatter prose.
101
+ if (entry.data.openapi) {
102
+ description = description ? `${description} (${entry.data.openapi})` : entry.data.openapi;
103
+ }
104
+ return { title: entry.data.title, href, description, slug };
105
+ })
106
+ .sort((a, b) => a.slug.localeCompare(b.slug));
107
+
108
+ const lines: string[] = [`# ${config.name}`, ''];
109
+ if (config.description) lines.push(`> ${config.description}`, '');
110
+ lines.push('## Docs', '');
111
+ for (const item of items) {
112
+ const entryLine = `- [${item.title}](${item.href})${item.description ? `: ${item.description}` : ''}`;
113
+ lines.push(entryLine);
114
+ }
115
+
116
+ let content = lines.join('\n') + '\n';
117
+ if (content.length > MAX_CHARS) {
118
+ // Truncate at the last full line inside the budget, then note how
119
+ // many entries got cut - rather than silently producing a
120
+ // mid-sentence-cutoff file, or (worse) one JSON/Markdown-breaking
121
+ // half-written link.
122
+ const truncatedLines = lines.slice(0, 4 + (config.description ? 2 : 0)); // "# name" / "" / ["> desc" / ""] / "## Docs" / ""
123
+ let runningLength = truncatedLines.join('\n').length + 1;
124
+ let omitted = 0;
125
+ for (const item of items) {
126
+ const entryLine = `- [${item.title}](${item.href})${item.description ? `: ${item.description}` : ''}`;
127
+ if (runningLength + entryLine.length + 1 > MAX_CHARS - 200) {
128
+ omitted++;
129
+ continue;
130
+ }
131
+ truncatedLines.push(entryLine);
132
+ runningLength += entryLine.length + 1;
133
+ }
134
+ truncatedLines.push('', `_Truncated — ${omitted} more page(s) omitted. See llms-full.txt or the site's own navigation for the complete list._`);
135
+ content = truncatedLines.join('\n') + '\n';
136
+ }
137
+
138
+ return new Response(content, {
139
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
140
+ });
141
+ };
@@ -0,0 +1,20 @@
1
+ // writedocs.json's dismissible `banner` (bannerSchema in lib/config.ts) - pairs
2
+ // with the [data-banner-dismissed] flash-prevention script in BaseLayout's
3
+ // <head>, which is what actually hides the banner on repeat visits; this
4
+ // only needs to persist the choice and hide it for the rest of the current
5
+ // page view once clicked.
6
+ export function initBanner(root: ParentNode) {
7
+ const btn = root.querySelector<HTMLButtonElement>('[data-banner-dismiss]');
8
+ if (!btn || btn.dataset.wdInit) return;
9
+ btn.dataset.wdInit = 'true';
10
+ btn.addEventListener('click', () => {
11
+ document.documentElement.dataset.bannerDismissed = 'true';
12
+ try {
13
+ localStorage.setItem('wd-banner-dismissed', 'true');
14
+ } catch (e) {
15
+ // localStorage unavailable - banner still hides for this page view
16
+ // via the [data-banner-dismissed] attribute set above, just won't
17
+ // persist across reloads.
18
+ }
19
+ });
20
+ }
@@ -0,0 +1,61 @@
1
+ // Wires up every `.wd-dropdown` on the page: click-to-toggle, click-outside
2
+ // and Escape to close, and (via plain CSS alongside this) hover-to-reveal.
3
+ // Generic by design - not scoped to any one feature's markup. Originally
4
+ // written for the topbar's own switcher/tab dropdowns, but the same
5
+ // `.wd-dropdown`/`.wd-dropdown-trigger`/`.wd-dropdown-menu`/
6
+ // `.wd-dropdown-menu-panel` shape is now reused by TopBar.astro (switchers,
7
+ // tabs, the small-screen topbar.links ellipsis) and CopyPageMenu.astro, and
8
+ // exported from here so any future component - MobileMenu.astro included,
9
+ // even though it doesn't render any `.wd-dropdown` markup today - can import
10
+ // and call it too without needing to know whether some other component
11
+ // already has.
12
+ //
13
+ // That "might get called more than once per page" scenario is exactly why
14
+ // the two `document`-level listeners at the bottom are behind their own
15
+ // module-level guard, separate from the existing per-trigger
16
+ // `dataset.wdInit` guard: querying `.wd-dropdown` from more than one
17
+ // component's own `<script>` (each already idempotent per-trigger) would,
18
+ // without this, still add a fresh pair of document click/keydown listeners
19
+ // on every single call - harmless individually, but wasteful if it keeps
20
+ // happening across repeated calls (e.g. once per component that imports
21
+ // this, or once per `astro:page-load` firing from more than one place).
22
+ let globalListenersBound = false;
23
+
24
+ export function initDropdowns(root: ParentNode) {
25
+ const dropdowns = Array.from(root.querySelectorAll<HTMLElement>('.wd-dropdown'));
26
+ if (dropdowns.length === 0) return;
27
+
28
+ const closeAll = () => {
29
+ dropdowns.forEach((d) => {
30
+ d.classList.remove('open');
31
+ d.querySelector('.wd-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
32
+ });
33
+ };
34
+
35
+ dropdowns.forEach((dropdown) => {
36
+ const trigger = dropdown.querySelector<HTMLButtonElement>('.wd-dropdown-trigger');
37
+ if (!trigger || trigger.dataset.wdInit) return;
38
+ trigger.dataset.wdInit = 'true';
39
+ trigger.addEventListener('click', (e) => {
40
+ e.stopPropagation();
41
+ const isOpen = dropdown.classList.contains('open');
42
+ closeAll();
43
+ if (!isOpen) {
44
+ dropdown.classList.add('open');
45
+ trigger.setAttribute('aria-expanded', 'true');
46
+ }
47
+ });
48
+ });
49
+
50
+ if (!globalListenersBound) {
51
+ globalListenersBound = true;
52
+ // Bound once against `document`, not `root` - closeAll() above already
53
+ // closes every dropdown found on the page regardless of which root a
54
+ // given call was scoped to, so there's no reason for these two to be
55
+ // scoped any narrower or bound more than once.
56
+ document.addEventListener('click', closeAll);
57
+ document.addEventListener('keydown', (e) => {
58
+ if (e.key === 'Escape') closeAll();
59
+ });
60
+ }
61
+ }
@@ -0,0 +1,66 @@
1
+ // Click-to-zoom lightbox for content images - opened by ImageZoom.astro's
2
+ // own overlay markup, mounted once in BaseLayout.astro alongside
3
+ // SearchModal. Every <img> inside .wd-article becomes clickable and opens
4
+ // a fullscreen view of itself, *except*:
5
+ // - Card's own image (.wd-card-image) - a UI element of the card
6
+ // itself (its header art), not "content" a reader would want to
7
+ // inspect at full size.
8
+ // - Icon's own inline image (.wd-icon-image) - a small inline glyph
9
+ // sized in em to match surrounding text, not a photo/screenshot.
10
+ // - any image explicitly opted out via a `nozoom` attribute - written
11
+ // as `<img noZoom src="..." />` in source (Image.astro's own
12
+ // `noZoom` prop renders the same attribute - see its own comment),
13
+ // but HTML attribute names are ASCII-case-insensitive during
14
+ // parsing, so the DOM (and this selector) sees it as `nozoom`
15
+ // regardless of how it was cased in source.
16
+ // Scoped to .wd-article (not a bare `img` selector) so this never
17
+ // touches chrome images - the topbar/sidebar logo, sidebar icons, or
18
+ // anything else outside actual page content.
19
+ export function initImageZoom(root: ParentNode) {
20
+ const overlay = document.querySelector<HTMLElement>('#wd-zoom-overlay');
21
+ const overlayImg = document.querySelector<HTMLImageElement>('#wd-zoom-image');
22
+ if (!overlay || !overlayImg) return;
23
+
24
+ const images = root.querySelectorAll<HTMLImageElement>(
25
+ '.wd-article img:not(.wd-card-image):not(.wd-icon-image):not([nozoom])',
26
+ );
27
+ images.forEach((img) => {
28
+ if (img.dataset.wdInit) return;
29
+ img.dataset.wdInit = 'true';
30
+ img.classList.add('wd-zoomable');
31
+ img.addEventListener('click', () => open(img));
32
+ });
33
+
34
+ // The overlay itself only needs its own listeners bound once - re-running
35
+ // initImageZoom() on every astro:page-load (view transitions swap in a
36
+ // whole new set of <img> elements, but ImageZoom.astro's overlay markup
37
+ // is part of BaseLayout's own persistent chrome, not the swapped page
38
+ // content) would otherwise stack up duplicate close listeners.
39
+ if (overlay.dataset.wdInit) return;
40
+ overlay.dataset.wdInit = 'true';
41
+
42
+ function open(img: HTMLImageElement) {
43
+ if (!overlay || !overlayImg) return;
44
+ // currentSrc (not src) accounts for a <picture>/srcset-based image
45
+ // resolving to something other than its own plain `src` attribute -
46
+ // falls back to `src` for the common case where there's no srcset.
47
+ overlayImg.src = img.currentSrc || img.src;
48
+ overlayImg.alt = img.alt;
49
+ overlay.hidden = false;
50
+ document.body.style.overflow = 'hidden';
51
+ }
52
+ function close() {
53
+ if (!overlay || !overlayImg) return;
54
+ overlay.hidden = true;
55
+ document.body.style.overflow = '';
56
+ overlayImg.src = '';
57
+ }
58
+ // No target === overlay check (unlike SearchModal's own backdrop-click
59
+ // handling) - there's no separate interactive panel to protect here,
60
+ // the whole overlay (backdrop, image, close button) is meant to close
61
+ // on any click within it.
62
+ overlay.addEventListener('click', close);
63
+ document.addEventListener('keydown', (e) => {
64
+ if (e.key === 'Escape' && !overlay.hidden) close();
65
+ });
66
+ }
@@ -0,0 +1,55 @@
1
+ // The <= 860px hamburger menu (MobileMenu.astro's own toggle button +
2
+ // overlay panel). The accordion rows inside the panel need no JS of their
3
+ // own - they're plain <details name="wd-mobile-accordion"> elements, which
4
+ // get native click/keyboard toggling and (in browsers supporting the
5
+ // `name` attribute's exclusive-accordion behavior) mutual exclusion for
6
+ // free, same as NavTree.astro's own collapsible groups already rely on
7
+ // <details> for. This function only owns the panel itself: open/close, the
8
+ // backdrop, Escape, and body scroll locking while it's open - same pattern
9
+ // initSearch() uses for its own modal.
10
+ export function initMobileMenu(root: ParentNode) {
11
+ const trigger = root.querySelector<HTMLButtonElement>('[data-mobile-menu-trigger]');
12
+ const menu = root.querySelector<HTMLElement>('#wd-mobile-menu');
13
+ const backdrop = menu?.querySelector<HTMLElement>('[data-mobile-menu-backdrop]');
14
+ // The panel's own close button (.wd-mobile-menu-header, next to its
15
+ // replica of the topbar brand) - the real hamburger-turned-X trigger up
16
+ // in the topbar is covered by the panel itself once open, so this is the
17
+ // only reachable way to close via click besides the backdrop.
18
+ const closeBtn = menu?.querySelector<HTMLButtonElement>('[data-mobile-menu-close]');
19
+ if (!trigger || !menu || !backdrop || trigger.dataset.wdInit) return;
20
+ trigger.dataset.wdInit = 'true';
21
+
22
+ function open() {
23
+ menu!.classList.add('open');
24
+ // `inert` (not just visibility/pointer-events, which only affect
25
+ // vision/mouse) is what keeps the closed panel's links out of Tab
26
+ // order and off a screen reader's radar - removed while open so its
27
+ // content becomes reachable again.
28
+ menu!.removeAttribute('inert');
29
+ trigger!.classList.add('open');
30
+ trigger!.setAttribute('aria-expanded', 'true');
31
+ document.body.style.overflow = 'hidden';
32
+ }
33
+ function close() {
34
+ menu!.classList.remove('open');
35
+ menu!.setAttribute('inert', '');
36
+ trigger!.classList.remove('open');
37
+ trigger!.setAttribute('aria-expanded', 'false');
38
+ document.body.style.overflow = '';
39
+ }
40
+
41
+ trigger.addEventListener('click', () => (menu.classList.contains('open') ? close() : open()));
42
+ backdrop.addEventListener('click', close);
43
+ closeBtn?.addEventListener('click', close);
44
+ document.addEventListener('keydown', (e) => {
45
+ if (e.key === 'Escape' && menu.classList.contains('open')) close();
46
+ });
47
+ // Resizing past the breakpoint while open (rotating a tablet, or
48
+ // widening a desktop browser window) - the panel/backdrop are already
49
+ // hard-hidden above 860px via CSS regardless, but without this the body
50
+ // scroll lock and the trigger's own "open" state would stick around with
51
+ // no visible panel left to close them.
52
+ window.matchMedia('(min-width: 861px)').addEventListener('change', (e) => {
53
+ if (e.matches && menu.classList.contains('open')) close();
54
+ });
55
+ }
@@ -0,0 +1,155 @@
1
+ // SearchModal.astro's own trigger button (rendered inside TopBar.astro) +
2
+ // overlay/modal. Owns opening/closing the modal, the ⌘K/Ctrl K shortcut,
3
+ // lazy-loading Pagefind on first open, and rendering results.
4
+ export function initSearch(root: ParentNode) {
5
+ const trigger = root.querySelector<HTMLButtonElement>('[data-search-trigger]');
6
+ const overlay = root.querySelector<HTMLElement>('#wd-search-overlay');
7
+ const input = root.querySelector<HTMLInputElement>('#wd-search-input');
8
+ const resultsEl = root.querySelector<HTMLElement>('#wd-search-results');
9
+ if (!trigger || !overlay || !input || !resultsEl || trigger.dataset.wdInit) return;
10
+ trigger.dataset.wdInit = 'true';
11
+
12
+ // Most keyboards outside macOS/iOS don't have a command key - "Ctrl K"
13
+ // reads more naturally there than the ⌘ glyph.
14
+ const kbd = trigger.querySelector('.wd-search-kbd');
15
+ if (kbd && !/Mac|iPhone|iPad/.test(navigator.userAgent)) {
16
+ kbd.textContent = 'Ctrl K';
17
+ }
18
+
19
+ // Loaded lazily on first open rather than on every page load - most
20
+ // visits never open search, and the module + its wasm binary are pure
21
+ // overhead until someone actually wants to search. Pagefind only writes
22
+ // this file as a postbuild step, after Astro's own build (and therefore
23
+ // Vite's bundling) has already finished - see run-pagefind.js - so it
24
+ // never exists at `writedocs dev` time, and doesn't exist yet even
25
+ // during a production build's own Vite transform pass.
26
+ //
27
+ // `/* @vite-ignore */` alone doesn't stop Vite from trying to resolve it
28
+ // anyway: that comment only suppresses static analysis for a
29
+ // *non-literal* dynamic import specifier - a literal string like
30
+ // '/pagefind/pagefind.js' still gets resolved at transform time
31
+ // regardless of the comment, which crashes `writedocs dev` outright once
32
+ // this file exists in the template. Routing the literal through a
33
+ // variable first makes the specifier opaque to static analysis,
34
+ // deferring resolution to the browser at runtime - where the .catch()
35
+ // below handles "not built yet" gracefully instead.
36
+ let pagefind: any = null;
37
+ let loadPromise: Promise<any> | null = null;
38
+ function ensurePagefind() {
39
+ if (!loadPromise) {
40
+ const pagefindUrl = '/pagefind/pagefind.js';
41
+ loadPromise = import(/* @vite-ignore */ pagefindUrl)
42
+ .then((mod: any) => {
43
+ pagefind = mod;
44
+ return mod.init();
45
+ })
46
+ .then(() => pagefind)
47
+ .catch(() => {
48
+ resultsEl.innerHTML =
49
+ '<div class="wd-search-empty">Search isn\'t available in this preview - run a full build to index it.</div>';
50
+ return null;
51
+ });
52
+ }
53
+ return loadPromise;
54
+ }
55
+
56
+ let currentResults: HTMLElement[] = [];
57
+ let activeIndex = -1;
58
+
59
+ function setActive(index: number) {
60
+ currentResults.forEach((el) => el.classList.remove('wd-search-result-active'));
61
+ activeIndex = index >= 0 && index < currentResults.length ? index : -1;
62
+ if (activeIndex >= 0) {
63
+ currentResults[activeIndex].classList.add('wd-search-result-active');
64
+ currentResults[activeIndex].scrollIntoView({ block: 'nearest' });
65
+ }
66
+ }
67
+
68
+ async function runSearch(term: string) {
69
+ if (!term.trim()) {
70
+ resultsEl.innerHTML = '<div class="wd-search-hint">Start typing to search...</div>';
71
+ currentResults = [];
72
+ activeIndex = -1;
73
+ return;
74
+ }
75
+ const pf = await ensurePagefind();
76
+ if (!pf) return;
77
+ // debouncedSearch resolves to null when a newer keystroke has already
78
+ // superseded this call - pagefind's own debounce, on top of (not
79
+ // instead of) the plain `input` listener below, so a fast typist never
80
+ // races two searches rendering out of order.
81
+ const search = await pf.debouncedSearch(term, {}, 150);
82
+ if (search === null) return;
83
+ if (search.results.length === 0) {
84
+ resultsEl.innerHTML = '<div class="wd-search-empty">No results.</div>';
85
+ currentResults = [];
86
+ activeIndex = -1;
87
+ return;
88
+ }
89
+ const entries = await Promise.all(search.results.slice(0, 12).map((r: any) => r.data()));
90
+ resultsEl.innerHTML = '';
91
+ for (const data of entries) {
92
+ const a = document.createElement('a');
93
+ a.className = 'wd-search-result';
94
+ a.href = data.url;
95
+ const title = document.createElement('div');
96
+ title.className = 'wd-search-result-title';
97
+ title.textContent = data.meta?.title ?? data.url;
98
+ const excerpt = document.createElement('div');
99
+ excerpt.className = 'wd-search-result-excerpt';
100
+ // innerHTML (not textContent) is deliberate: pagefind returns this
101
+ // pre-sanitized with <mark> wrapping the matched terms, which is the
102
+ // whole point - a plain-text excerpt couldn't highlight anything.
103
+ excerpt.innerHTML = data.excerpt;
104
+ a.append(title, excerpt);
105
+ resultsEl.appendChild(a);
106
+ }
107
+ currentResults = Array.from(resultsEl.querySelectorAll('.wd-search-result'));
108
+ setActive(0);
109
+ }
110
+
111
+ function open() {
112
+ overlay.hidden = false;
113
+ document.body.style.overflow = 'hidden';
114
+ input.value = '';
115
+ resultsEl.innerHTML = '<div class="wd-search-hint">Start typing to search...</div>';
116
+ currentResults = [];
117
+ activeIndex = -1;
118
+ ensurePagefind();
119
+ input.focus();
120
+ }
121
+
122
+ function close() {
123
+ overlay.hidden = true;
124
+ document.body.style.overflow = '';
125
+ trigger.focus();
126
+ }
127
+
128
+ trigger.addEventListener('click', open);
129
+ overlay.addEventListener('click', (e) => {
130
+ if (e.target === overlay) close();
131
+ });
132
+ input.addEventListener('input', () => runSearch(input.value));
133
+ input.addEventListener('keydown', (e) => {
134
+ if (e.key === 'ArrowDown') {
135
+ e.preventDefault();
136
+ setActive(activeIndex + 1);
137
+ } else if (e.key === 'ArrowUp') {
138
+ e.preventDefault();
139
+ setActive(activeIndex - 1);
140
+ } else if (e.key === 'Enter' && activeIndex >= 0) {
141
+ currentResults[activeIndex]?.click();
142
+ } else if (e.key === 'Escape') {
143
+ close();
144
+ }
145
+ });
146
+ document.addEventListener('keydown', (e) => {
147
+ if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
148
+ e.preventDefault();
149
+ if (overlay.hidden) open();
150
+ else close();
151
+ } else if (e.key === 'Escape' && !overlay.hidden) {
152
+ close();
153
+ }
154
+ });
155
+ }