@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,750 @@
1
+ ---
2
+ // The site's root HTML shell: <head> meta/SEO tags, the writedocs.json
3
+ // `integrations`/`scripts` passthrough, the color-theme flash-
4
+ // prevention script, the dismissible `banner`, and the three-column
5
+ // .wd-shell (sidebar/main/toc) that every page's own content lands in.
6
+ // Everything else that used to live directly in this file - the topbar,
7
+ // the mobile hamburger drawer, the search modal, the footer, and the
8
+ // analytics/AI-chat integration scripts - is now its own component,
9
+ // composed below. This file had grown to ~2200 lines covering all of
10
+ // those unrelated concerns in one place before that split; each piece is
11
+ // small enough on its own now to read start-to-finish without losing the
12
+ // thread, and a change to (say) the mobile menu no longer means scrolling
13
+ // past a thousand lines of unrelated topbar/search/footer code to find
14
+ // the right spot. This file is the only .astro file that stays directly
15
+ // in src/layout/ - every other component lives in src/layout/components/,
16
+ // and every plain .css file (base.css, banner.css, and the rest each
17
+ // component below imports for itself) lives in src/layout/styles/ - this
18
+ // file is the layout's single entry point/orchestrator, everything else
19
+ // is a piece it composes.
20
+ import fs from 'node:fs';
21
+ // Named `nodePath`, not the more natural `path` - this component already
22
+ // destructures a prop called `path` (the current page's own URL path,
23
+ // see Props below) from Astro.props, which would otherwise shadow a
24
+ // module-level `path` import inside this component's own scope, silently
25
+ // turning every `path.join(...)` call into a TypeError at render time
26
+ // ("path.join is not a function") rather than a compile-time error, since
27
+ // both are just plain identifiers to the bundler. Hit and fixed while
28
+ // wiring up findRootAssets() below - real bug, not a hypothetical.
29
+ import nodePath from 'node:path';
30
+ import {
31
+ resolveSiteUrl,
32
+ resolveAbsoluteUrl,
33
+ findRootAssets,
34
+ resolveNavbarColor,
35
+ contrastTextColor,
36
+ resolveFonts,
37
+ googleFontsHref,
38
+ fontFaceRule,
39
+ type DocsConfig,
40
+ type Selector,
41
+ type GlobalDropdownView,
42
+ type SeoFields,
43
+ } from '../lib/config';
44
+ import '../styles/global.css';
45
+ // rehypeKatex (astro.config.mjs) renders every $inline$/$$block$$ math
46
+ // span into real KaTeX markup at build time - this is the one runtime
47
+ // dependency that output needs: layout/glyph CSS for the .katex class
48
+ // tree, self-hosted (bundled by Vite, fonts included via its own url()
49
+ // asset resolution) rather than a KaTeX CDN link. Loaded site-wide, same
50
+ // as global.css above - a page with no math in it pays for one small,
51
+ // cached stylesheet and nothing else (no fonts are actually fetched
52
+ // unless a page's rendered KaTeX markup uses their glyphs). KaTeX's own
53
+ // output deliberately sets no explicit text color, inheriting `color`
54
+ // from whatever wraps it - so it already follows this site's light/dark
55
+ // theme with no extra CSS needed here.
56
+ import 'katex/dist/katex.min.css';
57
+ import './styles/base.css';
58
+ import './styles/banner.css';
59
+ // Astro's client-side router - swaps <body> content between same-origin
60
+ // navigations via the native View Transition API instead of a hard
61
+ // browser reload, so the topbar/sidebar/footer don't visibly flash/
62
+ // "twitch" on every click the way a full document reload does. Every
63
+ // interactive component's own <script> (TopBar's dropdowns, search,
64
+ // TableOfContents, Mermaid, the copy-page menu, ...) already calls its
65
+ // init function once directly *and* re-registers it on the
66
+ // `astro:page-load` event (see e.g. TableOfContents.astro's own
67
+ // `initToc(document); document.addEventListener('astro:page-load', ...)`
68
+ // pair) - that event only ever fires when this router is active, so this
69
+ // was already the intended architecture, just missing this one piece
70
+ // that actually turns it on.
71
+ import { ClientRouter } from 'astro:transitions';
72
+ import TopBar from './components/TopBar.astro';
73
+ import MobileMenu from './components/MobileMenu.astro';
74
+ import SearchModal from './components/SearchModal.astro';
75
+ import ImageZoom from './components/ImageZoom.astro';
76
+ import SiteFooter from './components/SiteFooter.astro';
77
+ import AnalyticsScripts from './components/AnalyticsScripts.astro';
78
+ import AskAiWidget from './components/AskAiWidget.astro';
79
+
80
+ interface Props {
81
+ config: DocsConfig;
82
+ title: string;
83
+ // Falls back to config.description when a page has none of its own -
84
+ // see [...slug].astro, which passes entry.data.description straight
85
+ // through without resolving the fallback itself.
86
+ description?: string;
87
+ // The current page's own path (e.g. "/" or "/guides/foo/"), used to
88
+ // build the canonical <link> and og:url/twitter:url - [...slug].astro
89
+ // already computes this via hrefForSlug(currentFileId) for its own
90
+ // prev/next links, so it's passed through rather than recomputed here.
91
+ path: string;
92
+ // Site-wide seo (writedocs.json) merged with this page's own frontmatter
93
+ // seo override - see mergeSeo() in lib/config.ts. [...slug].astro does
94
+ // the merging; this component just renders the result.
95
+ seo?: SeoFields;
96
+ // One Selector per level of the active page's navigation path (tabs,
97
+ // versions, languages, products, or a dropdown used as a nested level)
98
+ // - see resolveSections()/buildSelectors() in lib/config.ts for how
99
+ // this is computed from however the site's writedocs.json nests things.
100
+ // Passed straight through to TopBar.astro and MobileMenu.astro, which
101
+ // each derive their own view of it (TopBar's switcherSelectors/
102
+ // tabSelectors vs. MobileMenu's per-level accordion rows).
103
+ selectors?: Selector[];
104
+ // The always-visible topbar dropdown list from navigation.global.dropdowns
105
+ // - independent of the active selectors, shown on every page. Also
106
+ // passed straight through to TopBar.astro and MobileMenu.astro.
107
+ globalDropdowns?: GlobalDropdownView[];
108
+ // Per-page frontmatter `mode` (content.config.ts) - controls how much
109
+ // site chrome wraps this page. 'blank' drops the topbar and the whole
110
+ // .wd-shell (sidebar/toc columns) entirely, rendering only <slot />
111
+ // directly in <body> - see the template below. Every other mode
112
+ // (including 'custom') still gets the topbar; [...slug].astro is what
113
+ // decides whether to actually pass sidebar/toc slot content for
114
+ // 'wide'/'frame'/'custom' (BaseLayout has no sidebar/toc content of its
115
+ // own to conditionally omit - an unfilled named slot in Astro already
116
+ // renders nothing, so this component only needs to know whether to
117
+ // render the *column wrapper divs* at all).
118
+ mode?: 'default' | 'wide' | 'frame' | 'custom' | 'blank';
119
+ }
120
+ const {
121
+ config,
122
+ title,
123
+ description,
124
+ path,
125
+ seo = {},
126
+ selectors = [],
127
+ globalDropdowns = [],
128
+ mode = 'default',
129
+ } = Astro.props as Props;
130
+ const showSidebarCol = mode === 'default' || mode === 'wide';
131
+ const showTocCol = mode === 'default';
132
+ // Meta tag resolution - all derived once here rather than inline in the
133
+ // template below, so the JSX stays a straight list of conditional <meta>
134
+ // tags instead of repeating this logic at each call site.
135
+ const siteUrl = resolveSiteUrl(config);
136
+ const effectiveDescription = description ?? config.description;
137
+ const canonicalUrl = siteUrl ? `${siteUrl}${path}` : null;
138
+ const ogType = seo.ogType ?? 'website';
139
+ const absoluteOgImage = seo.ogImage ? resolveAbsoluteUrl(siteUrl, seo.ogImage) : undefined;
140
+ // summary_large_image needs a real image to make sense as a card size;
141
+ // falls back to the smaller "summary" card when no image is set, rather
142
+ // than always defaulting to the large-image card regardless.
143
+ const twitterCard = seo.twitterCard ?? (absoluteOgImage ? 'summary_large_image' : 'summary');
144
+ // Light-mode values come straight from styles.colors/styles.background
145
+ // (with defaults); dark-mode values fall back to their light counterpart
146
+ // when the site's own dark override doesn't set them, so a site only has
147
+ // to specify what actually changes for dark mode. Both sets are always
148
+ // computed and handed to the CSS below (light-mode ones as sensible
149
+ // starting defaults, but the .wd-* class hides/shows appropriately) - see
150
+ // the `[data-theme="dark"]` override rule below, which is what actually
151
+ // switches which set is live. Unlike every other bit of CSS this file
152
+ // used to own directly, this specific pair of rules can't move into a
153
+ // plain external .css file the way base.css/banner.css did - `define:vars`
154
+ // (right below) is what lets these raw hex/URL values get interpolated
155
+ // into custom-property assignments at all, and that directive only works
156
+ // on a <style> tag physically inside an .astro file's own template.
157
+ const lightPrimary = config.styles.colors.primary;
158
+ const lightText = config.styles.colors.text ?? '#0f172a';
159
+ const darkPrimary = config.styles.colors.dark?.primary ?? lightPrimary;
160
+ const darkText = config.styles.colors.dark?.text ?? '#e2e8f0';
161
+ // styles.background.colors is now the single source for any background
162
+ // color - both the flat --wd-background surface (dropdowns/modals/kbd
163
+ // chips/footer/topbar-fallback/etc., every other `var(--wd-background)`
164
+ // call site) and the <body> canvas paint below. There used to be a
165
+ // separate styles.colors.background/colors.dark.background pair driving
166
+ // just the --wd-background half, independent of this - see config.ts's
167
+ // own comment on why that split was removed; `lightBackground`/
168
+ // `darkBackground` here is what used to read that other field.
169
+ const lightBackground = config.styles.background?.colors?.light ?? '#ffffff';
170
+ const darkBackground = config.styles.background?.colors?.dark ?? '#0b1120';
171
+ // The topbar/mobile-menu-panel background - falls back to the page's own
172
+ // background (above) when a site doesn't set it, so an unconfigured navbar
173
+ // keeps blending into the page exactly as it always has; only sites that
174
+ // explicitly set `styles.navbar.light`/`.dark` get a topbar that looks
175
+ // different from the page behind it. resolveNavbarColor() (lib/config.ts)
176
+ // is what splits the string-or-{background,accent} union `navbar.light`/
177
+ // `.dark` can now be into its two parts - see its own comment.
178
+ const navLight = resolveNavbarColor(config.styles.navbar?.light, lightBackground);
179
+ const navDark = resolveNavbarColor(config.styles.navbar?.dark, darkBackground);
180
+ const lightNavbarBg = navLight.background;
181
+ const darkNavbarBg = navDark.background;
182
+ // Whether this side of `styles.navbar` was configured at all - string or
183
+ // object form, doesn't matter, just "is there a writedocs.json value here" -
184
+ // not merely "does lightNavbarBg differ from the page background", since
185
+ // a site could deliberately set navbar to the *same* color as the page
186
+ // background and this should still count as configured. What this gates:
187
+ // once true, the navbar's own text/icon color stops assuming it's sitting
188
+ // on something close to the page background (--wd-text's own tuning
189
+ // target) and switches to being computed fresh against whatever
190
+ // `background` actually resolved to.
191
+ const lightNavbarConfigured = config.styles.navbar?.light !== undefined;
192
+ const darkNavbarConfigured = config.styles.navbar?.dark !== undefined;
193
+ // --wd-navbar-foreground/-muted: the topbar's own text/icon color, plain
194
+ // (resting) state. Never a writedocs.json value, even indirectly - always
195
+ // contrastTextColor(lightNavbarBg) (lib/config.ts, picks black or white by
196
+ // luminance) the moment this side of `navbar` is configured at all, or the
197
+ // literal --wd-text/--wd-text-muted var references when it isn't, so an
198
+ // unconfigured navbar renders pixel-identical to before this feature
199
+ // existed. An earlier version of this let an object-form field double as
200
+ // both "the navbar's accent color" and "the navbar's text color" - real
201
+ // bug, caught immediately: a site picking a light accent (to stay legible
202
+ // against a dark/saturated background, the whole point of setting it) got
203
+ // that same light color as its *text*, which is exactly backwards. Text
204
+ // legibility isn't a design choice worth exposing at all here - it's
205
+ // always computed, correctly, every time. The muted variant mixes that
206
+ // same resolved color toward transparent (not toward lightNavbarBg, which
207
+ // would drag it back toward the background's own hue and undo the
208
+ // contrast fix) - a plain opacity reduction, so it stays legible
209
+ // regardless of what hue the navbar itself happens to be.
210
+ const lightNavbarFg = lightNavbarConfigured ? contrastTextColor(lightNavbarBg) : 'var(--wd-text)';
211
+ const darkNavbarFg = darkNavbarConfigured ? contrastTextColor(darkNavbarBg) : 'var(--wd-text)';
212
+ const lightNavbarFgMuted = lightNavbarConfigured
213
+ ? `color-mix(in srgb, ${lightNavbarFg} 70%, transparent)`
214
+ : 'var(--wd-text-muted)';
215
+ const darkNavbarFgMuted = darkNavbarConfigured
216
+ ? `color-mix(in srgb, ${darkNavbarFg} 70%, transparent)`
217
+ : 'var(--wd-text-muted)';
218
+ // --wd-navbar-accent: the tab-hover-underline/active-tab-fill color inside
219
+ // the topbar specifically - `accent` on the object form (a real, legitimate
220
+ // design choice, unlike text color above), falling back to the sitewide
221
+ // --wd-primary (today's exact behavior) when unset - a site that sets a
222
+ // navbar background but no accent keeps its ordinary brand-colored
223
+ // active-tab fill exactly as before.
224
+ const lightNavbarAccent = navLight.accent ?? lightPrimary;
225
+ const darkNavbarAccent = navDark.accent ?? darkPrimary;
226
+ // --wd-navbar-accent-text: the active tab's own text color, painted on top
227
+ // of --wd-navbar-accent above. Hardcoded to white before this feature
228
+ // existed, which only ever looked right because every accent color in
229
+ // practice (always --wd-primary until now) happened to be dark/saturated
230
+ // enough for white text - contrastTextColor() (lib/config.ts) picks
231
+ // black or white by actual luminance instead, so a light `accent` (e.g.
232
+ // a pale one against a dark navbar) still gets legible active-tab text
233
+ // rather than assuming white always works.
234
+ const lightNavbarAccentText = contrastTextColor(lightNavbarAccent);
235
+ const darkNavbarAccentText = contrastTextColor(darkNavbarAccent);
236
+ // --wd-navbar-border: the switcher pills' (version/language/product) own
237
+ // border. Never the flat --wd-border (a fixed neutral gray/slate tuned for
238
+ // sitting on the page background) once navbar is configured - that reads
239
+ // as a harsh, near-white outline against a saturated/colored navbar
240
+ // background, since it's not picked with that background in mind at all.
241
+ // Same mix-toward-transparent technique as the muted foreground above: a
242
+ // low-opacity tint of the already-contrast-correct navbarFg, so it stays
243
+ // soft and legible against any navbar hue instead of a flat, unrelated
244
+ // gray. Unconfigured navbar keeps the literal var(--wd-border) reference,
245
+ // pixel-identical to before this existed.
246
+ const lightNavbarBorder = lightNavbarConfigured
247
+ ? `color-mix(in srgb, ${lightNavbarFg} 25%, transparent)`
248
+ : 'var(--wd-border)';
249
+ const darkNavbarBorder = darkNavbarConfigured
250
+ ? `color-mix(in srgb, ${darkNavbarFg} 25%, transparent)`
251
+ : 'var(--wd-border)';
252
+ // The <body> canvas's own paint - same color as --wd-background above
253
+ // (there's only one background color to configure now), plus an optional
254
+ // image layered on top. `images.light`/`.dark` are plain public/-relative
255
+ // paths (same convention as styles.logo/favicon) - wrapped in url(...) here
256
+ // (or 'none' when unset) since CSS custom properties can't have url()
257
+ // applied to them after the fact (`url(var(--x))` isn't valid CSS - the
258
+ // value itself has to already be a full `url(...)` token, which is what
259
+ // these two variables hold).
260
+ const lightPageBgColor = lightBackground;
261
+ const darkPageBgColor = darkBackground;
262
+ const lightPageBgImage = config.styles.background?.images?.light
263
+ ? `url('${config.styles.background.images.light}')`
264
+ : 'none';
265
+ const darkPageBgImage = config.styles.background?.images?.dark
266
+ ? `url('${config.styles.background.images.dark}')`
267
+ : 'none';
268
+ // A logo is either one path used for both modes, or a {light, dark,
269
+ // label} object - normalized to a single light/dark pair here (the one
270
+ // place that needs to know which form writedocs.json used) so TopBar.astro,
271
+ // MobileMenu.astro, and SiteFooter.astro - each of which render their own
272
+ // copy of the brand link - never need to branch on it themselves. The
273
+ // brand link shows the image(s) alone by default - no site name text next
274
+ // to it, since a real logo asset is usually already a wordmark. `label`
275
+ // (only settable on the object form - see config.ts) opts back into
276
+ // showing text next to the image - or, since `light`/`dark` are
277
+ // themselves optional on that form, stands entirely on its own:
278
+ // `styles.logo: { label: "..." }` with no images at all overrides just the
279
+ // brand text without adding any logo image. A site with neither a logo
280
+ // image nor a label falls back to plain config.name text, so the brand
281
+ // link is never left empty.
282
+ const logo = config.styles.logo;
283
+ const logoLight = typeof logo === 'string' ? logo : logo?.light;
284
+ const logoDark = typeof logo === 'string' ? logo : logo?.dark;
285
+ const logoLabel = typeof logo === 'string' ? undefined : logo?.label;
286
+ const hasLogoImage = Boolean(logoLight || logoDark);
287
+ const brandLabel = logoLabel ?? (hasLogoImage ? undefined : config.name);
288
+
289
+ // writedocs.json's `footer.logo` (same {light, dark, label} shape as
290
+ // `styles.logo` above, via config.ts's shared logoSchema) - unset by
291
+ // default, in which case SiteFooter.astro just gets the same
292
+ // logoLight/logoDark/logoLabel as the topbar/mobile menu above. Set, it
293
+ // replaces those entirely (not just fills in whichever side is missing)
294
+ // before ever reaching SiteFooter - a footer wanting a different mark
295
+ // than the topbar (or none at all - `styles.logo` set, `footer.logo`
296
+ // deliberately absent-of-images-but-present, e.g. `{ label: "" }`, isn't
297
+ // a real use case worth solving here) shouldn't still show the sitewide
298
+ // one alongside or underneath it.
299
+ const footerLogoConfig = config.footer.logo;
300
+ const footerLogoLight = footerLogoConfig
301
+ ? typeof footerLogoConfig === 'string'
302
+ ? footerLogoConfig
303
+ : footerLogoConfig.light
304
+ : logoLight;
305
+ const footerLogoDark = footerLogoConfig
306
+ ? typeof footerLogoConfig === 'string'
307
+ ? footerLogoConfig
308
+ : footerLogoConfig.dark
309
+ : logoDark;
310
+
311
+ // Zero-config custom CSS/JS - any `.css`/`.js` file anywhere in the
312
+ // project (root, `docs/`, `snippets/`, `public/`, any subfolder -
313
+ // `dist/`/`node_modules/`/etc. excluded, see findRootAssets() in
314
+ // lib/config.ts) is auto-loaded on every page, no writedocs.json entry
315
+ // needed. Read fresh on every render (not cached at module scope) so an
316
+ // edit to one of these files shows up on the next `writedocs dev` page
317
+ // load without a server restart - same freshness `loadDocsConfig(contentDir)`
318
+ // itself already gives writedocs.json edits in [...slug].astro/404.astro.
319
+ // Rendered in addition to, not instead of, writedocs.json's own explicit
320
+ // `scripts` field - that still exists for external/CDN script URLs
321
+ // (`src`) and inline content that should only load under deliberate
322
+ // config, and renders before these so a file living in the project (the
323
+ // more locally-owned override) wins over a URL possibly pointing at
324
+ // someone else's asset.
325
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
326
+ const rootAssets = findRootAssets(contentDir);
327
+ // `name` is a POSIX-separated path relative to contentDir (e.g.
328
+ // "docs/theme.css") - split and rejoin with nodePath.join rather than
329
+ // handing the raw string straight to it, so a nested asset resolves
330
+ // correctly on every OS this might run on, not just POSIX ones.
331
+ const rootCssContents = rootAssets.css.map((name) => fs.readFileSync(nodePath.join(contentDir, ...name.split('/')), 'utf-8'));
332
+ const rootJsContents = rootAssets.js.map((name) => fs.readFileSync(nodePath.join(contentDir, ...name.split('/')), 'utf-8'));
333
+ // public/*.css and public/*.js auto-load too, but as <link>/<script src>
334
+ // pointing at their already-public URL (see findRootAssets()'s own
335
+ // comment for why - no point inlining a file that's also independently
336
+ // fetchable at that exact URL). publicJsHrefs is filtered against
337
+ // writedocs.json's own `scripts.head` so a site that hasn't yet removed an
338
+ // explicit `src` entry for the same public/ file doesn't get that href
339
+ // rendered twice - the explicit entry still works, it's just redundant
340
+ // with this now. publicCssHrefs has no equivalent filter to apply -
341
+ // there's no explicit stylesheet-URL config field anymore.
342
+ const explicitScriptSrcs = new Set(config.scripts.head.map((s) => s.src).filter((src): src is string => Boolean(src)));
343
+ const publicCssHrefs = rootAssets.publicCss;
344
+ const publicJsHrefs = rootAssets.publicJs.filter((href) => !explicitScriptSrcs.has(href));
345
+
346
+ // styles.fonts (see fontsSchema/resolveFonts() in lib/config.ts) - resolves
347
+ // to plain Inter, genuinely loaded via Google Fonts (not just named in a
348
+ // fallback stack the way base.css used to), when a site sets no `fonts`
349
+ // field at all. `fontStack()` quotes the family name (safe even for a
350
+ // single-word name, and required for one with spaces like "Playfair
351
+ // Display") and appends the same system-font fallback chain base.css
352
+ // always used, so a reader who somehow loads the page before the web font
353
+ // finishes fetching still sees a reasonable font rather than the browser
354
+ // default serif. `heading`/`body` are left `null` (not defaulted to the
355
+ // same value as `base`) whenever a site hasn't overridden them - base.css
356
+ // falls back to `--wd-font-family` itself via CSS var(), so a `null` here
357
+ // deliberately never gets its own CSS var written into the style block
358
+ // below.
359
+ const resolvedFonts = resolveFonts(config);
360
+ const fontsGoogleHref = googleFontsHref(resolvedFonts);
361
+ function fontStack(family: string): string {
362
+ return `'${family}', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
363
+ }
364
+ // heading/body already fall back to `resolvedFonts.base` right here (not
365
+ // left as a CSS var() fallback chain for :root below to resolve) - same
366
+ // "fully resolve in JS, plain values into define:vars" approach every
367
+ // other themeable value in this file already uses (see lightPrimary/
368
+ // darkPrimary etc. above), rather than introducing a different pattern
369
+ // just for fonts.
370
+ const wdFontFamily = fontStack(resolvedFonts.base.family);
371
+ const wdFontFamilyHeading = resolvedFonts.heading ? fontStack(resolvedFonts.heading.family) : wdFontFamily;
372
+ const wdFontFamilyBody = resolvedFonts.body ? fontStack(resolvedFonts.body.family) : wdFontFamily;
373
+ // One @font-face rule per source-based font actually configured (Google
374
+ // Fonts, covered by fontsGoogleHref above instead, never reach here) - this
375
+ // only ever gets the right *file* loaded (the @font-face `font-weight`
376
+ // descriptor, or the Google Fonts URL's `:wght@` axis inside
377
+ // googleFontsHref() above), not the actual rendered boldness of any
378
+ // element - see wdFontWeightCss right below for that other half.
379
+ const fontFaceCss = [fontFaceRule(resolvedFonts.base), fontFaceRule(resolvedFonts.heading), fontFaceRule(resolvedFonts.body)]
380
+ .filter(Boolean)
381
+ .join('\n');
382
+ // Without this, a configured `weight` only ever changed which font *file*
383
+ // loaded - h1-h6 still rendered at the browser's own default `font-weight:
384
+ // bold` (a user-agent-stylesheet rule, present regardless of which file
385
+ // actually loaded), so a heading weight of e.g. 400 was silently faux-
386
+ // bolded back to looking bold anyway, with no visible effect from
387
+ // changing the config at all. `resolvedFonts.heading?.weight ?? base.weight`
388
+ // falls back the same way family already does (an override with no
389
+ // weight of its own still inherits the top-level one); `undefined` when
390
+ // nothing at any level configured a weight, which deliberately renders no
391
+ // rule at all here - an unconfigured site keeps today's plain browser-
392
+ // default bold headings/normal body text exactly as before this feature
393
+ // existed, not a forced 400/700 nobody asked for.
394
+ const wdFontWeightHeading = resolvedFonts.heading?.weight ?? resolvedFonts.base.weight;
395
+ const wdFontWeightBody = resolvedFonts.body?.weight ?? resolvedFonts.base.weight;
396
+ const fontWeightCss = [
397
+ wdFontWeightHeading !== undefined ? `h1, h2, h3, h4, h5, h6 { font-weight: ${wdFontWeightHeading}; }` : '',
398
+ wdFontWeightBody !== undefined ? `body { font-weight: ${wdFontWeightBody}; }` : '',
399
+ ]
400
+ .filter(Boolean)
401
+ .join('\n');
402
+ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
403
+ ---
404
+ <!doctype html>
405
+ <html lang="en">
406
+ <head>
407
+ <meta charset="UTF-8" />
408
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
409
+ <title>{title} · {config.name}</title>
410
+ <ClientRouter />
411
+ {effectiveDescription && <meta name="description" content={effectiveDescription} />}
412
+ {seo.keywords && seo.keywords.length > 0 && <meta name="keywords" content={seo.keywords.join(', ')} />}
413
+ {seo.noindex && <meta name="robots" content="noindex, nofollow" />}
414
+ {canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
415
+ {config.styles.favicon && <link rel="icon" href={config.styles.favicon} />}
416
+ {/* styles.fonts - see resolveFonts()/googleFontsHref() in lib/config.ts.
417
+ Present (non-null) whenever at least one configured font (or the
418
+ Inter default, when styles.fonts is unset entirely) has no `source`
419
+ - i.e. is a Google Font, auto-loaded by family name. The two
420
+ preconnect hints warm up the connections googleapis.com's own CSS
421
+ response will immediately need (its @font-face rules point at
422
+ gstatic.com for the actual font files) before the browser has even
423
+ parsed that response - Google's own recommended pattern for using
424
+ their fonts. Rendered this early in <head> (right after favicon,
425
+ before Open Graph/Twitter meta) so the font starts loading as soon
426
+ as possible rather than competing with less render-critical tags
427
+ for bandwidth/priority. */}
428
+ {fontsGoogleHref && (
429
+ <>
430
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
431
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
432
+ <link rel="stylesheet" href={fontsGoogleHref} />
433
+ </>
434
+ )}
435
+ <!-- Open Graph -->
436
+ <meta property="og:title" content={title} />
437
+ {effectiveDescription && <meta property="og:description" content={effectiveDescription} />}
438
+ <meta property="og:type" content={ogType} />
439
+ <meta property="og:site_name" content={config.name} />
440
+ {canonicalUrl && <meta property="og:url" content={canonicalUrl} />}
441
+ {absoluteOgImage && <meta property="og:image" content={absoluteOgImage} />}
442
+ <!-- Twitter card -->
443
+ <meta name="twitter:card" content={twitterCard} />
444
+ <meta name="twitter:title" content={title} />
445
+ {effectiveDescription && <meta name="twitter:description" content={effectiveDescription} />}
446
+ {absoluteOgImage && <meta name="twitter:image" content={absoluteOgImage} />}
447
+ {/* writedocs.json's `integrations` (the six analytics providers) - see
448
+ AnalyticsScripts.astro. Rendered as early in <head> as reasonably
449
+ possible (right after the meta tags, before even the theme-
450
+ detection script below) - the usual placement every one of these
451
+ providers' own install instructions ask for, so pageview timing is
452
+ captured as close to actual page load as it can be. */}
453
+ <AnalyticsScripts config={config} />
454
+ <script is:inline>
455
+ // Sets [data-theme] and [data-banner-dismissed] on <html> before
456
+ // first paint, so the page never flashes the wrong mode or an
457
+ // already-dismissed banner. Preference order for theme: an explicit
458
+ // prior choice (localStorage), then the OS-level preference,
459
+ // defaulting to light. The click handlers that update both live in
460
+ // src/scripts/theme-toggle.ts and src/scripts/banner.ts.
461
+ //
462
+ // Also re-run on `astro:after-swap` (ClientRouter, see BaseLayout's
463
+ // own import of it below), not just the initial load - Astro's
464
+ // client-side navigation swaps <html>'s attributes wholesale to
465
+ // match the newly-fetched page's raw server-rendered markup (see
466
+ // swapRootAttributes() in astro/dist/transitions/swap-functions.js),
467
+ // which has neither of these dataset attributes baked in since
468
+ // they're pure client-side runtime state, not something the server
469
+ // ever renders. Without re-running this after every swap, a
470
+ // dismissed banner (or a chosen dark theme) would silently come
471
+ // back on the very next navigation - both attributes get wiped, not
472
+ // just reset to their default, so this has to unconditionally
473
+ // reapply them from localStorage every time, exactly as it does on
474
+ // first load. `astro:after-swap` fires synchronously right after
475
+ // that swap and before the browser's next paint, so this still
476
+ // prevents any visible flash on client-side navigations too.
477
+ function wdApplyStoredPreferences() {
478
+ try {
479
+ var stored = localStorage.getItem('wd-theme');
480
+ var theme = stored === 'light' || stored === 'dark'
481
+ ? stored
482
+ : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
483
+ document.documentElement.dataset.theme = theme;
484
+ } catch (e) {
485
+ document.documentElement.dataset.theme = 'light';
486
+ }
487
+ try {
488
+ document.documentElement.dataset.bannerDismissed = localStorage.getItem('wd-banner-dismissed') === 'true' ? 'true' : 'false';
489
+ } catch (e) {
490
+ document.documentElement.dataset.bannerDismissed = 'false';
491
+ }
492
+ }
493
+ wdApplyStoredPreferences();
494
+ document.addEventListener('astro:after-swap', wdApplyStoredPreferences);
495
+ </script>
496
+ <script>
497
+ // Preserves the sidebar's own scroll position across a ClientRouter
498
+ // navigation - see src/scripts/sidebar-scroll.ts for why this is
499
+ // needed at all (the sidebar is re-rendered fresh, server-side, on
500
+ // every page, so it's a brand-new DOM node each time, reset to
501
+ // scrollTop 0 like a real page reload would be). Same before-swap/
502
+ // after-swap pairing as wdApplyStoredPreferences above, and for the
503
+ // same reason: after-swap fires before the next paint, so there's no
504
+ // visible flash of the sidebar sitting at the top before jumping
505
+ // back to where the reader had it scrolled.
506
+ import { saveSidebarScroll, restoreSidebarScroll } from '../scripts/sidebar-scroll';
507
+
508
+ document.addEventListener('astro:before-swap', saveSidebarScroll);
509
+ document.addEventListener('astro:after-swap', restoreSidebarScroll);
510
+ </script>
511
+ <style
512
+ define:vars={{
513
+ wdPrimaryLight: lightPrimary,
514
+ wdBackgroundLight: lightBackground,
515
+ wdTextLight: lightText,
516
+ wdNavbarLight: lightNavbarBg,
517
+ wdNavbarFgLight: lightNavbarFg,
518
+ wdNavbarFgMutedLight: lightNavbarFgMuted,
519
+ wdNavbarAccentLight: lightNavbarAccent,
520
+ wdNavbarAccentTextLight: lightNavbarAccentText,
521
+ wdNavbarBorderLight: lightNavbarBorder,
522
+ wdPageBgColorLight: lightPageBgColor,
523
+ wdPageBgImageLight: lightPageBgImage,
524
+ wdFontFamily,
525
+ wdFontFamilyHeading,
526
+ wdFontFamilyBody,
527
+ wdPrimaryDark: darkPrimary,
528
+ wdBackgroundDark: darkBackground,
529
+ wdTextDark: darkText,
530
+ wdNavbarDark: darkNavbarBg,
531
+ wdNavbarFgDark: darkNavbarFg,
532
+ wdNavbarFgMutedDark: darkNavbarFgMuted,
533
+ wdNavbarAccentDark: darkNavbarAccent,
534
+ wdNavbarAccentTextDark: darkNavbarAccentText,
535
+ wdNavbarBorderDark: darkNavbarBorder,
536
+ wdPageBgColorDark: darkPageBgColor,
537
+ wdPageBgImageDark: darkPageBgImage,
538
+ }}
539
+ >
540
+ :root {
541
+ --wd-primary: var(--wdPrimaryLight);
542
+ --wd-background: var(--wdBackgroundLight);
543
+ --wd-text: var(--wdTextLight);
544
+ --wd-navbar-background: var(--wdNavbarLight);
545
+ --wd-navbar-foreground: var(--wdNavbarFgLight);
546
+ --wd-navbar-foreground-muted: var(--wdNavbarFgMutedLight);
547
+ --wd-navbar-accent: var(--wdNavbarAccentLight);
548
+ --wd-navbar-accent-text: var(--wdNavbarAccentTextLight);
549
+ --wd-navbar-border: var(--wdNavbarBorderLight);
550
+ --wd-page-bg-color: var(--wdPageBgColorLight);
551
+ --wd-page-bg-image: var(--wdPageBgImageLight);
552
+ --wd-text-muted: #64748b;
553
+ --wd-border: #e2e8f0;
554
+ --wd-surface: #f8fafc;
555
+ /* Not mode-dependent (a site's chosen font doesn't change between
556
+ light/dark) - set once here rather than duplicated in the
557
+ :root[data-theme='dark'] block below. */
558
+ --wd-font-family: var(--wdFontFamily);
559
+ --wd-font-family-heading: var(--wdFontFamilyHeading);
560
+ --wd-font-family-body: var(--wdFontFamilyBody);
561
+ }
562
+ /* Higher specificity than the plain :root rule above (an attribute
563
+ selector on :root beats an unqualified one), so this wins
564
+ regardless of source order once data-theme is set to "dark" by
565
+ the inline script above or the toggle button's click handler
566
+ (src/scripts/theme-toggle.ts). */
567
+ :root[data-theme='dark'] {
568
+ --wd-primary: var(--wdPrimaryDark);
569
+ --wd-background: var(--wdBackgroundDark);
570
+ --wd-text: var(--wdTextDark);
571
+ --wd-navbar-background: var(--wdNavbarDark);
572
+ --wd-navbar-foreground: var(--wdNavbarFgDark);
573
+ --wd-navbar-foreground-muted: var(--wdNavbarFgMutedDark);
574
+ --wd-navbar-accent: var(--wdNavbarAccentDark);
575
+ --wd-navbar-accent-text: var(--wdNavbarAccentTextDark);
576
+ --wd-navbar-border: var(--wdNavbarBorderDark);
577
+ --wd-page-bg-color: var(--wdPageBgColorDark);
578
+ --wd-page-bg-image: var(--wdPageBgImageDark);
579
+ --wd-text-muted: #94a3b8;
580
+ --wd-border: #1e293b;
581
+ --wd-surface: #131a2b;
582
+ }
583
+ </style>
584
+ {/* styles.fonts: local/externally-hosted font(s) (fontFaceRule() in
585
+ lib/config.ts - a local `source` path resolves the same way
586
+ styles.favicon/styles.logo/styles.background.images already do, see
587
+ collectConfiguredAssetPaths() + styles-asset-integration.js, no
588
+ extra wiring needed here beyond listing it there), plus a
589
+ font-weight override for h1-h6/body whenever `weight` was actually
590
+ configured somewhere (fontWeightCss above) - both concatenated
591
+ into one block since both need the same raw-CSS-text treatment.
592
+ set:html (not a plain <style>{fontExtraCss}</style>) because this
593
+ needs to render already-built CSS text as-is, not have Astro's own
594
+ CSS parser/scoper attempt to process an `@font-face` block or a
595
+ bare `h1, h2, ...` selector list assembled as a runtime string -
596
+ empty string renders nothing when neither piece has anything to
597
+ contribute (every configured font is a Google Font, and no
598
+ `weight` was set anywhere in `styles.fonts`). */}
599
+ {fontExtraCss && <style set:html={fontExtraCss} />}
600
+ {/* writedocs.json's `scripts.head` - see scriptEntrySchema in lib/config.ts.
601
+ Each entry is exactly one of an external/local `src` or inline
602
+ `content` (enforced by the schema's own .refine()), rendered right
603
+ before </head> so it runs before body content paints - the usual
604
+ spot a third-party script's own install instructions ask for. */}
605
+ {config.scripts.head.map((s) =>
606
+ s.src ? <script is:inline src={s.src} /> : <script is:inline set:html={s.content} />
607
+ )}
608
+ {/* Zero-config custom CSS - see findRootAssets() in lib/config.ts and
609
+ rootCssContents above. Rendered last in <head>, so a project-owned
610
+ file - anywhere in the project, not just the root - wins over
611
+ everything else writedocs generates itself. Inlined as raw <style>
612
+ content rather than a <link> - these files aren't necessarily under
613
+ `public/`, so there's no guaranteed build-time copy step making them
614
+ independently fetchable as a URL; inlining sidesteps needing one.
615
+ One <style> tag per file (not concatenated) so each is independently
616
+ visible/attributable
617
+ in devtools. */}
618
+ {rootCssContents.map((content) => (
619
+ <style is:inline set:html={content} />
620
+ ))}
621
+ {/* Zero-config custom CSS living in public/ - see findRootAssets()'s
622
+ own comment in lib/config.ts for why these render as <link> tags
623
+ pointing at their already-public URL instead of joining
624
+ rootCssContents above as inlined content. */}
625
+ {publicCssHrefs.map((href) => (
626
+ <link rel="stylesheet" href={href} />
627
+ ))}
628
+ </head>
629
+ <body>
630
+ {mode === 'blank' ? (
631
+ // No site chrome at all - no topbar, no sidebar/toc columns, not
632
+ // even the .wd-shell wrapper's own max-width - the page's own
633
+ // MDX/components control 100% of the layout, including whether
634
+ // there's any nav/branding at all. SearchModal.astro's own overlay
635
+ // markup and its ⌘K listener still work even with no visible
636
+ // trigger button to click (it's rendered unconditionally below,
637
+ // outside this mode === 'blank' branch), so that's left in rather
638
+ // than also gated on mode.
639
+ <slot />
640
+ ) : (
641
+ <Fragment>
642
+ {/* Everything above the footer, in one wrapper - see .wd-page/body in
643
+ base.css: body is a flex column, this is its flex: 1 0 auto item,
644
+ and the footer (a plain sibling below, not inside this wrapper) is
645
+ what that pushes down to the viewport's bottom edge on a page
646
+ short enough not to need scrolling - the "sticky footer" pattern,
647
+ not position: sticky/fixed (either of which would cover page
648
+ content instead of just trailing it once actually reached). */}
649
+ <div class="wd-page">
650
+ {config.banner && (
651
+ <div class={`wd-banner wd-banner-${config.banner.type}`} id="wd-banner">
652
+ <div class="wd-banner-inner">
653
+ <span class="wd-banner-content">{config.banner.content}</span>
654
+ {config.banner.dismissible && (
655
+ <button type="button" class="wd-banner-dismiss" aria-label="Dismiss banner" data-banner-dismiss>
656
+ &times;
657
+ </button>
658
+ )}
659
+ </div>
660
+ </div>
661
+ )}
662
+ <TopBar
663
+ config={config}
664
+ selectors={selectors}
665
+ globalDropdowns={globalDropdowns}
666
+ showSidebarCol={showSidebarCol}
667
+ logoLight={logoLight}
668
+ logoDark={logoDark}
669
+ brandLabel={brandLabel}
670
+ />
671
+ <MobileMenu
672
+ config={config}
673
+ selectors={selectors}
674
+ globalDropdowns={globalDropdowns}
675
+ showSidebarCol={showSidebarCol}
676
+ logoLight={logoLight}
677
+ logoDark={logoDark}
678
+ brandLabel={brandLabel}
679
+ >
680
+ <slot name="sidebar" slot="sidebar" />
681
+ </MobileMenu>
682
+ <div class="wd-shell">
683
+ {showSidebarCol && <div class="wd-sidebar-col"><slot name="sidebar" /></div>}
684
+ <div class="wd-main-col"><slot /></div>
685
+ {showTocCol && <div class="wd-toc-col"><slot name="toc" /></div>}
686
+ </div>
687
+ </div>
688
+ <SiteFooter config={config} logoLight={footerLogoLight} logoDark={footerLogoDark} />
689
+ </Fragment>
690
+ )}
691
+
692
+ <SearchModal />
693
+ <ImageZoom />
694
+
695
+ <script>
696
+ import { initBanner } from '../scripts/banner';
697
+ import { initTopbarOffset } from '../scripts/topbar-offset';
698
+
699
+ initBanner(document);
700
+ document.addEventListener('astro:page-load', () => initBanner(document));
701
+
702
+ // No ordering dependency on initBanner above - see topbar-offset.ts
703
+ // for why it measures only the topbar itself (not the banner) and
704
+ // why that's live-measured rather than a fixed CSS value.
705
+ initTopbarOffset();
706
+ document.addEventListener('astro:page-load', () => initTopbarOffset());
707
+ </script>
708
+ {/* writedocs.json's `scripts.body` - see scriptEntrySchema in lib/config.ts.
709
+ Rendered after everything above (including every component's own
710
+ <script> tags, which Astro/Vite already bundles to run in document
711
+ order) so it runs after every other script on the page has already
712
+ loaded/hydrated - matching where a third-party script's own install
713
+ instructions usually ask for a "body end" placement. */}
714
+ {config.scripts.body.map((s) =>
715
+ s.src ? <script is:inline src={s.src} /> : <script is:inline set:html={s.content} />
716
+ )}
717
+ {/* Zero-config custom JS - see findRootAssets() in lib/config.ts and
718
+ rootJsContents above. Rendered last, after writedocs.json's own
719
+ `scripts.body`, same "most locally-owned override runs last"
720
+ reasoning as the root CSS above. `is:inline` here isn't optional -
721
+ without it, Astro tries to process/bundle the tag as a module and
722
+ the build fails outright ("Cannot find the built path for ...
723
+ type=script&index=N") the same way a dynamic-`src` script did
724
+ while this file's own writedocs.json `scripts` support was first being
725
+ built - see that feature's own commit for the full story. */}
726
+ {rootJsContents.map((content) => (
727
+ <script is:inline set:html={content} />
728
+ ))}
729
+ {/* Zero-config custom JS living in public/ - see this file's own CSS
730
+ equivalent above and findRootAssets()'s comment in lib/config.ts.
731
+ `<script src>`, not `is:inline set:html` - the file's already a
732
+ fetchable URL, no reason to inline its content. Filtered against
733
+ config.scripts.head's own `src` entries (publicJsHrefs) for the
734
+ same duplicate-tag reason as the CSS <link>s above - note this
735
+ only dedupes against `scripts.head`, not `scripts.body` (a public/
736
+ file explicitly listed there instead would still also render here
737
+ as a second tag; `scripts.head` vs `scripts.body` placement is a
738
+ deliberate choice this can't second-guess, but `head` is by far
739
+ the more common spot for a plain local script and this hasn't come
740
+ up in practice). */}
741
+ {publicJsHrefs.map((href) => (
742
+ <script is:inline src={href} />
743
+ ))}
744
+ {/* writedocs.json's `integrations.askAi` - see AskAiWidget.astro. Rendered
745
+ last of all - it's a floating chat widget, not something anything
746
+ else on the page depends on running before, so there's no reason
747
+ to load it any earlier than absolutely last. */}
748
+ <AskAiWidget config={config} />
749
+ </body>
750
+ </html>