@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.
- package/LICENSE +15 -0
- package/README.md +17 -0
- package/astro.config.mjs +419 -0
- package/bin/writedocs.js +73 -0
- package/package.json +79 -0
- package/src/assets/wd_watermark.png +0 -0
- package/src/assets/wd_watermark_dark.png +0 -0
- package/src/cli/build-auth.js +53 -0
- package/src/cli/build.js +40 -0
- package/src/cli/dev.js +12 -0
- package/src/cli/generate-api-pages.js +359 -0
- package/src/cli/init.js +81 -0
- package/src/cli/preflight.js +40 -0
- package/src/cli/run-astro.js +57 -0
- package/src/cli/run-pagefind.js +66 -0
- package/src/cli/write-redirects-file.js +80 -0
- package/src/components/Accordion.astro +164 -0
- package/src/components/AccordionGroup.astro +40 -0
- package/src/components/ApiLangSelect.astro +168 -0
- package/src/components/ApiPlayground.astro +281 -0
- package/src/components/ApiReferencePanel.astro +1754 -0
- package/src/components/ApiSchemaField.astro +54 -0
- package/src/components/AppIcon.astro +32 -0
- package/src/components/Badge.astro +128 -0
- package/src/components/Callout.astro +168 -0
- package/src/components/Card.astro +136 -0
- package/src/components/CardGroup.astro +20 -0
- package/src/components/CodeGroup.astro +184 -0
- package/src/components/CopyPageMenu.astro +246 -0
- package/src/components/Danger.astro +12 -0
- package/src/components/Expandable.astro +126 -0
- package/src/components/Frame.astro +102 -0
- package/src/components/Hint.astro +99 -0
- package/src/components/Icon.astro +70 -0
- package/src/components/Image.astro +147 -0
- package/src/components/Info.astro +12 -0
- package/src/components/Note.astro +12 -0
- package/src/components/Parameter.astro +119 -0
- package/src/components/RequestExample.astro +33 -0
- package/src/components/ResponseExample.astro +19 -0
- package/src/components/Searchbar.astro +117 -0
- package/src/components/Step.astro +10 -0
- package/src/components/Steps.astro +32 -0
- package/src/components/Tab.astro +9 -0
- package/src/components/Tabs.astro +52 -0
- package/src/components/Tip.astro +12 -0
- package/src/components/Video.astro +135 -0
- package/src/components/Warning.astro +12 -0
- package/src/components/index.ts +48 -0
- package/src/content.config.ts +223 -0
- package/src/layout/BaseLayout.astro +750 -0
- package/src/layout/components/AnalyticsScripts.astro +77 -0
- package/src/layout/components/AskAiWidget.astro +37 -0
- package/src/layout/components/Breadcrumbs.astro +97 -0
- package/src/layout/components/ImageZoom.astro +19 -0
- package/src/layout/components/MobileMenu.astro +200 -0
- package/src/layout/components/NavTree.astro +351 -0
- package/src/layout/components/SearchModal.astro +42 -0
- package/src/layout/components/Sidebar.astro +122 -0
- package/src/layout/components/SiteFooter.astro +85 -0
- package/src/layout/components/TableOfContents.astro +117 -0
- package/src/layout/components/TopBar.astro +311 -0
- package/src/layout/styles/banner.css +44 -0
- package/src/layout/styles/base.css +234 -0
- package/src/layout/styles/dropdown.css +133 -0
- package/src/layout/styles/footer.css +108 -0
- package/src/layout/styles/image-zoom.css +50 -0
- package/src/layout/styles/mobile-menu.css +258 -0
- package/src/layout/styles/search-modal.css +122 -0
- package/src/layout/styles/topbar.css +437 -0
- package/src/lib/config.ts +2131 -0
- package/src/lib/mdx-auto-hydrate.js +70 -0
- package/src/lib/mdx-inject-builtins.js +87 -0
- package/src/lib/mdx-substitute-variables.js +66 -0
- package/src/lib/mdx-title-anchor-ids.js +84 -0
- package/src/lib/mermaid-rehype.js +72 -0
- package/src/lib/openapi-render.ts +479 -0
- package/src/lib/shiki-code-block.js +102 -0
- package/src/lib/shiki-copy-button.js +45 -0
- package/src/lib/styles-asset-integration.js +210 -0
- package/src/lib/writedocs-temp-dir.js +93 -0
- package/src/pages/404.astro +62 -0
- package/src/pages/[...slug].astro +1270 -0
- package/src/pages/[...slug].md.ts +78 -0
- package/src/pages/llms-full.txt.ts +71 -0
- package/src/pages/llms.txt.ts +141 -0
- package/src/scripts/banner.ts +20 -0
- package/src/scripts/dropdowns.ts +61 -0
- package/src/scripts/image-zoom.ts +66 -0
- package/src/scripts/mobile-menu.ts +55 -0
- package/src/scripts/search.ts +155 -0
- package/src/scripts/sidebar-scroll.ts +65 -0
- package/src/scripts/theme-toggle.ts +35 -0
- package/src/scripts/topbar-offset.ts +141 -0
- package/src/styles/global.css +18 -0
|
@@ -0,0 +1,2131 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { z } from 'astro/zod';
|
|
5
|
+
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------
|
|
8
|
+
// Pages & groups - the leaf content any container ultimately bottoms out
|
|
9
|
+
// at. A group can nest other groups arbitrarily deep, and can optionally
|
|
10
|
+
// link its own label to a page (`page`) independent of expanding or
|
|
11
|
+
// collapsing its children - see NavTree.astro for how that renders.
|
|
12
|
+
// ---------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const navPageSchema = z.string();
|
|
15
|
+
|
|
16
|
+
// A group ordinarily lists its own children by hand (`pages`), but can
|
|
17
|
+
// instead auto-generate them from an OpenAPI spec via `openapi: { src,
|
|
18
|
+
// path }` - resolved by loadDocsConfig() (via expandOpenApiInNavigation()
|
|
19
|
+
// below, reading the manifest generate-api-pages.js writes for this exact
|
|
20
|
+
// group) into a real `{ group, pages }` node - one sub-group per tag -
|
|
21
|
+
// before anything else in this file ever sees it, so
|
|
22
|
+
// resolveSections()/flattenNav()/buildNavTree()/etc. only ever need to
|
|
23
|
+
// understand the ordinary pages-array shape. `.strict()` on both variants
|
|
24
|
+
// is load-bearing (see withChildren()'s own comment on this pattern
|
|
25
|
+
// below): without it, a group written with both `pages` and `openapi` at
|
|
26
|
+
// once would silently have one dropped instead of rejected.
|
|
27
|
+
const navGroupPagesSchema: z.ZodType<{ group: string; page?: string; pages: NavItem[] }> = z.lazy(() =>
|
|
28
|
+
z
|
|
29
|
+
.object({
|
|
30
|
+
group: z.string(),
|
|
31
|
+
page: z.string().optional(),
|
|
32
|
+
pages: z.array(navItemSchema),
|
|
33
|
+
})
|
|
34
|
+
.strict()
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const navGroupOpenApiSchema = z.object({
|
|
38
|
+
group: z.string(),
|
|
39
|
+
openapi: z
|
|
40
|
+
.object({
|
|
41
|
+
// Path to an OpenAPI 3.x spec, relative to the content directory
|
|
42
|
+
// (alongside writedocs.json) - same convention the old top-level
|
|
43
|
+
// `openapi` field used. See generate-api-pages.js (the pre-Astro
|
|
44
|
+
// build/dev step that actually parses this and writes the
|
|
45
|
+
// manifest/operation JSON this group's pages are expanded from).
|
|
46
|
+
src: z.string(),
|
|
47
|
+
// Base URL path every page generated from this spec is namespaced
|
|
48
|
+
// under, e.g. "/api" -> pages served at /api/<tag>/<operation>/.
|
|
49
|
+
// Also doubles as this spec's own unique key under
|
|
50
|
+
// writedocsTempDir()'s openapi/<path>/ directory (manifest.json + operations/*.json),
|
|
51
|
+
// so two openapi groups in the same writedocs.json must use different
|
|
52
|
+
// `path` values - generate-api-pages.js throws a clear error if
|
|
53
|
+
// they collide.
|
|
54
|
+
path: z.string(),
|
|
55
|
+
})
|
|
56
|
+
.strict(),
|
|
57
|
+
}).strict();
|
|
58
|
+
|
|
59
|
+
export type GroupNavItem =
|
|
60
|
+
| { group: string; page?: string; pages: NavItem[] }
|
|
61
|
+
| { group: string; openapi: { src: string; path: string } };
|
|
62
|
+
|
|
63
|
+
const navGroupSchema: z.ZodType<GroupNavItem> = z.union([navGroupPagesSchema, navGroupOpenApiSchema]);
|
|
64
|
+
|
|
65
|
+
// A bare external link sitting directly in a `pages` array, alongside
|
|
66
|
+
// page slugs and groups - e.g. a "Support" link to an external help
|
|
67
|
+
// desk shown right in the sidebar, rather than only reachable via
|
|
68
|
+
// navigation.global.dropdowns. Discriminated from navGroupSchema by
|
|
69
|
+
// its required `label`/`href` keys (vs `group`/`pages`/`openapi`), so a
|
|
70
|
+
// plain (non-strict at the union level) schema is enough for Zod to pick
|
|
71
|
+
// the right branch; `.strict()` here still catches a typo'd extra key.
|
|
72
|
+
const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict();
|
|
73
|
+
|
|
74
|
+
const navItemSchema: z.ZodType<NavItem> = z.lazy(() =>
|
|
75
|
+
z.union([navPageSchema, navGroupSchema, navLinkSchema])
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export type NavItem = string | GroupNavItem | { label: string; href: string };
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------
|
|
81
|
+
// Containers: tabs, versions, languages, dropdowns, products.
|
|
82
|
+
//
|
|
83
|
+
// This schema is modeled directly on Mintlify's writedocs.json
|
|
84
|
+
// (https://mintlify.com/writedocs.json / mintlify.com/docs/organize/navigation):
|
|
85
|
+
// all five container kinds are structurally identical except for their
|
|
86
|
+
// own identifying field. Each owns *exactly one* of {pages, tabs,
|
|
87
|
+
// versions, languages, dropdowns, products} as its content, or is a bare
|
|
88
|
+
// external `href` link with no content of its own. That symmetry is what
|
|
89
|
+
// lets any of them nest inside any other - a tab can contain versions, a
|
|
90
|
+
// version can contain languages, a language can contain tabs, and so on,
|
|
91
|
+
// bottoming out at a `pages` array. `withChildren()` is the single place
|
|
92
|
+
// that shape is defined, instead of hand-writing the union five times.
|
|
93
|
+
// ---------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export type NavChildren =
|
|
96
|
+
| { pages: NavItem[] }
|
|
97
|
+
| { tabs: TabItem[] }
|
|
98
|
+
| { versions: VersionItem[] }
|
|
99
|
+
| { languages: LanguageItem[] }
|
|
100
|
+
| { dropdowns: DropdownItem[] }
|
|
101
|
+
| { products: ProductItem[] }
|
|
102
|
+
| { href: string };
|
|
103
|
+
|
|
104
|
+
export type TabItem = { tab: string; icon?: string } & NavChildren;
|
|
105
|
+
export type VersionItem = { version: string; label?: string; tag?: string; default?: boolean } & NavChildren;
|
|
106
|
+
export type LanguageItem = { language: string; label?: string } & NavChildren;
|
|
107
|
+
export type DropdownItem = { dropdown: string; icon?: string } & NavChildren;
|
|
108
|
+
export type ProductItem = { product: string; icon?: string; description?: string } & NavChildren;
|
|
109
|
+
|
|
110
|
+
// `.strict()` on every variant is load-bearing, not decoration: Zod
|
|
111
|
+
// objects silently strip unrecognized keys by default, so without it a
|
|
112
|
+
// node written with two children fields at once (e.g. both `pages` and
|
|
113
|
+
// `versions`) would just have the second one dropped instead of
|
|
114
|
+
// rejected - defeating the entire "exactly one child kind per level"
|
|
115
|
+
// rule this schema exists to enforce (mirroring Mintlify's own "a tab
|
|
116
|
+
// cannot contain both anchors and groups at the same level").
|
|
117
|
+
function withChildren<Base extends z.ZodRawShape>(base: Base) {
|
|
118
|
+
return z.union([
|
|
119
|
+
z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
|
|
120
|
+
z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
|
|
121
|
+
z.object({ ...base, versions: z.array(versionSchema).min(1) }).strict(),
|
|
122
|
+
z.object({ ...base, languages: z.array(languageSchema).min(1) }).strict(),
|
|
123
|
+
z.object({ ...base, dropdowns: z.array(dropdownSchema).min(1) }).strict(),
|
|
124
|
+
z.object({ ...base, products: z.array(productSchema).min(1) }).strict(),
|
|
125
|
+
z.object({ ...base, href: z.string() }).strict(),
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Each of these five is defined via z.lazy() because withChildren()
|
|
130
|
+
// references all five by name (mutual recursion) - safe because none of
|
|
131
|
+
// them are actually *called* until a real writedocs.json is parsed, by which
|
|
132
|
+
// point every const below has been assigned. The same pattern already
|
|
133
|
+
// used for navItemSchema/navGroupSchema above.
|
|
134
|
+
const tabSchema: z.ZodType<TabItem> = z.lazy(() =>
|
|
135
|
+
withChildren({ tab: z.string(), icon: z.string().optional() })
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const versionSchema: z.ZodType<VersionItem> = z.lazy(() =>
|
|
139
|
+
withChildren({
|
|
140
|
+
version: z.string(),
|
|
141
|
+
label: z.string().optional(),
|
|
142
|
+
tag: z.string().optional(),
|
|
143
|
+
default: z.boolean().optional(),
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const languageSchema: z.ZodType<LanguageItem> = z.lazy(() =>
|
|
148
|
+
withChildren({ language: z.string(), label: z.string().optional() })
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const dropdownSchema: z.ZodType<DropdownItem> = z.lazy(() =>
|
|
152
|
+
withChildren({ dropdown: z.string(), icon: z.string().optional() })
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const productSchema: z.ZodType<ProductItem> = z.lazy(() =>
|
|
156
|
+
withChildren({ product: z.string(), icon: z.string().optional(), description: z.string().optional() })
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------
|
|
160
|
+
// Root navigation: a flat array (shorthand for one implicit section - the
|
|
161
|
+
// common case) or an object choosing exactly one primary organizational
|
|
162
|
+
// pattern, per Mintlify's convention ("choose one primary organizational
|
|
163
|
+
// pattern at the root level of your navigation").
|
|
164
|
+
//
|
|
165
|
+
// `global.dropdowns` is the one exception to "exactly one pattern": it's
|
|
166
|
+
// not a primary pattern, it's a persistent set of topbar dropdown
|
|
167
|
+
// triggers that render on every page regardless of which tab/version/
|
|
168
|
+
// language/product is active - equivalent to Mintlify's
|
|
169
|
+
// `navigation.global.anchors`. This replaces the earlier `{tabs,
|
|
170
|
+
// dropdowns}` shape (both present at once), which doesn't generalize
|
|
171
|
+
// once versions/languages/products are also root-level choices.
|
|
172
|
+
// ---------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export type NavigationConfig =
|
|
175
|
+
| NavItem[]
|
|
176
|
+
| { global?: { dropdowns: DropdownItem[] }; tabs: TabItem[] }
|
|
177
|
+
| { global?: { dropdowns: DropdownItem[] }; versions: VersionItem[] }
|
|
178
|
+
| { global?: { dropdowns: DropdownItem[] }; languages: LanguageItem[] }
|
|
179
|
+
| { global?: { dropdowns: DropdownItem[] }; dropdowns: DropdownItem[] }
|
|
180
|
+
| { global?: { dropdowns: DropdownItem[] }; products: ProductItem[] };
|
|
181
|
+
|
|
182
|
+
const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
|
|
183
|
+
|
|
184
|
+
const navigationSchema = z.union([
|
|
185
|
+
z.array(navItemSchema),
|
|
186
|
+
z.object({ global: globalSchema.optional(), tabs: z.array(tabSchema).min(1) }).strict(),
|
|
187
|
+
z.object({ global: globalSchema.optional(), versions: z.array(versionSchema).min(1) }).strict(),
|
|
188
|
+
z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
|
|
189
|
+
z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
|
|
190
|
+
z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict(),
|
|
191
|
+
]) as z.ZodType<NavigationConfig>;
|
|
192
|
+
|
|
193
|
+
const DEFAULT_PRIMARY = '#6366f1';
|
|
194
|
+
|
|
195
|
+
// A logo is either one path used for both color modes, or a
|
|
196
|
+
// { light, dark, label } object - each image shown only while that mode
|
|
197
|
+
// is active (see BaseLayout.astro's .wd-logo-light/.wd-logo-dark CSS).
|
|
198
|
+
// The topbar shows *only* the image by default - no site name text next
|
|
199
|
+
// to it, since a real logo asset usually already is a wordmark. `label`
|
|
200
|
+
// is the opt-in way to add text back next to the image (e.g. a product
|
|
201
|
+
// name alongside a symbol-only mark); it only exists on the object form,
|
|
202
|
+
// since a single shared image path is just as easy to bake the label
|
|
203
|
+
// into directly.
|
|
204
|
+
//
|
|
205
|
+
// `light`/`dark` are themselves optional (unlike the string form, which
|
|
206
|
+
// is inherently "both"): a site with no logo *images* at all can still
|
|
207
|
+
// set `styles.logo: { label: "..." }` to override the topbar's fallback
|
|
208
|
+
// text without providing any image - see BaseLayout.astro's
|
|
209
|
+
// hasLogoImage/logoLabel/brandLabel derivation, where an object with
|
|
210
|
+
// only `label` set already resolves correctly (brandLabel = logoLabel,
|
|
211
|
+
// same as if an image were also present) without needing any change
|
|
212
|
+
// there; only this schema needed loosening; light/dark were previously
|
|
213
|
+
// both required whenever the object form was used at all.
|
|
214
|
+
//
|
|
215
|
+
// Standalone const (rather than inlined into stylesSchema.logo below) so
|
|
216
|
+
// footer.logo further down can share the exact same union instead of an
|
|
217
|
+
// independently-written copy - see footerSchema's own comment on why a
|
|
218
|
+
// footer logo needs this at all.
|
|
219
|
+
const logoSchema = z.union([
|
|
220
|
+
z.string(),
|
|
221
|
+
z.object({ light: z.string().optional(), dark: z.string().optional(), label: z.string().optional() }).strict(),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
// Same {light, dark}-with-fallback shape applies to `colors.dark`: any
|
|
225
|
+
// of primary/text left unset there falls back to the light-mode value
|
|
226
|
+
// already given above, so a site only has to specify what actually
|
|
227
|
+
// differs in dark mode. There used to be a `background`/`dark.background`
|
|
228
|
+
// pair here too, doing effectively the same job as `background.colors`
|
|
229
|
+
// further down (see that field's own comment) - two writedocs.json fields
|
|
230
|
+
// both named "background" that visually looked interchangeable but
|
|
231
|
+
// weren't quite (one drove --wd-background site-wide, the other only
|
|
232
|
+
// the <body> canvas), which was confusing to author against and easy to
|
|
233
|
+
// half-configure by setting only one. Removed in favor of `background`
|
|
234
|
+
// alone being the single place to set any background color - see that
|
|
235
|
+
// field's comment for what it now covers.
|
|
236
|
+
// One side of `styles.navbar` (light or dark) - either a bare background
|
|
237
|
+
// color (the original shape) or `{ background, accent? }` once a site
|
|
238
|
+
// wants its own accent color (the active-tab fill / hover underline)
|
|
239
|
+
// inside the navbar specifically, independent of the sitewide
|
|
240
|
+
// `styles.colors.primary`. Deliberately does NOT carry a text/icon color
|
|
241
|
+
// field at all - see `navbar`'s own comment below (stylesSchema) for why
|
|
242
|
+
// that's resolved automatically instead of being configurable. Kept as a
|
|
243
|
+
// standalone const rather than inlined so `light`/`dark` share the exact
|
|
244
|
+
// same union rather than two independently-written (and possibly
|
|
245
|
+
// drifting) copies of it.
|
|
246
|
+
const navbarColorValueSchema = z.union([
|
|
247
|
+
z.string(),
|
|
248
|
+
z
|
|
249
|
+
.object({
|
|
250
|
+
background: z.string(),
|
|
251
|
+
accent: z.string().optional(),
|
|
252
|
+
})
|
|
253
|
+
.strict(),
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
// One font declaration - almost exactly Mintlify's own `fonts` shape (see
|
|
257
|
+
// docs/dev/docs/site-config.mdx's own "Fonts" section for the research this
|
|
258
|
+
// was based on): a bare Google Fonts family name (`family` alone - Google
|
|
259
|
+
// Fonts loads it automatically, see googleFontsHref() below), or a local/
|
|
260
|
+
// externally-hosted font file (`source` + `format` together; `source` is
|
|
261
|
+
// either a project-relative path like the other five asset-fallback fields
|
|
262
|
+
// this schema already has - see collectConfiguredAssetPaths() - or a full
|
|
263
|
+
// https:// URL to an externally-hosted font). `weight` does two things at
|
|
264
|
+
// once, both driven by BaseLayout.astro: it narrows which file actually
|
|
265
|
+
// loads (folded into the Google Fonts URL's `:wght@` axis, or the
|
|
266
|
+
// generated `@font-face` rule's own `font-weight` descriptor for a
|
|
267
|
+
// `source` font - see googleFontsHref()/fontFaceRule() below), *and* it's
|
|
268
|
+
// applied as a real CSS `font-weight` on h1-h6 (for `heading`) or `body`
|
|
269
|
+
// (for `body`) - see BaseLayout's own `wdFontWeightHeading`/
|
|
270
|
+
// `wdFontWeightBody`. Both halves matter: loading only the 400-weight file
|
|
271
|
+
// while leaving h1-h6 at the browser's UA-default `font-weight: bold`
|
|
272
|
+
// faux-bolds that file back to looking bold anyway, with no visible
|
|
273
|
+
// change from configuring a lighter weight at all - the bug this second
|
|
274
|
+
// half fixes (caught from real user-reported behavior, not anticipated
|
|
275
|
+
// up front). Left unset anywhere in `styles.fonts` renders no font-weight
|
|
276
|
+
// rule at all, keeping today's plain browser-default bold headings/normal
|
|
277
|
+
// body text unchanged for a site that hasn't touched this field.
|
|
278
|
+
const fontVariantSchema = z
|
|
279
|
+
.object({
|
|
280
|
+
family: z.string(),
|
|
281
|
+
weight: z.number().optional(),
|
|
282
|
+
source: z.string().optional(),
|
|
283
|
+
format: z.enum(['woff', 'woff2']).optional(),
|
|
284
|
+
})
|
|
285
|
+
.strict();
|
|
286
|
+
|
|
287
|
+
export type FontVariant = z.infer<typeof fontVariantSchema>;
|
|
288
|
+
|
|
289
|
+
// The top-level shape adds `heading`/`body`: each independently optional,
|
|
290
|
+
// each falling back to the top-level family/weight/source/format when
|
|
291
|
+
// unset (see resolveFonts() below) - so a site can set one font for
|
|
292
|
+
// everything (`fonts.family` alone), or split headings and body text
|
|
293
|
+
// without needing to repeat itself for whichever side isn't changing.
|
|
294
|
+
const fontsSchema = z
|
|
295
|
+
.object({
|
|
296
|
+
family: z.string(),
|
|
297
|
+
weight: z.number().optional(),
|
|
298
|
+
source: z.string().optional(),
|
|
299
|
+
format: z.enum(['woff', 'woff2']).optional(),
|
|
300
|
+
heading: fontVariantSchema.optional(),
|
|
301
|
+
body: fontVariantSchema.optional(),
|
|
302
|
+
})
|
|
303
|
+
.strict();
|
|
304
|
+
|
|
305
|
+
export type FontsConfig = z.infer<typeof fontsSchema>;
|
|
306
|
+
|
|
307
|
+
const stylesSchema = z
|
|
308
|
+
.object({
|
|
309
|
+
colors: z
|
|
310
|
+
.object({
|
|
311
|
+
primary: z.string().default(DEFAULT_PRIMARY),
|
|
312
|
+
text: z.string().optional(),
|
|
313
|
+
dark: z
|
|
314
|
+
.object({
|
|
315
|
+
primary: z.string().optional(),
|
|
316
|
+
text: z.string().optional(),
|
|
317
|
+
})
|
|
318
|
+
.optional(),
|
|
319
|
+
})
|
|
320
|
+
.default({ primary: DEFAULT_PRIMARY }),
|
|
321
|
+
logo: logoSchema.optional(),
|
|
322
|
+
favicon: z.string().optional(),
|
|
323
|
+
// No default value here (unlike `colors.primary`'s DEFAULT_PRIMARY) -
|
|
324
|
+
// resolveFonts() below is where "Inter" actually gets applied as the
|
|
325
|
+
// site-wide default whenever this field is left unset entirely, so
|
|
326
|
+
// that fallback lives in one place alongside the rest of the
|
|
327
|
+
// resolution logic rather than being duplicated as a schema default
|
|
328
|
+
// *and* a resolver fallback.
|
|
329
|
+
fonts: fontsSchema.optional(),
|
|
330
|
+
// The Shiki theme *name* (not a full theme object/JSON - see
|
|
331
|
+
// https://shiki.style/themes for the built-in list) each fenced
|
|
332
|
+
// (```) MDX code block, and the API playground's request/response
|
|
333
|
+
// snippets, use in light/dark mode - read out of writedocs.json by both
|
|
334
|
+
// astro.config.mjs (Shiki's dual-theme markdown config) and
|
|
335
|
+
// ApiReferencePanel.astro (its own separate <Code/> usages, which
|
|
336
|
+
// don't inherit markdown.shikiConfig) via resolveCodeblockTheme()
|
|
337
|
+
// below, the single source of truth for the 'github-light'/
|
|
338
|
+
// 'github-dark' fallback. Left per-key optional (not defaulted in
|
|
339
|
+
// the schema itself) so a site can override just one side (e.g.
|
|
340
|
+
// only `dark`) and still get the ordinary default for the other.
|
|
341
|
+
codeblocks: z
|
|
342
|
+
.object({
|
|
343
|
+
light: z.string().optional(),
|
|
344
|
+
dark: z.string().optional(),
|
|
345
|
+
// Maps a fenced (```) block's own language tag to whichever real
|
|
346
|
+
// Shiki grammar actually highlights it, before that tag ever
|
|
347
|
+
// reaches Shiki - see resolveCodeblockLangAlias() below for the
|
|
348
|
+
// one built-in entry (mdx -> jsx) this ships with by default and
|
|
349
|
+
// why. A site can add its own entries for any other language tag
|
|
350
|
+
// that either doesn't tokenize well under its own grammar, or
|
|
351
|
+
// that a site wants to write under a shorter/friendlier fence tag
|
|
352
|
+
// than Shiki's own bundled id (e.g. `groovy: 'java'` for a close-
|
|
353
|
+
// enough approximation Shiki doesn't ship a dedicated grammar for
|
|
354
|
+
// at all) - merged with, not replacing, the built-in default, so
|
|
355
|
+
// adding one of a site's own doesn't require re-declaring mdx.
|
|
356
|
+
langAlias: z.record(z.string(), z.string()).optional(),
|
|
357
|
+
})
|
|
358
|
+
.strict()
|
|
359
|
+
.optional(),
|
|
360
|
+
// The topbar's own background, independent of `background` below (the
|
|
361
|
+
// page's) - a site may want its navbar to stand out (a dark or
|
|
362
|
+
// brand-colored bar over a light page) rather than blend into the
|
|
363
|
+
// page, which is the default when this is left unset. Same {light,
|
|
364
|
+
// dark} shape (and same per-key-optional, not defaulted here) as
|
|
365
|
+
// `codeblocks` right above, rather than nested under `colors` - it's a
|
|
366
|
+
// topbar-specific override, not a third general-purpose page color, so
|
|
367
|
+
// it reads more like "one more themed surface" alongside codeblocks
|
|
368
|
+
// than a sibling of primary/text. BaseLayout.astro's own
|
|
369
|
+
// lightNavbarBg/darkNavbarBg resolution is what falls each side back
|
|
370
|
+
// to the page background when unset.
|
|
371
|
+
//
|
|
372
|
+
// Each side (`light`/`dark`) is either a bare string - just the
|
|
373
|
+
// background color, exactly the original shape, kept for backwards
|
|
374
|
+
// compatibility - or `{ background, accent? }`, for when a site also
|
|
375
|
+
// wants the navbar's own active-tab-fill/hover-underline color to
|
|
376
|
+
// differ from the sitewide `styles.colors.primary` (`accent`'s only
|
|
377
|
+
// job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
|
|
378
|
+
// text/icon color field here at all: BaseLayout.astro always resolves
|
|
379
|
+
// the navbar's plain text/icon color itself, picking black or white by
|
|
380
|
+
// contrast against whatever `background` resolves to
|
|
381
|
+
// (contrastTextColor() below) the moment this field is configured at
|
|
382
|
+
// all (string or object) - never a value read from writedocs.json. A
|
|
383
|
+
// manually-set text color is a legibility footgun a site author can
|
|
384
|
+
// get wrong (or drift out of sync after later changing `background`
|
|
385
|
+
// without remembering to update it too) in a way "compute it
|
|
386
|
+
// correctly, every time" simply can't - there's no legitimate reason
|
|
387
|
+
// to want *illegible* navbar text, unlike `accent`, which is a real
|
|
388
|
+
// aesthetic choice. Real bug this fixes: a site setting `navbar.light`
|
|
389
|
+
// to the same value as `colors.primary` (a plausible thing to reach
|
|
390
|
+
// for - "brand-colored navbar") made every topbar label/icon/link
|
|
391
|
+
// render in the default near-black text color against that same
|
|
392
|
+
// saturated background, all but unreadable - and an earlier version of
|
|
393
|
+
// this feature that let a writedocs.json-supplied color double as the text
|
|
394
|
+
// color reintroduced the identical bug one level down, just requiring
|
|
395
|
+
// one more (still guessable-wrong) field to trigger it.
|
|
396
|
+
navbar: z
|
|
397
|
+
.object({
|
|
398
|
+
light: navbarColorValueSchema.optional(),
|
|
399
|
+
dark: navbarColorValueSchema.optional(),
|
|
400
|
+
})
|
|
401
|
+
.strict()
|
|
402
|
+
.optional(),
|
|
403
|
+
// The single place to configure any background color: `colors` is a
|
|
404
|
+
// solid color (falls back to '#ffffff'/'#0b1120' when unset - see
|
|
405
|
+
// BaseLayout.astro's lightBackground/darkBackground resolution), and
|
|
406
|
+
// `images` layers an image on top of it (or is the whole background
|
|
407
|
+
// by itself, if `colors` is left at its default). This one pair now
|
|
408
|
+
// drives everything background-related site-wide - --wd-background
|
|
409
|
+
// (dropdowns/modals/kbd chips/footer/topbar-fallback/etc., see every
|
|
410
|
+
// `var(--wd-background)` call site) *and* the <body> canvas behind the
|
|
411
|
+
// main/toc columns - rather than the two being independently
|
|
412
|
+
// configurable fields that happened to look alike but didn't affect
|
|
413
|
+
// the same things (see this schema's own comment on `colors.dark`
|
|
414
|
+
// above for why that split was removed). Both are per-mode optional,
|
|
415
|
+
// same "only specify what differs" pattern as codeblocks/navbar above.
|
|
416
|
+
// The topbar and footer still get their own explicit opaque
|
|
417
|
+
// background (topbar.css/footer.css) specifically so an `images`
|
|
418
|
+
// background doesn't show through them; see those files' own
|
|
419
|
+
// comments. The sidebar column has no background of its own (base.css)
|
|
420
|
+
// and lets it show through, same as the main/toc columns.
|
|
421
|
+
background: z
|
|
422
|
+
.object({
|
|
423
|
+
colors: z
|
|
424
|
+
.object({
|
|
425
|
+
light: z.string().optional(),
|
|
426
|
+
dark: z.string().optional(),
|
|
427
|
+
})
|
|
428
|
+
.strict()
|
|
429
|
+
.optional(),
|
|
430
|
+
images: z
|
|
431
|
+
.object({
|
|
432
|
+
light: z.string().optional(),
|
|
433
|
+
dark: z.string().optional(),
|
|
434
|
+
})
|
|
435
|
+
.strict()
|
|
436
|
+
.optional(),
|
|
437
|
+
})
|
|
438
|
+
.strict()
|
|
439
|
+
.optional(),
|
|
440
|
+
})
|
|
441
|
+
.default({ colors: { primary: DEFAULT_PRIMARY } });
|
|
442
|
+
|
|
443
|
+
// `href` is always required - a link with nowhere to go isn't a link.
|
|
444
|
+
// `label`/`icon` are each individually optional, but not both at once
|
|
445
|
+
// (enforced by the .refine() below, same shape as scriptEntrySchema's own
|
|
446
|
+
// exactly-one-of check above) - a link needs *something* visible to render,
|
|
447
|
+
// whichever of the two, and can have both (icon rendered to the left of the
|
|
448
|
+
// label - see TopBar.astro/SiteFooter.astro, the two renderers of this
|
|
449
|
+
// shape). An icon-only link (no label) is the common case for something
|
|
450
|
+
// like a bare GitHub mark in the topbar; label-only (no icon) is every
|
|
451
|
+
// plain text link this already supported before `icon` existed - both
|
|
452
|
+
// still need to keep working unchanged, hence neither field being outright
|
|
453
|
+
// required on its own.
|
|
454
|
+
const topbarLinkSchema = z
|
|
455
|
+
.object({
|
|
456
|
+
label: z.string().optional(),
|
|
457
|
+
href: z.string(),
|
|
458
|
+
// Same string shape as every other writedocs.json `icon` field (a plain
|
|
459
|
+
// Lucide name, or the explicit "collection:icon-name" form for
|
|
460
|
+
// anything else - see resolveIcon()/AppIcon.astro) - not documented
|
|
461
|
+
// again here since that convention is already established elsewhere
|
|
462
|
+
// in this file (tabs/switchers/socials all take the identical shape).
|
|
463
|
+
icon: z.string().optional(),
|
|
464
|
+
})
|
|
465
|
+
.strict()
|
|
466
|
+
.refine((link) => Boolean(link.label) || Boolean(link.icon), {
|
|
467
|
+
message: 'Each topbar.links/footer column link needs a "label", an "icon", or both - a link with neither has nothing to render.',
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// One column of the footer (BaseLayout.astro's <footer class="wd-footer">) -
|
|
471
|
+
// an optional heading (e.g. "Resources", "Community") plus a list of links,
|
|
472
|
+
// reusing the exact same {label, icon, href} shape as topbar.links above (a
|
|
473
|
+
// footer link is the same thing rendered in a different place, no reason
|
|
474
|
+
// for a second, identical-but-differently-named schema). `title` is
|
|
475
|
+
// optional so a single-column footer with no heading (just a bare list of
|
|
476
|
+
// links) is valid too.
|
|
477
|
+
const footerColumnSchema = z
|
|
478
|
+
.object({
|
|
479
|
+
title: z.string().optional(),
|
|
480
|
+
links: z.array(topbarLinkSchema).default([]),
|
|
481
|
+
})
|
|
482
|
+
.strict();
|
|
483
|
+
|
|
484
|
+
// Off by default (empty `columns`, same convention as `topbar` above - a
|
|
485
|
+
// footer with nothing configured just doesn't render at all, see
|
|
486
|
+
// BaseLayout.astro's `hasFooterContent` check) rather than `.optional()`
|
|
487
|
+
// like contextMenu: unlike that field, an empty/default footer has no
|
|
488
|
+
// build-output or routing implications to gate, it's purely visual, so
|
|
489
|
+
// there's no reason to distinguish "unset" from "set to nothing" the way
|
|
490
|
+
// contextMenu needs to.
|
|
491
|
+
// Same {light, dark, label} union as `styles.logo` (via the shared
|
|
492
|
+
// logoSchema above) - unset by default, in which case the footer just
|
|
493
|
+
// shows the sitewide `styles.logo` (BaseLayout.astro's existing
|
|
494
|
+
// logoLight/logoDark/logoLabel, already threaded into SiteFooter as-is).
|
|
495
|
+
// Set here, it replaces that entirely rather than filling in only the
|
|
496
|
+
// missing side of it - a footer.logo with only `dark` set shows *just*
|
|
497
|
+
// the dark-mode image, not styles.logo's light image plus this dark one -
|
|
498
|
+
// the same all-or-nothing-relative-to-styles.logo resolution BaseLayout.astro
|
|
499
|
+
// already applies.
|
|
500
|
+
const footerSchema = z
|
|
501
|
+
.object({
|
|
502
|
+
columns: z.array(footerColumnSchema).default([]),
|
|
503
|
+
logo: logoSchema.optional(),
|
|
504
|
+
})
|
|
505
|
+
.strict()
|
|
506
|
+
.default({ columns: [] });
|
|
507
|
+
|
|
508
|
+
export type FooterColumn = z.infer<typeof footerColumnSchema>;
|
|
509
|
+
export type FooterConfig = z.infer<typeof footerSchema>;
|
|
510
|
+
|
|
511
|
+
// Shared by both writedocs.json's top-level `seo` (site-wide defaults) and a
|
|
512
|
+
// page's own frontmatter `seo` (per-page overrides) - see
|
|
513
|
+
// content.config.ts's docsSchema, which imports this exact schema rather
|
|
514
|
+
// than redeclaring the same shape a second time. mergeSeo() below is what
|
|
515
|
+
// actually combines the two, field by field, at render time.
|
|
516
|
+
export const seoFieldsSchema = z
|
|
517
|
+
.object({
|
|
518
|
+
// Open Graph / Twitter card image - a relative path (resolved against
|
|
519
|
+
// `domain` below into an absolute URL, since most social crawlers
|
|
520
|
+
// require one) or an already-absolute https:// URL.
|
|
521
|
+
ogImage: z.string().optional(),
|
|
522
|
+
// og:type - "website" for most pages, "article" for blog-post-shaped
|
|
523
|
+
// content, etc. Defaults to "website" if never set anywhere.
|
|
524
|
+
ogType: z.string().optional(),
|
|
525
|
+
twitterCard: z.enum(['summary', 'summary_large_image']).optional(),
|
|
526
|
+
keywords: z.array(z.string()).optional(),
|
|
527
|
+
// Renders <meta name="robots" content="noindex, nofollow" /> and
|
|
528
|
+
// (see astro.config.mjs's sitemap `filter`) excludes the page from
|
|
529
|
+
// sitemap.xml entirely - both driven off this one flag, since listing
|
|
530
|
+
// a page in the sitemap while also telling crawlers not to index it
|
|
531
|
+
// would be self-contradictory.
|
|
532
|
+
noindex: z.boolean().optional(),
|
|
533
|
+
})
|
|
534
|
+
.strict();
|
|
535
|
+
|
|
536
|
+
export type SeoFields = z.infer<typeof seoFieldsSchema>;
|
|
537
|
+
|
|
538
|
+
/** Field-by-field merge of a page's own `seo` frontmatter over writedocs.json's
|
|
539
|
+
* site-wide `seo` defaults - a page only overrides the specific fields it
|
|
540
|
+
* sets, falling back to the site default for everything else, rather than
|
|
541
|
+
* a page's (possibly partial) `seo` object replacing the site's wholesale. */
|
|
542
|
+
export function mergeSeo(site: SeoFields, page?: SeoFields): SeoFields {
|
|
543
|
+
if (!page) return site;
|
|
544
|
+
return {
|
|
545
|
+
ogImage: page.ogImage ?? site.ogImage,
|
|
546
|
+
ogType: page.ogType ?? site.ogType,
|
|
547
|
+
twitterCard: page.twitterCard ?? site.twitterCard,
|
|
548
|
+
keywords: page.keywords ?? site.keywords,
|
|
549
|
+
noindex: page.noindex ?? site.noindex,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Controls for the OpenAPI API playground's "Try it" modal - separate
|
|
554
|
+
// from a group's own `openapi: { src, path }` field (which spec to use,
|
|
555
|
+
// and where) since this is about how a *request* it sends behaves, not
|
|
556
|
+
// about the spec itself.
|
|
557
|
+
const apiSchema = z
|
|
558
|
+
.object({
|
|
559
|
+
// Whether the Try-it modal's Send button routes its request through
|
|
560
|
+
// writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
|
|
561
|
+
// than calling the API directly from the browser. Almost no
|
|
562
|
+
// real-world API sends back Access-Control-Allow-Origin headers
|
|
563
|
+
// permitting an arbitrary docs site's origin, so a direct browser
|
|
564
|
+
// fetch() from the Try-it modal fails for most real APIs without
|
|
565
|
+
// this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
|
|
566
|
+
// actual request-forwarding logic. Defaults to enabled; a site can
|
|
567
|
+
// set this to `false` to always call the API directly instead (the
|
|
568
|
+
// API is already CORS-permissive, same-origin in some deployments,
|
|
569
|
+
// or the site owner doesn't want requests routed through a third
|
|
570
|
+
// party at all).
|
|
571
|
+
proxy: z.boolean().default(true),
|
|
572
|
+
})
|
|
573
|
+
.strict()
|
|
574
|
+
.default({ proxy: true });
|
|
575
|
+
|
|
576
|
+
// The "Copy page" dropdown shown next to a page's title - copy the raw
|
|
577
|
+
// Markdown to the clipboard, open the raw Markdown in a new tab, or
|
|
578
|
+
// deep-link into an AI assistant with a prompt pointing at it. Opt-in:
|
|
579
|
+
// undefined (the field simply absent from writedocs.json) means the feature
|
|
580
|
+
// is off entirely - no menu rendered, no .md routes generated at build
|
|
581
|
+
// time either (see [...slug].md.ts) - rather than defaulting to on,
|
|
582
|
+
// since it changes both the UI and the build output. Once a site does
|
|
583
|
+
// write a `contextMenu` key (even `{}`), copying/viewing-as-Markdown are
|
|
584
|
+
// always included (they need nothing but the page's own content); only
|
|
585
|
+
// `openIn` (the AI-assistant deep-links, which need an absolute URL to
|
|
586
|
+
// point the assistant at) is independently configurable.
|
|
587
|
+
const contextMenuSchema = z
|
|
588
|
+
.object({
|
|
589
|
+
openIn: z.array(z.enum(['chatgpt', 'claude', 'perplexity'])).default(['chatgpt', 'claude', 'perplexity']),
|
|
590
|
+
})
|
|
591
|
+
.strict();
|
|
592
|
+
|
|
593
|
+
export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
|
|
594
|
+
|
|
595
|
+
// One entry in writedocs.json's `redirects` array - wired almost directly into
|
|
596
|
+
// Astro's own `redirects` config option (astro.config.mjs), which is what
|
|
597
|
+
// actually generates the redirect pages. `permanent` is deliberately not
|
|
598
|
+
// part of this schema: with no server/adapter (this project always builds
|
|
599
|
+
// `output: 'static'` with none installed), Astro's own docs say a static
|
|
600
|
+
// redirect "does not support status codes" at all - it's always a client-
|
|
601
|
+
// side `<meta http-equiv="refresh">` page, full stop. Accepting a
|
|
602
|
+
// `permanent` field here that silently did nothing would be worse than
|
|
603
|
+
// not offering it.
|
|
604
|
+
const redirectSchema = z
|
|
605
|
+
.object({
|
|
606
|
+
source: z.string(),
|
|
607
|
+
destination: z.string(),
|
|
608
|
+
})
|
|
609
|
+
.strict();
|
|
610
|
+
|
|
611
|
+
export type RedirectConfig = z.infer<typeof redirectSchema>;
|
|
612
|
+
|
|
613
|
+
// A site-wide banner rendered above the topbar on every page (except
|
|
614
|
+
// `mode: blank`, which drops all site chrome including this - see
|
|
615
|
+
// BaseLayout.astro). Optional/undefined by default, same convention as
|
|
616
|
+
// `contextMenu` above - a site with no banner configured gets no banner
|
|
617
|
+
// markup at all, not an empty one.
|
|
618
|
+
const bannerSchema = z
|
|
619
|
+
.object({
|
|
620
|
+
content: z.string(),
|
|
621
|
+
// Persisted via localStorage once dismissed (see BaseLayout.astro's
|
|
622
|
+
// initBanner()) - a static site has no server-side session to track
|
|
623
|
+
// this against, so "dismissed" only ever means "dismissed in this
|
|
624
|
+
// browser". A banner without `dismissible` reappears on every visit,
|
|
625
|
+
// appropriate for something that should stay visible until the site
|
|
626
|
+
// author removes it from writedocs.json themselves (e.g. "this version is
|
|
627
|
+
// deprecated"), not something a reader can permanently clear.
|
|
628
|
+
dismissible: z.boolean().default(false),
|
|
629
|
+
type: z.enum(['info', 'warning', 'critical']).default('info'),
|
|
630
|
+
})
|
|
631
|
+
.strict();
|
|
632
|
+
|
|
633
|
+
export type BannerConfig = z.infer<typeof bannerSchema>;
|
|
634
|
+
|
|
635
|
+
// Content for the custom 404 page (src/pages/404.astro) - Astro's own
|
|
636
|
+
// file-based convention (a static build emits this as 404.html at the
|
|
637
|
+
// site root; most static hosts pick it up automatically for unmatched
|
|
638
|
+
// routes). Both fields are optional with hardcoded fallbacks in 404.astro
|
|
639
|
+
// itself, so a site doesn't have to set either to get a reasonably
|
|
640
|
+
// branded 404 page (still rendered inside the normal BaseLayout/topbar
|
|
641
|
+
// chrome, just with placeholder content).
|
|
642
|
+
const notFoundSchema = z
|
|
643
|
+
.object({
|
|
644
|
+
title: z.string().optional(),
|
|
645
|
+
description: z.string().optional(),
|
|
646
|
+
})
|
|
647
|
+
.strict()
|
|
648
|
+
.default({});
|
|
649
|
+
|
|
650
|
+
export type NotFoundConfig = z.infer<typeof notFoundSchema>;
|
|
651
|
+
|
|
652
|
+
// One <script> tag to inject - exactly one of `src` (an external/local
|
|
653
|
+
// file, rendered as `<script src="...">`) or `content` (inline JS,
|
|
654
|
+
// rendered via `<script is:inline set:html="...">`) has to be set, not
|
|
655
|
+
// both and not neither - enforced by the `.refine()` below rather than
|
|
656
|
+
// leaving both optional and silently rendering an empty tag if a site
|
|
657
|
+
// author gets this wrong.
|
|
658
|
+
const scriptEntrySchema = z
|
|
659
|
+
.object({
|
|
660
|
+
src: z.string().optional(),
|
|
661
|
+
content: z.string().optional(),
|
|
662
|
+
})
|
|
663
|
+
.strict()
|
|
664
|
+
.refine((v) => (v.src ? !v.content : !!v.content), {
|
|
665
|
+
message: 'Each scripts.head/scripts.body entry needs exactly one of "src" or "content", not both or neither.',
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
export type ScriptEntry = z.infer<typeof scriptEntrySchema>;
|
|
669
|
+
|
|
670
|
+
// Raw third-party script injection - analytics snippets, chat widgets,
|
|
671
|
+
// anything that needs a real <script> tag writedocs.json's own structured
|
|
672
|
+
// fields (styles, integrations-style config, ...) don't have a dedicated
|
|
673
|
+
// slot for yet. `head` renders right before </head>; `body` renders right
|
|
674
|
+
// before </body> (after everything else has already loaded/hydrated -
|
|
675
|
+
// see BaseLayout.astro), matching where a third-party script's own
|
|
676
|
+
// install instructions usually say to put it.
|
|
677
|
+
const scriptsSchema = z
|
|
678
|
+
.object({
|
|
679
|
+
head: z.array(scriptEntrySchema).default([]),
|
|
680
|
+
body: z.array(scriptEntrySchema).default([]),
|
|
681
|
+
})
|
|
682
|
+
.strict()
|
|
683
|
+
.default({ head: [], body: [] });
|
|
684
|
+
|
|
685
|
+
export type ScriptsConfig = z.infer<typeof scriptsSchema>;
|
|
686
|
+
|
|
687
|
+
// writedocs.json's `integrations` - a curated, high-value subset of analytics
|
|
688
|
+
// providers, each getting its own minimal config object (just the id(s)
|
|
689
|
+
// it actually needs) rather than requiring a site author to hand-write
|
|
690
|
+
// the provider's own install snippet through the generic `scripts` field
|
|
691
|
+
// above. This is deliberately its own thing, not built as sugar over
|
|
692
|
+
// `scripts` the way the roadmap once implied it might be - most of these
|
|
693
|
+
// providers' real snippets need `defer`/`async`/`data-*` attributes
|
|
694
|
+
// ScriptEntry's plain `{ src?, content? }` shape has no way to express,
|
|
695
|
+
// so BaseLayout.astro renders each provider with its own bespoke markup
|
|
696
|
+
// instead of generating a ScriptEntry array to feed through the generic
|
|
697
|
+
// renderer. Every snippet shape below was sourced from that provider's
|
|
698
|
+
// own current official docs (fetched directly, not recalled from
|
|
699
|
+
// training data) while building this feature - see
|
|
700
|
+
// docs/dev/docs/integrations.mdx for links and dates.
|
|
701
|
+
//
|
|
702
|
+
// Every provider field is optional and independent - a site can turn on
|
|
703
|
+
// any subset (including all of them at once, for a migration period) or
|
|
704
|
+
// none. No "the" analytics field; a site picks whichever provider(s) it
|
|
705
|
+
// actually uses.
|
|
706
|
+
|
|
707
|
+
const ga4IntegrationSchema = z
|
|
708
|
+
.object({
|
|
709
|
+
// A GA4 "Measurement ID", always shaped like "G-XXXXXXXXXX" - not
|
|
710
|
+
// validated against that shape here, since Google could change the
|
|
711
|
+
// prefix/length without this schema needing to track it.
|
|
712
|
+
measurementId: z.string(),
|
|
713
|
+
})
|
|
714
|
+
.strict();
|
|
715
|
+
|
|
716
|
+
const googleTagManagerIntegrationSchema = z
|
|
717
|
+
.object({
|
|
718
|
+
// A GTM container id, shaped like "GTM-XXXXXXX".
|
|
719
|
+
containerId: z.string(),
|
|
720
|
+
})
|
|
721
|
+
.strict();
|
|
722
|
+
|
|
723
|
+
const plausibleIntegrationSchema = z
|
|
724
|
+
.object({
|
|
725
|
+
domain: z.string(),
|
|
726
|
+
// Plausible's own hosted script URL by default - override for a
|
|
727
|
+
// self-hosted instance, a reverse-proxy path (Plausible's own
|
|
728
|
+
// documented way to dodge ad-blockers), or one of Plausible's
|
|
729
|
+
// documented script *extensions* (e.g.
|
|
730
|
+
// "https://plausible.io/js/script.hash.outbound-links.js").
|
|
731
|
+
src: z.string().default('https://plausible.io/js/script.js'),
|
|
732
|
+
})
|
|
733
|
+
.strict();
|
|
734
|
+
|
|
735
|
+
const fathomIntegrationSchema = z
|
|
736
|
+
.object({
|
|
737
|
+
// Fathom's own short "Site ID", not a full URL or measurement id.
|
|
738
|
+
siteId: z.string(),
|
|
739
|
+
})
|
|
740
|
+
.strict();
|
|
741
|
+
|
|
742
|
+
const posthogIntegrationSchema = z
|
|
743
|
+
.object({
|
|
744
|
+
// PostHog calls this a "project API key" (starts with "phc_") in its
|
|
745
|
+
// own docs - `apiKey` here rather than that exact name, matching this
|
|
746
|
+
// schema's own plainer naming convention for the equivalent id on
|
|
747
|
+
// every other provider above.
|
|
748
|
+
apiKey: z.string(),
|
|
749
|
+
// PostHog is region-sharded (US/EU) with no single universal default
|
|
750
|
+
// - "https://us.i.posthog.com" is PostHog's own default for a new
|
|
751
|
+
// project, but a site on their EU cloud (or a self-hosted instance)
|
|
752
|
+
// must override this to the host shown in their own project
|
|
753
|
+
// settings, or events silently go nowhere.
|
|
754
|
+
apiHost: z.string().default('https://us.i.posthog.com'),
|
|
755
|
+
})
|
|
756
|
+
.strict();
|
|
757
|
+
|
|
758
|
+
const umamiIntegrationSchema = z
|
|
759
|
+
.object({
|
|
760
|
+
websiteId: z.string(),
|
|
761
|
+
// Unlike Plausible/PostHog, Umami has no single hosted default that
|
|
762
|
+
// works for most users - it's commonly self-hosted, and even Umami
|
|
763
|
+
// Cloud users get a per-region script URL. Defaults to Umami Cloud's
|
|
764
|
+
// own documented script URL, which only happens to be correct for a
|
|
765
|
+
// Cloud user on that specific region; anyone self-hosting (the more
|
|
766
|
+
// common case for this particular provider) must override it to
|
|
767
|
+
// their own instance's own /script.js path.
|
|
768
|
+
src: z.string().default('https://cloud.umami.is/script.js'),
|
|
769
|
+
})
|
|
770
|
+
.strict();
|
|
771
|
+
|
|
772
|
+
// AI chat over the site's own docs content, via a third-party provider
|
|
773
|
+
// (DocsBot, currently the only one wired up) rather than a self-hosted
|
|
774
|
+
// RAG pipeline - see the roadmap's own "AI chat over docs content" line
|
|
775
|
+
// in docs/dev/docs/roadmap.mdx, which this fulfills via integration
|
|
776
|
+
// rather than by building the retrieval/generation stack in-house.
|
|
777
|
+
// Deliberately named `askAi` here, not `docsbot` - this is the
|
|
778
|
+
// writedocs.json-facing, provider-neutral name for the feature (matching
|
|
779
|
+
// the "Ask AI" label this kind of button/widget is conventionally
|
|
780
|
+
// given in docs sites generally), independent of which vendor happens
|
|
781
|
+
// to sit behind it today. `id` matches DocsBot's own embed config
|
|
782
|
+
// field name exactly (`DocsBotAI.init({ id: 'teamId/botId' })`) - it's
|
|
783
|
+
// a single opaque "teamId/botId" string DocsBot treats as one value,
|
|
784
|
+
// not two separate ids, so this schema doesn't split it either.
|
|
785
|
+
const askAiIntegrationSchema = z
|
|
786
|
+
.object({
|
|
787
|
+
id: z.string(),
|
|
788
|
+
})
|
|
789
|
+
.strict();
|
|
790
|
+
|
|
791
|
+
const integrationsSchema = z
|
|
792
|
+
.object({
|
|
793
|
+
ga4: ga4IntegrationSchema.optional(),
|
|
794
|
+
googleTagManager: googleTagManagerIntegrationSchema.optional(),
|
|
795
|
+
plausible: plausibleIntegrationSchema.optional(),
|
|
796
|
+
fathom: fathomIntegrationSchema.optional(),
|
|
797
|
+
posthog: posthogIntegrationSchema.optional(),
|
|
798
|
+
umami: umamiIntegrationSchema.optional(),
|
|
799
|
+
askAi: askAiIntegrationSchema.optional(),
|
|
800
|
+
})
|
|
801
|
+
.strict()
|
|
802
|
+
.default({});
|
|
803
|
+
|
|
804
|
+
export type IntegrationsConfig = z.infer<typeof integrationsSchema>;
|
|
805
|
+
|
|
806
|
+
export const docsConfigSchema = z.object({
|
|
807
|
+
name: z.string(),
|
|
808
|
+
description: z.string().optional(),
|
|
809
|
+
styles: stylesSchema,
|
|
810
|
+
navigation: navigationSchema,
|
|
811
|
+
// Platform name -> profile/page URL (e.g. `{ "github": "https://
|
|
812
|
+
// github.com/...", "twitter": "https://twitter.com/..." }`), free-form
|
|
813
|
+
// rather than an enum of known platforms - the key doubles as the icon
|
|
814
|
+
// reference BaseLayout.astro's footer resolves via resolveIcon() below
|
|
815
|
+
// (bare `"github"` -> `lucide:github`; use an explicit
|
|
816
|
+
// `"simple-icons:whatever"` key instead when the bare lucide name isn't
|
|
817
|
+
// right for a given platform). Rendered as a row of icon links in the
|
|
818
|
+
// footer - see hasFooterContent/`.wd-footer-socials` in BaseLayout.astro.
|
|
819
|
+
socials: z.record(z.string(), z.string()).default({}),
|
|
820
|
+
topbar: z
|
|
821
|
+
.object({ links: z.array(topbarLinkSchema).default([]) })
|
|
822
|
+
.default({ links: [] }),
|
|
823
|
+
// Columns of links below the page content - see footerSchema/
|
|
824
|
+
// footerColumnSchema above. Renders alongside `socials` above (as a row
|
|
825
|
+
// of icon links) in the same <footer> - see BaseLayout.astro.
|
|
826
|
+
footer: footerSchema,
|
|
827
|
+
api: apiSchema,
|
|
828
|
+
// The site's own deployed domain, e.g. "docs.example.com" or
|
|
829
|
+
// "https://docs.example.com" (the scheme is optional - resolveSiteUrl()
|
|
830
|
+
// below normalizes either form to a full https:// origin). Powers three
|
|
831
|
+
// things that all need an absolute origin to work: sitemap.xml
|
|
832
|
+
// generation (astro.config.mjs only registers @astrojs/sitemap when this
|
|
833
|
+
// is set - a sitemap of relative URLs isn't meaningful), the canonical
|
|
834
|
+
// <link> and og:url/twitter meta tags (BaseLayout.astro), and resolving
|
|
835
|
+
// a relative `seo.ogImage` into an absolute URL for social crawlers.
|
|
836
|
+
// Left optional and simply skipped (with a log message, not an error) at
|
|
837
|
+
// every one of those call sites when unset - most fixtures/local builds
|
|
838
|
+
// have no real deployed domain yet.
|
|
839
|
+
domain: z.string().optional(),
|
|
840
|
+
// Site-wide meta tag defaults - see seoFieldsSchema above. A page's own
|
|
841
|
+
// frontmatter `seo` (content.config.ts's docsSchema) overrides these
|
|
842
|
+
// field by field via mergeSeo(), rather than needing to repeat every
|
|
843
|
+
// field on every page.
|
|
844
|
+
seo: seoFieldsSchema.default({}),
|
|
845
|
+
// The "Copy page" dropdown - see contextMenuSchema above. Absent by
|
|
846
|
+
// default (no menu, no .md routes).
|
|
847
|
+
contextMenu: contextMenuSchema.optional(),
|
|
848
|
+
// See redirectSchema above. Wired directly into Astro's own `redirects`
|
|
849
|
+
// config option in astro.config.mjs.
|
|
850
|
+
redirects: z.array(redirectSchema).default([]),
|
|
851
|
+
// Site-wide `[[key]]` substitution values, applied to page prose text
|
|
852
|
+
// at build time (see the remarkSubstituteVariables plugin wired into
|
|
853
|
+
// astro.config.mjs) - e.g. `{ "productName": "Acme" }` lets every page
|
|
854
|
+
// write `[[productName]]` once instead of hardcoding it everywhere.
|
|
855
|
+
// Double square brackets, not the more familiar `{{key}}` - MDX
|
|
856
|
+
// reserves single curly braces for embedded JS expressions, so
|
|
857
|
+
// `{{productName}}` in an .mdx file parses as a JS object-literal
|
|
858
|
+
// expression (`{productName}`, shorthand syntax) rather than literal
|
|
859
|
+
// text, and throws `ReferenceError: productName is not defined` at
|
|
860
|
+
// render time - see remarkSubstituteVariables' own comment in
|
|
861
|
+
// mdx-substitute-variables.js for the full explanation. Deliberately a
|
|
862
|
+
// flat string map, not typed values - this is text substitution into
|
|
863
|
+
// prose, not a templating language.
|
|
864
|
+
variables: z.record(z.string(), z.string()).default({}),
|
|
865
|
+
// See bannerSchema above. Absent by default (no banner rendered).
|
|
866
|
+
banner: bannerSchema.optional(),
|
|
867
|
+
// See notFoundSchema above.
|
|
868
|
+
notFound: notFoundSchema,
|
|
869
|
+
// See scriptsSchema above.
|
|
870
|
+
scripts: scriptsSchema,
|
|
871
|
+
// See integrationsSchema above.
|
|
872
|
+
integrations: integrationsSchema,
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
export type DocsConfig = z.infer<typeof docsConfigSchema>;
|
|
876
|
+
|
|
877
|
+
/** Normalizes writedocs.json's `domain` into a full origin with no trailing
|
|
878
|
+
* slash (e.g. "docs.example.com" -> "https://docs.example.com"), or null
|
|
879
|
+
* if the site hasn't set one. The single source of truth for "does this
|
|
880
|
+
* site have a real deployed URL" - astro.config.mjs (sitemap
|
|
881
|
+
* registration), BaseLayout.astro (canonical/OG/Twitter URLs), and
|
|
882
|
+
* resolveAbsoluteUrl() below all call this rather than reading
|
|
883
|
+
* config.domain directly, so the http(s):// normalization only happens
|
|
884
|
+
* in one place. */
|
|
885
|
+
export function resolveSiteUrl(config: Pick<DocsConfig, 'domain'>): string | null {
|
|
886
|
+
if (!config.domain) return null;
|
|
887
|
+
const withScheme = /^https?:\/\//i.test(config.domain) ? config.domain : `https://${config.domain}`;
|
|
888
|
+
return withScheme.replace(/\/+$/, '');
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Resolves a possibly-relative asset path (e.g. `seo.ogImage: "/card.png"`)
|
|
892
|
+
* against the site's own domain into an absolute URL - social crawlers
|
|
893
|
+
* (Facebook/Twitter/Slack unfurls) generally require an absolute
|
|
894
|
+
* og:image/twitter:image URL, a same-origin relative path isn't reliably
|
|
895
|
+
* respected. Returns the value unchanged if it's already absolute, or if
|
|
896
|
+
* there's no siteUrl to resolve it against (a relative path is still
|
|
897
|
+
* better than nothing in that case - most social crawlers do at least
|
|
898
|
+
* attempt to fetch it relative to the page they scraped). */
|
|
899
|
+
export function resolveAbsoluteUrl(siteUrl: string | null, value: string): string {
|
|
900
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
901
|
+
if (!siteUrl) return value;
|
|
902
|
+
return `${siteUrl}${value.startsWith('/') ? '' : '/'}${value}`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** The resolved (never-undefined) Shiki theme names for light/dark code
|
|
906
|
+
* blocks - shared by astro.config.mjs (sitewide MDX code-fence
|
|
907
|
+
* highlighting) and ApiReferencePanel.astro (the API playground's own
|
|
908
|
+
* separate <Code/> usages, which don't inherit markdown.shikiConfig at
|
|
909
|
+
* all - see astro.config.mjs's own comment on that) so both read the
|
|
910
|
+
* same writedocs.json field and fall back to the same defaults instead of
|
|
911
|
+
* each hardcoding its own copy. */
|
|
912
|
+
export function resolveCodeblockTheme(config: DocsConfig): { light: string; dark: string } {
|
|
913
|
+
return {
|
|
914
|
+
light: config.styles.codeblocks?.light ?? 'github-light',
|
|
915
|
+
dark: config.styles.codeblocks?.dark ?? 'github-dark',
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// mdx is a real, bundled Shiki grammar (@shikijs/langs' mdx.mjs - it does
|
|
920
|
+
// exist, this isn't a "Shiki doesn't know this language" situation), but in
|
|
921
|
+
// practice it tokenizes ```mdx fences into one single run per line with no
|
|
922
|
+
// internal token boundaries at all - every character, JSX tag or not,
|
|
923
|
+
// lands in the exact same TextMate scope and renders in the theme's plain
|
|
924
|
+
// foreground color. Confirmed directly against real built output: every
|
|
925
|
+
// span in a ```mdx block's compiled HTML carries the identical inline
|
|
926
|
+
// color, none of the tag/attribute/string distinction a JS or JSX fence
|
|
927
|
+
// gets. Aliasing to jsx - a close structural match for the JSX-heavy
|
|
928
|
+
// snippets these fences are actually used for in this codebase's own docs
|
|
929
|
+
// (`<Callout>`, `<Card>`, etc.) - actually highlights the tags/attributes,
|
|
930
|
+
// at the cost of not distinctly coloring the markdown-prose portions
|
|
931
|
+
// interleaved between them (jsx's grammar doesn't know about those) - a
|
|
932
|
+
// worthwhile trade given the alternative is no color at all. See
|
|
933
|
+
// astro.config.mjs's own shikiConfig.langAlias for where this actually
|
|
934
|
+
// gets used. */
|
|
935
|
+
const DEFAULT_CODEBLOCK_LANG_ALIAS: Record<string, string> = { mdx: 'jsx' };
|
|
936
|
+
|
|
937
|
+
/** The resolved fence-language alias map - the built-in mdx -> jsx default
|
|
938
|
+
* above, merged with (not replaced by) whatever a site adds under its own
|
|
939
|
+
* writedocs.json styles.codeblocks.langAlias, so a site can extend this list
|
|
940
|
+
* without having to redeclare the built-in entry to keep it. Same
|
|
941
|
+
* "shared resolver, not each consumer re-deriving its own defaults"
|
|
942
|
+
* pattern resolveCodeblockTheme() right above already establishes -
|
|
943
|
+
* though today only astro.config.mjs actually consumes this one, unlike
|
|
944
|
+
* that one, since ApiReferencePanel.astro's own <Code/> usages never
|
|
945
|
+
* render a `mdx`-tagged snippet (API operation samples are curl/js/
|
|
946
|
+
* python/etc., not MDX markup) to begin with. */
|
|
947
|
+
export function resolveCodeblockLangAlias(config: DocsConfig): Record<string, string> {
|
|
948
|
+
return { ...DEFAULT_CODEBLOCK_LANG_ALIAS, ...config.styles.codeblocks?.langAlias };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const DEFAULT_FONT_FAMILY = 'Inter';
|
|
952
|
+
|
|
953
|
+
export interface ResolvedFonts {
|
|
954
|
+
base: FontVariant;
|
|
955
|
+
heading: FontVariant | null;
|
|
956
|
+
body: FontVariant | null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Resolves writedocs.json's `styles.fonts` into the three font declarations
|
|
960
|
+
* BaseLayout.astro actually needs to render: `base` (the site-wide
|
|
961
|
+
* default - every element gets this unless `heading`/`body` narrows it
|
|
962
|
+
* further), and `heading`/`body`, each `null` when not independently
|
|
963
|
+
* configured (letting BaseLayout fall back to `base` for whichever side
|
|
964
|
+
* wasn't overridden, rather than this function silently copying `base`
|
|
965
|
+
* into both and losing the distinction between "explicitly set to the
|
|
966
|
+
* same font" and "just inheriting the default"). The one hardcoded
|
|
967
|
+
* default in this whole feature lives right here: no `styles.fonts` at
|
|
968
|
+
* all resolves to plain Inter, loaded for real (a Google Fonts `<link>`,
|
|
969
|
+
* not just a name in a fallback stack that only renders correctly for a
|
|
970
|
+
* reader who happens to already have Inter installed - see base.css's
|
|
971
|
+
* old `font-family` rule, which was exactly that, before this feature
|
|
972
|
+
* existed). */
|
|
973
|
+
export function resolveFonts(config: DocsConfig): ResolvedFonts {
|
|
974
|
+
const fonts = config.styles.fonts;
|
|
975
|
+
const base: FontVariant = fonts
|
|
976
|
+
? { family: fonts.family, weight: fonts.weight, source: fonts.source, format: fonts.format }
|
|
977
|
+
: { family: DEFAULT_FONT_FAMILY };
|
|
978
|
+
return { base, heading: fonts?.heading ?? null, body: fonts?.body ?? null };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** The Google Fonts CSS2 API URL for every font in `fonts` that isn't a
|
|
982
|
+
* `source`-based (local/externally-hosted) font - `null` if there's
|
|
983
|
+
* nothing to load this way at all (every configured font has its own
|
|
984
|
+
* `source`). One request covers every family needed (`&family=` repeated
|
|
985
|
+
* per unique family+weight pair, deduped so the same pair - e.g. `base`
|
|
986
|
+
* and `body` both left at the site default - isn't requested twice)
|
|
987
|
+
* rather than a separate `<link>` per font. `display=swap` avoids an
|
|
988
|
+
* invisible-text flash while the font file loads (renders in the
|
|
989
|
+
* fallback stack immediately, swaps once the real font is ready) -
|
|
990
|
+
* Google's own recommended default for exactly this use case. */
|
|
991
|
+
export function googleFontsHref(fonts: ResolvedFonts): string | null {
|
|
992
|
+
const entries = [fonts.base, fonts.heading, fonts.body].filter(
|
|
993
|
+
(f): f is FontVariant => f !== null && !f.source
|
|
994
|
+
);
|
|
995
|
+
if (entries.length === 0) return null;
|
|
996
|
+
const seen = new Set<string>();
|
|
997
|
+
const params: string[] = [];
|
|
998
|
+
for (const f of entries) {
|
|
999
|
+
const key = `${f.family}|${f.weight ?? ''}`;
|
|
1000
|
+
if (seen.has(key)) continue;
|
|
1001
|
+
seen.add(key);
|
|
1002
|
+
const familyParam = f.family.trim().replace(/\s+/g, '+');
|
|
1003
|
+
params.push(f.weight ? `family=${familyParam}:wght@${f.weight}` : `family=${familyParam}`);
|
|
1004
|
+
}
|
|
1005
|
+
return `https://fonts.googleapis.com/css2?${params.join('&')}&display=swap`;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** A `@font-face` rule for one `source`-based font (local project path or
|
|
1009
|
+
* externally-hosted URL), or `''` for a Google Font (no `source` - see
|
|
1010
|
+
* googleFontsHref() above, the other half of font loading). `weight`
|
|
1011
|
+
* becomes the rule's `font-weight` *descriptor* here - it tells the
|
|
1012
|
+
* browser which weight this specific file represents, so a `font-weight`
|
|
1013
|
+
* CSS value requested elsewhere (BaseLayout.astro's own
|
|
1014
|
+
* `wdFontWeightHeading`/`wdFontWeightBody`, see its comment) picks the
|
|
1015
|
+
* real matching file instead of synthetically ("faux") bolding/
|
|
1016
|
+
* thinning a mismatched one. This function only ever produces the
|
|
1017
|
+
* `@font-face` rule itself - actually applying `font-weight` to any
|
|
1018
|
+
* element (h1-h6, body) is BaseLayout's job, not this one's. */
|
|
1019
|
+
export function fontFaceRule(font: FontVariant | null): string {
|
|
1020
|
+
if (!font || !font.source) return '';
|
|
1021
|
+
const weightDecl = font.weight !== undefined ? ` font-weight: ${font.weight};` : '';
|
|
1022
|
+
return `@font-face { font-family: '${font.family}'; src: url('${font.source}') format('${font.format ?? 'woff2'}'); font-display: swap;${weightDecl} }`;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Splits one side of `styles.navbar` (the string-or-object union
|
|
1026
|
+
* navbarColorValueSchema allows) into its two parts, filling in the
|
|
1027
|
+
* fallback background when the field is unset at all. `accent` stays
|
|
1028
|
+
* `undefined` - not defaulted here - for both the bare-string case and
|
|
1029
|
+
* the object case where a site set `background` without `accent`;
|
|
1030
|
+
* BaseLayout.astro is what turns that `undefined` into "fall back to
|
|
1031
|
+
* --wd-primary" for the accent CSS var. There's no `foreground` here at
|
|
1032
|
+
* all to resolve - the navbar's text/icon color is never read from
|
|
1033
|
+
* writedocs.json, see `navbar`'s own schema comment (stylesSchema) for why. */
|
|
1034
|
+
export function resolveNavbarColor(
|
|
1035
|
+
value: string | { background: string; accent?: string } | undefined,
|
|
1036
|
+
fallbackBackground: string
|
|
1037
|
+
): { background: string; accent: string | undefined } {
|
|
1038
|
+
if (!value) return { background: fallbackBackground, accent: undefined };
|
|
1039
|
+
if (typeof value === 'string') return { background: value, accent: undefined };
|
|
1040
|
+
return { background: value.background, accent: value.accent };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Picks black or white text for readable contrast against `hexColor`,
|
|
1044
|
+
* via the standard relative-luminance formula (ITU-R BT.601 weights -
|
|
1045
|
+
* the same "perceived brightness" approximation used all over the web
|
|
1046
|
+
* for exactly this "what text color goes on this swatch" problem, not
|
|
1047
|
+
* the more expensive WCAG relative-luminance formula, which isn't
|
|
1048
|
+
* needed for a binary choose-the-less-bad-option decision like this
|
|
1049
|
+
* one). Used two ways in BaseLayout.astro: for `--wd-navbar-accent-text`
|
|
1050
|
+
* (the active tab's own fill used to always be `var(--wd-primary)` with
|
|
1051
|
+
* hardcoded `color: #fff`, which only actually read fine because every
|
|
1052
|
+
* default/example primary color so far has been dark/saturated enough
|
|
1053
|
+
* for white text; once a site's navbar accent can be *any* color -
|
|
1054
|
+
* `styles.navbar.light.accent`, falling back to `styles.colors.primary`
|
|
1055
|
+
* when unset, see resolveNavbarColor() above - that assumption can't
|
|
1056
|
+
* hold unconditionally), and for `--wd-navbar-foreground` itself once
|
|
1057
|
+
* `styles.navbar` is configured at all (the navbar's plain text/icon
|
|
1058
|
+
* color - see `navbar`'s own schema comment for why that's always
|
|
1059
|
+
* computed, never a writedocs.json value). Malformed input (not a 6-digit
|
|
1060
|
+
* `#rrggbb` hex) falls back to white rather than throwing - same "don't
|
|
1061
|
+
* fail a build over a cosmetic color value" posture every other color
|
|
1062
|
+
* field here takes (none of them validate hex syntax either). */
|
|
1063
|
+
export function contrastTextColor(hexColor: string): '#000000' | '#ffffff' {
|
|
1064
|
+
const match = /^#?([0-9a-f]{6})$/i.exec(hexColor.trim());
|
|
1065
|
+
if (!match) return '#ffffff';
|
|
1066
|
+
const hex = match[1];
|
|
1067
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
1068
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
1069
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
1070
|
+
const luminance = (299 * r + 587 * g + 114 * b) / 1000;
|
|
1071
|
+
return luminance > 150 ? '#000000' : '#ffffff';
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Whether an href points off-site - has an explicit scheme (`https:`,
|
|
1075
|
+
* `mailto:`, `tel:`, ...) or is protocol-relative (`//...`) - versus a
|
|
1076
|
+
* same-site path, which this codebase always produces as a single
|
|
1077
|
+
* leading slash (hrefForSlug() in [...slug].astro). Used everywhere a
|
|
1078
|
+
* nav link is rendered to decide whether it should open in a new tab;
|
|
1079
|
+
* deliberately a plain string check rather than a schema-level flag,
|
|
1080
|
+
* so it applies uniformly to every href source (topbar.links, a
|
|
1081
|
+
* switcher/tab pill pointing at a bare `href` container, a global
|
|
1082
|
+
* dropdown's own link, a sidebar link leaf) without threading an
|
|
1083
|
+
* `external` field through every one of those call sites. */
|
|
1084
|
+
export function isExternalHref(href: string): boolean {
|
|
1085
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// ---------------------------------------------------------------------
|
|
1089
|
+
// Icons - writedocs.json's `icon` fields (TabItem, DropdownItem, ProductItem,
|
|
1090
|
+
// Card) are plain strings with no schema-level distinction between "an
|
|
1091
|
+
// emoji, paste it verbatim" and "an icon-set name, look it up". Resolved
|
|
1092
|
+
// here rather than validated in the schema, since both are valid uses of
|
|
1093
|
+
// the same string field and the right rendering only becomes obvious once
|
|
1094
|
+
// you look at the value's shape.
|
|
1095
|
+
// ---------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
export type IconResolution =
|
|
1098
|
+
| { kind: 'iconify'; name: string } // ready for astro-icon/components' <Icon name=... />
|
|
1099
|
+
| { kind: 'text'; value: string }; // literal text/emoji, rendered as-is
|
|
1100
|
+
|
|
1101
|
+
const DEFAULT_ICON_COLLECTION = 'lucide';
|
|
1102
|
+
|
|
1103
|
+
/** Resolves a writedocs.json `icon` string into either an Iconify icon id or
|
|
1104
|
+
* literal text, covering three forms:
|
|
1105
|
+
* - "collection:icon-name" (e.g. "mdi:server", "simple-icons:github")
|
|
1106
|
+
* - used as-is against whatever @iconify-json/* collections are
|
|
1107
|
+
* installed (see astro.config.mjs / package.json).
|
|
1108
|
+
* - a bare name using only letters/digits/hyphens (e.g. "smartphone",
|
|
1109
|
+
* "book-open") - defaults to the "lucide" collection, matching what
|
|
1110
|
+
* docs.json-examples/ already assumes for plain icon-name strings.
|
|
1111
|
+
* - anything else (an emoji, a symbol, arbitrary text) - rendered
|
|
1112
|
+
* verbatim, preserving the original "just paste an emoji" behavior
|
|
1113
|
+
* from before icon-library support existed. */
|
|
1114
|
+
export function resolveIcon(icon: string): IconResolution {
|
|
1115
|
+
if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: icon };
|
|
1116
|
+
if (/^[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: `${DEFAULT_ICON_COLLECTION}:${icon}` };
|
|
1117
|
+
return { kind: 'text', value: icon };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ---------------------------------------------------------------------
|
|
1121
|
+
// OpenAPI navigation expansion - turns a group's `openapi: { src, path }`
|
|
1122
|
+
// shorthand into a concrete `{ group, pages }` (one sub-group per tag),
|
|
1123
|
+
// using the per-spec manifest generate-api-pages.js writes to
|
|
1124
|
+
// writedocsTempDir()'s openapi/<path>/manifest.json before Astro starts (both
|
|
1125
|
+
// `writedocs dev` and `writedocs build` run it first - see
|
|
1126
|
+
// src/cli/dev.js, src/cli/build.js). Applied once, here in
|
|
1127
|
+
// loadDocsConfig(), so every other function in this file
|
|
1128
|
+
// (resolveSections, flattenNav, buildNavTree, ...) only ever sees plain
|
|
1129
|
+
// page-slug strings and ordinary `{ group, pages }` nodes, and never
|
|
1130
|
+
// needs to know the `openapi` group shorthand exists. A writedocs.json can
|
|
1131
|
+
// have any number of these groups, each pointing at its own spec and
|
|
1132
|
+
// mounted under its own `path` - generate-api-pages.js namespaces each
|
|
1133
|
+
// spec's manifest/operations under that same `path`, so there's no
|
|
1134
|
+
// cross-spec collision as long as every group uses a distinct `path`.
|
|
1135
|
+
// ---------------------------------------------------------------------
|
|
1136
|
+
|
|
1137
|
+
export interface OpenApiManifestEntry {
|
|
1138
|
+
slug: string;
|
|
1139
|
+
method: string;
|
|
1140
|
+
path: string;
|
|
1141
|
+
tags: string[];
|
|
1142
|
+
title: string;
|
|
1143
|
+
generated: boolean;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** `specPath` is the owning group's own `openapi.path` (e.g. "/api") -
|
|
1147
|
+
* generate-api-pages.js writes each spec's manifest under a directory
|
|
1148
|
+
* named after that same value, so this only ever needs to know which
|
|
1149
|
+
* group is asking, not anything about the spec's contents itself. */
|
|
1150
|
+
function loadOpenApiManifest(contentDir: string, specPath: string): OpenApiManifestEntry[] | null {
|
|
1151
|
+
const normalized = specPath.replace(/^\/+|\/+$/g, '');
|
|
1152
|
+
const manifestPath = path.join(writedocsTempDir(contentDir), 'openapi', normalized, 'manifest.json');
|
|
1153
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
1154
|
+
try {
|
|
1155
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
1156
|
+
} catch {
|
|
1157
|
+
return null; // stale/partial write from an interrupted previous run - treat as absent
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Expands one group's `openapi: { src, path }` into the `pages` array
|
|
1162
|
+
* it stands in for - one sub-group per tag (in first-seen order),
|
|
1163
|
+
* untagged operations collected into a trailing "Other" group. Returns
|
|
1164
|
+
* an empty array (an empty, harmless group) rather than throwing if the
|
|
1165
|
+
* manifest is missing - generate-api-pages.js always runs before this
|
|
1166
|
+
* does (see src/cli/dev.js/build.js), so a missing manifest here means
|
|
1167
|
+
* generation hasn't happened yet rather than a user error worth
|
|
1168
|
+
* crashing the dev server over. */
|
|
1169
|
+
function expandOpenApiGroupPages(openapiRef: { src: string; path: string }, contentDir: string): NavItem[] {
|
|
1170
|
+
const manifest = loadOpenApiManifest(contentDir, openapiRef.path);
|
|
1171
|
+
if (!manifest) return [];
|
|
1172
|
+
|
|
1173
|
+
const seenTags: string[] = [];
|
|
1174
|
+
const byTag = new Map<string, string[]>();
|
|
1175
|
+
const untagged: string[] = [];
|
|
1176
|
+
for (const op of manifest) {
|
|
1177
|
+
const tag = op.tags[0];
|
|
1178
|
+
if (!tag) {
|
|
1179
|
+
untagged.push(op.slug);
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (!byTag.has(tag)) {
|
|
1183
|
+
byTag.set(tag, []);
|
|
1184
|
+
seenTags.push(tag);
|
|
1185
|
+
}
|
|
1186
|
+
byTag.get(tag)!.push(op.slug);
|
|
1187
|
+
}
|
|
1188
|
+
const groups: NavItem[] = seenTags.map((tag) => ({ group: tag, pages: byTag.get(tag)! }));
|
|
1189
|
+
return untagged.length > 0 ? [...groups, { group: 'Other', pages: untagged }] : groups;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function expandOpenApiInPages(pages: NavItem[], contentDir: string): NavItem[] {
|
|
1193
|
+
return pages.map((item): NavItem => {
|
|
1194
|
+
if (typeof item === 'string') return item;
|
|
1195
|
+
if ('href' in item) return item;
|
|
1196
|
+
if ('openapi' in item) {
|
|
1197
|
+
// A group using the { group, openapi: { src, path } } shorthand -
|
|
1198
|
+
// replace it with a real { group, pages } node built from that
|
|
1199
|
+
// spec's own manifest, so nothing downstream needs to know the
|
|
1200
|
+
// shorthand ever existed.
|
|
1201
|
+
return { group: item.group, pages: expandOpenApiGroupPages(item.openapi, contentDir) };
|
|
1202
|
+
}
|
|
1203
|
+
// An ordinary { group, page?, pages } - recurse into its own pages,
|
|
1204
|
+
// since an openapi-group can be nested inside a hand-authored group
|
|
1205
|
+
// too (e.g. wrapping it to add hand-written pages alongside the
|
|
1206
|
+
// auto-generated ones).
|
|
1207
|
+
return { ...item, pages: expandOpenApiInPages(item.pages, contentDir) };
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** Mirrors walkSections()'s traversal (tabs/versions/languages/
|
|
1212
|
+
* dropdowns/products, each bottoming out at `pages`), but rewrites
|
|
1213
|
+
* rather than collects - every `pages` array anywhere in the tree gets
|
|
1214
|
+
* run through expandOpenApiInPages(). */
|
|
1215
|
+
function expandOpenApiInContainer(node: NavChildren, contentDir: string): NavChildren {
|
|
1216
|
+
if ('pages' in node) return { pages: expandOpenApiInPages(node.pages, contentDir) };
|
|
1217
|
+
if ('href' in node) return node;
|
|
1218
|
+
if ('tabs' in node) {
|
|
1219
|
+
return { tabs: node.tabs.map((t) => ({ ...t, ...expandOpenApiInContainer(t, contentDir) })) };
|
|
1220
|
+
}
|
|
1221
|
+
if ('versions' in node) {
|
|
1222
|
+
return { versions: node.versions.map((v) => ({ ...v, ...expandOpenApiInContainer(v, contentDir) })) };
|
|
1223
|
+
}
|
|
1224
|
+
if ('languages' in node) {
|
|
1225
|
+
return { languages: node.languages.map((l) => ({ ...l, ...expandOpenApiInContainer(l, contentDir) })) };
|
|
1226
|
+
}
|
|
1227
|
+
if ('dropdowns' in node) {
|
|
1228
|
+
return { dropdowns: node.dropdowns.map((d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) })) };
|
|
1229
|
+
}
|
|
1230
|
+
if ('products' in node) {
|
|
1231
|
+
return { products: node.products.map((p) => ({ ...p, ...expandOpenApiInContainer(p, contentDir) })) };
|
|
1232
|
+
}
|
|
1233
|
+
return node;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function expandOpenApiInNavigation(navigation: NavigationConfig, contentDir: string): NavigationConfig {
|
|
1237
|
+
if (Array.isArray(navigation)) return expandOpenApiInPages(navigation, contentDir);
|
|
1238
|
+
const expanded = { ...navigation, ...expandOpenApiInContainer(navigation as Container, contentDir) };
|
|
1239
|
+
if (navigation.global?.dropdowns) {
|
|
1240
|
+
expanded.global = {
|
|
1241
|
+
dropdowns: navigation.global.dropdowns.map(
|
|
1242
|
+
(d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) }) as DropdownItem
|
|
1243
|
+
),
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return expanded as NavigationConfig;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
export function loadDocsConfig(contentDir: string): DocsConfig {
|
|
1250
|
+
const configPath = path.join(contentDir, 'writedocs.json');
|
|
1251
|
+
if (!fs.existsSync(configPath)) {
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
`[writedocs] writedocs.json not found at ${configPath}. Run "writedocs init" to scaffold one.`
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1257
|
+
const result = docsConfigSchema.safeParse(raw);
|
|
1258
|
+
if (!result.success) {
|
|
1259
|
+
const issues = result.error.issues
|
|
1260
|
+
.map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
1261
|
+
.join('\n');
|
|
1262
|
+
throw new Error(`[writedocs] writedocs.json failed validation:\n${issues}`);
|
|
1263
|
+
}
|
|
1264
|
+
// A no-op pass over ordinary navigation trees (no openapi groups) -
|
|
1265
|
+
// always run, rather than gated behind a global manifest check, since
|
|
1266
|
+
// there's no longer a single global spec to check for.
|
|
1267
|
+
result.data.navigation = expandOpenApiInNavigation(result.data.navigation, contentDir);
|
|
1268
|
+
return result.data;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// --- Locating a page by file id, independent of its effective slug -----
|
|
1272
|
+
//
|
|
1273
|
+
// writedocs.json's `pages` arrays, and every helper above/below that walks
|
|
1274
|
+
// them (flattenNav, firstSlugOf, containerContainsSlug, buildNavTree, ...),
|
|
1275
|
+
// always identify a page by its file id - its path relative to the
|
|
1276
|
+
// content directory (project root), extension stripped, with a trailing
|
|
1277
|
+
// `/index` segment dropped (matching Astro's own default id computation -
|
|
1278
|
+
// see the `/index` note below) - regardless of how that page ends up
|
|
1279
|
+
// being served. docs/ is not special: a file at `docs/guides/x.mdx` has
|
|
1280
|
+
// file id `docs/guides/x`, the exact same rule applied to a file
|
|
1281
|
+
// anywhere else in the project.
|
|
1282
|
+
//
|
|
1283
|
+
// A page's actual URL is a separate question, and Astro's own glob()
|
|
1284
|
+
// content loader already has a first-class answer for it: if a page's
|
|
1285
|
+
// frontmatter sets `slug`, `entry.id` (and therefore the route Astro
|
|
1286
|
+
// builds for it) becomes that value verbatim instead of the file-path
|
|
1287
|
+
// default - see astro/dist/content/loaders/glob.js's generateIdDefault:
|
|
1288
|
+
// `if (data.slug) return data.slug`. content.config.ts's `slug` schema
|
|
1289
|
+
// field is deliberately the same field Astro already recognizes, so
|
|
1290
|
+
// nothing here needs to reimplement the override or track it separately -
|
|
1291
|
+
// it only needs a way to find a page BY file id (to resolve writedocs.json's
|
|
1292
|
+
// references) even once `entry.id` no longer equals it.
|
|
1293
|
+
//
|
|
1294
|
+
// --- Page discovery -------------------------------------------------
|
|
1295
|
+
//
|
|
1296
|
+
// Any .md/.mdx file anywhere under the content directory becomes a page
|
|
1297
|
+
// candidate the moment it has *any* frontmatter block at all - docs/ has
|
|
1298
|
+
// no special status here, it's just a folder like any other (a
|
|
1299
|
+
// conventional, recommended place to put most pages, not a requirement).
|
|
1300
|
+
// content.config.ts's `pages` collection is the same docsSchema applied
|
|
1301
|
+
// to exactly this file set. A file with zero frontmatter (no `---` block
|
|
1302
|
+
// whatsoever) is never a page candidate - a snippet (see
|
|
1303
|
+
// docs/dev/docs/snippets.mdx) typically has none, which is what lets it
|
|
1304
|
+
// live anywhere without tripping schema validation. A file that *does*
|
|
1305
|
+
// open a frontmatter block but is missing a required field (`title`)
|
|
1306
|
+
// still fails validation exactly as before - only "no frontmatter at
|
|
1307
|
+
// all" is new; a genuine mistake (frontmatter present, title forgotten)
|
|
1308
|
+
// stays a loud build error rather than being silently treated as
|
|
1309
|
+
// non-page content.
|
|
1310
|
+
//
|
|
1311
|
+
// A handful of directories are never scanned regardless of what's in
|
|
1312
|
+
// them - build output, dependencies, and writedocs' own working
|
|
1313
|
+
// directories, none of which a site author would ever intend as page
|
|
1314
|
+
// content. Only excluded at the content directory's own top level (a
|
|
1315
|
+
// hand-authored `guides/dist-notes.mdx` isn't mistaken for the build
|
|
1316
|
+
// output directory `dist/` just because a folder two levels down happens
|
|
1317
|
+
// to also be named `dist`).
|
|
1318
|
+
const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
|
|
1319
|
+
|
|
1320
|
+
/** Recursively finds every .md/.mdx file under `contentDir` that has a
|
|
1321
|
+
* frontmatter block, skipping the handful of build/dependency
|
|
1322
|
+
* directories a real content directory tends to also contain (see
|
|
1323
|
+
* EXCLUDED_TOP_LEVEL_DIRS above) - docs/ is scanned exactly like any
|
|
1324
|
+
* other folder, no special-casing. Returns POSIX-relative paths (from
|
|
1325
|
+
* `contentDir`) suitable to hand straight to Astro's `glob()` loader as
|
|
1326
|
+
* a literal `pattern` array - see content.config.ts's `pages`
|
|
1327
|
+
* collection, and [...slug].astro, which needs the identical list to
|
|
1328
|
+
* decide whether calling `getCollection('pages')` is worth doing at all
|
|
1329
|
+
* (see content.config.ts's own comment on why an empty collection still
|
|
1330
|
+
* needs to exist, just backed by a no-op loader, to avoid Astro's "does
|
|
1331
|
+
* not exist or is empty" warning). Also reused directly by
|
|
1332
|
+
* astro.config.mjs's noindex/sitemap scan, so that scan always sees
|
|
1333
|
+
* exactly the same file set that actually becomes a page - no risk of
|
|
1334
|
+
* the two drifting apart. Synchronous and re-run from scratch wherever
|
|
1335
|
+
* it's called rather than cached and shared across modules - consistent
|
|
1336
|
+
* with how loadDocsConfig() itself is already called repeatedly across
|
|
1337
|
+
* this codebase instead of threaded through as shared state, and cheap
|
|
1338
|
+
* enough in practice (a docs site's own file count) not to matter. */
|
|
1339
|
+
export function findAllPages(contentDir: string): string[] {
|
|
1340
|
+
const results: string[] = [];
|
|
1341
|
+
function walk(dir: string, relBase: string) {
|
|
1342
|
+
let entries: fs.Dirent[];
|
|
1343
|
+
try {
|
|
1344
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1345
|
+
} catch {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
for (const entry of entries) {
|
|
1349
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1350
|
+
const abs = path.join(dir, entry.name);
|
|
1351
|
+
if (entry.isDirectory()) {
|
|
1352
|
+
if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
|
|
1353
|
+
walk(abs, rel);
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
if (!entry.isFile() || !/\.mdx?$/i.test(entry.name)) continue;
|
|
1357
|
+
let raw: string;
|
|
1358
|
+
try {
|
|
1359
|
+
raw = fs.readFileSync(abs, 'utf-8');
|
|
1360
|
+
} catch {
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
const { data } = matter(raw);
|
|
1364
|
+
if (Object.keys(data).length === 0) continue; // no frontmatter at all - not a page
|
|
1365
|
+
results.push(rel);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
walk(contentDir, '');
|
|
1369
|
+
return results;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
|
|
1373
|
+
* `public/` included - auto-loaded site-wide with zero `writedocs.json`
|
|
1374
|
+
* config, on top of (not instead of) the explicit `scripts` field
|
|
1375
|
+
* (bannerSchema and friends, above). Drop a file in, it loads;
|
|
1376
|
+
* there's no field naming which ones to use, matching the same "just
|
|
1377
|
+
* works" convention `docs/`'s own file discovery already follows (see
|
|
1378
|
+
* findAllPages() above / `content-pipeline.mdx`) - a site author already
|
|
1379
|
+
* drops content files in and expects them found, rather than also
|
|
1380
|
+
* listing every one in writedocs.json.
|
|
1381
|
+
*
|
|
1382
|
+
* Two separate walks, because `public/` needs different treatment than
|
|
1383
|
+
* everywhere else:
|
|
1384
|
+
*
|
|
1385
|
+
* - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
|
|
1386
|
+
* `snippets/`, any custom folder) - reused as the walk-with-exclusions
|
|
1387
|
+
* shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
|
|
1388
|
+
* set (skipped only at the project root, same as there), so `dist/`,
|
|
1389
|
+
* `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
|
|
1390
|
+
* half only - see below) `public/` are never walked into. BaseLayout.astro
|
|
1391
|
+
* reads each one's raw content and inlines it as a `<style>`/
|
|
1392
|
+
* `<script is:inline>` tag.
|
|
1393
|
+
* - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
|
|
1394
|
+
* walked separately (starting from `<contentDir>/public` rather than
|
|
1395
|
+
* `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
|
|
1396
|
+
* doesn't apply here - there's no `public/public/` or `public/dist/`
|
|
1397
|
+
* convention to guard against). Returned as public-URL-rooted hrefs
|
|
1398
|
+
* (a leading `/`, no `public` segment - `public/custom.css` becomes
|
|
1399
|
+
* `/custom.css`) rather than content-dir-relative paths, since these
|
|
1400
|
+
* files are already served as static assets at exactly that URL once
|
|
1401
|
+
* Astro copies `public/` into the build output. BaseLayout.astro
|
|
1402
|
+
* renders these as ordinary `<link rel="stylesheet">`/`<script src>`
|
|
1403
|
+
* tags pointing at that URL instead of inlining their content -
|
|
1404
|
+
* inlining would duplicate every byte (once in the page's own HTML,
|
|
1405
|
+
* once more as the independently-fetchable static file at that same
|
|
1406
|
+
* URL) for no benefit, where a `<link>`/`<script src>` gets normal
|
|
1407
|
+
* browser caching across pages instead of repeating the content on
|
|
1408
|
+
* every single page's markup.
|
|
1409
|
+
*
|
|
1410
|
+
* Both halves are broader than they might sound - a stray `.js` file
|
|
1411
|
+
* kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
|
|
1412
|
+
* (a snippet's own local helper, an image gallery's lightbox script
|
|
1413
|
+
* someone dropped in `public/` to reference from a raw `<script src>`
|
|
1414
|
+
* in an .mdx file, say) gets auto-injected sitewide the same as a
|
|
1415
|
+
* deliberate one; there's no separate "this one's just tooling" signal
|
|
1416
|
+
* to opt out of the convention short of renaming its extension.
|
|
1417
|
+
*
|
|
1418
|
+
* Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
|
|
1419
|
+
* by their respective path for deterministic load order across rebuilds
|
|
1420
|
+
* - same reasoning as llms.txt's own alphabetical-by-slug sort (see
|
|
1421
|
+
* llms.txt.ts) - filesystem readdir order isn't guaranteed portable
|
|
1422
|
+
* across OSes or directory-walk order otherwise.
|
|
1423
|
+
*
|
|
1424
|
+
* `css`/`js` return POSIX-separated paths relative to `contentDir`, not
|
|
1425
|
+
* absolute paths or file contents - BaseLayout.astro (the sole caller)
|
|
1426
|
+
* resolves and reads each one's content itself, right before inlining
|
|
1427
|
+
* it, so a file's content is always current as of that specific
|
|
1428
|
+
* request/build rather than cached here across a `writedocs dev`
|
|
1429
|
+
* session. `publicCss`/`publicJs` return the public-URL hrefs described
|
|
1430
|
+
* above - nothing to read, Astro's own static-file serving/copy already
|
|
1431
|
+
* handles those. */
|
|
1432
|
+
export function findRootAssets(
|
|
1433
|
+
contentDir: string
|
|
1434
|
+
): { css: string[]; js: string[]; publicCss: string[]; publicJs: string[] } {
|
|
1435
|
+
const css: string[] = [];
|
|
1436
|
+
const js: string[] = [];
|
|
1437
|
+
function walk(dir: string, relBase: string) {
|
|
1438
|
+
let entries: fs.Dirent[];
|
|
1439
|
+
try {
|
|
1440
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1441
|
+
} catch {
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
for (const entry of entries) {
|
|
1445
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1446
|
+
const abs = path.join(dir, entry.name);
|
|
1447
|
+
if (entry.isDirectory()) {
|
|
1448
|
+
if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
|
|
1449
|
+
walk(abs, rel);
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
if (!entry.isFile()) continue;
|
|
1453
|
+
if (/\.css$/i.test(entry.name)) css.push(rel);
|
|
1454
|
+
else if (/\.js$/i.test(entry.name)) js.push(rel);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
walk(contentDir, '');
|
|
1458
|
+
css.sort();
|
|
1459
|
+
js.sort();
|
|
1460
|
+
|
|
1461
|
+
const publicCss: string[] = [];
|
|
1462
|
+
const publicJs: string[] = [];
|
|
1463
|
+
function walkPublic(dir: string, relBase: string) {
|
|
1464
|
+
let entries: fs.Dirent[];
|
|
1465
|
+
try {
|
|
1466
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1467
|
+
} catch {
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
for (const entry of entries) {
|
|
1471
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1472
|
+
const abs = path.join(dir, entry.name);
|
|
1473
|
+
if (entry.isDirectory()) {
|
|
1474
|
+
walkPublic(abs, rel);
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
if (!entry.isFile()) continue;
|
|
1478
|
+
if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
|
|
1479
|
+
else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
walkPublic(path.join(contentDir, 'public'), '');
|
|
1483
|
+
publicCss.sort();
|
|
1484
|
+
publicJs.sort();
|
|
1485
|
+
|
|
1486
|
+
return { css, js, publicCss, publicJs };
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** The root-relative URL paths (leading `/`, e.g. `/images/hero.svg`)
|
|
1490
|
+
* referenced by the writedocs.json/styles fields that point at a static asset
|
|
1491
|
+
* *by URL* rather than embedding it inline: `styles.favicon`,
|
|
1492
|
+
* `styles.logo` (both the plain-string and `{light, dark}` object forms),
|
|
1493
|
+
* `styles.background.images.{light,dark}`, and `seo.ogImage`. External
|
|
1494
|
+
* URLs (anything not starting with `/` - `https://...`, mainly) are
|
|
1495
|
+
* filtered out, since those need no local file resolution at all.
|
|
1496
|
+
* Deduped, since e.g. `logo.light` and `background.images.light`
|
|
1497
|
+
* coincidentally pointing at the same file shouldn't resolve/copy it
|
|
1498
|
+
* twice.
|
|
1499
|
+
*
|
|
1500
|
+
* Used by `stylesAssetFallback()` (`styles-asset-integration.js`,
|
|
1501
|
+
* imported from `astro.config.mjs`) to let every one of these resolve
|
|
1502
|
+
* from *anywhere* in the project, not just `public/` - previously,
|
|
1503
|
+
* `public/` was the one place `styles.background.images` (etc.) had to
|
|
1504
|
+
* live, since Astro's own `publicDir` copy is the only thing that ever
|
|
1505
|
+
* served them; everywhere else in this codebase's own "drop a file
|
|
1506
|
+
* anywhere, it's found" convention (`findRootAssets()` right above,
|
|
1507
|
+
* `findAllPages()` for content) already worked project-wide. See that
|
|
1508
|
+
* integration's own comment for the actual resolution mechanism (a dev-time
|
|
1509
|
+
* middleware plus a post-build copy step, not a duplicated `publicDir`)
|
|
1510
|
+
* and why a straight copy-into-`public/`-on-disk approach was rejected.
|
|
1511
|
+
*
|
|
1512
|
+
* Logo light/dark resolution here deliberately mirrors BaseLayout.astro's
|
|
1513
|
+
* own `logoLight`/`logoDark` derivation exactly (a plain-string `logo`
|
|
1514
|
+
* counts as both light and dark) - two independent implementations of
|
|
1515
|
+
* that same union-unwrapping would only be one accidental edit away from
|
|
1516
|
+
* disagreeing with each other. `footer.logo` (footerSchema) gets the
|
|
1517
|
+
* same treatment, independently of `styles.logo` - both are collected
|
|
1518
|
+
* unconditionally here (not just whichever one BaseLayout.astro would
|
|
1519
|
+
* actually end up using for a given page), since this function has no
|
|
1520
|
+
* page context to know which page modes render a footer at all; an
|
|
1521
|
+
* unreferenced path collected here that never actually renders anywhere
|
|
1522
|
+
* is harmless (nothing copies/resolves a file that's never requested),
|
|
1523
|
+
* but a real footer.logo file silently 404ing because this function
|
|
1524
|
+
* didn't know to resolve it is exactly the bug this comment is warning
|
|
1525
|
+
* future edits away from repeating.
|
|
1526
|
+
*
|
|
1527
|
+
* Per-page frontmatter `seo.ogImage` overrides are deliberately out of
|
|
1528
|
+
* scope - those aren't visible from a `DocsConfig` alone (they live in
|
|
1529
|
+
* each page's own frontmatter, merged in per-request by [...slug].astro/
|
|
1530
|
+
* BaseLayout.astro), and resolving every page's own override would need
|
|
1531
|
+
* a full content scan this function has no reason to also become. A
|
|
1532
|
+
* page overriding `seo.ogImage` to something outside `public/` still
|
|
1533
|
+
* needs to put it there for now. */
|
|
1534
|
+
export function collectConfiguredAssetPaths(config: DocsConfig): string[] {
|
|
1535
|
+
const logo = config.styles.logo;
|
|
1536
|
+
const logoLight = typeof logo === 'string' ? logo : logo?.light;
|
|
1537
|
+
const logoDark = typeof logo === 'string' ? logo : logo?.dark;
|
|
1538
|
+
const footerLogo = config.footer.logo;
|
|
1539
|
+
const footerLogoLight = typeof footerLogo === 'string' ? footerLogo : footerLogo?.light;
|
|
1540
|
+
const footerLogoDark = typeof footerLogo === 'string' ? footerLogo : footerLogo?.dark;
|
|
1541
|
+
const fonts = config.styles.fonts;
|
|
1542
|
+
const raw: (string | undefined)[] = [
|
|
1543
|
+
config.styles.favicon,
|
|
1544
|
+
logoLight,
|
|
1545
|
+
logoDark,
|
|
1546
|
+
footerLogoLight,
|
|
1547
|
+
footerLogoDark,
|
|
1548
|
+
config.styles.background?.images?.light,
|
|
1549
|
+
config.styles.background?.images?.dark,
|
|
1550
|
+
config.seo?.ogImage,
|
|
1551
|
+
// A `styles.fonts` `source` is only ever handled here when it's a
|
|
1552
|
+
// project-relative path (the `p.startsWith('/')` filter below already
|
|
1553
|
+
// excludes both Google Font names, which never start with "/", and a
|
|
1554
|
+
// full https:// external font URL, which the browser fetches directly
|
|
1555
|
+
// - neither needs resolving/copying through this pipeline at all).
|
|
1556
|
+
fonts?.source,
|
|
1557
|
+
fonts?.heading?.source,
|
|
1558
|
+
fonts?.body?.source,
|
|
1559
|
+
];
|
|
1560
|
+
const paths = raw.filter((p): p is string => Boolean(p) && p.startsWith('/'));
|
|
1561
|
+
return Array.from(new Set(paths));
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
export interface DocsEntryLike {
|
|
1565
|
+
id: string;
|
|
1566
|
+
filePath?: string;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** Recovers a content entry's writedocs.json-facing file id (its path
|
|
1570
|
+
* relative to the content directory, extension stripped, trailing
|
|
1571
|
+
* `/index` dropped), independent of any frontmatter `slug` override -
|
|
1572
|
+
* `entry.filePath` is always root-relative and POSIX-separated (how
|
|
1573
|
+
* Astro's content layer records it, relative to whatever `--root` Astro
|
|
1574
|
+
* itself was invoked with - see run-astro.js), so both it and the
|
|
1575
|
+
* content directory are resolved to absolute paths before comparing,
|
|
1576
|
+
* rather than string-matching a prefix that could be relative,
|
|
1577
|
+
* absolute, or platform-separated inconsistently.
|
|
1578
|
+
*
|
|
1579
|
+
* The trailing-`/index`-drop mirrors Astro's own default id computation
|
|
1580
|
+
* exactly (`getContentEntryIdAndSlug()` in astro/dist/content/
|
|
1581
|
+
* utils.js: segments are joined then `.replace(/\/index$/, '')`) -
|
|
1582
|
+
* without it, a nested index file like `docs/guides/index.mdx` (Astro's
|
|
1583
|
+
* own default id: "docs/guides") would recover the wrong file id here
|
|
1584
|
+
* ("docs/guides/index"), and a writedocs.json reference written to match the
|
|
1585
|
+
* page's real URL would fail to resolve. A single-segment `index.mdx`
|
|
1586
|
+
* at the content root is unaffected either way - the regex requires a
|
|
1587
|
+
* preceding `/`, which a bare "index" doesn't have (matching Astro's
|
|
1588
|
+
* own behavior: a root-level index.mdx keeps file id "index", not "").
|
|
1589
|
+
*
|
|
1590
|
+
* Full per-segment slugification (github-slugger, applied by Astro to
|
|
1591
|
+
* every path segment) is deliberately *not* replicated here - every
|
|
1592
|
+
* filename in this codebase's own fixtures and every filename this
|
|
1593
|
+
* function needs to have handled correctly is already slug-safe
|
|
1594
|
+
* (lowercase, hyphenated, no spaces/unicode), so slugification is
|
|
1595
|
+
* always a no-op in practice; only the `/index`-stripping behavior is
|
|
1596
|
+
* reproduced, since that's the one part of Astro's algorithm this
|
|
1597
|
+
* change newly exercises (a file that used to be a collection's own
|
|
1598
|
+
* base-root index, exempt from stripping, and is now nested one level
|
|
1599
|
+
* deeper).
|
|
1600
|
+
*
|
|
1601
|
+
* Entries from the `generatedDocs` collection (auto-generated OpenAPI
|
|
1602
|
+
* stub pages - see content.config.ts / generate-api-pages.js) live
|
|
1603
|
+
* under writedocsTempDir()'s generated-docs/ directory - an OS temp
|
|
1604
|
+
* directory location entirely outside contentDir, not a subdirectory
|
|
1605
|
+
* of it - so `relativeToContentDir` for one of these always starts with
|
|
1606
|
+
* `..` (walking back out of contentDir to reach it), and the check
|
|
1607
|
+
* right below falls back to `entry.id` instead of computing a
|
|
1608
|
+
* nonsensical id relative to a directory the file was never actually
|
|
1609
|
+
* under. Those entries always set their own `slug` frontmatter
|
|
1610
|
+
* explicitly, so there's no independent "file path" identity worth
|
|
1611
|
+
* recovering for them the way there is for a hand-written page that
|
|
1612
|
+
* might move around - `entry.id` (Astro's already-resolved slug)
|
|
1613
|
+
* already IS the exact value generate-api-pages.js's own manifest
|
|
1614
|
+
* references them by. */
|
|
1615
|
+
export function fileIdForEntry(
|
|
1616
|
+
contentDir: string,
|
|
1617
|
+
packageRoot: string,
|
|
1618
|
+
entry: DocsEntryLike
|
|
1619
|
+
): string {
|
|
1620
|
+
if (!entry.filePath) return entry.id;
|
|
1621
|
+
const absoluteContentDir = path.resolve(contentDir);
|
|
1622
|
+
const absoluteFilePath = path.resolve(packageRoot, entry.filePath);
|
|
1623
|
+
const relativeToContentDir = path.relative(absoluteContentDir, absoluteFilePath);
|
|
1624
|
+
if (relativeToContentDir.startsWith('..')) return entry.id;
|
|
1625
|
+
const posixRelative = relativeToContentDir.split(path.sep).join('/');
|
|
1626
|
+
if (EXCLUDED_TOP_LEVEL_DIRS.has(posixRelative.split('/')[0])) return entry.id;
|
|
1627
|
+
return posixRelative.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** Normalizes a raw `entry.id` into the bare, slash-free form every
|
|
1631
|
+
* route/href in this codebase assumes. Once a page sets a frontmatter
|
|
1632
|
+
* `slug`, `entry.id` becomes that value completely verbatim - Astro's
|
|
1633
|
+
* glob loader applies no normalization of its own (see
|
|
1634
|
+
* generateIdDefault in astro/dist/content/loaders/glob.js) - so
|
|
1635
|
+
* `slug: /` or `slug: /guides/new-name` (both natural things to write,
|
|
1636
|
+
* mirroring how every href elsewhere in writedocs.json already has a
|
|
1637
|
+
* leading slash) would otherwise produce broken multi-slash hrefs
|
|
1638
|
+
* wherever hrefForSlug wraps the value in its own `/${slug}/`, and a
|
|
1639
|
+
* bare "/" would additionally fail to be recognized as claiming root,
|
|
1640
|
+
* colliding with the synthetic root-redirect route. */
|
|
1641
|
+
export function normalizeEntryId(id: string): string {
|
|
1642
|
+
const trimmed = id.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
1643
|
+
return trimmed === '' ? 'index' : trimmed;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
export interface FlatNavEntry {
|
|
1647
|
+
slug: string;
|
|
1648
|
+
group: string | null;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
export function flattenNav(navigation: NavItem[], group: string | null = null): FlatNavEntry[] {
|
|
1652
|
+
return navigation.flatMap((item): FlatNavEntry[] => {
|
|
1653
|
+
if (typeof item === 'string') {
|
|
1654
|
+
return [{ slug: item, group }];
|
|
1655
|
+
}
|
|
1656
|
+
if ('href' in item) return []; // external link leaf, not a content page - no route/prev-next entry
|
|
1657
|
+
// loadDocsConfig() always expands `{ group, openapi }` shorthand into
|
|
1658
|
+
// a real `{ group, pages }` before anything reaches here (see
|
|
1659
|
+
// expandOpenApiInNavigation() above) - this is just a defensive
|
|
1660
|
+
// no-op for the shouldn't-happen case of an unexpanded node.
|
|
1661
|
+
if ('openapi' in item) return [];
|
|
1662
|
+
const ownPage: FlatNavEntry[] = item.page ? [{ slug: item.page, group: item.group }] : [];
|
|
1663
|
+
return [...ownPage, ...flattenNav(item.pages, item.group)];
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// --- Sections -------------------------------------------------------
|
|
1668
|
+
//
|
|
1669
|
+
// A Section is the atomic unit that owns one page tree and therefore one
|
|
1670
|
+
// sidebar - every real content page belongs to exactly one Section,
|
|
1671
|
+
// whichever `pages` leaf it's listed under, however deep in the
|
|
1672
|
+
// tabs/versions/languages/dropdowns/products tree that leaf lives.
|
|
1673
|
+
//
|
|
1674
|
+
// A Section's `path` records the full chain of containers from the
|
|
1675
|
+
// navigation root down to it, one PathSegment per level. This is
|
|
1676
|
+
// everything the topbar needs to render the right selector controls
|
|
1677
|
+
// (tabs bar, version dropdown, ...) and highlight the right option in
|
|
1678
|
+
// each - without the router or layout needing to know how deep or in
|
|
1679
|
+
// what order the site author nested things.
|
|
1680
|
+
|
|
1681
|
+
export type PathSegment =
|
|
1682
|
+
| { kind: 'tab'; items: TabItem[]; index: number }
|
|
1683
|
+
| { kind: 'version'; items: VersionItem[]; index: number }
|
|
1684
|
+
| { kind: 'language'; items: LanguageItem[]; index: number }
|
|
1685
|
+
| { kind: 'dropdown'; items: DropdownItem[]; index: number }
|
|
1686
|
+
| { kind: 'product'; items: ProductItem[]; index: number };
|
|
1687
|
+
|
|
1688
|
+
export interface Section {
|
|
1689
|
+
pages: NavItem[];
|
|
1690
|
+
path: PathSegment[];
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
type Container = NavChildren;
|
|
1694
|
+
type NamedContainer = TabItem | VersionItem | LanguageItem | DropdownItem | ProductItem;
|
|
1695
|
+
|
|
1696
|
+
function walkSections(node: Container, path: PathSegment[], out: Section[]): void {
|
|
1697
|
+
if ('pages' in node) {
|
|
1698
|
+
out.push({ pages: node.pages, path });
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
if ('href' in node) return; // external link, not a Section
|
|
1702
|
+
if ('tabs' in node) {
|
|
1703
|
+
node.tabs.forEach((item, index) =>
|
|
1704
|
+
walkSections(item, [...path, { kind: 'tab', items: node.tabs, index }], out)
|
|
1705
|
+
);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if ('versions' in node) {
|
|
1709
|
+
node.versions.forEach((item, index) =>
|
|
1710
|
+
walkSections(item, [...path, { kind: 'version', items: node.versions, index }], out)
|
|
1711
|
+
);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
if ('languages' in node) {
|
|
1715
|
+
node.languages.forEach((item, index) =>
|
|
1716
|
+
walkSections(item, [...path, { kind: 'language', items: node.languages, index }], out)
|
|
1717
|
+
);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
if ('dropdowns' in node) {
|
|
1721
|
+
node.dropdowns.forEach((item, index) =>
|
|
1722
|
+
walkSections(item, [...path, { kind: 'dropdown', items: node.dropdowns, index }], out)
|
|
1723
|
+
);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
if ('products' in node) {
|
|
1727
|
+
node.products.forEach((item, index) =>
|
|
1728
|
+
walkSections(item, [...path, { kind: 'product', items: node.products, index }], out)
|
|
1729
|
+
);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
export function resolveSections(navigation: NavigationConfig): Section[] {
|
|
1735
|
+
if (Array.isArray(navigation)) return [{ pages: navigation, path: [] }];
|
|
1736
|
+
const sections: Section[] = [];
|
|
1737
|
+
walkSections(navigation as Container, [], sections);
|
|
1738
|
+
// global.dropdowns sits outside the primary pattern, but its pages
|
|
1739
|
+
// still need routes generated for them - walk each entry too, with an
|
|
1740
|
+
// empty path (they don't participate in the tabs/version/etc.
|
|
1741
|
+
// selector chain, only in the always-visible globalDropdowns list).
|
|
1742
|
+
for (const dropdown of navigation.global?.dropdowns ?? []) {
|
|
1743
|
+
walkSections(dropdown, [], sections);
|
|
1744
|
+
}
|
|
1745
|
+
return sections;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/** Which Section (by index into resolveSections()'s result) a page belongs to. */
|
|
1749
|
+
export function findSectionIndexForSlug(sections: Section[], slug: string): number {
|
|
1750
|
+
const index = sections.findIndex((s) => flattenNav(s.pages).some((entry) => entry.slug === slug));
|
|
1751
|
+
return index === -1 ? 0 : index;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/** The always-visible topbar dropdown list - independent of whichever
|
|
1755
|
+
* primary pattern/section is active (Mintlify's equivalent is
|
|
1756
|
+
* `navigation.global.anchors`). */
|
|
1757
|
+
export function resolveGlobalDropdowns(navigation: NavigationConfig): DropdownItem[] {
|
|
1758
|
+
if (Array.isArray(navigation)) return [];
|
|
1759
|
+
return navigation.global?.dropdowns ?? [];
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/** The first real page slug reachable by descending into a container's
|
|
1763
|
+
* content, however deep - used to compute where a selector option (a
|
|
1764
|
+
* tab, a version, ...) navigates to when chosen. Null for an external
|
|
1765
|
+
* `href` leaf, which the caller renders as a plain link using the
|
|
1766
|
+
* node's own href instead. Versions prefer whichever entry is marked
|
|
1767
|
+
* `default` (falling back to the first), matching Mintlify's version
|
|
1768
|
+
* default rule. */
|
|
1769
|
+
/** Tries `firstSlugOf()` against each item in order, returning the first
|
|
1770
|
+
* non-null result - a plain `items[0]` pick (the previous behavior)
|
|
1771
|
+
* breaks the moment that first sibling happens to be a bare `href` leaf
|
|
1772
|
+
* (returns null, with nothing that tries the next one), which is a real
|
|
1773
|
+
* case now that any container - not just a nested dropdown - can be a
|
|
1774
|
+
* bare external link (e.g. a `tabs` array whose first entry is
|
|
1775
|
+
* `{ tab: "Status", href: "..." }`). */
|
|
1776
|
+
function firstSlugAmong(items: Container[]): string | null {
|
|
1777
|
+
for (const item of items) {
|
|
1778
|
+
const slug = firstSlugOf(item);
|
|
1779
|
+
if (slug !== null) return slug;
|
|
1780
|
+
}
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
export function firstSlugOf(node: Container): string | null {
|
|
1785
|
+
if ('pages' in node) return flattenNav(node.pages)[0]?.slug ?? null;
|
|
1786
|
+
if ('href' in node) return null;
|
|
1787
|
+
if ('tabs' in node) return firstSlugAmong(node.tabs);
|
|
1788
|
+
if ('versions' in node) {
|
|
1789
|
+
// Try the `default`-tagged version(s) first (matching the old
|
|
1790
|
+
// "preferred" pick), then fall through to the rest in order - same
|
|
1791
|
+
// href-leaf-with-no-fallback concern as `tabs` above, just with an
|
|
1792
|
+
// extra preference pass in front of it.
|
|
1793
|
+
const defaults = node.versions.filter((v) => v.default);
|
|
1794
|
+
const rest = node.versions.filter((v) => !v.default);
|
|
1795
|
+
return firstSlugAmong([...defaults, ...rest]);
|
|
1796
|
+
}
|
|
1797
|
+
if ('languages' in node) return firstSlugAmong(node.languages);
|
|
1798
|
+
if ('dropdowns' in node) return firstSlugAmong(node.dropdowns);
|
|
1799
|
+
if ('products' in node) return firstSlugAmong(node.products);
|
|
1800
|
+
return null;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/** For a version/language switcher, finds where the reader's current
|
|
1804
|
+
* page would live inside a *different* version/language's own subtree,
|
|
1805
|
+
* so switching keeps them on the same conceptual page instead of always
|
|
1806
|
+
* bouncing to that option's first page (see buildSelectors() below,
|
|
1807
|
+
* which is the only caller). "Same page" is approximated positionally
|
|
1808
|
+
* rather than by name, since nothing guarantees a version/language's
|
|
1809
|
+
* own identifier lines up with its pages' file paths - a version
|
|
1810
|
+
* tagged "2025-09" could just as easily keep its pages under a "v1"
|
|
1811
|
+
* folder with no relation to that string, so there's no naming
|
|
1812
|
+
* convention to key off safely; this heuristic only ever compares tree
|
|
1813
|
+
* position, never path text. (docs.json-examples/00-kitchen-sink/'s own
|
|
1814
|
+
* versions - "2025-09"/"2026-01" under Core Platform - happen to have
|
|
1815
|
+
* folder names that line up with their version strings, so it doesn't
|
|
1816
|
+
* demonstrate the mismatched-folder-name case directly; the guarantee
|
|
1817
|
+
* still doesn't exist regardless of what one fixture happens to do.)
|
|
1818
|
+
*
|
|
1819
|
+
* Two things have to line up for a page to count as "the same" one:
|
|
1820
|
+
* first, `remainingPath` (whatever tab/product/dropdown was chosen
|
|
1821
|
+
* *below* the version/language being switched, on the reader's actual
|
|
1822
|
+
* page) has to exist at the same index in `item`'s own subtree too -
|
|
1823
|
+
* an option isn't guaranteed to nest the same way that many levels
|
|
1824
|
+
* down (a version could add/remove a tab), so any mismatch here bails
|
|
1825
|
+
* out to null immediately rather than guessing. Second, once both
|
|
1826
|
+
* bottom out at a leaf `pages` list, `position` (the reader's own page
|
|
1827
|
+
* index within *its* pages list) has to exist in `item`'s pages list
|
|
1828
|
+
* too - two versions/languages of the same docs are usually authored
|
|
1829
|
+
* with matching page order even when the file paths differ, so this
|
|
1830
|
+
* is a reasonable proxy for "the same page" without relying on names.
|
|
1831
|
+
*
|
|
1832
|
+
* Returns null - meaning "no equivalent page, fall back to
|
|
1833
|
+
* firstSlugOf()" - on any structural mismatch or an out-of-range
|
|
1834
|
+
* position, rather than guessing at a wrong page. */
|
|
1835
|
+
export function equivalentPageIn(
|
|
1836
|
+
item: Container,
|
|
1837
|
+
remainingPath: PathSegment[],
|
|
1838
|
+
position: number
|
|
1839
|
+
): string | null {
|
|
1840
|
+
if ('pages' in item) return flattenNav(item.pages)[position]?.slug ?? null;
|
|
1841
|
+
if ('href' in item) return null;
|
|
1842
|
+
const [next, ...rest] = remainingPath;
|
|
1843
|
+
if (!next) return null; // the reader's own page didn't go this deep - no basis to pick a branch
|
|
1844
|
+
if ('tabs' in item && next.kind === 'tab') {
|
|
1845
|
+
const target = item.tabs[next.index];
|
|
1846
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1847
|
+
}
|
|
1848
|
+
if ('versions' in item && next.kind === 'version') {
|
|
1849
|
+
const target = item.versions[next.index];
|
|
1850
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1851
|
+
}
|
|
1852
|
+
if ('languages' in item && next.kind === 'language') {
|
|
1853
|
+
const target = item.languages[next.index];
|
|
1854
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1855
|
+
}
|
|
1856
|
+
if ('dropdowns' in item && next.kind === 'dropdown') {
|
|
1857
|
+
const target = item.dropdowns[next.index];
|
|
1858
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1859
|
+
}
|
|
1860
|
+
if ('products' in item && next.kind === 'product') {
|
|
1861
|
+
const target = item.products[next.index];
|
|
1862
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1863
|
+
}
|
|
1864
|
+
return null; // structural mismatch - this option nests differently at this depth
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/** The first real page slug in the whole site, root navigation pattern
|
|
1868
|
+
* included - used to redirect `/` somewhere sensible when nothing in
|
|
1869
|
+
* the navigation happens to be a page literally named "index" (the
|
|
1870
|
+
* usual "docs/index.mdx is the homepage" convention). Mirrors
|
|
1871
|
+
* firstSlugOf()'s per-container descent, plus the flat-array root case
|
|
1872
|
+
* firstSlugOf() alone can't handle since a bare array isn't a Container. */
|
|
1873
|
+
export function firstSlugOfNavigation(navigation: NavigationConfig): string | null {
|
|
1874
|
+
if (Array.isArray(navigation)) return flattenNav(navigation)[0]?.slug ?? null;
|
|
1875
|
+
return firstSlugOf(navigation as Container);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/** Whether `slug` is reachable anywhere underneath a container, however
|
|
1879
|
+
* deep - used for computing active state on nodes that aren't part of
|
|
1880
|
+
* the active Section's own `path` (global dropdown entries). */
|
|
1881
|
+
export function containerContainsSlug(node: Container, slug: string): boolean {
|
|
1882
|
+
if ('pages' in node) return flattenNav(node.pages).some((entry) => entry.slug === slug);
|
|
1883
|
+
if ('href' in node) return false;
|
|
1884
|
+
if ('tabs' in node) return node.tabs.some((t) => containerContainsSlug(t, slug));
|
|
1885
|
+
if ('versions' in node) return node.versions.some((v) => containerContainsSlug(v, slug));
|
|
1886
|
+
if ('languages' in node) return node.languages.some((l) => containerContainsSlug(l, slug));
|
|
1887
|
+
if ('dropdowns' in node) return node.dropdowns.some((d) => containerContainsSlug(d, slug));
|
|
1888
|
+
if ('products' in node) return node.products.some((p) => containerContainsSlug(p, slug));
|
|
1889
|
+
return false;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function labelOf(item: NamedContainer): string {
|
|
1893
|
+
if ('tab' in item) return item.tab;
|
|
1894
|
+
if ('version' in item) return item.label ?? item.version;
|
|
1895
|
+
if ('language' in item) return item.label ?? item.language;
|
|
1896
|
+
if ('dropdown' in item) return item.dropdown;
|
|
1897
|
+
return item.product;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function iconOf(item: NamedContainer): string | undefined {
|
|
1901
|
+
return (item as { icon?: string }).icon;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// --- Topbar selectors -------------------------------------------------
|
|
1905
|
+
//
|
|
1906
|
+
// One Selector per PathSegment on the active Section's path. Tabs render
|
|
1907
|
+
// as the horizontal pill bar (all options always visible, exactly one
|
|
1908
|
+
// current); versions/languages/products, and dropdowns used as a nested
|
|
1909
|
+
// path segment, render as a single switcher control instead - the
|
|
1910
|
+
// trigger shows the *current* option, its menu lists the alternatives.
|
|
1911
|
+
// See BaseLayout.astro for the actual markup per kind.
|
|
1912
|
+
|
|
1913
|
+
export interface SelectorOption {
|
|
1914
|
+
label: string;
|
|
1915
|
+
icon?: string;
|
|
1916
|
+
tag?: string;
|
|
1917
|
+
href: string;
|
|
1918
|
+
active: boolean;
|
|
1919
|
+
// Set when this option's own container is a `dropdowns` list (e.g. a
|
|
1920
|
+
// tab whose content is `dropdowns` instead of `pages`) - the tab pill
|
|
1921
|
+
// itself becomes a dropdown-trigger showing these as its menu, rather
|
|
1922
|
+
// than a separate dropdown control rendered alongside it. Only ever
|
|
1923
|
+
// populated one level deep (a dropdown option doesn't itself get a
|
|
1924
|
+
// nested `dropdown` - matches the one level of "a tab/dropdown owns a
|
|
1925
|
+
// dropdowns list" this is meant to cover).
|
|
1926
|
+
dropdown?: SelectorOption[];
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
export interface Selector {
|
|
1930
|
+
kind: PathSegment['kind'];
|
|
1931
|
+
options: SelectorOption[];
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/** An option's own `dropdowns` menu, if it has one (see SelectorOption.dropdown
|
|
1935
|
+
* above) - `activeSegment` is the *next* PathSegment after this option's own
|
|
1936
|
+
* segment, only passed when this option is the active one on its level, so a
|
|
1937
|
+
* sibling option that also happens to own `dropdowns` doesn't spuriously mark
|
|
1938
|
+
* one of its entries active just because some *other* option is currently
|
|
1939
|
+
* selected. */
|
|
1940
|
+
function dropdownMenuOf(
|
|
1941
|
+
node: NavChildren,
|
|
1942
|
+
hrefForSlug: (slug: string) => string,
|
|
1943
|
+
activeSegment: PathSegment | undefined
|
|
1944
|
+
): SelectorOption[] | undefined {
|
|
1945
|
+
if (!('dropdowns' in node)) return undefined;
|
|
1946
|
+
return node.dropdowns.map((d, i) => ({
|
|
1947
|
+
label: d.dropdown,
|
|
1948
|
+
icon: iconOf(d),
|
|
1949
|
+
href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
|
|
1950
|
+
active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
|
|
1951
|
+
}));
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/** `activePagePosition` is the reader's current page's own index within
|
|
1955
|
+
* its Section's flattened pages list (-1 if it can't be found there,
|
|
1956
|
+
* which just disables the position-preserving behavior below) - see
|
|
1957
|
+
* [...slug].astro for how it's computed. Only version/language
|
|
1958
|
+
* switchers try to preserve position across options; tabs/dropdowns/
|
|
1959
|
+
* products keep linking to firstSlugOf() unconditionally, since those
|
|
1960
|
+
* represent genuinely different content (an "API Reference" tab isn't
|
|
1961
|
+
* "the same page" as a "Guides" tab just because they're both first),
|
|
1962
|
+
* unlike a version/language of what's meant to be the same docs. */
|
|
1963
|
+
export function buildSelectors(
|
|
1964
|
+
path: PathSegment[],
|
|
1965
|
+
hrefForSlug: (slug: string) => string,
|
|
1966
|
+
activePagePosition: number = -1
|
|
1967
|
+
): Selector[] {
|
|
1968
|
+
return path.map((segment, segIndex): Selector => {
|
|
1969
|
+
const preservesPosition =
|
|
1970
|
+
(segment.kind === 'version' || segment.kind === 'language') && activePagePosition >= 0;
|
|
1971
|
+
const remainingPath = path.slice(segIndex + 1);
|
|
1972
|
+
return {
|
|
1973
|
+
kind: segment.kind,
|
|
1974
|
+
options: (segment.items as NamedContainer[]).map((item, i) => {
|
|
1975
|
+
const isActive = i === segment.index;
|
|
1976
|
+
const equivalentSlug = preservesPosition
|
|
1977
|
+
? equivalentPageIn(item as Container, remainingPath, activePagePosition)
|
|
1978
|
+
: null;
|
|
1979
|
+
const targetSlug = equivalentSlug ?? firstSlugOf(item as Container) ?? 'index';
|
|
1980
|
+
return {
|
|
1981
|
+
label: labelOf(item),
|
|
1982
|
+
icon: iconOf(item),
|
|
1983
|
+
tag: 'tag' in item ? item.tag : undefined,
|
|
1984
|
+
href: 'href' in item ? item.href : hrefForSlug(targetSlug),
|
|
1985
|
+
active: isActive,
|
|
1986
|
+
dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
|
|
1987
|
+
};
|
|
1988
|
+
}),
|
|
1989
|
+
};
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// --- Global dropdowns ---------------------------------------------------
|
|
1994
|
+
//
|
|
1995
|
+
// Unlike path-segment selectors, global dropdowns aren't a mutually
|
|
1996
|
+
// exclusive "pick one" choice - `global.dropdowns` is an array where
|
|
1997
|
+
// *every* entry renders as its own always-visible trigger button
|
|
1998
|
+
// simultaneously (Mintlify's anchors work the same way). A trigger's
|
|
1999
|
+
// menu lists its own direct pages as quick links; a bare-href entry is
|
|
2000
|
+
// just a plain link with no menu; a deeply-nested entry (tabs/versions/
|
|
2001
|
+
// etc. instead of direct pages) falls back to linking straight to its
|
|
2002
|
+
// first page, since building a rich flyout for that combination isn't
|
|
2003
|
+
// worth the complexity for what's meant to be a quick-links affordance.
|
|
2004
|
+
|
|
2005
|
+
export interface GlobalDropdownView {
|
|
2006
|
+
label: string;
|
|
2007
|
+
icon?: string;
|
|
2008
|
+
href: string | null;
|
|
2009
|
+
items: SelectorOption[];
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
export function buildGlobalDropdowns(
|
|
2013
|
+
dropdowns: DropdownItem[],
|
|
2014
|
+
currentSlug: string,
|
|
2015
|
+
titleForSlug: (slug: string) => string,
|
|
2016
|
+
hrefForSlug: (slug: string) => string
|
|
2017
|
+
): GlobalDropdownView[] {
|
|
2018
|
+
return dropdowns.map((d): GlobalDropdownView => {
|
|
2019
|
+
if ('href' in d) {
|
|
2020
|
+
return { label: d.dropdown, icon: d.icon, href: d.href, items: [] };
|
|
2021
|
+
}
|
|
2022
|
+
if ('pages' in d) {
|
|
2023
|
+
const items = flattenNav(d.pages).map((entry) => ({
|
|
2024
|
+
label: titleForSlug(entry.slug),
|
|
2025
|
+
href: hrefForSlug(entry.slug),
|
|
2026
|
+
active: entry.slug === currentSlug,
|
|
2027
|
+
}));
|
|
2028
|
+
return { label: d.dropdown, icon: d.icon, href: null, items };
|
|
2029
|
+
}
|
|
2030
|
+
const slug = firstSlugOf(d);
|
|
2031
|
+
return { label: d.dropdown, icon: d.icon, href: slug ? hrefForSlug(slug) : '#', items: [] };
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// --- Sidebar (recursive) -------------------------------------------------
|
|
2036
|
+
|
|
2037
|
+
// A group node's `pageSlug` is the page its own label links to (null if
|
|
2038
|
+
// it's a pure disclosure/label with no page of its own - the group only
|
|
2039
|
+
// groups). NavTree.astro renders depth-0 groups as static, non-collapsible
|
|
2040
|
+
// section titles (linked if `pageSlug` is set, plain text otherwise) and
|
|
2041
|
+
// every deeper group as a collapsible row styled like a page item, with a
|
|
2042
|
+
// chevron, defaulting open when it contains the active page - see
|
|
2043
|
+
// navTreeContainsSlug() below.
|
|
2044
|
+
export type NavTreeNode =
|
|
2045
|
+
| { kind: 'page'; slug: string; title: string; method: string | null }
|
|
2046
|
+
| { kind: 'group'; label: string; pageSlug: string | null; children: NavTreeNode[] }
|
|
2047
|
+
| { kind: 'link'; label: string; href: string };
|
|
2048
|
+
|
|
2049
|
+
/** Builds the sidebar's view model, preserving group nesting depth.
|
|
2050
|
+
* `methodForSlug` is optional (most sites have no OpenAPI pages at all)
|
|
2051
|
+
* and, when given, returns the HTTP method to badge a page with in the
|
|
2052
|
+
* sidebar (e.g. "GET") or null for an ordinary page - see
|
|
2053
|
+
* NavTree.astro for how that badge renders. */
|
|
2054
|
+
export function buildNavTree(
|
|
2055
|
+
navigation: NavItem[],
|
|
2056
|
+
titleForSlug: (slug: string) => string,
|
|
2057
|
+
methodForSlug?: (slug: string) => string | null
|
|
2058
|
+
): NavTreeNode[] {
|
|
2059
|
+
return navigation.map((item): NavTreeNode => {
|
|
2060
|
+
if (typeof item === 'string') {
|
|
2061
|
+
return {
|
|
2062
|
+
kind: 'page',
|
|
2063
|
+
slug: item,
|
|
2064
|
+
title: titleForSlug(item),
|
|
2065
|
+
method: methodForSlug?.(item) ?? null,
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
if ('href' in item) {
|
|
2069
|
+
return { kind: 'link', label: item.label, href: item.href };
|
|
2070
|
+
}
|
|
2071
|
+
// loadDocsConfig() always expands `{ group, openapi }` shorthand into
|
|
2072
|
+
// a real `{ group, pages }` before anything reaches here (see
|
|
2073
|
+
// expandOpenApiInNavigation() in the OpenAPI section above) - this is
|
|
2074
|
+
// just a defensive no-op for the shouldn't-happen case of an
|
|
2075
|
+
// unexpanded node reaching the sidebar builder.
|
|
2076
|
+
if ('openapi' in item) {
|
|
2077
|
+
return { kind: 'group', label: item.group, pageSlug: null, children: [] };
|
|
2078
|
+
}
|
|
2079
|
+
return {
|
|
2080
|
+
kind: 'group',
|
|
2081
|
+
label: item.group,
|
|
2082
|
+
pageSlug: item.page ?? null,
|
|
2083
|
+
children: buildNavTree(item.pages, titleForSlug, methodForSlug),
|
|
2084
|
+
};
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/** Whether `slug` is the group's own attached page, or belongs to any page/group nested inside it. */
|
|
2089
|
+
export function navTreeContainsSlug(nodes: NavTreeNode[], slug: string): boolean {
|
|
2090
|
+
return nodes.some((node) => {
|
|
2091
|
+
if (node.kind === 'page') return node.slug === slug;
|
|
2092
|
+
if (node.kind === 'link') return false;
|
|
2093
|
+
return node.pageSlug === slug || navTreeContainsSlug(node.children, slug);
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
export interface BreadcrumbCrumb {
|
|
2098
|
+
label: string;
|
|
2099
|
+
href: string | null;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/** The chain of ancestor group labels (root first) that `slug` is nested
|
|
2103
|
+
* under within `nodes` - empty when the page sits at the top level of its
|
|
2104
|
+
* Section with no enclosing group at all, in which case the caller should
|
|
2105
|
+
* skip rendering breadcrumbs entirely (there'd be nothing to show but the
|
|
2106
|
+
* fixed home icon Breadcrumbs.astro always renders first).
|
|
2107
|
+
*
|
|
2108
|
+
* Deliberately never includes the current page itself - only the groups
|
|
2109
|
+
* it's nested under (that's already the <h1> right below, repeating it
|
|
2110
|
+
* in the breadcrumb trail would just be noise). A group whose own
|
|
2111
|
+
* attached page (`pageSlug`, see NavTreeNode) *is* `slug` is excluded
|
|
2112
|
+
* from its own trail for the same reason - you're looking at that page,
|
|
2113
|
+
* it doesn't need to also list itself as its own ancestor. A group only
|
|
2114
|
+
* appears here when `slug` is nested *inside* it (its own page, if any,
|
|
2115
|
+
* links to that group's landing page - `href: null` for a label-only
|
|
2116
|
+
* group with no page of its own to link to). */
|
|
2117
|
+
export function ancestorGroupsForSlug(
|
|
2118
|
+
nodes: NavTreeNode[],
|
|
2119
|
+
slug: string,
|
|
2120
|
+
hrefForSlug: (slug: string) => string
|
|
2121
|
+
): BreadcrumbCrumb[] {
|
|
2122
|
+
for (const node of nodes) {
|
|
2123
|
+
if (node.kind !== 'group') continue;
|
|
2124
|
+
if (node.pageSlug === slug) return [];
|
|
2125
|
+
if (navTreeContainsSlug(node.children, slug)) {
|
|
2126
|
+
const crumb: BreadcrumbCrumb = { label: node.label, href: node.pageSlug ? hrefForSlug(node.pageSlug) : null };
|
|
2127
|
+
return [crumb, ...ancestorGroupsForSlug(node.children, slug, hrefForSlug)];
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
return [];
|
|
2131
|
+
}
|