@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,53 @@
1
+ /**
2
+ * `writedocs build` produces the artifact that actually gets deployed, so
3
+ * unlike `dev`/`init` it isn't freely runnable by anyone who installs the
4
+ * package - it's gated behind a key that a separate service (`key-server/`
5
+ * in this repo) actually decides the validity of.
6
+ *
7
+ * This deliberately isn't a local check. An earlier version compared the
8
+ * supplied key against an env var set on the same machine
9
+ * (WRITEDOCS_BUILD_SECRET) - but since writedocs ships its full source to
10
+ * everyone who installs it, anyone could read that check and satisfy it
11
+ * with a value they made up themselves. Calling out to a server that
12
+ * actually holds the set of issued keys means a key's validity is decided
13
+ * by whoever runs that server, not by whoever is running `build`.
14
+ *
15
+ * See docs/dev/docs/deploy.mdx for how to deploy key-server/ and issue
16
+ * keys, and key-server/README.md for the service's own endpoints.
17
+ */
18
+ export async function requireBuildKey(providedKey) {
19
+ const serverUrl = process.env.WRITEDOCS_KEY_SERVER_URL;
20
+ if (!serverUrl) {
21
+ console.error('[writedocs] build is not available.');
22
+ process.exit(1);
23
+ }
24
+ if (!providedKey) {
25
+ console.error('[writedocs] build requires a valid --key (or WRITEDOCS_API_KEY).');
26
+ process.exit(1);
27
+ }
28
+
29
+ let valid = false;
30
+ try {
31
+ const res = await fetch(new URL('/v1/validate', serverUrl), {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ key: providedKey }),
35
+ signal: AbortSignal.timeout(10_000),
36
+ });
37
+ if (res.ok) {
38
+ const body = await res.json();
39
+ valid = body?.valid === true;
40
+ }
41
+ } catch (err) {
42
+ // Fails closed on a network error/timeout too, same as an explicit
43
+ // rejection - an unreachable authorization server is not treated as
44
+ // "no opinion, let it through".
45
+ console.error(`[writedocs] Could not reach the build authorization server: ${err.message}`);
46
+ process.exit(1);
47
+ }
48
+
49
+ if (!valid) {
50
+ console.error('[writedocs] build requires a valid --key (or WRITEDOCS_API_KEY) - the server rejected this one.');
51
+ process.exit(1);
52
+ }
53
+ }
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { runAstro } from './run-astro.js';
4
+ import { runPagefind } from './run-pagefind.js';
5
+ import { preflightCheck } from './preflight.js';
6
+ import { generateApiPages } from './generate-api-pages.js';
7
+ import { writeRedirectsFile } from './write-redirects-file.js';
8
+ import { writedocsBuildStagingDir } from '../lib/writedocs-temp-dir.js';
9
+
10
+ export async function runBuild({ contentDir, packageRoot }) {
11
+ preflightCheck(contentDir);
12
+ await generateApiPages({ contentDir });
13
+ console.log(`[writedocs] Building ${contentDir} -> ${contentDir}/dist`);
14
+ await runAstro(['build', '--root', packageRoot], { packageRoot, contentDir });
15
+ const distDir = path.join(contentDir, 'dist');
16
+ // Astro just wrote its actual output to writedocsBuildStagingDir()
17
+ // (astro.config.mjs's own `outDir`), not distDir directly - see that
18
+ // function's own comment (writedocs-temp-dir.js) for why. fs.cpSync
19
+ // (unlike the fs.rename() Astro uses internally to get *into* that
20
+ // staging dir in the first place) copies across filesystem boundaries
21
+ // without issue, so this is the one step that actually needs to bridge
22
+ // packageRoot and contentDir potentially living on different devices.
23
+ // distDir is cleared first so a rebuild doesn't leave a stale file
24
+ // behind from a previous build that the new one no longer produces -
25
+ // the same "the output directory reflects exactly this build, nothing
26
+ // older" expectation `astro build` itself already guarantees when
27
+ // writing directly into outDir.
28
+ const stagingDir = writedocsBuildStagingDir(packageRoot, contentDir);
29
+ fs.rmSync(distDir, { recursive: true, force: true });
30
+ fs.cpSync(stagingDir, distDir, { recursive: true });
31
+ fs.rmSync(stagingDir, { recursive: true, force: true });
32
+ // Astro's own writedocs.json `redirects` + the automatic "/" redirect only
33
+ // ever produce client-side meta-refresh pages (see write-redirects-file.js)
34
+ // - this turns those into real instant edge redirects on hosts that read
35
+ // a `_redirects` file (Cloudflare Pages, Netlify), purely additively.
36
+ writeRedirectsFile(distDir, contentDir);
37
+ console.log('[writedocs] Indexing search...');
38
+ await runPagefind(distDir, { packageRoot });
39
+ console.log(`[writedocs] Done. Output written to ${contentDir}/dist`);
40
+ }
package/src/cli/dev.js ADDED
@@ -0,0 +1,12 @@
1
+ import { runAstro } from './run-astro.js';
2
+ import { preflightCheck } from './preflight.js';
3
+ import { generateApiPages } from './generate-api-pages.js';
4
+
5
+ export async function runDev({ contentDir, packageRoot, port }) {
6
+ preflightCheck(contentDir);
7
+ await generateApiPages({ contentDir });
8
+ const args = ['dev', '--root', packageRoot];
9
+ if (port) args.push('--port', String(port));
10
+ console.log(`[writedocs] Serving ${contentDir}`);
11
+ await runAstro(args, { packageRoot, contentDir });
12
+ }
@@ -0,0 +1,359 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import SwaggerParser from '@apidevtools/swagger-parser';
4
+ import matter from 'gray-matter';
5
+ import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
6
+
7
+ const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
8
+
9
+ /** Canonical "METHOD /path" key used everywhere an operation needs to be
10
+ * identified: the manifest, a hand-written page's `openapi:` frontmatter,
11
+ * and the ApiPlayground component's own `operation` prop all use exactly
12
+ * this format, so no separate id scheme needs to be invented or kept in
13
+ * sync. */
14
+ function operationKey(method, urlPath) {
15
+ return `${method.toUpperCase()} ${urlPath}`;
16
+ }
17
+
18
+ /** Filesystem-safe form of an operation key, for the per-operation JSON
19
+ * file written under writedocsTempDir()/openapi/<path>/operations/ - the
20
+ * sanitization only needs to be *stable and collision-free*, not
21
+ * reversible (the operation's own method/path are stored inside the
22
+ * JSON body too). */
23
+ function operationFileName(method, urlPath) {
24
+ return `${method.toLowerCase()}_${urlPath.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')}.json`;
25
+ }
26
+
27
+ function slugify(value) {
28
+ return (
29
+ value
30
+ .toLowerCase()
31
+ .replace(/[{}]/g, '')
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '') || 'root'
34
+ );
35
+ }
36
+
37
+ /** Strips leading/trailing slashes from a group's `openapi.path`, giving
38
+ * the value used both as the URL prefix segment and as this spec's own
39
+ * namespace directory under writedocsTempDir()/openapi/ - e.g. "/api" -> "api",
40
+ * "/v2/api/" -> "v2/api" (multi-segment paths just nest normally, both
41
+ * as generated docs/ subdirectories and as a manifest directory path). */
42
+ function normalizePathPrefix(rawPath) {
43
+ return rawPath.replace(/^\/+|\/+$/g, '');
44
+ }
45
+
46
+ /** The generated stub's own file id (writedocs.json-navigation-facing path,
47
+ * and the actual URL it's served at) for an operation with no
48
+ * hand-written override - namespaced under the owning group's own
49
+ * `path` prefix (so two openapi groups never collide with each other),
50
+ * then grouped by its first tag (or "untagged") purely for directory
51
+ * tidiness, mirroring how a hand-authored API reference is usually
52
+ * organized. Also doubles as the stub's own on-disk path relative to
53
+ * writedocsTempDir()'s generated-docs/ directory (see
54
+ * generateApiPagesForGroup below) -
55
+ * there's no docs/ tree to avoid colliding with any more (unlike a
56
+ * hand-written page, which could live anywhere), so this can be the
57
+ * literal file path too, with no separate "_generated/" prefix needed
58
+ * to keep it out of a hand-written page's own namespace. */
59
+ function generatedSlugFor(pathPrefix, method, urlPath, tags) {
60
+ const tagSlug = slugify(tags[0] ?? 'untagged');
61
+ const opSlug = slugify(`${method}-${urlPath}`);
62
+ return `${pathPrefix}/${tagSlug}/${opSlug}`;
63
+ }
64
+
65
+ /** Directories at the content root a hand-written-override scan never
66
+ * descends into - mirrors EXCLUDED_TOP_LEVEL_DIRS in lib/config.ts's
67
+ * findAllPages() exactly (this file deliberately doesn't import that TS
68
+ * module - see collectOpenApiGroups()'s own doc comment on the same
69
+ * point - so the same small list is just duplicated here). docs/ has no
70
+ * special status - scanned like any other folder, same as page
71
+ * discovery itself. */
72
+ const OVERRIDE_SCAN_EXCLUDED_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
73
+
74
+ /** Recursively scans `contentDir` for .md/.mdx files claiming an
75
+ * operation via frontmatter `openapi: "METHOD /path"`, recording each
76
+ * one's file id - its path relative to `contentDir`, extension
77
+ * stripped, trailing `/index` segment dropped (mirroring Astro's own
78
+ * default id computation exactly - see fileIdForEntry()'s comment in
79
+ * lib/config.ts for why that stripping matters: without it, an override
80
+ * page at e.g. `docs/webhooks/index.mdx` would be recorded under a file
81
+ * id ("docs/webhooks/index") the rest of the routing pipeline would
82
+ * never actually resolve, since the page's real id is "docs/webhooks") -
83
+ * into `overrides`, skipping OVERRIDE_SCAN_EXCLUDED_DIRS at the content
84
+ * root's own top level. */
85
+ function scanDirForOverrides(dir, relBase, overrides) {
86
+ let entries;
87
+ try {
88
+ entries = fs.readdirSync(dir, { withFileTypes: true });
89
+ } catch {
90
+ return;
91
+ }
92
+ for (const entry of entries) {
93
+ const full = path.join(dir, entry.name);
94
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
95
+ if (entry.isDirectory()) {
96
+ if (relBase === '' && OVERRIDE_SCAN_EXCLUDED_DIRS.has(entry.name)) continue;
97
+ scanDirForOverrides(full, rel, overrides);
98
+ continue;
99
+ }
100
+ if (!/\.mdx?$/i.test(entry.name)) continue;
101
+ const raw = fs.readFileSync(full, 'utf-8');
102
+ const { data } = matter(raw);
103
+ if (typeof data.openapi !== 'string') continue;
104
+ const fileId = rel.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
105
+ overrides.set(data.openapi.trim().replace(/\s+/g, ' '), fileId);
106
+ }
107
+ }
108
+
109
+ /** Scans for hand-written pages that claim an operation via frontmatter
110
+ * `openapi: "METHOD /path"` - these always win over an auto-generated
111
+ * stub for the same operation, so an author can add custom prose above
112
+ * the playground for any specific endpoint without losing the
113
+ * auto-generated coverage for everything else. Scanned once and shared
114
+ * across every openapi group in writedocs.json (not scoped to a single spec)
115
+ * - simplest to reason about, and in practice a method+path colliding
116
+ * across two genuinely different specs mounted in the same site is rare
117
+ * enough not to be worth a more elaborate per-spec disambiguation scheme
118
+ * unless it turns out to matter. A single scan of the whole content
119
+ * directory (docs/ has no special status - see scanDirForOverrides()
120
+ * above), so a hand-authored override page works the same wherever it
121
+ * lives. */
122
+ function findHandWrittenOverrides(contentDir) {
123
+ const overrides = new Map(); // operationKey -> file id
124
+ scanDirForOverrides(contentDir, '', overrides);
125
+ return overrides;
126
+ }
127
+
128
+ function resolveSecurity(operation, spec) {
129
+ const requirements = operation.security ?? spec.security ?? [];
130
+ const schemes = spec.components?.securitySchemes ?? {};
131
+ return requirements
132
+ .flatMap((req) => Object.keys(req))
133
+ .map((name) => ({ name, ...schemes[name] }))
134
+ .filter((s) => s.type);
135
+ }
136
+
137
+ function buildOperations(spec) {
138
+ const operations = [];
139
+ for (const [urlPath, pathItem] of Object.entries(spec.paths ?? {})) {
140
+ for (const method of HTTP_METHODS) {
141
+ const operation = pathItem[method];
142
+ if (!operation) continue;
143
+ const parameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])];
144
+ operations.push({
145
+ method: method.toUpperCase(),
146
+ path: urlPath,
147
+ operationId: operation.operationId ?? null,
148
+ summary: operation.summary ?? null,
149
+ description: operation.description ?? null,
150
+ tags: operation.tags ?? [],
151
+ parameters,
152
+ requestBody: operation.requestBody ?? null,
153
+ responses: operation.responses ?? {},
154
+ servers: operation.servers ?? spec.servers ?? [],
155
+ security: resolveSecurity(operation, spec),
156
+ });
157
+ }
158
+ }
159
+ return operations;
160
+ }
161
+
162
+ function rmrf(dir) {
163
+ fs.rmSync(dir, { recursive: true, force: true });
164
+ }
165
+
166
+ /** Walks writedocs.json's raw `navigation` tree (any shape - a bare array, or
167
+ * an object choosing tabs/versions/languages/dropdowns/products, plus
168
+ * `global.dropdowns`) looking for group nodes shaped like
169
+ * `{ group, openapi: { src, path } }`, however deeply nested inside
170
+ * hand-authored groups or containers. Runs directly against the raw
171
+ * JSON (before writedocs.json's own zod validation even happens - this CLI
172
+ * step runs first, see generateApiPages() below), so it deliberately
173
+ * doesn't import anything from lib/config.ts and just duck-types each
174
+ * node the same way lib/config.ts's own walkSections()/
175
+ * expandOpenApiInContainer() do. */
176
+ function collectOpenApiGroups(navigation) {
177
+ const found = [];
178
+
179
+ function fromPagesItem(item) {
180
+ if (!item || typeof item !== 'object') return; // plain page-slug string - not a group
181
+ if (item.group && item.openapi) {
182
+ found.push(item);
183
+ return;
184
+ }
185
+ if (Array.isArray(item.pages)) {
186
+ for (const child of item.pages) fromPagesItem(child);
187
+ }
188
+ // otherwise a { label, href } link leaf - nothing to collect
189
+ }
190
+
191
+ function fromContainer(node) {
192
+ if (!node || typeof node !== 'object') return;
193
+ if (Array.isArray(node.pages)) {
194
+ for (const item of node.pages) fromPagesItem(item);
195
+ } else if (Array.isArray(node.tabs)) {
196
+ for (const t of node.tabs) fromContainer(t);
197
+ } else if (Array.isArray(node.versions)) {
198
+ for (const v of node.versions) fromContainer(v);
199
+ } else if (Array.isArray(node.languages)) {
200
+ for (const l of node.languages) fromContainer(l);
201
+ } else if (Array.isArray(node.dropdowns)) {
202
+ for (const d of node.dropdowns) fromContainer(d);
203
+ } else if (Array.isArray(node.products)) {
204
+ for (const p of node.products) fromContainer(p);
205
+ }
206
+ // otherwise a bare { href } container - nothing to collect
207
+ }
208
+
209
+ if (Array.isArray(navigation)) {
210
+ for (const item of navigation) fromPagesItem(item);
211
+ } else if (navigation && typeof navigation === 'object') {
212
+ if (Array.isArray(navigation.global?.dropdowns)) {
213
+ for (const d of navigation.global.dropdowns) fromContainer(d);
214
+ }
215
+ fromContainer(navigation);
216
+ }
217
+
218
+ return found;
219
+ }
220
+
221
+ /** Generates every page for one `{ group, openapi: { src, path } }`
222
+ * navigation node: dereferences its spec, writes a stub page per
223
+ * operation (unless a hand-written override already claims it), and
224
+ * writes this spec's own manifest + resolved operation JSON under
225
+ * writedocsTempDir()'s openapi/<path>/ directory - namespaced by `path` so multiple specs
226
+ * in the same writedocs.json never collide with each other on disk, exactly
227
+ * as they won't collide in the URL space either (both keyed off the
228
+ * same `path` value). */
229
+ async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, overrides }) {
230
+ const pathPrefix = normalizePathPrefix(group.openapi.path);
231
+ const specPath = path.resolve(contentDir, group.openapi.src);
232
+ if (!fs.existsSync(specPath)) {
233
+ throw new Error(
234
+ `[writedocs] writedocs.json's "${group.group}" group has openapi.src "${group.openapi.src}" ` +
235
+ `(resolved to ${specPath}), which doesn't exist`
236
+ );
237
+ }
238
+
239
+ console.log(`[writedocs] Parsing OpenAPI spec for "${group.group}": ${group.openapi.src}`);
240
+ const spec = await SwaggerParser.dereference(specPath);
241
+ const operations = buildOperations(spec);
242
+
243
+ const openapiOutDir = path.join(writedocsTempDir(contentDir), 'openapi', pathPrefix);
244
+ fs.mkdirSync(path.join(openapiOutDir, 'operations'), { recursive: true });
245
+
246
+ const manifest = [];
247
+ for (const operation of operations) {
248
+ const key = operationKey(operation.method, operation.path);
249
+ const overrideSlug = overrides.get(key);
250
+ const generated = !overrideSlug;
251
+ const slug = overrideSlug ?? generatedSlugFor(pathPrefix, operation.method, operation.path, operation.tags);
252
+ const title = operation.summary ?? `${operation.method} ${operation.path}`;
253
+
254
+ if (generated) {
255
+ // Written under writedocsTempDir()'s generated-docs/ directory
256
+ // (its own content collection - see content.config.ts) rather than
257
+ // anywhere inside the project itself, so a site's own project
258
+ // folder never shows these machine-generated files when a reader
259
+ // browses it directly. `slug` here doubles as
260
+ // this file's own path (relative to that collection's base) and
261
+ // its frontmatter override, so the page's served URL is pinned to
262
+ // exactly this value regardless of whatever id Astro's own
263
+ // default (path-derived) computation would otherwise have picked.
264
+ const filePath = path.join(generatedDocsDir, `${slug}.mdx`);
265
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
266
+ const frontmatter = [
267
+ '---',
268
+ `title: ${JSON.stringify(title)}`,
269
+ operation.description ? `description: ${JSON.stringify(operation.description)}` : null,
270
+ `openapi: ${JSON.stringify(key)}`,
271
+ `slug: ${JSON.stringify(slug)}`,
272
+ '---',
273
+ '',
274
+ ]
275
+ .filter((line) => line !== null)
276
+ .join('\n');
277
+ fs.writeFileSync(filePath, frontmatter);
278
+ }
279
+
280
+ manifest.push({ slug, method: operation.method, path: operation.path, tags: operation.tags, title, generated });
281
+
282
+ const opFile = path.join(openapiOutDir, 'operations', operationFileName(operation.method, operation.path));
283
+ fs.writeFileSync(opFile, JSON.stringify(operation, null, 2));
284
+ }
285
+
286
+ fs.writeFileSync(path.join(openapiOutDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
287
+ console.log(`[writedocs] Generated ${operations.length} API operation page(s) for "${group.group}"`);
288
+ }
289
+
290
+ /**
291
+ * Runs before Astro starts (both `writedocs dev` and `writedocs build`):
292
+ * finds every `{ group, openapi: { src, path } }` node anywhere in
293
+ * writedocs.json's `navigation` tree, and for each one, parses its spec and
294
+ * either points each operation at an existing hand-written page
295
+ * (frontmatter `openapi: "METHOD /path"`) or writes a minimal generated
296
+ * stub under writedocsTempDir()'s generated-docs/ directory - its own
297
+ * content collection (see content.config.ts), kept entirely out of the
298
+ * project itself so a site's own project folder never shows these
299
+ * machine-generated files. (An earlier version of this tried
300
+ * docs/_generated/ - and, before that, a docs/.generated/ dot-directory,
301
+ * which Astro's glob loader silently skips outright since fast-glob-style
302
+ * dotfile exclusion can't be turned off through its public options -
303
+ * before settling on a fully separate collection instead, which
304
+ * sidesteps both problems.) Also writes an openapi/<path>/manifest.json
305
+ * (consumed by lib/config.ts to expand a group's `openapi` shorthand into
306
+ * concrete `{ group, pages }`) and one resolved operation JSON per
307
+ * operation under openapi/<path>/operations/ (consumed by
308
+ * ApiPlayground.astro/ApiReferencePanel.astro at render time - keeping
309
+ * the actual $ref dereferencing work in this one place rather than
310
+ * repeating it, or re-adding swagger-parser as an Astro/Vite-side
311
+ * dependency, per page render), both also under writedocsTempDir(). Each
312
+ * spec's own output is namespaced under its group's `path`, so any number
313
+ * of openapi groups can coexist in one writedocs.json without colliding with
314
+ * each other.
315
+ *
316
+ * No-ops (after clearing any stale output from a previous run) if
317
+ * writedocs.json's navigation has no openapi groups at all - most sites don't
318
+ * have one.
319
+ */
320
+ export async function generateApiPages({ contentDir }) {
321
+ const writedocsJsonPath = path.join(contentDir, 'writedocs.json');
322
+ const generatedDocsDir = path.join(writedocsTempDir(contentDir), 'generated-docs');
323
+ const openapiOutDir = path.join(writedocsTempDir(contentDir), 'openapi');
324
+
325
+ let config;
326
+ try {
327
+ config = JSON.parse(fs.readFileSync(writedocsJsonPath, 'utf-8'));
328
+ } catch {
329
+ return; // preflightCheck() (run first, see dev.js/build.js) already reports this
330
+ }
331
+
332
+ // Always start from a clean slate: a group's `path` (or the group
333
+ // itself) may have changed or been removed since the last run, and
334
+ // there's no cheap way to tell stale generated output apart from
335
+ // still-current output without just regenerating everything.
336
+ rmrf(generatedDocsDir);
337
+ rmrf(openapiOutDir);
338
+
339
+ const groups = collectOpenApiGroups(config.navigation);
340
+ if (groups.length === 0) return;
341
+
342
+ const seenPaths = new Map(); // normalized path -> owning group's label
343
+ for (const group of groups) {
344
+ const normalized = normalizePathPrefix(group.openapi.path);
345
+ const owner = seenPaths.get(normalized);
346
+ if (owner) {
347
+ throw new Error(
348
+ `[writedocs] writedocs.json has two openapi groups both mounted at "${group.openapi.path}" ` +
349
+ `("${owner}" and "${group.group}") - give each group's openapi.path a distinct value.`
350
+ );
351
+ }
352
+ seenPaths.set(normalized, group.group);
353
+ }
354
+
355
+ const overrides = findHandWrittenOverrides(contentDir);
356
+ for (const group of groups) {
357
+ await generateApiPagesForGroup({ contentDir, generatedDocsDir, group, overrides });
358
+ }
359
+ }
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const WRITEDOCS_JSON = {
5
+ name: 'My Docs',
6
+ description: 'Documentation site built with Writedocs',
7
+ styles: {
8
+ colors: { primary: '#6366f1' },
9
+ },
10
+ navigation: [
11
+ { group: 'Getting Started', pages: ['index', 'docs/getting-started'] },
12
+ ],
13
+ topbar: { links: [] },
14
+ };
15
+
16
+ // Lives at the project root, next to writedocs.json itself - not inside docs/
17
+ // - so it keeps file id "index" and serves at "/" with no frontmatter
18
+ // `slug` override needed. See "How a project is structured" in the
19
+ // public docs (site/docs/index.mdx) for why: a nested index.mdx (one
20
+ // inside any folder, docs/ included) has its trailing `/index` segment
21
+ // stripped by Astro's own default id computation, landing it at that
22
+ // folder's own path instead of "/".
23
+ const INDEX_MDX = `---
24
+ title: Introduction
25
+ description: Welcome to your new docs site
26
+ ---
27
+
28
+ Welcome to your new documentation site, built with Writedocs.
29
+
30
+ <Callout type="tip">
31
+ Edit \`index.mdx\` to get started, and add pages to \`writedocs.json\`'s
32
+ navigation to make them appear in the sidebar.
33
+ </Callout>
34
+ `;
35
+
36
+ const GETTING_STARTED_MDX = `---
37
+ title: Getting Started
38
+ ---
39
+
40
+ <Steps>
41
+ <Step title="Edit content">
42
+ A page is any \`.md\`/\`.mdx\` file with a frontmatter block -
43
+ \`docs/\` is a convenient place to put most of them, but not a
44
+ requirement.
45
+ </Step>
46
+ <Step title="Update navigation">
47
+ Add the page's path (without extension) to \`writedocs.json\` - a page
48
+ under \`docs/\` is referenced with that prefix, e.g. \`docs/guides/x\`
49
+ for \`docs/guides/x.mdx\`.
50
+ </Step>
51
+ <Step title="Preview">
52
+ Run \`writedocs dev\` and open the printed local URL.
53
+ </Step>
54
+ </Steps>
55
+ `;
56
+
57
+ export async function runInit({ targetDir }) {
58
+ fs.mkdirSync(path.join(targetDir, 'docs'), { recursive: true });
59
+
60
+ const configPath = path.join(targetDir, 'writedocs.json');
61
+ if (fs.existsSync(configPath)) {
62
+ console.error(`[writedocs] writedocs.json already exists in ${targetDir}, skipping.`);
63
+ } else {
64
+ fs.writeFileSync(configPath, JSON.stringify(WRITEDOCS_JSON, null, 2) + '\n');
65
+ console.log(`[writedocs] Created writedocs.json`);
66
+ }
67
+
68
+ const indexPath = path.join(targetDir, 'index.mdx');
69
+ if (!fs.existsSync(indexPath)) {
70
+ fs.writeFileSync(indexPath, INDEX_MDX);
71
+ console.log(`[writedocs] Created index.mdx`);
72
+ }
73
+
74
+ const gsPath = path.join(targetDir, 'docs', 'getting-started.mdx');
75
+ if (!fs.existsSync(gsPath)) {
76
+ fs.writeFileSync(gsPath, GETTING_STARTED_MDX);
77
+ console.log(`[writedocs] Created docs/getting-started.mdx`);
78
+ }
79
+
80
+ console.log(`\n[writedocs] Ready. Run "writedocs dev" to preview your site.`);
81
+ }
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Fast, dependency-light sanity check that runs before handing off to
6
+ * Astro, so users get an immediate, clear error instead of a Vite stack
7
+ * trace when writedocs.json is missing or malformed JSON.
8
+ */
9
+ export function preflightCheck(contentDir) {
10
+ const configPath = path.join(contentDir, 'writedocs.json');
11
+ if (!fs.existsSync(configPath)) {
12
+ // The config file used to be named docs.json (renamed to writedocs.json
13
+ // for a clearer, collision-free name - "docs.json" reads like it could
14
+ // be *any* project's own docs config, not specifically writedocs'). A
15
+ // project that still has the old filename around gets a specific
16
+ // rename hint instead of the generic "none found" message below, since
17
+ // that's a one-line fix rather than a real missing-config problem.
18
+ const legacyConfigPath = path.join(contentDir, 'docs.json');
19
+ if (fs.existsSync(legacyConfigPath)) {
20
+ console.error(`[writedocs] Found docs.json in ${contentDir}, but the config file is now named writedocs.json.`);
21
+ console.error(`[writedocs] Rename it: mv docs.json writedocs.json`);
22
+ process.exit(1);
23
+ }
24
+ console.error(`[writedocs] No writedocs.json found in ${contentDir}`);
25
+ console.error(`[writedocs] Run "writedocs init" to create one.`);
26
+ process.exit(1);
27
+ }
28
+ try {
29
+ JSON.parse(fs.readFileSync(configPath, 'utf-8'));
30
+ } catch (err) {
31
+ console.error(`[writedocs] writedocs.json is not valid JSON: ${err.message}`);
32
+ process.exit(1);
33
+ }
34
+ // No docs/-folder check here: docs/ isn't a required directory - a page
35
+ // can live anywhere in the project (see findAllPages() in
36
+ // src/lib/config.ts). A writedocs.json whose navigation references a
37
+ // page that genuinely doesn't exist anywhere still fails loudly, just
38
+ // later, with a more specific error naming the missing page - see
39
+ // getStaticPaths() in src/pages/[...slug].astro.
40
+ }
@@ -0,0 +1,57 @@
1
+ import { createRequire } from 'node:module';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ /**
6
+ * Resolves the astro CLI entrypoint relative to this package (not the
7
+ * consumer's project), so it works regardless of how writedocs was
8
+ * installed (flat or nested node_modules).
9
+ */
10
+ function resolveAstroBin(packageRoot) {
11
+ const require = createRequire(path.join(packageRoot, 'package.json'));
12
+ const astroPkgPath = require.resolve('astro/package.json');
13
+ const astroPkg = require(astroPkgPath);
14
+ return path.join(path.dirname(astroPkgPath), astroPkg.bin.astro);
15
+ }
16
+
17
+ export function runAstro(args, { packageRoot, contentDir }) {
18
+ const astroBin = resolveAstroBin(packageRoot);
19
+ return new Promise((resolve, reject) => {
20
+ const child = spawn(process.execPath, [astroBin, ...args], {
21
+ stdio: 'inherit',
22
+ // Explicit, not inherited: astro.config.mjs's own `outDir` lives
23
+ // inside packageRoot (writedocsBuildStagingDir() - see its own
24
+ // comment in writedocs-temp-dir.js for the full EXDEV story this is
25
+ // one half of), and Astro's static builder only stages its
26
+ // prerendered output *inside* that outDir - rather than falling
27
+ // back to `<process.cwd()>/.astro/.prerender/` - when outDir starts
28
+ // with process.cwd() (getOutDirWithinCwd, astro/dist/core/build/
29
+ // common.js). Left unset, this child would just inherit whatever
30
+ // directory the *parent* writedocs CLI process happened to be
31
+ // launched from - unrelated to packageRoot for a real globally-
32
+ // installed CLI invoked from wherever the user's shell happens to
33
+ // be - so the fallback branch would fire regardless, staging
34
+ // outside packageRoot again and reintroducing the exact module-
35
+ // resolution problem this whole design is meant to avoid. Pinning
36
+ // cwd to packageRoot here guarantees the "starts with cwd" check
37
+ // passes deterministically, independent of the parent process's own
38
+ // cwd.
39
+ cwd: packageRoot,
40
+ env: {
41
+ ...process.env,
42
+ WRITEDOCS_CONTENT_DIR: contentDir,
43
+ // Astro is always invoked with `--root packageRoot` (see build.js/
44
+ // dev.js), so every CollectionEntry's `filePath` comes back
45
+ // relative to *this*, not to contentDir or the process's cwd.
46
+ // Exposed so lib/config.ts's fileIdForEntry() can resolve it back
47
+ // into a docs/-relative file id - see that function for why.
48
+ WRITEDOCS_PACKAGE_ROOT: packageRoot,
49
+ },
50
+ });
51
+ child.on('exit', (code) => {
52
+ if (code === 0) resolve();
53
+ else reject(new Error(`astro ${args[0]} exited with code ${code}`));
54
+ });
55
+ child.on('error', reject);
56
+ });
57
+ }