Skip to content

Commit cfd472f

Browse files
rbbydotdevclaude
andcommitted
Add remarkSpacExample plugin: live spac-to-YAML tabs in docs
New remark plugin that transforms ```spac code blocks into tabbed examples showing both the TypeScript source and the resulting OpenAPI YAML, generated at build time by actually running spac. Implementation: - remark-spac-example.ts: uses jiti to evaluate spac code at build time - Writes temp file, imports it, calls api.emit({ yaml: true }) - Replaces code block with Fumadocs Tabs component (spac + OpenAPI YAML) - Registered in source.config.ts alongside existing remark plugins - Tab/Tabs components added to MDX component map Getting started tutorial updated to use ```spac instead of ```ts, with `export default api` so the plugin can evaluate it. Also: - spac/package.json: add "default" exports condition for jiti compat - website/package.json: add jiti, tsx, spac as dependencies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 63774de commit cfd472f

7 files changed

Lines changed: 152 additions & 5 deletions

File tree

packages/spac/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
"types": "dist/index.d.ts",
77
"exports": {
88
".": {
9+
"types": "./dist/index.d.ts",
910
"import": "./dist/index.js",
10-
"types": "./dist/index.d.ts"
11+
"default": "./dist/index.js"
1112
}
1213
},
1314
"scripts": {

packages/website/content/docs/tutorials/getting-started.mdx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ pnpm add spac @sinclair/typebox
1313

1414
Create a file called `api.ts`:
1515

16-
```ts
16+
```spac
1717
import { Api, named } from 'spac'
1818
import { Type } from '@sinclair/typebox'
1919
@@ -51,9 +51,7 @@ api.group('/pets', g => {
5151
.tag('pets')
5252
})
5353
54-
// Emit the OpenAPI document
55-
const spec = api.emit()
56-
console.log(JSON.stringify(spec, null, 2))
54+
export default api
5755
```
5856

5957
## Run it

packages/website/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@
1515
"@takumi-rs/image-response": "^0.72.0",
1616
"@spac/theme": "workspace:*",
1717
"fumadocs-core": "16.6.17",
18+
"jiti": "^2.0.0",
1819
"fumadocs-mdx": "14.2.10",
1920
"fumadocs-typescript": "^5.1.5",
2021
"fumadocs-ui": "16.6.17",
2122
"lucide-react": "^0.577.0",
2223
"next": "16.1.6",
2324
"react": "^19.2.4",
2425
"react-dom": "^19.2.4",
26+
"spac": "workspace:*",
2527
"tailwind-merge": "^3.5.0"
2628
},
2729
"devDependencies": {
30+
"tsx": "^4.21.0",
2831
"@biomejs/biome": "^2.4.6",
2932
"@tailwindcss/postcss": "^4.2.1",
3033
"@types/mdx": "^2.0.13",

packages/website/source.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
44
import { remarkAutoTypeTable } from 'fumadocs-typescript';
55
import { remarkJSDocExample } from './src/lib/remark-jsdoc-example';
66
import { remarkCLIUsage } from './src/lib/remark-cli-usage';
7+
import { remarkSpacExample } from './src/lib/remark-spac-example';
78

89
const require = createRequire(import.meta.url);
910
const shikiDark = require('@spac/theme/shiki-dark.json');
@@ -28,6 +29,7 @@ export default defineConfig({
2829
[remarkAutoTypeTable, { name: 'AutoTypeTable' }],
2930
remarkJSDocExample,
3031
remarkCLIUsage,
32+
remarkSpacExample,
3133
],
3234
rehypeCodeOptions: {
3335
themes: {

packages/website/src/components/mdx.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import defaultMdxComponents from 'fumadocs-ui/mdx';
22
import { TypeTable } from 'fumadocs-ui/components/type-table';
3+
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
34
import type { MDXComponents } from 'mdx/types';
45

56
export function getMDXComponents(components?: MDXComponents) {
67
return {
78
...defaultMdxComponents,
89
TypeTable,
10+
Tab,
11+
Tabs,
912
...components,
1013
} satisfies MDXComponents;
1114
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { createJiti } from 'jiti'
2+
import { fileURLToPath } from 'node:url'
3+
import { writeFileSync, unlinkSync, mkdtempSync, existsSync, rmdirSync } from 'node:fs'
4+
import { join, dirname } from 'node:path'
5+
6+
/**
7+
* Remark plugin that transforms ```spac code blocks into tabbed examples
8+
* showing both the spac TypeScript source and the resulting OpenAPI YAML.
9+
*
10+
* The code is evaluated at build time via jiti, so the YAML is always in sync.
11+
*
12+
* The code MUST export the Api instance as `export default api`.
13+
*/
14+
15+
function findRoot(): string {
16+
let dir = dirname(fileURLToPath(import.meta.url))
17+
while (dir !== dirname(dir)) {
18+
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir
19+
dir = dirname(dir)
20+
}
21+
return process.cwd()
22+
}
23+
24+
const root = findRoot()
25+
const jiti = createJiti(join(root, 'packages', 'website', 'eval.ts'))
26+
27+
function evaluateSpacCode(code: string): string | null {
28+
const tmp = mkdtempSync(join(root, '.spac-eval-'))
29+
const entryFile = join(tmp, 'entry.ts')
30+
31+
// Strip `export default` — jiti will handle module.exports
32+
const cleanCode = code.replace(/export\s+default\s+/, 'module.exports = ')
33+
writeFileSync(entryFile, cleanCode)
34+
35+
try {
36+
const mod = jiti(entryFile) as any
37+
const api = mod?.default ?? mod
38+
if (!api?.emit) {
39+
console.warn('[remark-spac-example] No Api.emit() found in code')
40+
return null
41+
}
42+
const result = api.emit({ yaml: true })
43+
return typeof result === 'string' ? result : result.yaml
44+
} catch (e: any) {
45+
console.warn('[remark-spac-example] Failed to evaluate:', e.message?.slice(0, 300))
46+
return null
47+
} finally {
48+
try { unlinkSync(entryFile) } catch {}
49+
try { rmdirSync(tmp) } catch {}
50+
}
51+
}
52+
53+
function walk(
54+
node: any,
55+
type: string,
56+
fn: (node: any, index: number, parent: any) => void,
57+
_index = 0,
58+
parent: any = null,
59+
): void {
60+
if (node.type === type && parent != null) {
61+
fn(node, _index, parent)
62+
}
63+
if (node.children) {
64+
for (let i = node.children.length - 1; i >= 0; i--) {
65+
walk(node.children[i], type, fn, i, node)
66+
}
67+
}
68+
}
69+
70+
export function remarkSpacExample() {
71+
return (tree: any) => {
72+
walk(tree, 'code', (node, index, parent) => {
73+
if (node.lang !== 'spac') return
74+
75+
const tsCode = node.value as string
76+
const yaml = evaluateSpacCode(tsCode)
77+
78+
if (!yaml) {
79+
node.lang = 'ts'
80+
return
81+
}
82+
83+
const tabsNode = {
84+
type: 'mdxJsxFlowElement',
85+
name: 'Tabs',
86+
attributes: [
87+
{
88+
type: 'mdxJsxAttribute',
89+
name: 'items',
90+
value: {
91+
type: 'mdxJsxAttributeValueExpression',
92+
value: "['spac', 'OpenAPI YAML']",
93+
data: {
94+
estree: {
95+
type: 'Program',
96+
body: [{
97+
type: 'ExpressionStatement',
98+
expression: {
99+
type: 'ArrayExpression',
100+
elements: [
101+
{ type: 'Literal', value: 'spac' },
102+
{ type: 'Literal', value: 'OpenAPI YAML' },
103+
],
104+
},
105+
}],
106+
sourceType: 'module',
107+
},
108+
},
109+
},
110+
},
111+
],
112+
children: [
113+
{
114+
type: 'mdxJsxFlowElement',
115+
name: 'Tab',
116+
attributes: [{ type: 'mdxJsxAttribute', name: 'value', value: 'spac' }],
117+
children: [{ type: 'code', lang: 'ts', value: tsCode }],
118+
},
119+
{
120+
type: 'mdxJsxFlowElement',
121+
name: 'Tab',
122+
attributes: [{ type: 'mdxJsxAttribute', name: 'value', value: 'OpenAPI YAML' }],
123+
children: [{ type: 'code', lang: 'yaml', value: yaml }],
124+
},
125+
],
126+
}
127+
128+
parent.children.splice(index, 1, tabsNode)
129+
})
130+
}
131+
}

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)