@writedocs/generator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +17 -0
  3. package/astro.config.mjs +419 -0
  4. package/bin/writedocs.js +73 -0
  5. package/package.json +79 -0
  6. package/src/assets/wd_watermark.png +0 -0
  7. package/src/assets/wd_watermark_dark.png +0 -0
  8. package/src/cli/build-auth.js +53 -0
  9. package/src/cli/build.js +40 -0
  10. package/src/cli/dev.js +12 -0
  11. package/src/cli/generate-api-pages.js +359 -0
  12. package/src/cli/init.js +81 -0
  13. package/src/cli/preflight.js +40 -0
  14. package/src/cli/run-astro.js +57 -0
  15. package/src/cli/run-pagefind.js +66 -0
  16. package/src/cli/write-redirects-file.js +80 -0
  17. package/src/components/Accordion.astro +164 -0
  18. package/src/components/AccordionGroup.astro +40 -0
  19. package/src/components/ApiLangSelect.astro +168 -0
  20. package/src/components/ApiPlayground.astro +281 -0
  21. package/src/components/ApiReferencePanel.astro +1754 -0
  22. package/src/components/ApiSchemaField.astro +54 -0
  23. package/src/components/AppIcon.astro +32 -0
  24. package/src/components/Badge.astro +128 -0
  25. package/src/components/Callout.astro +168 -0
  26. package/src/components/Card.astro +136 -0
  27. package/src/components/CardGroup.astro +20 -0
  28. package/src/components/CodeGroup.astro +184 -0
  29. package/src/components/CopyPageMenu.astro +246 -0
  30. package/src/components/Danger.astro +12 -0
  31. package/src/components/Expandable.astro +126 -0
  32. package/src/components/Frame.astro +102 -0
  33. package/src/components/Hint.astro +99 -0
  34. package/src/components/Icon.astro +70 -0
  35. package/src/components/Image.astro +147 -0
  36. package/src/components/Info.astro +12 -0
  37. package/src/components/Note.astro +12 -0
  38. package/src/components/Parameter.astro +119 -0
  39. package/src/components/RequestExample.astro +33 -0
  40. package/src/components/ResponseExample.astro +19 -0
  41. package/src/components/Searchbar.astro +117 -0
  42. package/src/components/Step.astro +10 -0
  43. package/src/components/Steps.astro +32 -0
  44. package/src/components/Tab.astro +9 -0
  45. package/src/components/Tabs.astro +52 -0
  46. package/src/components/Tip.astro +12 -0
  47. package/src/components/Video.astro +135 -0
  48. package/src/components/Warning.astro +12 -0
  49. package/src/components/index.ts +48 -0
  50. package/src/content.config.ts +223 -0
  51. package/src/layout/BaseLayout.astro +750 -0
  52. package/src/layout/components/AnalyticsScripts.astro +77 -0
  53. package/src/layout/components/AskAiWidget.astro +37 -0
  54. package/src/layout/components/Breadcrumbs.astro +97 -0
  55. package/src/layout/components/ImageZoom.astro +19 -0
  56. package/src/layout/components/MobileMenu.astro +200 -0
  57. package/src/layout/components/NavTree.astro +351 -0
  58. package/src/layout/components/SearchModal.astro +42 -0
  59. package/src/layout/components/Sidebar.astro +122 -0
  60. package/src/layout/components/SiteFooter.astro +85 -0
  61. package/src/layout/components/TableOfContents.astro +117 -0
  62. package/src/layout/components/TopBar.astro +311 -0
  63. package/src/layout/styles/banner.css +44 -0
  64. package/src/layout/styles/base.css +234 -0
  65. package/src/layout/styles/dropdown.css +133 -0
  66. package/src/layout/styles/footer.css +108 -0
  67. package/src/layout/styles/image-zoom.css +50 -0
  68. package/src/layout/styles/mobile-menu.css +258 -0
  69. package/src/layout/styles/search-modal.css +122 -0
  70. package/src/layout/styles/topbar.css +437 -0
  71. package/src/lib/config.ts +2131 -0
  72. package/src/lib/mdx-auto-hydrate.js +70 -0
  73. package/src/lib/mdx-inject-builtins.js +87 -0
  74. package/src/lib/mdx-substitute-variables.js +66 -0
  75. package/src/lib/mdx-title-anchor-ids.js +84 -0
  76. package/src/lib/mermaid-rehype.js +72 -0
  77. package/src/lib/openapi-render.ts +479 -0
  78. package/src/lib/shiki-code-block.js +102 -0
  79. package/src/lib/shiki-copy-button.js +45 -0
  80. package/src/lib/styles-asset-integration.js +210 -0
  81. package/src/lib/writedocs-temp-dir.js +93 -0
  82. package/src/pages/404.astro +62 -0
  83. package/src/pages/[...slug].astro +1270 -0
  84. package/src/pages/[...slug].md.ts +78 -0
  85. package/src/pages/llms-full.txt.ts +71 -0
  86. package/src/pages/llms.txt.ts +141 -0
  87. package/src/scripts/banner.ts +20 -0
  88. package/src/scripts/dropdowns.ts +61 -0
  89. package/src/scripts/image-zoom.ts +66 -0
  90. package/src/scripts/mobile-menu.ts +55 -0
  91. package/src/scripts/search.ts +155 -0
  92. package/src/scripts/sidebar-scroll.ts +65 -0
  93. package/src/scripts/theme-toggle.ts +35 -0
  94. package/src/scripts/topbar-offset.ts +141 -0
  95. package/src/styles/global.css +18 -0
@@ -0,0 +1,32 @@
1
+ <div class="wd-steps"><slot /></div>
2
+ <style is:global>
3
+ .wd-steps {
4
+ counter-reset: wd-step;
5
+ margin: 1.25rem 0;
6
+ }
7
+ .wd-step {
8
+ position: relative;
9
+ padding-left: 2.5rem;
10
+ padding-bottom: 1.5rem;
11
+ border-left: 2px solid var(--wd-border);
12
+ margin-left: 0.9rem;
13
+ }
14
+ .wd-step:last-child { border-color: transparent; padding-bottom: 0; }
15
+ .wd-step::before {
16
+ counter-increment: wd-step;
17
+ content: counter(wd-step);
18
+ position: absolute;
19
+ left: -0.95rem;
20
+ top: 0;
21
+ width: 1.8rem;
22
+ height: 1.8rem;
23
+ border-radius: 50%;
24
+ background: var(--wd-primary);
25
+ color: white;
26
+ font-size: 0.85rem;
27
+ font-weight: 600;
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ }
32
+ </style>
@@ -0,0 +1,9 @@
1
+ ---
2
+ interface Props {
3
+ title: string;
4
+ }
5
+ const { title } = Astro.props as Props;
6
+ ---
7
+ <div class="wd-tab" data-title={title}>
8
+ <slot />
9
+ </div>
@@ -0,0 +1,52 @@
1
+ <div class="wd-tabs">
2
+ <slot />
3
+ </div>
4
+ <script>
5
+ function initTabs(root: ParentNode) {
6
+ root.querySelectorAll<HTMLElement>('.wd-tabs').forEach((tabs) => {
7
+ if (tabs.dataset.wdInit) return;
8
+ tabs.dataset.wdInit = 'true';
9
+ const panels = Array.from(tabs.querySelectorAll<HTMLElement>(':scope > .wd-tab'));
10
+ const nav = document.createElement('div');
11
+ nav.className = 'wd-tabs-nav';
12
+ panels.forEach((panel, i) => {
13
+ const btn = document.createElement('button');
14
+ btn.type = 'button';
15
+ btn.textContent = panel.dataset.title ?? `Tab ${i + 1}`;
16
+ btn.className = 'wd-tabs-btn' + (i === 0 ? ' active' : '');
17
+ btn.addEventListener('click', () => {
18
+ nav.querySelectorAll('.wd-tabs-btn').forEach((b) => b.classList.remove('active'));
19
+ panels.forEach((p) => (p.style.display = 'none'));
20
+ btn.classList.add('active');
21
+ panel.style.display = 'block';
22
+ });
23
+ nav.appendChild(btn);
24
+ panel.style.display = i === 0 ? 'block' : 'none';
25
+ });
26
+ tabs.prepend(nav);
27
+ });
28
+ }
29
+ initTabs(document);
30
+ document.addEventListener('astro:page-load', () => initTabs(document));
31
+ </script>
32
+ <style is:global>
33
+ .wd-tabs-nav {
34
+ display: flex;
35
+ gap: 0.25rem;
36
+ border-bottom: 1px solid var(--wd-border);
37
+ margin-bottom: 1rem;
38
+ }
39
+ .wd-tabs-btn {
40
+ background: none;
41
+ border: none;
42
+ padding: 0.5rem 0.9rem;
43
+ cursor: pointer;
44
+ font-size: 0.9rem;
45
+ color: var(--wd-text-muted);
46
+ border-bottom: 2px solid transparent;
47
+ }
48
+ .wd-tabs-btn.active {
49
+ color: var(--wd-primary);
50
+ border-bottom-color: var(--wd-primary);
51
+ }
52
+ </style>
@@ -0,0 +1,12 @@
1
+ ---
2
+ // Shorthand for <Callout type="tip">. See Callout.astro's own comment.
3
+ import Callout from './Callout.astro';
4
+ interface Props {
5
+ title?: string;
6
+ // See Callout.astro's own comment - set automatically by
7
+ // remarkCalloutAnchorIds, not meant to be passed by hand.
8
+ _titleId?: string;
9
+ }
10
+ const { title, _titleId } = Astro.props as Props;
11
+ ---
12
+ <Callout type="tip" title={title} _titleId={_titleId}><slot /></Callout>
@@ -0,0 +1,135 @@
1
+ ---
2
+ // Renders either a native <video> or an <iframe> embed from the same
3
+ // `src` prop, auto-detected rather than requiring the author to pick -
4
+ // `src="media/clip.mp4"` (a direct video file, local or remote) gets a
5
+ // real <video controls>; `src="https://www.youtube.com/embed/..."` (or
6
+ // Vimeo, Loom, any other embed URL with no recognizable video file
7
+ // extension) gets an <iframe>. Same block-level, own-line, centered
8
+ // shape as Image/Frame, and shares their exact card language (border-
9
+ // radius, padding, box-shadow) - "same border radius as image and
10
+ // frame" was the explicit ask this was built against.
11
+ interface Props {
12
+ src: string;
13
+ // A raw CSS width ("500px", "80%", "40rem", ...) - named `width`
14
+ // here (not `size`, Image's own name for the same idea) to match
15
+ // this component's own requested API exactly. Same "applied to the
16
+ // wrapping figure, not the media element" approach Image.astro's own
17
+ // `size` prop uses (see its comment for the real bug that came from
18
+ // getting this wrong once already) - the whole component, card
19
+ // background included, sizes to `width`, not just the video/iframe
20
+ // inside it.
21
+ width?: string;
22
+ // Only meaningful for the <video> branch - ignored (not rendered as
23
+ // an attribute at all) for an <iframe> embed, since a YouTube/Vimeo
24
+ // player controls its own playback UI, autoplay policy, and looping
25
+ // through its own embed URL query params instead.
26
+ controls?: boolean;
27
+ autoplay?: boolean;
28
+ loop?: boolean;
29
+ muted?: boolean;
30
+ // Alt text has no real equivalent for video - `title` is the
31
+ // accessible-name attribute both <video> (via a wrapping description)
32
+ // and <iframe> (a required attribute for a11y tooling to identify
33
+ // embedded content) actually use.
34
+ title?: string;
35
+ caption?: string;
36
+ }
37
+ const {
38
+ src,
39
+ width,
40
+ controls = true,
41
+ autoplay = false,
42
+ loop = false,
43
+ muted = false,
44
+ title,
45
+ caption,
46
+ } = Astro.props as Props;
47
+ const isFile = /\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?(#.*)?$/i.test(src);
48
+ const sizeStyle = width ? `--wd-video-size:${width}` : undefined;
49
+ // Autoplaying video muted is a hard browser requirement, not a style
50
+ // choice - every major browser silently blocks (or, worse, plays with
51
+ // sound the visitor didn't ask for and then gets auto-paused
52
+ // inconsistently) an unmuted autoplay <video>, so an author setting
53
+ // autoplay without also remembering muted would get a broken-looking
54
+ // embed rather than the "plays immediately" behavior they actually
55
+ // asked for. Forcing it here means autoplay just works the one way
56
+ // it's actually able to.
57
+ const effectiveMuted = muted || autoplay;
58
+ ---
59
+
60
+ <figure class:list={["wd-video-figure", caption && "wd-video-figure-framed"]} style={sizeStyle}>
61
+ {
62
+ isFile ? (
63
+ <video
64
+ class="wd-video"
65
+ src={src}
66
+ controls={controls}
67
+ autoplay={autoplay}
68
+ loop={loop}
69
+ muted={effectiveMuted}
70
+ playsinline
71
+ title={title}
72
+ />
73
+ ) : (
74
+ <iframe
75
+ class="wd-video wd-video-embed"
76
+ src={src}
77
+ title={title ?? "Embedded video"}
78
+ loading="lazy"
79
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
80
+ allowfullscreen
81
+ />
82
+ )
83
+ }
84
+ {caption && <figcaption class="wd-video-caption">{caption}</figcaption>}
85
+ </figure>
86
+ <style>
87
+ /* Same three rules as Image.astro's own .wd-image-figure/
88
+ .wd-image-figure-framed/.wd-image-caption - see that file's own
89
+ comments for why width lives on the figure (not the media element),
90
+ why padding is what creates the inset-in-a-card look, and why
91
+ box-shadow exists alongside the border. Kept in sync on purpose -
92
+ both components are explicitly meant to look identical modulo
93
+ what's actually inside them. */
94
+ .wd-video-figure {
95
+ width: var(--wd-video-size, 100%);
96
+ max-width: 100%;
97
+ margin: 1.25rem auto;
98
+ }
99
+ .wd-video-figure-framed {
100
+ border: 1px solid var(--wd-border);
101
+ border-radius: 0.75rem;
102
+ background: var(--wd-surface);
103
+ padding: 0.5rem;
104
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
105
+ }
106
+ .wd-video {
107
+ display: block;
108
+ width: 100%;
109
+ height: auto;
110
+ border: 0;
111
+ border-radius: 0.75rem;
112
+ }
113
+ /* Unlike <img>/<video> (both read their own intrinsic dimensions once
114
+ loaded, so width: 100%; height: auto keeps them proportional on
115
+ their own), a bare <iframe> has no intrinsic size at all - without
116
+ something constraining its height it either collapses to the UA
117
+ default (~150px) or needs an explicit pixel height the embedding
118
+ author would have to compute themselves. aspect-ratio: 16 / 9 is
119
+ the standard embed shape (YouTube, Vimeo, Loom, ...) and needs no
120
+ JS/ResizeObserver trick to stay proportional as width changes -
121
+ it's a real, if imperfect, default for embeds that aren't actually
122
+ 16:9 (a vertical/Shorts-style video letterboxes instead of filling
123
+ the frame), which there's no way to detect automatically from a
124
+ bare embed URL.
125
+ */
126
+ .wd-video-embed {
127
+ aspect-ratio: 16 / 9;
128
+ }
129
+ .wd-video-caption {
130
+ margin-top: 0.6rem;
131
+ text-align: center;
132
+ color: var(--wd-text-muted);
133
+ font-size: 0.75rem;
134
+ }
135
+ </style>
@@ -0,0 +1,12 @@
1
+ ---
2
+ // Shorthand for <Callout type="warning">. See Callout.astro's own comment.
3
+ import Callout from './Callout.astro';
4
+ interface Props {
5
+ title?: string;
6
+ // See Callout.astro's own comment - set automatically by
7
+ // remarkCalloutAnchorIds, not meant to be passed by hand.
8
+ _titleId?: string;
9
+ }
10
+ const { title, _titleId } = Astro.props as Props;
11
+ ---
12
+ <Callout type="warning" title={title} _titleId={_titleId}><slot /></Callout>
@@ -0,0 +1,48 @@
1
+ // Re-exports every component a page's own MDX gets for free (see the
2
+ // `components` map passed to `<Content components={components} />` in
3
+ // src/pages/[...slug].astro) as a stable, importable barrel - so
4
+ // hand-authored content that *isn't* rendered through that same call site
5
+ // can still use them without reaching into the package's internals.
6
+ //
7
+ // The one real consumer today is snippets (see docs/dev/docs/snippets.mdx):
8
+ // a page's own MDX never needs this - Callout, Card, etc. are already in
9
+ // scope there with no import at all - but an MDX *snippet*, imported and
10
+ // rendered as `<MySnippet />` from inside that page, is its own
11
+ // independently-compiled MDX component with its own (empty, unless passed
12
+ // explicitly) components map, so a `<Callout>` used inside a snippet's own
13
+ // body has nothing to resolve against without an import of its own. This
14
+ // module is that import: `import { Callout } from 'writedocs/components'`
15
+ // (see the `"./components"` entry in package.json's `exports` field, which
16
+ // is what makes that bare specifier resolve at all - Node/Vite's package
17
+ // self-reference resolution, no relative path into node_modules needed).
18
+ //
19
+ // Deliberately the same set passed into <Content components={...}> and no
20
+ // more - AppIcon, ApiPlayground, ApiReferencePanel, CopyPageMenu etc. are
21
+ // internal building blocks other components/layouts use, never meant to be
22
+ // dropped into a page's MDX directly, so they're left out here too.
23
+ export { default as Callout } from './Callout.astro';
24
+ export { default as Note } from './Note.astro';
25
+ export { default as Info } from './Info.astro';
26
+ export { default as Tip } from './Tip.astro';
27
+ export { default as Warning } from './Warning.astro';
28
+ export { default as Danger } from './Danger.astro';
29
+ export { default as Card } from './Card.astro';
30
+ export { default as CardGroup } from './CardGroup.astro';
31
+ export { default as Tabs } from './Tabs.astro';
32
+ export { default as Tab } from './Tab.astro';
33
+ export { default as CodeGroup } from './CodeGroup.astro';
34
+ export { default as Accordion } from './Accordion.astro';
35
+ export { default as AccordionGroup } from './AccordionGroup.astro';
36
+ export { default as Steps } from './Steps.astro';
37
+ export { default as Step } from './Step.astro';
38
+ export { default as Hint } from './Hint.astro';
39
+ export { default as Image } from './Image.astro';
40
+ export { default as Frame } from './Frame.astro';
41
+ export { default as Video } from './Video.astro';
42
+ export { default as Parameter } from './Parameter.astro';
43
+ export { default as Expandable } from './Expandable.astro';
44
+ export { default as Searchbar } from './Searchbar.astro';
45
+ export { default as Badge } from './Badge.astro';
46
+ export { default as Icon } from './Icon.astro';
47
+ export { default as RequestExample } from './RequestExample.astro';
48
+ export { default as ResponseExample } from './ResponseExample.astro';
@@ -0,0 +1,223 @@
1
+ import { defineCollection } from 'astro:content';
2
+ import { glob } from 'astro/loaders';
3
+ import type { Loader } from 'astro/loaders';
4
+ import { z } from 'astro/zod';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { seoFieldsSchema, findAllPages } from './lib/config';
9
+ import { writedocsTempDir } from './lib/writedocs-temp-dir.js';
10
+
11
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
12
+
13
+ // Astro's glob loader resolves `base` via `new URL(base, root)`. On Windows,
14
+ // a raw path string like "C:\foo\docs" gets misparsed by the WHATWG URL
15
+ // parser (the drive letter "C:" looks like a URL scheme), which breaks
16
+ // downstream `fileURLToPath` calls. Passing an explicit file:// URL sidesteps
17
+ // that entirely and works the same on every platform.
18
+ //
19
+ // Auto-generated OpenAPI stub pages (see generate-api-pages.js) live under
20
+ // writedocsTempDir() - an OS-temp-directory location keyed off contentDir,
21
+ // not anywhere inside the content directory itself - so they never show up
22
+ // mixed in with a site's own hand-written pages, and writedocs never writes
23
+ // generated files into a person's own project folder. The same location is
24
+ // also where build-time manifests/caches live - see writedocs-temp-dir.js's
25
+ // own doc comment. A separate collection (rather than a second base folder
26
+ // glued onto `pages` somehow) because Astro's loader API has no supported
27
+ // way to point one collection's glob() at two base directories sharing a
28
+ // single store without each call's own "delete anything I didn't just see"
29
+ // cleanup wiping out the other's entries - [...slug].astro merges both
30
+ // collections' entries back into one list at query time instead, which
31
+ // sidesteps that entirely.
32
+ const generatedDocsDir = path.join(writedocsTempDir(contentDir), 'generated-docs');
33
+ const generatedDocsBase = pathToFileURL(generatedDocsDir + path.sep);
34
+
35
+ const docsSchema = z.object({
36
+ title: z.string(),
37
+ description: z.string().optional(),
38
+ // Overrides the URL this page is served at, independent of where the
39
+ // file actually lives - writedocs.json's `pages` arrays always keep
40
+ // referencing the file's own path regardless. This is Astro's own
41
+ // glob()-loader convention (a `slug` frontmatter field becomes
42
+ // `entry.id` verbatim - see generateIdDefault in
43
+ // astro/dist/content/loaders/glob.js), not writedocs-specific
44
+ // behavior; declaring it here just brings it into the schema (and
45
+ // its docs) rather than leaving it an undocumented Astro feature.
46
+ // See fileIdForEntry() in lib/config.ts for how routes/links still
47
+ // resolve a page by its file id once this diverges from `entry.id`.
48
+ // Leading/trailing slashes are fine either way ("/", "/guides/x",
49
+ // "guides/x" and "guides/x/" all mean the same thing) - see
50
+ // normalizeEntryId() in lib/config.ts, which is what actually
51
+ // strips them before this value is ever used as a route or href.
52
+ slug: z.string().optional(),
53
+ // Marks this page as an OpenAPI operation reference: "METHOD /path"
54
+ // matching an operation in the owning group's OpenAPI spec, e.g.
55
+ // "GET /pets/{petId}" - [...slug].astro renders <ApiPlayground /> for
56
+ // any page with this field set, below the page's own MDX body (if
57
+ // any). Sites don't write this by hand for most pages; it's either
58
+ // set on a generated stub in the `generatedDocs` collection below (see
59
+ // generate-api-pages.js) or hand-authored to "eject" one specific
60
+ // operation into a real file (in `docs`) with custom prose - either
61
+ // way, the value is always exactly the same "METHOD /path" key
62
+ // generate-api-pages.js uses to look up the operation's full resolved
63
+ // schema/examples at render time.
64
+ openapi: z.string().optional(),
65
+ // Controls how much of the site's own chrome (topbar, sidebar, table of
66
+ // contents) wraps this page - see BaseLayout.astro/[...slug].astro for
67
+ // what each value actually removes:
68
+ // default - the normal three-column reading layout (all chrome).
69
+ // wide - drops the table of contents; the article itself also
70
+ // renders wider, for content that wants the extra room
71
+ // (wide tables, side-by-side images).
72
+ // frame - drops the sidebar and table of contents, but keeps the
73
+ // topbar and the article's own normal presentation
74
+ // ("frame" as in: still inside the site's outer frame).
75
+ // custom - drops the sidebar and table of contents, the
76
+ // auto-rendered <h1>, and prev/next nav, and skips the
77
+ // article's own prose width/padding too - a blank canvas
78
+ // for a hand-built landing/home page made entirely of
79
+ // components - but keeps the topbar, so the page still
80
+ // has site branding/nav/search/theme-toggle available.
81
+ // blank - everything 'custom' drops, plus the topbar too: no site
82
+ // chrome at all, just <slot />. For a page that wants to
83
+ // look nothing like the rest of the site (an auth screen,
84
+ // a print-style page).
85
+ mode: z.enum(['default', 'wide', 'frame', 'custom', 'blank']).default('default'),
86
+ // Per-page meta tag overrides - same shape as writedocs.json's top-level
87
+ // `seo` (see seoFieldsSchema in lib/config.ts, the single source of
88
+ // truth for this shape). A page only needs to set the specific fields
89
+ // it wants to override; mergeSeo() falls back to the site-wide default
90
+ // for anything left unset. See BaseLayout.astro for where this and the
91
+ // site-wide seo actually get merged and rendered.
92
+ seo: seoFieldsSchema.optional(),
93
+ });
94
+
95
+ /** Wraps another loader so it's skipped entirely - no filesystem scan, no
96
+ * "directory doesn't exist"/"no files found" warning - when its own base
97
+ * directory doesn't exist yet. Astro requires every collection declared
98
+ * in this file to have a loader, but most sites have no OpenAPI groups
99
+ * at all (generate-api-pages.js only ever creates a generated-docs/
100
+ * directory under writedocsTempDir() when at least one exists), so without this
101
+ * the `generatedDocs` collection below would print a spurious warning on
102
+ * every single build/dev run for the common case of a site with no
103
+ * OpenAPI pages. */
104
+ function skipIfMissing(inner: Loader): Loader {
105
+ return {
106
+ name: inner.name,
107
+ load: async (context) => {
108
+ if (!fs.existsSync(generatedDocsBase)) return;
109
+ return inner.load(context);
110
+ },
111
+ };
112
+ }
113
+
114
+ const generatedDocs = defineCollection({
115
+ loader: skipIfMissing(glob({ pattern: '**/*.{md,mdx}', base: generatedDocsBase })),
116
+ schema: docsSchema,
117
+ });
118
+
119
+ // Every hand-written page in the project, wherever it lives - see
120
+ // findAllPages() in lib/config.ts for the exact discovery rule (any
121
+ // .md/.mdx file with a frontmatter block, the usual build/dependency
122
+ // directories excluded - docs/ has no special status, it's scanned like
123
+ // any other folder). A single collection covering the whole content
124
+ // directory works here (unlike generatedDocs, which needs its own
125
+ // separate collection - see that const's own comment) because there's
126
+ // only one base to worry about: the content root itself.
127
+ //
128
+ // The literal file list (rather than a `**/*.{md,mdx}` pattern) is what
129
+ // actually implements the frontmatter-based filter: glob()'s `pattern`
130
+ // option takes glob patterns, not a predicate function, so the predicate
131
+ // has to run first, here, to produce the list glob() then just matches
132
+ // literally. Astro's own default id computation, scoped to this same
133
+ // content-root base, is what gives a page under docs/ its "docs/..."
134
+ // file id and a page at the root its bare file id - see
135
+ // fileIdForEntry()'s own comment in lib/config.ts for the one place that
136
+ // id computation needs to be independently recovered (once a page's
137
+ // frontmatter `slug` has overridden `entry.id` itself).
138
+ const contentRootBase = pathToFileURL(contentDir + path.sep);
139
+
140
+ /** Wraps astro's own glob() loader so pages are re-discovered live during
141
+ * `astro dev`, not just once when this module first evaluates.
142
+ *
143
+ * The naive approach - `glob({ pattern: findAllPages(contentDir), base })`,
144
+ * computed once at module load, as this used to be - works fine for the
145
+ * *first* load, but astro's glob loader's own dev-mode watcher only ever
146
+ * matches entries against whatever literal pattern list it was constructed
147
+ * with (see matchesGlob() in astro's glob.js, called from its `watcher.on(
148
+ * 'add', ...)` handler): a file created after that list was computed can
149
+ * never match it, so it silently never becomes a page until the dev server
150
+ * is restarted and this module re-evaluates findAllPages() from scratch.
151
+ * That's the exact bug this wrapper fixes.
152
+ *
153
+ * A plain wildcard pattern (`**\/*.{md,mdx}`) doesn't work as a substitute,
154
+ * either: whether a given .md/.mdx file counts as a page at all depends on
155
+ * its *content* (does it have a frontmatter block? - see findAllPages()'s
156
+ * own doc comment, this is what lets e.g. snippets/ partials with no
157
+ * frontmatter coexist in the content dir without becoming pages
158
+ * themselves), not just its path, and glob()'s `pattern` option only does
159
+ * path matching - it can't express that filter. Something has to re-run
160
+ * findAllPages() itself, content and all, whenever the file set changes.
161
+ *
162
+ * So: this re-invokes astro's own glob() loader - reusing all of its real
163
+ * parsing/rendering/digest-based caching logic rather than reimplementing
164
+ * any of that - with a freshly recomputed literal file list every time
165
+ * something relevant changes on disk, instead of once. The inner loader's
166
+ * own watcher wiring is deliberately skipped each time (`watcher` is
167
+ * stripped from the context passed to it) - this loader keeps exactly one
168
+ * watcher subscription of its own instead, covering every case uniformly
169
+ * (a new page file appearing, one being deleted, frontmatter being added
170
+ * to or removed from an existing file, ordinary content edits) rather than
171
+ * relying on the inner loader's add/change/unlink handlers, which - as
172
+ * above - are only ever bound to whatever the pattern looked like at the
173
+ * moment they were registered. */
174
+ function livePages(dir: string, base: URL): Loader {
175
+ return {
176
+ name: 'writedocs-live-pages',
177
+ load: async (context) => {
178
+ const { watcher, store, logger } = context;
179
+
180
+ async function resync() {
181
+ const files = findAllPages(dir);
182
+ if (files.length === 0) {
183
+ // Mirrors the old skipIfEmpty() wrapper: skip calling the inner
184
+ // loader entirely (rather than passing it an empty/impossible
185
+ // pattern) so astro doesn't print its own "no files found"
186
+ // warning for a freshly-scaffolded project with no content yet -
187
+ // but still clear out any previously-synced entries, in case a
188
+ // page's last file was just deleted mid dev-session rather than
189
+ // this being the initial load of an empty project.
190
+ for (const id of store.keys()) store.delete(id);
191
+ return;
192
+ }
193
+ await glob({ pattern: files, base }).load({ ...context, watcher: undefined });
194
+ }
195
+
196
+ await resync();
197
+ if (!watcher) return; // no watcher outside `astro dev` (e.g. `astro build`)
198
+
199
+ watcher.add(fileURLToPath(base));
200
+ let pending: ReturnType<typeof setTimeout> | undefined;
201
+ const scheduleResync = (changedPath: string) => {
202
+ if (!/\.mdx?$/i.test(changedPath)) return;
203
+ clearTimeout(pending);
204
+ // Debounced: an editor's "save" can fire several fs events in
205
+ // quick succession (e.g. atomic-write = unlink+add), and adding
206
+ // several files at once shouldn't trigger a resync per file.
207
+ pending = setTimeout(() => {
208
+ resync().catch((err) => logger.error(`Failed to reload pages: ${err.message}`));
209
+ }, 100);
210
+ };
211
+ watcher.on('add', scheduleResync);
212
+ watcher.on('unlink', scheduleResync);
213
+ watcher.on('change', scheduleResync);
214
+ },
215
+ };
216
+ }
217
+
218
+ const pages = defineCollection({
219
+ loader: livePages(contentDir, contentRootBase),
220
+ schema: docsSchema,
221
+ });
222
+
223
+ export const collections = { pages, generatedDocs };