@writedocs/generator 0.4.6 → 0.4.8

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 (48) hide show
  1. package/astro.config.mjs +37 -3
  2. package/package.json +1 -1
  3. package/src/components/Accordion.astro +22 -5
  4. package/src/components/AppIcon.astro +5 -0
  5. package/src/components/Callout.astro +15 -5
  6. package/src/components/Card.astro +64 -13
  7. package/src/components/Check.astro +13 -0
  8. package/src/components/Color.astro +61 -0
  9. package/src/components/ColorItem.astro +93 -0
  10. package/src/components/ColorRow.astro +39 -0
  11. package/src/components/Columns.astro +11 -0
  12. package/src/components/Frame.astro +10 -1
  13. package/src/components/GitHubRepo.astro +156 -0
  14. package/src/components/Hint.astro +50 -4
  15. package/src/components/Panel.astro +11 -0
  16. package/src/components/ParamField.astro +25 -0
  17. package/src/components/Parameter.astro +41 -4
  18. package/src/components/Prompt.astro +143 -0
  19. package/src/components/ResponseField.astro +18 -0
  20. package/src/components/Step.astro +22 -3
  21. package/src/components/Steps.astro +22 -0
  22. package/src/components/Tab.astro +7 -1
  23. package/src/components/Tabs.astro +27 -4
  24. package/src/components/Tile.astro +65 -0
  25. package/src/components/Tooltip.astro +13 -0
  26. package/src/components/Tree.astro +213 -0
  27. package/src/components/TreeFile.astro +17 -0
  28. package/src/components/TreeFolder.astro +28 -0
  29. package/src/components/Update.astro +171 -0
  30. package/src/components/View.astro +214 -0
  31. package/src/components/Visibility.astro +11 -0
  32. package/src/components/compound.ts +21 -0
  33. package/src/components/index.ts +12 -0
  34. package/src/content.config.ts +68 -2
  35. package/src/layout/components/NavTree.astro +34 -0
  36. package/src/layout/styles/base.css +12 -0
  37. package/src/lib/config.ts +1412 -1306
  38. package/src/lib/inline-markdown.js +29 -0
  39. package/src/lib/mdx-inject-builtins.js +20 -2
  40. package/src/lib/mdx-mintlify.js +99 -0
  41. package/src/lib/mdx-title-anchor-ids.js +7 -2
  42. package/src/lib/shiki-code-block.js +140 -26
  43. package/src/lib/visibility.js +29 -0
  44. package/src/pages/[...slug].astro +110 -7
  45. package/src/pages/[...slug].md.ts +7 -1
  46. package/src/pages/llms-full.txt.ts +5 -1
  47. package/src/pages/llms.txt.ts +3 -0
  48. package/src/styles/global.css +10 -0
@@ -0,0 +1,214 @@
1
+ ---
2
+ // Mintlify's <View title="..." icon="..."> - content for one of several
3
+ // alternatives (a language, a framework) on the same page. Every distinct
4
+ // `title` on the page becomes an option in one switcher, placed under the
5
+ // page title (or before the first View on a page without one); only the
6
+ // selected title's Views show. As on Mintlify, the table of contents only
7
+ // lists the headings in the visible Views.
8
+ //
9
+ // The choice is remembered (localStorage) across pages, so a reader who
10
+ // picks "Python" keeps seeing Python wherever the same title exists. A
11
+ // link to an anchor inside a hidden View switches to that View first.
12
+ import AppIcon from './AppIcon.astro';
13
+ interface Props {
14
+ title: string;
15
+ icon?: string;
16
+ }
17
+ const { title, icon } = Astro.props as Props;
18
+ ---
19
+ <div class="wd-view" data-view-title={title}>
20
+ {icon && <span class="wd-view-icon-src" hidden><AppIcon icon={icon} class="wd-view-icon" /></span>}
21
+ <slot />
22
+ </div>
23
+ <script>
24
+ const STORAGE_KEY = 'wd-view';
25
+
26
+ function initViews(root: Document) {
27
+ const views = Array.from(root.querySelectorAll<HTMLElement>('.wd-view[data-view-title]'));
28
+ if (views.length === 0 || root.querySelector('.wd-view-switcher')) return;
29
+
30
+ const titles = [...new Set(views.map((v) => v.dataset.viewTitle!))];
31
+ const iconFor = (title: string) =>
32
+ views.find((v) => v.dataset.viewTitle === title)?.querySelector<HTMLElement>('.wd-view-icon-src > *') ?? null;
33
+
34
+ let stored: string | null = null;
35
+ try {
36
+ stored = localStorage.getItem(STORAGE_KEY);
37
+ } catch {}
38
+ const hashTarget = location.hash ? document.getElementById(decodeURIComponent(location.hash.slice(1))) : null;
39
+ const hashView = hashTarget?.closest<HTMLElement>('.wd-view')?.dataset.viewTitle;
40
+ let current = hashView ?? (stored && titles.includes(stored) ? stored : titles[0]);
41
+
42
+ // The switcher: a button showing the current option, and a menu of all.
43
+ const switcher = document.createElement('div');
44
+ switcher.className = 'wd-view-switcher';
45
+ const button = document.createElement('button');
46
+ button.type = 'button';
47
+ button.className = 'wd-view-button';
48
+ button.setAttribute('aria-haspopup', 'listbox');
49
+ button.setAttribute('aria-expanded', 'false');
50
+ const menu = document.createElement('ul');
51
+ menu.className = 'wd-view-menu';
52
+ menu.setAttribute('role', 'listbox');
53
+ menu.hidden = true;
54
+
55
+ const label = (title: string) => {
56
+ const frag = document.createDocumentFragment();
57
+ const icon = iconFor(title);
58
+ if (icon) frag.appendChild(icon.cloneNode(true));
59
+ frag.appendChild(document.createTextNode(title));
60
+ return frag;
61
+ };
62
+ const options = titles.map((title) => {
63
+ const li = document.createElement('li');
64
+ li.setAttribute('role', 'option');
65
+ li.tabIndex = -1;
66
+ li.dataset.viewTitle = title;
67
+ li.appendChild(label(title));
68
+ li.addEventListener('click', () => {
69
+ select(title, true);
70
+ close();
71
+ button.focus();
72
+ });
73
+ li.addEventListener('keydown', (e) => {
74
+ const i = options.indexOf(li);
75
+ if (e.key === 'ArrowDown') options[Math.min(i + 1, options.length - 1)].focus();
76
+ else if (e.key === 'ArrowUp') options[Math.max(i - 1, 0)].focus();
77
+ else if (e.key === 'Enter' || e.key === ' ') li.click();
78
+ else if (e.key === 'Escape') {
79
+ close();
80
+ button.focus();
81
+ } else return;
82
+ e.preventDefault();
83
+ });
84
+ menu.appendChild(li);
85
+ return li;
86
+ });
87
+
88
+ const open = () => {
89
+ menu.hidden = false;
90
+ button.setAttribute('aria-expanded', 'true');
91
+ (options.find((o) => o.dataset.viewTitle === current) ?? options[0]).focus();
92
+ };
93
+ const close = () => {
94
+ menu.hidden = true;
95
+ button.setAttribute('aria-expanded', 'false');
96
+ };
97
+ button.addEventListener('click', () => (menu.hidden ? open() : close()));
98
+ document.addEventListener('click', (e) => {
99
+ if (!switcher.contains(e.target as Node)) close();
100
+ });
101
+
102
+ function select(title: string, remember: boolean) {
103
+ current = title;
104
+ views.forEach((v) => (v.hidden = v.dataset.viewTitle !== title));
105
+ button.replaceChildren(label(title));
106
+ const chevron = document.createElement('span');
107
+ chevron.className = 'wd-view-chevron';
108
+ chevron.setAttribute('aria-hidden', 'true');
109
+ button.appendChild(chevron);
110
+ options.forEach((o) => o.setAttribute('aria-selected', String(o.dataset.viewTitle === title)));
111
+ // Table of contents: only headings the reader can currently see.
112
+ document.querySelectorAll<HTMLAnchorElement>('.wd-toc a[data-toc-slug]').forEach((a) => {
113
+ const heading = document.getElementById(a.dataset.tocSlug ?? '');
114
+ const li = a.closest('li');
115
+ if (li) li.hidden = Boolean(heading?.closest('.wd-view[hidden]'));
116
+ });
117
+ if (remember) {
118
+ try {
119
+ localStorage.setItem(STORAGE_KEY, title);
120
+ } catch {}
121
+ }
122
+ }
123
+
124
+ switcher.append(button, menu);
125
+ const header = document.querySelector('.wd-article-header');
126
+ if (header) header.after(switcher);
127
+ else views[0].before(switcher);
128
+ select(current, false);
129
+ if (hashTarget && hashView) hashTarget.scrollIntoView();
130
+
131
+ // Same for an in-page link to an anchor inside a hidden View.
132
+ window.addEventListener('hashchange', () => {
133
+ if (!switcher.isConnected) return;
134
+ const el = location.hash ? document.getElementById(decodeURIComponent(location.hash.slice(1))) : null;
135
+ const title = el?.closest<HTMLElement>('.wd-view[hidden]')?.dataset.viewTitle;
136
+ if (!title) return;
137
+ select(title, false);
138
+ el!.scrollIntoView();
139
+ });
140
+ }
141
+ initViews(document);
142
+ document.addEventListener('astro:page-load', () => initViews(document));
143
+ </script>
144
+ <style>
145
+ .wd-view[hidden] {
146
+ display: none;
147
+ }
148
+ :global(.wd-view-switcher) {
149
+ position: relative;
150
+ display: inline-block;
151
+ margin: 0 0 1rem;
152
+ }
153
+ :global(.wd-view-button) {
154
+ display: inline-flex;
155
+ align-items: center;
156
+ gap: 0.45rem;
157
+ padding: 0.35rem 0.75rem;
158
+ border: 1px solid var(--wd-border);
159
+ border-radius: 0.5rem;
160
+ background: var(--wd-background);
161
+ color: var(--wd-text);
162
+ font-size: 0.875rem;
163
+ cursor: pointer;
164
+ }
165
+ :global(.wd-view-button:hover) {
166
+ border-color: var(--wd-primary);
167
+ }
168
+ :global(.wd-view-chevron) {
169
+ width: 0.4rem;
170
+ height: 0.4rem;
171
+ margin-left: 0.2rem;
172
+ border-right: 1.5px solid currentColor;
173
+ border-bottom: 1.5px solid currentColor;
174
+ transform: translateY(-2px) rotate(45deg);
175
+ }
176
+ :global(.wd-view-menu) {
177
+ position: absolute;
178
+ top: calc(100% + 0.3rem);
179
+ left: 0;
180
+ z-index: 20;
181
+ min-width: 100%;
182
+ margin: 0;
183
+ padding: 0.3rem;
184
+ list-style: none;
185
+ border: 1px solid var(--wd-border);
186
+ border-radius: 0.5rem;
187
+ background: var(--wd-background);
188
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
189
+ }
190
+ :global(.wd-view-menu li) {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 0.45rem;
194
+ margin: 0;
195
+ padding: 0.35rem 0.6rem;
196
+ border-radius: 0.35rem;
197
+ font-size: 0.875rem;
198
+ white-space: nowrap;
199
+ cursor: pointer;
200
+ }
201
+ :global(.wd-view-menu li:hover),
202
+ :global(.wd-view-menu li:focus),
203
+ :global(.wd-view-menu li[aria-selected='true']) {
204
+ outline: none;
205
+ background: color-mix(in srgb, var(--wd-primary) 10%, transparent);
206
+ color: var(--wd-primary);
207
+ }
208
+ :global(svg.wd-view-icon),
209
+ :global(span.wd-view-icon) {
210
+ width: 1em;
211
+ height: 1em;
212
+ flex-shrink: 0;
213
+ }
214
+ </style>
@@ -0,0 +1,11 @@
1
+ ---
2
+ // Mintlify's <Visibility for="humans|agents">. On the rendered site,
3
+ // `humans` content shows and `agents` content doesn't; the per-page `.md`
4
+ // route and llms-full.txt do the opposite (see lib/visibility.js). A
5
+ // <Visibility> with no `for` counts as `humans`.
6
+ interface Props {
7
+ for?: 'humans' | 'agents';
8
+ }
9
+ const { for: audience = 'humans' } = Astro.props as Props;
10
+ ---
11
+ {audience !== 'agents' && <slot />}
@@ -0,0 +1,21 @@
1
+ // Mintlify's dotted component names - <Tree.Folder>, <Color.Item>,
2
+ // <GitHub.Repo>, ... MDX compiles <Tree.Folder> to a property lookup on
3
+ // whatever `Tree` resolves to, so the parts are attached as properties of
4
+ // their parent here, once, and everything that hands components to MDX (the
5
+ // src/components/index.ts barrel and [...slug].astro's `components` map)
6
+ // takes these three from this module rather than importing the .astro files
7
+ // directly. `FileTree` is Mintlify's other name for `Tree`.
8
+ import TreeRoot from './Tree.astro';
9
+ import TreeFolder from './TreeFolder.astro';
10
+ import TreeFile from './TreeFile.astro';
11
+ import ColorRoot from './Color.astro';
12
+ import ColorRow from './ColorRow.astro';
13
+ import ColorItem from './ColorItem.astro';
14
+ import GitHubRepo from './GitHubRepo.astro';
15
+
16
+ export const Tree = Object.assign(TreeRoot, { Folder: TreeFolder, File: TreeFile });
17
+ export const FileTree = Tree;
18
+ export const Color = Object.assign(ColorRoot, { Row: ColorRow, Item: ColorItem });
19
+ // Mintlify has no bare <GitHub> - only <GitHub.Repo> - so this is just a
20
+ // namespace object, not a component.
21
+ export const GitHub = { Repo: GitHubRepo };
@@ -46,3 +46,15 @@ export { default as Badge } from './Badge.astro';
46
46
  export { default as Icon } from './Icon.astro';
47
47
  export { default as RequestExample } from './RequestExample.astro';
48
48
  export { default as ResponseExample } from './ResponseExample.astro';
49
+ export { default as Check } from './Check.astro';
50
+ export { default as ParamField } from './ParamField.astro';
51
+ export { default as ResponseField } from './ResponseField.astro';
52
+ export { default as Columns } from './Columns.astro';
53
+ export { default as Tooltip } from './Tooltip.astro';
54
+ export { default as Update } from './Update.astro';
55
+ export { default as Tile } from './Tile.astro';
56
+ export { default as Panel } from './Panel.astro';
57
+ export { default as Prompt } from './Prompt.astro';
58
+ export { default as View } from './View.astro';
59
+ export { default as Visibility } from './Visibility.astro';
60
+ export { Tree, FileTree, Color, GitHub } from './compound';
@@ -32,6 +32,8 @@ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
32
32
  const generatedDocsDir = path.join(writedocsTempDir(contentDir), 'generated-docs');
33
33
  const generatedDocsBase = pathToFileURL(generatedDocsDir + path.sep);
34
34
 
35
+ const MINTLIFY_MODE_ALIASES: Record<string, string> = { center: 'frame', assistant: 'default' };
36
+
35
37
  const docsSchema = z.object({
36
38
  title: z.string(),
37
39
  description: z.string().optional(),
@@ -82,7 +84,19 @@ const docsSchema = z.object({
82
84
  // chrome at all, just <slot />. For a page that wants to
83
85
  // look nothing like the rest of the site (an auth screen,
84
86
  // a print-style page).
85
- mode: z.enum(['default', 'wide', 'frame', 'custom', 'blank']).default('default'),
87
+ //
88
+ // Two Mintlify values are accepted so a migrated page doesn't fail the
89
+ // build (see MINTLIFY_MODE_ALIASES below): `center` is the same layout
90
+ // as our `frame`, and `assistant` (a full-page Mintlify AI chat, which
91
+ // writedocs doesn't have) falls back to `default`. Mintlify's own
92
+ // `frame` means something else (a canvas that keeps the sidebar) - it's
93
+ // left as our `frame`; see docs/dev/docs/mintlify-compat.mdx.
94
+ mode: z
95
+ .preprocess(
96
+ (value) => (typeof value === 'string' && value in MINTLIFY_MODE_ALIASES ? MINTLIFY_MODE_ALIASES[value] : value),
97
+ z.enum(['default', 'wide', 'frame', 'custom', 'blank']),
98
+ )
99
+ .default('default'),
86
100
  // Per-page meta tag overrides - same shape as writedocs.json's top-level
87
101
  // `seo` (see seoFieldsSchema in lib/config.ts, the single source of
88
102
  // truth for this shape). A page only needs to set the specific fields
@@ -90,7 +104,59 @@ const docsSchema = z.object({
90
104
  // for anything left unset. See BaseLayout.astro for where this and the
91
105
  // site-wide seo actually get merged and rendered.
92
106
  seo: seoFieldsSchema.optional(),
93
- });
107
+ // Mintlify's spelling of `seo.noindex` - a top-level `noindex: true`.
108
+ // Folded into `seo` by the transform below, so everything downstream
109
+ // (the robots meta tag, sitemap.xml, llms.txt) keeps reading
110
+ // `seo.noindex` only.
111
+ noindex: z.boolean().optional(),
112
+ // Mintlify's short navigation label - used for the sidebar and the
113
+ // topbar dropdown menus in place of `title` ([...slug].astro's
114
+ // navTitleForSlug). The page's own <h1> and prev/next links keep `title`.
115
+ sidebarTitle: z.string().optional(),
116
+ // Mintlify's spellings of `seo.keywords`, `seo.ogImage`, `seo.ogType`
117
+ // and `seo.twitterCard`, folded into `seo` below like `noindex`.
118
+ keywords: z.array(z.string()).optional(),
119
+ 'og:image': z.string().optional(),
120
+ 'og:type': z.string().optional(),
121
+ 'twitter:card': z.enum(['summary', 'summary_large_image']).optional(),
122
+ // Mintlify's sidebar/page-chrome fields - see NavTree.astro and
123
+ // [...slug].astro for where each is used:
124
+ // icon - shown before the page's label in the sidebar.
125
+ // tag - a short label after it (e.g. "NEW").
126
+ // deprecated - a "Deprecated" label in the sidebar and next
127
+ // to the page's <h1>.
128
+ // hidden - left out of the sidebar, dropdowns and
129
+ // prev/next, but still built and reachable by
130
+ // URL. Also noindexed, as on Mintlify.
131
+ // url - an external link: the page's sidebar entry
132
+ // links straight to it, and the page's own URL
133
+ // redirects there.
134
+ // hideFooterPagination - no prev/next links on this page.
135
+ // hideApiMarker - no HTTP method badge on this page's sidebar
136
+ // entry.
137
+ icon: z.string().optional(),
138
+ tag: z.string().optional(),
139
+ deprecated: z.boolean().optional(),
140
+ hidden: z.boolean().optional(),
141
+ url: z.string().optional(),
142
+ hideFooterPagination: z.boolean().optional(),
143
+ hideApiMarker: z.boolean().optional(),
144
+ }).transform(
145
+ ({ noindex, keywords, 'og:image': ogImage, 'og:type': ogType, 'twitter:card': twitterCard, ...data }) => {
146
+ // Top-level Mintlify keys fill in `seo` only where the page's own `seo`
147
+ // doesn't already set that field - an explicit `seo` value always wins.
148
+ // A `hidden` page is noindexed unless it says otherwise.
149
+ const fromMintlify = { noindex: noindex ?? (data.hidden ? true : undefined), keywords, ogImage, ogType, twitterCard };
150
+ const seo = { ...data.seo };
151
+ let changed = false;
152
+ for (const [key, value] of Object.entries(fromMintlify)) {
153
+ if (value === undefined || seo[key as keyof typeof seo] !== undefined) continue;
154
+ (seo as Record<string, unknown>)[key] = value;
155
+ changed = true;
156
+ }
157
+ return changed ? { ...data, seo } : data;
158
+ },
159
+ );
94
160
 
95
161
  /** Wraps another loader so it's skipped entirely - no filesystem scan, no
96
162
  * "directory doesn't exist"/"no files found" warning - when its own base
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { navTreeContainsSlug, isExternalHref, type NavTreeNode } from "../../lib/config";
3
+ import AppIcon from "../../components/AppIcon.astro";
3
4
  import { methodClass } from "../../lib/openapi-render";
4
5
 
5
6
  interface Props {
@@ -22,7 +23,10 @@ const { nodes, hrefForSlug, currentSlug, depth = 0 } = Astro.props as Props;
22
23
  {node.method && (
23
24
  <span class={`wd-nav-method wd-nav-method-${methodClass(node.method)}`}>{node.method}</span>
24
25
  )}
26
+ {node.icon && <AppIcon icon={node.icon} class="wd-nav-page-icon" />}
25
27
  <span class="wd-nav-title">{node.title}</span>
28
+ {node.deprecated && <span class="wd-nav-tag wd-nav-tag-deprecated">Deprecated</span>}
29
+ {node.tag && <span class="wd-nav-tag">{node.tag}</span>}
26
30
  </a>
27
31
  </li>
28
32
  );
@@ -227,6 +231,36 @@ const { nodes, hrefForSlug, currentSlug, depth = 0 } = Astro.props as Props;
227
231
  /* text-overflow: ellipsis;
228
232
  white-space: nowrap; */
229
233
  }
234
+ /* A page's own frontmatter `icon` (Mintlify's) - :global() because
235
+ AppIcon.astro renders the svg/span, not this component. */
236
+ :global(svg.wd-nav-page-icon),
237
+ :global(span.wd-nav-page-icon) {
238
+ flex-shrink: 0;
239
+ width: 1em;
240
+ height: 1em;
241
+ }
242
+ :global(span.wd-nav-page-icon) {
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: center;
246
+ }
247
+ /* Frontmatter `tag` / `deprecated` - a small pill after the title, same
248
+ shape as the method badge. */
249
+ .wd-nav-tag {
250
+ flex-shrink: 0;
251
+ padding: 0.05rem 0.4rem;
252
+ border-radius: 999px;
253
+ font-size: 0.62rem;
254
+ font-weight: 700;
255
+ line-height: 1.5;
256
+ letter-spacing: 0.02em;
257
+ color: var(--wd-primary);
258
+ background: color-mix(in srgb, var(--wd-primary) 12%, transparent);
259
+ }
260
+ .wd-nav-tag-deprecated {
261
+ color: #d97706;
262
+ background: color-mix(in srgb, #d97706 15%, transparent);
263
+ }
230
264
  .wd-nav-method {
231
265
  flex-shrink: 0;
232
266
  display: inline-block;
@@ -232,3 +232,15 @@ pre {
232
232
  font-weight: var(--shiki-dark-font-weight) !important;
233
233
  text-decoration: var(--shiki-dark-text-decoration) !important;
234
234
  }
235
+
236
+ /* An icon given as an image URL or path (resolveIcon()'s 'image' kind,
237
+ rendered by AppIcon.astro) - 1em square inside its caller's own icon
238
+ span, so it sizes like a glyph icon would. Lives here rather than in
239
+ AppIcon.astro so it's part of the shared stylesheet, not an inline
240
+ <style> added to every page. */
241
+ img.wd-icon-img {
242
+ display: block;
243
+ width: 1em;
244
+ height: 1em;
245
+ object-fit: contain;
246
+ }