[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt.
roe.dev
1#!/usr/bin/env node
2/**
3 * Regenerate TS types from the lexicon JSON files under `lexicons/`.
4 *
5 * The generated `shared/lex/index.ts` (full XRPC client surface) is removed
6 * after generation because we drive the PDS via `AtpAgent` from
7 * `@atproto/api` and only need the per-record types and the validator
8 * registry. A hand-written barrel is restored from this script.
9 */
10import { spawn } from 'node:child_process'
11import { readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12import { join, resolve } from 'node:path'
13import { fileURLToPath } from 'node:url'
14
15const here = fileURLToPath(new URL('.', import.meta.url))
16const root = resolve(here, '..')
17const lexRoot = join(root, 'lexicons')
18const outDir = join(root, 'shared/lex')
19
20const lexiconFiles = []
21function walk (dir) {
22 for (const entry of readdirSync(dir, { withFileTypes: true })) {
23 const full = join(dir, entry.name)
24 if (entry.isDirectory()) walk(full)
25 else if (entry.isFile() && entry.name.endsWith('.json')) lexiconFiles.push(full)
26 }
27}
28walk(lexRoot)
29
30if (!lexiconFiles.length) {
31 console.error('No lexicon JSON files found under', lexRoot)
32 process.exit(1)
33}
34
35console.log(`Generating types for ${lexiconFiles.length} lexicons → ${outDir}`)
36
37/**
38 * `@atproto/lex-cli gen-api` prompts to overwrite each generated file. We
39 * pipe a stream of `y\n` into its stdin instead of shelling out to
40 * `yes y | …`, so lexicon paths containing spaces or shell metacharacters
41 * can't break (or be abused to alter) the command line.
42 */
43await new Promise((resolvePromise, reject) => {
44 const child = spawn(
45 'pnpm',
46 ['dlx', '@atproto/lex-cli', 'gen-api', outDir, ...lexiconFiles],
47 { stdio: ['pipe', 'inherit', 'inherit'], cwd: root },
48 )
49 const writeY = () => {
50 while (child.stdin.writable && child.stdin.write('y\n')) {
51 // keep filling the buffer until the kernel says back off
52 }
53 }
54 child.stdin.on('drain', writeY)
55 child.stdin.on('error', () => {})
56 writeY()
57 child.on('error', reject)
58 child.on('exit', code => {
59 child.stdin.end()
60 if (code === 0) resolvePromise()
61 else reject(new Error(`lex-cli exited with code ${code}`))
62 })
63})
64
65/**
66 * Rewrite the generated relative-imports from `.js` to `.ts` so Node’s native
67 * type-stripping (`node script.ts`) can resolve them at runtime without a
68 * loader. TypeScript is configured with `allowImportingTsExtensions` to
69 * accept the same shape at type-check time.
70 */
71function rewriteJsToTs (dir) {
72 for (const entry of readdirSync(dir, { withFileTypes: true })) {
73 const full = join(dir, entry.name)
74 if (entry.isDirectory()) {
75 rewriteJsToTs(full)
76 continue
77 }
78 if (!entry.isFile() || !entry.name.endsWith('.ts')) continue
79 const src = readFileSync(full, 'utf8')
80 const next = src.replace(/((?:from|import)\s+['"])((?:\.{1,2}\/)[^'"]+?)\.js(['"])/g, '$1$2.ts$3')
81 if (next !== src) writeFileSync(full, next)
82 }
83}
84rewriteJsToTs(outDir)
85
86const generatedClient = join(outDir, 'index.ts')
87rmSync(generatedClient, { force: true })
88
89/**
90 * Build the namespace-export lines by scanning the generated `types/`
91 * tree, so adding a new lexicon JSON doesn't need a matching edit to this
92 * script.
93 */
94function collectNamespaceExports (dir, prefix = '') {
95 const lines = []
96 for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
97 const full = join(dir, entry.name)
98 if (entry.isDirectory()) {
99 lines.push(...collectNamespaceExports(full, `${prefix}${capitalise(entry.name)}`))
100 continue
101 }
102 if (!entry.isFile() || !entry.name.endsWith('.ts')) continue
103 const base = entry.name.replace(/\.ts$/, '')
104 const namespace = `${prefix}${capitalise(base)}`
105 const relPath = `./types/${full.slice(typesRoot.length + 1).replace(/\\/g, '/')}`
106 lines.push(`export * as ${namespace} from '${relPath}'`)
107 }
108 return lines
109}
110function capitalise (s) {
111 return s.charAt(0).toUpperCase() + s.slice(1)
112}
113const typesRoot = join(outDir, 'types')
114const namespaceLines = collectNamespaceExports(typesRoot).join('\n')
115
116const barrel = `/**
117 * Auto-generated barrel for the dev.roe lexicons. Re-run \`pnpm lex:gen\` to
118 * refresh after adding or removing lexicon JSON files.
119 *
120 * The generated \`gen-api\` client surface was deleted because we drive the PDS
121 * via the existing \`AtpAgent\` from \`@atproto/api\`; we only need the typed
122 * record interfaces and the validator registry.
123 */
124export { schemas, lexicons, validate, schemaDict } from './lexicons.ts'
125export type { $Typed, Un$Typed, OmitKey } from './util.ts'
126
127${namespaceLines}
128`
129writeFileSync(generatedClient, barrel)
130
131console.log('Done. Barrel written to', generatedClient)