@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,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
|
+
}
|
package/src/cli/build.js
ADDED
|
@@ -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
|
+
}
|
package/src/cli/init.js
ADDED
|
@@ -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
|
+
}
|