@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,351 @@
1
+ ---
2
+ import { navTreeContainsSlug, isExternalHref, type NavTreeNode } from "../../lib/config";
3
+ import { methodClass } from "../../lib/openapi-render";
4
+
5
+ interface Props {
6
+ nodes: NavTreeNode[];
7
+ hrefForSlug: (slug: string) => string;
8
+ currentSlug: string;
9
+ depth?: number;
10
+ }
11
+
12
+ const { nodes, hrefForSlug, currentSlug, depth = 0 } = Astro.props as Props;
13
+ ---
14
+
15
+ <ul class={`wd-nav-list wd-nav-depth-${depth}`}>
16
+ {
17
+ nodes.map((node) => {
18
+ if (node.kind === "page") {
19
+ return (
20
+ <li>
21
+ <a href={hrefForSlug(node.slug)} class={node.slug === currentSlug ? "active" : ""}>
22
+ {node.method && (
23
+ <span class={`wd-nav-method wd-nav-method-${methodClass(node.method)}`}>{node.method}</span>
24
+ )}
25
+ <span class="wd-nav-title">{node.title}</span>
26
+ </a>
27
+ </li>
28
+ );
29
+ }
30
+
31
+ if (node.kind === "link") {
32
+ // A bare external link sitting directly in the page tree (see
33
+ // NavItem's `{ label, href }` leaf in lib/config.ts) - styled like
34
+ // a page row, but never "active" (it's not a content page) and
35
+ // always opens in a new tab since it's meant to point off-site.
36
+ const external = isExternalHref(node.href);
37
+ return (
38
+ <li>
39
+ <a
40
+ href={node.href}
41
+ class="wd-nav-link-leaf"
42
+ target={external ? "_blank" : undefined}
43
+ rel={external ? "noopener noreferrer" : undefined}
44
+ >
45
+ <span>{node.label}</span>
46
+ {external && (
47
+ <svg class="wd-nav-external-icon" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
48
+ <path
49
+ d="M3 1.5H8.5V7M8.5 1.5L1.5 8.5"
50
+ stroke="currentColor"
51
+ stroke-width="1.2"
52
+ fill="none"
53
+ stroke-linecap="round"
54
+ stroke-linejoin="round"
55
+ />
56
+ </svg>
57
+ )}
58
+ </a>
59
+ </li>
60
+ );
61
+ }
62
+
63
+ if (depth === 0) {
64
+ // Top-level group = a static sidebar section title, never a
65
+ // disclosure control - matches the reference design where the first
66
+ // group level is just a bold label above its pages.
67
+ return (
68
+ <li class="wd-nav-group">
69
+ <div class="wd-nav-section-title">
70
+ {node.pageSlug ? (
71
+ <a href={hrefForSlug(node.pageSlug)} class={node.pageSlug === currentSlug ? "active" : ""}>
72
+ {node.label}
73
+ </a>
74
+ ) : (
75
+ node.label
76
+ )}
77
+ </div>
78
+ <Astro.self nodes={node.children} hrefForSlug={hrefForSlug} currentSlug={currentSlug} depth={depth + 1} />
79
+ </li>
80
+ );
81
+ }
82
+
83
+ // Depth >= 1: a nested group renders as a collapsible row, styled and
84
+ // inset exactly like a sibling page link, with a chevron on the
85
+ // right. Defaults open whenever the active page lives anywhere inside
86
+ // it (itself or a descendant), so landing on a child page never
87
+ // leaves its own parent collapsed.
88
+ const isActive = node.pageSlug === currentSlug;
89
+ const open = isActive || navTreeContainsSlug(node.children, currentSlug);
90
+ return (
91
+ <li class="wd-nav-collapsible">
92
+ <details open={open}>
93
+ <summary class={isActive ? "active" : ""}>
94
+ {node.pageSlug ? (
95
+ <a href={hrefForSlug(node.pageSlug)} class={isActive ? "active" : ""}>
96
+ {node.label}
97
+ </a>
98
+ ) : (
99
+ <span class="wd-nav-group-label">{node.label}</span>
100
+ )}
101
+ <svg class="wd-nav-chevron" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
102
+ <path
103
+ d="M3.5 2L6.5 5L3.5 8"
104
+ stroke="currentColor"
105
+ stroke-width="1.4"
106
+ fill="none"
107
+ stroke-linecap="round"
108
+ stroke-linejoin="round"
109
+ />
110
+ </svg>
111
+ </summary>
112
+ <Astro.self nodes={node.children} hrefForSlug={hrefForSlug} currentSlug={currentSlug} depth={depth + 1} />
113
+ </details>
114
+ </li>
115
+ );
116
+ })
117
+ }
118
+ </ul>
119
+
120
+ <style>
121
+ .wd-nav-list {
122
+ list-style: none;
123
+ margin: 0;
124
+ padding: 0;
125
+ }
126
+
127
+ .wd-nav-list li a,
128
+ .wd-nav-list li summary {
129
+ border-left: 1px solid transparent;
130
+ }
131
+
132
+ .wd-nav-list li a:not(summary a):hover,
133
+ .wd-nav-list li summary:hover {
134
+ border-left: 1px solid var(--wd-primary);
135
+ }
136
+
137
+ .wd-nav-list li a.active:not(summary a),
138
+ .wd-nav-list li summary.active {
139
+ border-left: 1px solid var(--wd-primary);
140
+ }
141
+
142
+ .wd-nav-depth-0 {
143
+ margin-bottom: 1.25rem;
144
+ }
145
+ .wd-nav-section-title {
146
+ padding: 0;
147
+ margin-bottom: 0.4rem;
148
+ color: var(--wd-text-muted);
149
+ font-size: 0.75rem;
150
+ font-weight: 600;
151
+ text-transform: uppercase;
152
+ letter-spacing: 0.04em;
153
+ }
154
+ /* Overrides `.wd-nav-list a`'s own block/padding/font-size/pill-when-
155
+ active styling further below - without this, a group-with-a-page's
156
+ title (e.g. "Concepts" linking to its own overview page) inherits
157
+ the same big rounded background pill a regular page row gets when
158
+ active, which looks completely out of place sitting next to a
159
+ plain-text sibling title like "Getting Started". A linked section
160
+ title should still read as a section title - same size/weight/
161
+ case/color as an unlinked one - just tinted primary on hover/active
162
+ instead. The extra `.wd-nav-group` ancestor qualifier (2 classes)
163
+ is load-bearing, not decoration: `.wd-nav-section-title a` alone
164
+ has the exact same specificity as `.wd-nav-list a` (1 class + 1
165
+ type each), so whichever rule happens to sit later in the
166
+ stylesheet would silently win regardless of intent - this avoids
167
+ that tie entirely. Mirrors the same "override to neutral, re-apply
168
+ only color" pattern already used for .wd-nav-collapsible summary a
169
+ below (which sidesteps the same tie via a `summary` type selector
170
+ instead). */
171
+ .wd-nav-group .wd-nav-section-title a {
172
+ display: inline;
173
+ padding: 0;
174
+ border-radius: 0;
175
+ background: none;
176
+ color: inherit;
177
+ font-size: inherit;
178
+ font-weight: inherit;
179
+ text-decoration: none;
180
+ }
181
+ .wd-nav-group .wd-nav-section-title a.active,
182
+ .wd-nav-group .wd-nav-section-title a:hover {
183
+ background: none;
184
+ color: var(--wd-primary);
185
+ }
186
+ .wd-nav-depth-1,
187
+ .wd-nav-depth-2,
188
+ .wd-nav-depth-3 {
189
+ margin-bottom: 0.5rem;
190
+ border-left: 1px solid var(--wd-border);
191
+ }
192
+
193
+ .wd-nav-depth-2,
194
+ .wd-nav-depth-3 {
195
+ margin-bottom: 0.5rem;
196
+ margin-left: 0.75rem;
197
+ border-left: 1px solid var(--wd-border);
198
+ }
199
+ .wd-nav-list a {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 0.45rem;
203
+ padding: 0.2rem 0.75rem;
204
+ margin: 0.1rem 0;
205
+ border-radius: 0 0.4rem 0.4rem 0;
206
+ text-decoration: none;
207
+ color: var(--wd-text-muted);
208
+ font-size: 0.9rem;
209
+ }
210
+ /* Primary-tinted rather than a neutral surface gray - a faint preview
211
+ of the active pill's own color (12% alpha), at half its strength, so
212
+ hover reads as "this could become active" instead of a generic
213
+ hover state shared with every other clickable thing on the page. */
214
+ .wd-nav-list a:hover {
215
+ background: color-mix(in srgb, var(--wd-primary) 6%, transparent);
216
+ color: var(--wd-text);
217
+ }
218
+ .wd-nav-list a.active {
219
+ background: color-mix(in srgb, var(--wd-primary) 12%, transparent);
220
+ color: var(--wd-primary);
221
+ font-weight: 500;
222
+ }
223
+ .wd-nav-title {
224
+ flex: 1;
225
+ min-width: 0;
226
+ overflow: hidden;
227
+ /* text-overflow: ellipsis;
228
+ white-space: nowrap; */
229
+ }
230
+ .wd-nav-method {
231
+ flex-shrink: 0;
232
+ display: inline-block;
233
+ padding: 0.05rem 0.4rem;
234
+ border-radius: 0.3rem;
235
+ font-size: 0.62rem;
236
+ font-weight: 700;
237
+ letter-spacing: 0.02em;
238
+ line-height: 1.5;
239
+ }
240
+ .wd-nav-method-get {
241
+ background: color-mix(in srgb, #16a34a 15%, transparent);
242
+ color: #16a34a;
243
+ }
244
+ .wd-nav-method-post {
245
+ background: color-mix(in srgb, #2563eb 15%, transparent);
246
+ color: #2563eb;
247
+ }
248
+ .wd-nav-method-put {
249
+ background: color-mix(in srgb, #9333ea 15%, transparent);
250
+ color: #9333ea;
251
+ }
252
+ .wd-nav-method-patch {
253
+ background: color-mix(in srgb, #d97706 15%, transparent);
254
+ color: #d97706;
255
+ }
256
+ .wd-nav-method-delete {
257
+ background: color-mix(in srgb, #dc2626 15%, transparent);
258
+ color: #dc2626;
259
+ }
260
+ .wd-nav-method-other {
261
+ background: var(--wd-surface);
262
+ color: var(--wd-text-muted);
263
+ }
264
+ .wd-nav-link-leaf {
265
+ display: flex !important;
266
+ align-items: center;
267
+ justify-content: space-between;
268
+ gap: 0.5rem;
269
+ }
270
+ .wd-nav-external-icon {
271
+ flex-shrink: 0;
272
+ color: var(--wd-text-muted);
273
+ }
274
+
275
+ /* Depth >= 1 group: a collapsible row that looks like a page link. */
276
+ .wd-nav-collapsible details {
277
+ margin: 0;
278
+ }
279
+ .wd-nav-collapsible summary {
280
+ display: flex;
281
+ align-items: center;
282
+ justify-content: space-between;
283
+ gap: 0.5rem;
284
+ padding: 0.2rem 0.75rem;
285
+ margin: 0.1rem 0;
286
+ border-radius: 0 0.4rem 0.4rem 0;
287
+ cursor: pointer;
288
+ color: var(--wd-text-muted);
289
+ font-size: 0.9rem;
290
+ list-style: none;
291
+ }
292
+ .wd-nav-collapsible summary::-webkit-details-marker {
293
+ display: none;
294
+ }
295
+ .wd-nav-collapsible summary::marker {
296
+ content: "";
297
+ }
298
+ /* Same primary-tinted hover as a plain page link above. */
299
+ .wd-nav-collapsible summary:hover {
300
+ background: color-mix(in srgb, var(--wd-primary) 6%, transparent);
301
+ color: var(--wd-text);
302
+ }
303
+ /* When the group's own attached page is the current page, the pill
304
+ background lives on <summary> itself (not the inner <a> below,
305
+ which is deliberately stripped of its own background/padding so the
306
+ chevron can share the row) - otherwise only the label text would be
307
+ highlighted, leaving the chevron sitting outside the pill. This is
308
+ the same background/radius a plain `.wd-nav-list a.active` pill
309
+ uses, so an active group header looks identical to an active leaf
310
+ page row instead of falling back to text-only bold+color. */
311
+ .wd-nav-collapsible summary.active {
312
+ background: color-mix(in srgb, var(--wd-primary) 12%, transparent);
313
+ border-left: 1px solid var(--wd-primary);
314
+ }
315
+
316
+ .wd-nav-collapsible summary.active > a {
317
+ border-left: none;
318
+ }
319
+ /* Overrides `.wd-nav-list a`'s own block/padding - the row's padding is
320
+ owned by <summary> now, the label inside is just flex content. Wins
321
+ on specificity (class + 2 tags vs class + 1 tag) regardless of
322
+ source order. */
323
+ .wd-nav-collapsible summary a,
324
+ .wd-nav-collapsible summary .wd-nav-group-label {
325
+ display: block;
326
+ padding: 0;
327
+ border-radius: 0;
328
+ background: none;
329
+ color: inherit;
330
+ font-weight: inherit;
331
+ flex: 1;
332
+ min-width: 0;
333
+ }
334
+ .wd-nav-collapsible summary a:hover {
335
+ background: none;
336
+ color: var(--wd-text);
337
+ }
338
+ .wd-nav-collapsible summary a.active {
339
+ background: none;
340
+ color: var(--wd-primary);
341
+ font-weight: 500;
342
+ }
343
+ .wd-nav-chevron {
344
+ flex-shrink: 0;
345
+ color: var(--wd-text-muted);
346
+ transition: transform 0.15s ease;
347
+ }
348
+ .wd-nav-collapsible details[open] > summary .wd-nav-chevron {
349
+ transform: rotate(90deg);
350
+ }
351
+ </style>
@@ -0,0 +1,42 @@
1
+ ---
2
+ // The Pagefind-backed search overlay/modal - opened by TopBar.astro's own
3
+ // `.wd-search-trigger` button (⌘K/Ctrl K also works, see
4
+ // src/scripts/search.ts) from anywhere on the site. Fully self-contained:
5
+ // takes no props, since none of its own markup depends on writedocs.json
6
+ // config - unlike every other component split out of BaseLayout.astro.
7
+ // Always rendered regardless of a page's `mode` (including 'blank'), so
8
+ // search stays reachable via the keyboard shortcut even on a page with no
9
+ // visible trigger button to click.
10
+ //
11
+ // Split out of BaseLayout.astro, which had grown to ~2200 lines covering
12
+ // several unrelated concerns in one file - see that component's own
13
+ // comment for the fuller rationale.
14
+ import '../styles/search-modal.css';
15
+ ---
16
+ <div class="wd-search-overlay" id="wd-search-overlay" hidden>
17
+ <div class="wd-search-modal" role="dialog" aria-modal="true" aria-label="Search">
18
+ <div class="wd-search-input-row">
19
+ <svg class="wd-search-icon" width="16" height="16" viewBox="0 0 14 14" aria-hidden="true">
20
+ <circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.4" fill="none" />
21
+ <path d="M9.5 9.5L13 13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
22
+ </svg>
23
+ <input
24
+ type="text"
25
+ class="wd-search-input"
26
+ id="wd-search-input"
27
+ placeholder="Search docs..."
28
+ autocomplete="off"
29
+ autocapitalize="off"
30
+ spellcheck="false"
31
+ />
32
+ <kbd class="wd-search-esc">Esc</kbd>
33
+ </div>
34
+ <div class="wd-search-results" id="wd-search-results"></div>
35
+ </div>
36
+ </div>
37
+ <script>
38
+ import { initSearch } from '../../scripts/search';
39
+
40
+ initSearch(document);
41
+ document.addEventListener('astro:page-load', () => initSearch(document));
42
+ </script>
@@ -0,0 +1,122 @@
1
+ ---
2
+ import type { NavTreeNode } from "../../lib/config";
3
+ import NavTree from "./NavTree.astro";
4
+ // Two colorways of the same "Powered by writedocs" mark, not a
5
+ // theme-matched pair in the wd-logo-light/wd-logo-dark sense (that
6
+ // convention names each file by which theme it's *shown in*) - these
7
+ // are named by the mark's own ink color instead, and the darker one
8
+ // (wd_watermark_dark.png) is actually the better-contrast choice
9
+ // against the light theme's near-white background, while the lighter,
10
+ // unsuffixed one reads better against the dark theme's near-black one.
11
+ // Imported (not referenced by string path) since this is a
12
+ // framework-bundled asset living in src/assets, not something any
13
+ // individual site's own public/ folder provides - astro.config.mjs
14
+ // points publicDir at the site's public/ instead, so a plain <img
15
+ // src="/..."> here would 404 on every real site build.
16
+ import watermarkForLightTheme from "../../assets/wd_watermark_dark.png";
17
+ import watermarkForDarkTheme from "../../assets/wd_watermark.png";
18
+
19
+ // Deploy-time-only opt-out, not a writedocs.json field: a site's own writedocs.json
20
+ // is part of its checked-in content (something the author controls), while
21
+ // this is meant to be set by whatever's actually running the build (CI,
22
+ // hosting platform env config, etc.) without touching that content - the
23
+ // same reasoning as WRITEDOCS_CONTENT_DIR/WRITEDOCS_PACKAGE_ROOT already
24
+ // being plain process.env reads rather than config fields (see
25
+ // astro.config.mjs and this component's sibling files). Any value other
26
+ // than the literal string "true" leaves the watermark showing, so an
27
+ // unset/misspelled var fails toward the visible, "unmodified" default
28
+ // rather than silently hiding it.
29
+ const watermarkDisabled = process.env.WRITEDOCS_DISABLE_WATERMARK === "true";
30
+
31
+ interface Props {
32
+ navTree: NavTreeNode[];
33
+ hrefForSlug: (slug: string) => string;
34
+ currentSlug: string;
35
+ }
36
+ const { navTree, hrefForSlug, currentSlug } = Astro.props as Props;
37
+ ---
38
+
39
+ <nav class="wd-sidebar">
40
+ <NavTree nodes={navTree} hrefForSlug={hrefForSlug} currentSlug={currentSlug} />
41
+ {!watermarkDisabled && (
42
+ <div class="wd-sidebar-watermark">
43
+ <img src={watermarkForLightTheme.src} alt="Powered by writedocs" class="wd-watermark-light" />
44
+ <img src={watermarkForDarkTheme.src} alt="Powered by writedocs" class="wd-watermark-dark" />
45
+ </div>
46
+ )}
47
+ </nav>
48
+ <style>
49
+ /* Sticky + its own scrollbar, not just "moves along until it runs out
50
+ of column to stick within" (position: sticky's default behavior,
51
+ which is what this had before - a tall nav tree would eventually
52
+ unstick and get carried away by the page's own scroll once
53
+ .wd-sidebar-col's bottom edge passed the viewport). max-height caps
54
+ it to the visible area below the topbar and overflow-y gives it an
55
+ independent scrollbar, so the page scrolls its content while the
56
+ sidebar scrolls itself - same treatment TableOfContents.astro's
57
+ .wd-toc already has (deliberately matching its `top`/`max-height`
58
+ values exactly, so both columns start scrolling at the same height
59
+ when a page has both). Reset back to normal (non-sticky, no cap) on
60
+ narrow screens in BaseLayout.astro's `@media (max-width: 860px)`
61
+ block, where the sidebar stacks full-width above the content instead
62
+ of sitting beside it. */
63
+ .wd-sidebar {
64
+ padding: 1.5rem 1rem 1.5rem 0;
65
+ position: sticky;
66
+ /* --wd-topbar-offset (src/scripts/topbar-offset.ts) is the topbar's
67
+ real measured height - a fixed 5rem (this var()'s own fallback)
68
+ only matched the topbar's simplest, single-row case; matched
69
+ exactly by TableOfContents.astro's own .wd-toc, see this file's
70
+ own comment above on why that pairing matters. */
71
+ top: var(--wd-topbar-offset, 5rem);
72
+ max-height: calc(100vh - var(--wd-topbar-offset, 5rem));
73
+ overflow-y: auto;
74
+ }
75
+ /* Desktop only (see the :global(.wd-sidebar-col) guard) - turns the
76
+ sidebar into a fixed-height flex column so .wd-sidebar-watermark's
77
+ margin-top: auto below can push it all the way to the bottom of the
78
+ visible area when the nav tree doesn't fill it, instead of sitting
79
+ right after the last nav item with a gap of empty space beneath it.
80
+ height (not just the max-height above) is what makes this work -
81
+ max-height alone only caps how tall the box *can* get, it doesn't
82
+ force it to actually be that tall when content is shorter, which is
83
+ exactly the "empty space below the watermark" case this replaces.
84
+ Scoped to .wd-sidebar-col specifically (not the plain .wd-sidebar
85
+ this component always renders) so the *reused* copy inside
86
+ MobileMenu.astro's own scrollable panel is untouched - forcing a
87
+ tall fixed height there would leave an awkward blank stretch inside
88
+ the mobile drawer under a short nav list, where "pinned to the
89
+ bottom of the screen" isn't really representative of a panel the
90
+ reader can already see the bottom of by scrolling. */
91
+ :global(.wd-sidebar-col) .wd-sidebar {
92
+ height: calc(100vh - var(--wd-topbar-offset, 5rem));
93
+ display: flex;
94
+ flex-direction: column;
95
+ }
96
+ /* Last thing in the nav column. margin-top: auto only has real estate
97
+ to push into on desktop (where the rule above gives .wd-sidebar a
98
+ real fixed height) - inside the mobile menu's reused copy, .wd-
99
+ sidebar is never a flex container, so this margin resolves to 0 and
100
+ the watermark just falls in after the last nav item instead, same
101
+ as before that rule existed. Either way, padding-top + border-top
102
+ stay unconditional, so there's always at least that much separation
103
+ from the nav items above it. */
104
+ .wd-sidebar-watermark {
105
+ margin-top: auto;
106
+ padding-top: 1rem;
107
+ border-top: 1px solid var(--wd-border);
108
+ }
109
+ /* No `display` here - that's the base.css wd-watermark-light/-dark
110
+ toggle's job alone (default display:none on the inactive one,
111
+ display:block on the active one). This rule's own selector
112
+ specificity (a class + an attribute-scoping selector + a type
113
+ selector) is higher than that toggle's plain single/double-class
114
+ rules, so a `display: block` here would win the cascade
115
+ unconditionally on BOTH images regardless of [data-theme] -
116
+ exactly the "both always visible" bug this replaces. */
117
+ .wd-sidebar-watermark img {
118
+ height: 14px;
119
+ width: auto;
120
+ opacity: 0.7;
121
+ }
122
+ </style>
@@ -0,0 +1,85 @@
1
+ ---
2
+ // writedocs.json's `footer.columns`/`socials` - see footerSchema/`socials` in
3
+ // lib/config.ts. Renders nothing at all (not even an empty bordered band)
4
+ // when a site hasn't configured either - both fields default to empty, so
5
+ // `hasFooterContent` is never undefined either way. Callers don't need to
6
+ // check this themselves; this component decides on its own whether it has
7
+ // anything to show.
8
+ //
9
+ // Split out of BaseLayout.astro, which had grown to ~2200 lines covering
10
+ // several unrelated concerns in one file - see that component's own
11
+ // comment for the fuller rationale.
12
+ import { isExternalHref, type DocsConfig } from '../../lib/config';
13
+ import AppIcon from '../../components/AppIcon.astro';
14
+ import '../styles/footer.css';
15
+
16
+ interface Props {
17
+ config: DocsConfig;
18
+ logoLight?: string;
19
+ logoDark?: string;
20
+ }
21
+ const { config, logoLight, logoDark } = Astro.props as Props;
22
+ const hasLogoImage = Boolean(logoLight || logoDark);
23
+ const socialEntries = Object.entries(config.socials);
24
+ // A `socials` key doubles as its own icon reference (resolveIcon() in
25
+ // lib/config.ts, via <AppIcon>) - fine as-is for a bare name like
26
+ // "github", but a key using the explicit "collection:icon-name" form
27
+ // (e.g. "simple-icons:discord", for a platform Lucide doesn't have a
28
+ // matching icon for) would otherwise end up as this link's own aria-label
29
+ // verbatim, colon and all - not what a screen reader user wants read
30
+ // aloud. Strips down to just the part after the last colon for the label
31
+ // specifically; the full key still goes to AppIcon unchanged.
32
+ const socialLabel = (platform: string) => platform.split(':').pop() ?? platform;
33
+ const hasFooterContent = config.footer.columns.length > 0 || socialEntries.length > 0;
34
+ ---
35
+ {hasFooterContent && (
36
+ <footer class="wd-footer">
37
+ <div class="wd-footer-inner">
38
+ {hasLogoImage && (
39
+ <a class="wd-footer-brand" href="/">
40
+ {logoLight && <img src={logoLight} alt={config.name} height="24" class="wd-logo-light" />}
41
+ {logoDark && <img src={logoDark} alt={config.name} height="24" class="wd-logo-dark" />}
42
+ </a>
43
+ )}
44
+ {config.footer.columns.length > 0 && (
45
+ <div class="wd-footer-columns">
46
+ {config.footer.columns.map((column) => (
47
+ <div class="wd-footer-column">
48
+ {column.title && <div class="wd-footer-column-title">{column.title}</div>}
49
+ <ul class="wd-footer-column-links">
50
+ {column.links.map((link) => (
51
+ <li>
52
+ <a
53
+ href={link.href}
54
+ target={isExternalHref(link.href) ? '_blank' : undefined}
55
+ rel={isExternalHref(link.href) ? 'noopener noreferrer' : undefined}
56
+ aria-label={!link.label ? link.icon : undefined}
57
+ >
58
+ <AppIcon icon={link.icon} class="wd-tab-icon" />
59
+ {link.label}
60
+ </a>
61
+ </li>
62
+ ))}
63
+ </ul>
64
+ </div>
65
+ ))}
66
+ </div>
67
+ )}
68
+ {socialEntries.length > 0 && (
69
+ <div class="wd-footer-socials">
70
+ {socialEntries.map(([platform, url]) => (
71
+ <a
72
+ href={url}
73
+ class="wd-footer-social-link"
74
+ aria-label={socialLabel(platform)}
75
+ target={isExternalHref(url) ? '_blank' : undefined}
76
+ rel={isExternalHref(url) ? 'noopener noreferrer' : undefined}
77
+ >
78
+ <AppIcon icon={platform} class="wd-footer-social-icon" />
79
+ </a>
80
+ ))}
81
+ </div>
82
+ )}
83
+ </div>
84
+ </footer>
85
+ )}