@sanity/tsdoc
Version:
Generate API reference docs from TypeScript projects and store in a Sanity-friendly JSON format. Render a static frontend, or as React components.
218 lines (173 loc) • 6.17 kB
text/typescript
/* eslint-disable no-console */
import {_loadConfig, APIDocument, APIPackageDocument, APISymbolDocument} from '@sanity/tsdoc'
import react from '@vitejs/plugin-react'
import chokidar from 'chokidar'
import cors from 'cors'
import express from 'express'
import {readFile} from 'fs/promises'
import globby from 'globby'
import mkdirp from 'mkdirp'
import path from 'path'
import {createServer as createViteServer, defineConfig} from 'vite'
import {_writeHTML} from './_writeHTML'
import {_writeScript} from './_writeScript'
async function _loadDocs(files: string[]) {
const docs: APIDocument[] = []
for (const f of files) {
const buf = await readFile(f)
docs.push(...JSON.parse(buf.toString()))
}
const _packageDocs = docs.filter((d) => d._type === 'api.package') as APIPackageDocument[]
const _symbolDocs = docs.filter((d) => d._type === 'api.symbol') as APISymbolDocument[]
const packageDocs: APIPackageDocument[] = []
const symbolDocs: APISymbolDocument[] = []
for (const _pkgDoc of _packageDocs) {
const pkg = packageDocs.find((p) => p.scope === _pkgDoc.scope && p.name === _pkgDoc.name)
if (pkg) {
if (_pkgDoc.latestRelease) {
pkg.latestRelease = _pkgDoc.latestRelease
pkg.releases.push({
..._pkgDoc.latestRelease,
_key: _pkgDoc.latestRelease._ref,
})
}
} else {
packageDocs.push({
..._pkgDoc,
releases: [...(_pkgDoc.releases || [])],
})
}
}
for (const _symbolDoc of _symbolDocs) {
const symbol = symbolDocs.find((s) => s._id === _symbolDoc._id)
if (!symbol) {
symbolDocs.push(_symbolDoc)
}
}
const uniqueDocs = docs.filter((d) => d._type !== 'api.package' && d._type !== 'api.symbol')
return [...packageDocs, ...symbolDocs, ...uniqueDocs]
}
/** @beta */
export async function devCommand(options: {cwd: string}): Promise<void> {
const {cwd} = options
const config = await _loadConfig({packagePath: cwd})
const {alias, port = 1337} = config?.app || {}
const outDir = path.resolve(cwd, '.tsdoc')
await mkdirp(outDir)
await _writeHTML({outDir: path.resolve(cwd, '.tsdoc')})
await _writeScript({outDir: path.resolve(cwd, '.tsdoc')})
const files = await globby(config?.input?.pattern || 'etc/**/*.json', {
cwd,
})
const initialDocs = await _loadDocs(files)
const dataWatcher = chokidar.watch(config?.input?.pattern || 'etc/**/*.json', {
cwd,
ignoreInitial: true,
})
// TODO: should be typed `ExpressResponse` or similar
const sockets: express.Response[] = []
function send(socket: express.Response, msg: any) {
socket.write(`data: ${JSON.stringify(msg)}\n\n`)
}
function broadcast(msg: any) {
for (const socket of sockets) {
send(socket, msg)
}
}
dataWatcher.on('all', (eventType) => {
if (eventType === 'change') {
_loadDocs(files).then((docs) => {
broadcast({type: 'docs', docs})
})
}
})
const viteConfig = defineConfig({
plugins: [react({babel: {plugins: [['babel-plugin-react-compiler', {target: '19'}]]}})],
resolve: {alias},
})
const vite = await createViteServer({
...viteConfig,
appType: 'custom',
configFile: false,
logLevel: 'info',
root: cwd,
server: {
hmr: {
port: 15319,
},
middlewareMode: true,
port,
},
cacheDir: 'node_modules/.tsdoc/vite',
})
const app = express()
app.get('/events', cors({origin: true}), (_req, res) => {
res.set({
'Cache-Control': 'no-cache',
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
})
res.flushHeaders()
// Tell the client to retry every 10 seconds if connectivity is lost
res.write('retry: 10000\n\n')
sockets.push(res)
_loadDocs(files).then((docs) => {
send(res, {type: 'docs', docs})
})
_req.on('close', () => {
const idx = sockets.indexOf(res)
if (idx > -1) {
sockets.splice(idx, 1)
}
})
})
app.use(vite.middlewares)
app.get('*', async (req, res, next) => {
const url = req.originalUrl
try {
// @ts-expect-error - find out why the version type is not being inferred
const releaseVersion = initialDocs.find((d: any) => d._type === 'api.release')?.version
// 1. Read index.html
let template = await readFile(path.resolve(outDir, 'index.html'), 'utf-8')
// 2. Apply Vite HTML transforms. This injects the Vite HMR client, and
// also applies HTML transforms from Vite plugins, e.g. global preambles
// from @vitejs/plugin-react
template = await vite.transformIndexHtml(url, template)
// 3. Load the server entry. vite.ssrLoadModule automatically transforms
// your ESM source code to be usable in Node.js! There is no bundling
// required, and provides efficient invalidation similar to HMR.
// const {render} = await vite.ssrLoadModule('/src/entry-server.js')
// 4. render the app HTML. This assumes entry-server.js's exported `render`
// function calls appropriate framework SSR APIs,
// e.g. ReactDOMServer.renderToString()
// const appHtml = await render(url)
// 5. Inject the app-rendered HTML into the template.
// const html = template.replace(`<!--ssr-outlet-->`, appHtml)
const html = template.replace(
'<div id="root"></div>',
[
`<div id="root"></div><script type="module">`,
`window.__INITIAL_STATE__=`,
JSON.stringify({docs: initialDocs, releaseVersion}),
`</script>`,
].join(''),
)
// 6. Send the rendered HTML back.
res.status(200).set({'Content-Type': 'text/html'}).end(html)
} catch (e) {
if (e instanceof Error) {
// If an error is caught, let Vite fix the stack trace so it maps back to
// your actual source code.
vite.ssrFixStacktrace(e)
}
next(e)
}
})
const server = app.listen(port, () => {
console.log(`listening on http://localhost:${port}`)
})
server.on('close', () => {
console.log(`server closed`)
process.exit(1)
})
}