@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,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,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 };
|