@reapi/docs 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/bin.js +7 -0
- package/package.json +40 -0
- package/preview/app.tsx +145 -0
- package/preview/index.html +12 -0
- package/preview/main.tsx +5 -0
- package/preview/styles.css +56 -0
- package/src/check.js +12 -0
- package/src/cli.js +49 -0
- package/src/compile-dir.js +73 -0
- package/src/dev.js +112 -0
- package/src/push.js +48 -0
package/bin.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reapi/docs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "ReAPI docs CLI — local WYSIWYG preview and sync for portal docs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"reapi-docs": "./bin.js"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@tailwindcss/typography": "^0.5.20",
|
|
11
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
12
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
13
|
+
"chokidar": "^4.0.3",
|
|
14
|
+
"react": "^19.1.0",
|
|
15
|
+
"react-dom": "^19.1.0",
|
|
16
|
+
"tailwindcss": "^4.0.0",
|
|
17
|
+
"vite": "^6.0.0",
|
|
18
|
+
"@reapi/docs-ui": "0.1.0",
|
|
19
|
+
"@reapi/spec-ui": "0.1.0",
|
|
20
|
+
"@reapi/docs-compile": "0.1.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/mdast": "^4.0.4",
|
|
24
|
+
"@types/react": "^19.0.0",
|
|
25
|
+
"@types/react-dom": "^19.0.0",
|
|
26
|
+
"typescript": "^5.7.3"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin.js",
|
|
30
|
+
"src",
|
|
31
|
+
"preview",
|
|
32
|
+
"!preview/sources.gen.css"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/preview/app.tsx
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { ApiOperation, DocsEmbedProvider, MdxContent } from '@reapi/docs-ui';
|
|
3
|
+
import type { Root } from 'mdast';
|
|
4
|
+
|
|
5
|
+
interface SitePayload {
|
|
6
|
+
config: { name: string; description?: string; nav: { group: string; pages: string[] }[] } | null;
|
|
7
|
+
handle: string | null;
|
|
8
|
+
pages: { path: string; title?: string }[];
|
|
9
|
+
errors: { path: string; message: string; line?: number }[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PagePayload {
|
|
13
|
+
frontmatter: Record<string, unknown>;
|
|
14
|
+
ast: Root;
|
|
15
|
+
embeds: string[];
|
|
16
|
+
openapiRef: { api: string; method: string; path: string } | null;
|
|
17
|
+
file: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const fetchJson = async <T,>(url: string): Promise<T | null> => {
|
|
21
|
+
const res = await fetch(url);
|
|
22
|
+
return res.ok ? ((await res.json()) as T) : null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function usePath(): [string, (p: string) => void] {
|
|
26
|
+
const [path, setPath] = useState(window.location.hash.slice(2) || 'index');
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const onHash = () => setPath(window.location.hash.slice(2) || 'index');
|
|
29
|
+
window.addEventListener('hashchange', onHash);
|
|
30
|
+
return () => window.removeEventListener('hashchange', onHash);
|
|
31
|
+
}, []);
|
|
32
|
+
return [path, (p) => (window.location.hash = `/${p}`)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function App() {
|
|
36
|
+
const [site, setSite] = useState<SitePayload | null>(null);
|
|
37
|
+
const [page, setPage] = useState<PagePayload | null>(null);
|
|
38
|
+
const [specs, setSpecs] = useState<Record<string, Record<string, unknown>>>({});
|
|
39
|
+
const [path] = usePath();
|
|
40
|
+
|
|
41
|
+
const load = useCallback(async () => {
|
|
42
|
+
const s = await fetchJson<SitePayload>('/__docs/site');
|
|
43
|
+
setSite(s);
|
|
44
|
+
const p = await fetchJson<PagePayload>(`/__docs/page?path=${encodeURIComponent(path)}`);
|
|
45
|
+
setPage(p);
|
|
46
|
+
if (p?.embeds.length) {
|
|
47
|
+
const entries = await Promise.all(
|
|
48
|
+
p.embeds.map(async (api) => {
|
|
49
|
+
const pub = await fetchJson<{ spec: Record<string, unknown> }>(
|
|
50
|
+
`/__docs/spec?api=${encodeURIComponent(api)}`,
|
|
51
|
+
);
|
|
52
|
+
return pub ? ([api, pub.spec] as const) : null;
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
setSpecs(Object.fromEntries(entries.filter((e): e is [string, Record<string, unknown>] => !!e)));
|
|
56
|
+
} else {
|
|
57
|
+
setSpecs({});
|
|
58
|
+
}
|
|
59
|
+
}, [path]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
void load();
|
|
63
|
+
}, [load]);
|
|
64
|
+
|
|
65
|
+
// live reload on recompile (vite ws custom event from the dev plugin)
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!import.meta.hot) return;
|
|
68
|
+
const handler = () => void load();
|
|
69
|
+
import.meta.hot.on('docs:update', handler);
|
|
70
|
+
return () => import.meta.hot?.off('docs:update', handler);
|
|
71
|
+
}, [load]);
|
|
72
|
+
|
|
73
|
+
if (!site) return <p className="p-10 text-sm text-muted-foreground">Loading…</p>;
|
|
74
|
+
|
|
75
|
+
const fm = (page?.frontmatter ?? {}) as { title?: string; description?: string };
|
|
76
|
+
const pageRef = page?.openapiRef ?? null;
|
|
77
|
+
const titleOf = (p: string) => site.pages.find((x) => x.path === p)?.title ?? p.split('/').pop();
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="mx-auto flex max-w-6xl gap-10 px-6">
|
|
81
|
+
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 overflow-y-auto py-10 md:block">
|
|
82
|
+
<p className="text-sm font-semibold">{site.config?.name ?? 'Docs preview'}</p>
|
|
83
|
+
<p className="mt-0.5 text-[11px] text-muted-foreground">local preview</p>
|
|
84
|
+
<nav className="mt-6 space-y-5">
|
|
85
|
+
{(site.config?.nav ?? []).map((group) => (
|
|
86
|
+
<div key={group.group}>
|
|
87
|
+
<p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
88
|
+
{group.group}
|
|
89
|
+
</p>
|
|
90
|
+
<ul>
|
|
91
|
+
{group.pages.map((p) => (
|
|
92
|
+
<li key={p}>
|
|
93
|
+
<a
|
|
94
|
+
href={`#/${p}`}
|
|
95
|
+
className={`block truncate rounded px-2 py-1 text-[13px] ${
|
|
96
|
+
p === path
|
|
97
|
+
? 'bg-accent font-medium'
|
|
98
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
99
|
+
}`}
|
|
100
|
+
>
|
|
101
|
+
{titleOf(p)}
|
|
102
|
+
</a>
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</nav>
|
|
109
|
+
</aside>
|
|
110
|
+
|
|
111
|
+
<main className="min-w-0 flex-1 py-10">
|
|
112
|
+
{site.errors.length > 0 && (
|
|
113
|
+
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
|
|
114
|
+
<p className="font-semibold">{site.errors.length} compile problem(s)</p>
|
|
115
|
+
<ul className="mt-1 list-inside list-disc font-mono">
|
|
116
|
+
{site.errors.map((e, i) => (
|
|
117
|
+
<li key={i}>
|
|
118
|
+
{e.path}
|
|
119
|
+
{e.line ? `:${e.line}` : ''} — {e.message}
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
{!page ? (
|
|
126
|
+
<p className="text-sm text-muted-foreground">
|
|
127
|
+
No page at <code className="font-mono">{path}</code>. Create{' '}
|
|
128
|
+
<code className="font-mono">{path}.mdx</code> or pick one from the sidebar.
|
|
129
|
+
</p>
|
|
130
|
+
) : (
|
|
131
|
+
<DocsEmbedProvider specs={specs}>
|
|
132
|
+
<article className="prose prose-sm max-w-3xl prose-headings:scroll-mt-20 prose-pre:rounded-md prose-pre:bg-muted prose-pre:p-3 prose-pre:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-px prose-code:font-mono prose-code:before:content-none prose-code:after:content-none">
|
|
133
|
+
{fm.title && <h1>{fm.title}</h1>}
|
|
134
|
+
{fm.description && <p className="lead text-muted-foreground">{fm.description}</p>}
|
|
135
|
+
<MdxContent ast={page.ast} />
|
|
136
|
+
{pageRef && (
|
|
137
|
+
<ApiOperation api={pageRef.api} operation={`${pageRef.method} ${pageRef.path}`} />
|
|
138
|
+
)}
|
|
139
|
+
</article>
|
|
140
|
+
</DocsEmbedProvider>
|
|
141
|
+
)}
|
|
142
|
+
</main>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>ReAPI docs preview</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/preview/main.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
/* @source paths for the shared render packages are generated at startup
|
|
3
|
+
(dev.js) — their install location differs between the workspace (TS
|
|
4
|
+
source) and an npm install (dist). */
|
|
5
|
+
@import './sources.gen.css';
|
|
6
|
+
@plugin '@tailwindcss/typography';
|
|
7
|
+
|
|
8
|
+
/* same tokens as the portal so the preview is faithful */
|
|
9
|
+
:root {
|
|
10
|
+
--radius: 0.625rem;
|
|
11
|
+
--background: oklch(1 0 0);
|
|
12
|
+
--foreground: oklch(0.145 0 0);
|
|
13
|
+
--primary: oklch(0.205 0 0);
|
|
14
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
15
|
+
--secondary: oklch(0.97 0 0);
|
|
16
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
17
|
+
--muted: oklch(0.97 0 0);
|
|
18
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
19
|
+
--accent: oklch(0.97 0 0);
|
|
20
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
21
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
22
|
+
--border: oklch(0.922 0 0);
|
|
23
|
+
--input: oklch(0.922 0 0);
|
|
24
|
+
--ring: oklch(0.708 0 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@theme inline {
|
|
28
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
29
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
30
|
+
--radius-lg: var(--radius);
|
|
31
|
+
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
|
32
|
+
--font-mono: ui-monospace, SFMono-Regular, monospace;
|
|
33
|
+
--color-background: var(--background);
|
|
34
|
+
--color-foreground: var(--foreground);
|
|
35
|
+
--color-primary: var(--primary);
|
|
36
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
37
|
+
--color-secondary: var(--secondary);
|
|
38
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
39
|
+
--color-muted: var(--muted);
|
|
40
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
41
|
+
--color-accent: var(--accent);
|
|
42
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
43
|
+
--color-destructive: var(--destructive);
|
|
44
|
+
--color-border: var(--border);
|
|
45
|
+
--color-input: var(--input);
|
|
46
|
+
--color-ring: var(--ring);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@layer base {
|
|
50
|
+
* {
|
|
51
|
+
@apply border-border outline-ring/50;
|
|
52
|
+
}
|
|
53
|
+
body {
|
|
54
|
+
@apply bg-background text-foreground font-sans;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { compileDir, printErrors } from './compile-dir.js';
|
|
2
|
+
|
|
3
|
+
export async function checkCommand({ dir }) {
|
|
4
|
+
const { pages, errors } = await compileDir(dir);
|
|
5
|
+
console.log(`Compiled ${pages.size} page(s) from ${dir}`);
|
|
6
|
+
if (errors.length) {
|
|
7
|
+
printErrors(errors);
|
|
8
|
+
console.error(`\n${errors.length} problem(s).`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
console.log('✓ No problems — ready to push.');
|
|
12
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { devCommand } from './dev.js';
|
|
2
|
+
import { checkCommand } from './check.js';
|
|
3
|
+
import { pushCommand } from './push.js';
|
|
4
|
+
|
|
5
|
+
const HELP = `reapi-docs — ReAPI portal docs toolkit
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
reapi-docs dev [--dir .] [--port 4747] [--api-origin URL] local preview (WYSIWYG)
|
|
9
|
+
reapi-docs check [--dir .] compile, print errors
|
|
10
|
+
reapi-docs push [--dir .] [--api-origin URL] sync to your portal
|
|
11
|
+
(auth: REAPI_SESSION env = better-auth session token)
|
|
12
|
+
|
|
13
|
+
The docs dir holds docs.json + *.mdx pages (see docs/portal.md).
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export function parseFlags(argv) {
|
|
17
|
+
const flags = {};
|
|
18
|
+
const rest = [];
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const a = argv[i];
|
|
21
|
+
if (a.startsWith('--')) {
|
|
22
|
+
flags[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
|
|
23
|
+
} else {
|
|
24
|
+
rest.push(a);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { flags, rest };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function run(argv) {
|
|
31
|
+
const [command, ...args] = argv;
|
|
32
|
+
const { flags } = parseFlags(args);
|
|
33
|
+
const options = {
|
|
34
|
+
dir: flags.dir ?? process.cwd(),
|
|
35
|
+
port: Number(flags.port ?? 4747),
|
|
36
|
+
apiOrigin: flags['api-origin'] ?? process.env.REAPI_API_ORIGIN ?? 'https://api.reapi.test',
|
|
37
|
+
};
|
|
38
|
+
switch (command) {
|
|
39
|
+
case 'dev':
|
|
40
|
+
return devCommand(options);
|
|
41
|
+
case 'check':
|
|
42
|
+
return checkCommand(options);
|
|
43
|
+
case 'push':
|
|
44
|
+
return pushCommand(options);
|
|
45
|
+
default:
|
|
46
|
+
console.log(HELP);
|
|
47
|
+
if (command && command !== 'help') process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { compileMdx, normalizePagePath, parseDocsConfig, parseOpenapiRef } from '@reapi/docs-compile';
|
|
4
|
+
|
|
5
|
+
const IGNORED = new Set(['node_modules', '.git', '.next', 'dist']);
|
|
6
|
+
|
|
7
|
+
/** All .mdx/.md files under dir (relative paths, posix separators). */
|
|
8
|
+
export async function listPageFiles(dir, prefix = '') {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
11
|
+
if (entry.name.startsWith('.') || IGNORED.has(entry.name)) continue;
|
|
12
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
13
|
+
if (entry.isDirectory()) {
|
|
14
|
+
out.push(...(await listPageFiles(path.join(dir, entry.name), rel)));
|
|
15
|
+
} else if (/\.(mdx?|markdown)$/i.test(entry.name)) {
|
|
16
|
+
// repo README is for GitHub, not the site
|
|
17
|
+
if (!prefix && /^readme\.(mdx?|markdown)$/i.test(entry.name)) continue;
|
|
18
|
+
out.push(rel);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compile the whole docs dir exactly like the server sync does — same
|
|
26
|
+
* compiler package, so what previews locally is what publishes.
|
|
27
|
+
* Returns { config, rawConfig, pages: Map<path, CompiledPage>, files, errors }.
|
|
28
|
+
*/
|
|
29
|
+
export async function compileDir(dir) {
|
|
30
|
+
const errors = [];
|
|
31
|
+
let config = null;
|
|
32
|
+
let rawConfig = null;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const text = await fs.readFile(path.join(dir, 'docs.json'), 'utf8');
|
|
36
|
+
rawConfig = JSON.parse(text);
|
|
37
|
+
const parsed = parseDocsConfig(text);
|
|
38
|
+
config = parsed.config;
|
|
39
|
+
for (const e of parsed.errors) errors.push({ path: 'docs.json', ...e });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
errors.push({ path: 'docs.json', message: `cannot read docs.json: ${err.message}` });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const files = await listPageFiles(dir);
|
|
45
|
+
const pages = new Map();
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const source = await fs.readFile(path.join(dir, file), 'utf8');
|
|
48
|
+
const compiled = compileMdx(source);
|
|
49
|
+
const slug = normalizePagePath(file);
|
|
50
|
+
// pre-parse the full-page embed ref — the browser preview must not load
|
|
51
|
+
// the node-targeted compile bundle
|
|
52
|
+
const openapiRef = parseOpenapiRef(compiled.frontmatter?.openapi);
|
|
53
|
+
pages.set(slug, { ...compiled, openapiRef, file });
|
|
54
|
+
for (const e of compiled.errors) errors.push({ path: file, ...e });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (config) {
|
|
58
|
+
for (const group of config.nav) {
|
|
59
|
+
for (const p of group.pages) {
|
|
60
|
+
if (!pages.has(p)) errors.push({ path: 'docs.json', message: `nav page "${p}" has no matching file` });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { config, rawConfig, pages, files, errors };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function printErrors(errors) {
|
|
69
|
+
for (const e of errors) {
|
|
70
|
+
const pos = e.line ? `:${e.line}${e.column ? `:${e.column}` : ''}` : '';
|
|
71
|
+
console.error(` ✗ ${e.path}${pos} ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/dev.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { watch } from 'chokidar';
|
|
6
|
+
import { createServer } from 'vite';
|
|
7
|
+
import react from '@vitejs/plugin-react';
|
|
8
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
9
|
+
import { compileDir } from './compile-dir.js';
|
|
10
|
+
|
|
11
|
+
const previewRoot = fileURLToPath(new URL('../preview', import.meta.url));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tailwind must scan the shared render packages for class names. Their
|
|
15
|
+
* location depends on how this CLI is installed (workspace TS source vs npm
|
|
16
|
+
* dist — compiled JS keeps the class strings), so resolve at runtime and
|
|
17
|
+
* generate the @source file the preview CSS imports.
|
|
18
|
+
*/
|
|
19
|
+
async function writeTailwindSources() {
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const dirs = ['@reapi/docs-ui', '@reapi/spec-ui'].map((name) =>
|
|
22
|
+
path.dirname(require.resolve(name)),
|
|
23
|
+
);
|
|
24
|
+
const css = dirs.map((dir) => `@source '${dir.replace(/\\/g, '/')}';`).join('\n') + '\n';
|
|
25
|
+
await fs.writeFile(path.join(previewRoot, 'sources.gen.css'), css);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Local WYSIWYG preview (portal.md §6): the watcher recompiles through the
|
|
30
|
+
* SAME @reapi/docs-compile the server uses, and the preview app renders with
|
|
31
|
+
* the SAME @reapi/docs-ui the portal uses — parity is by construction, not
|
|
32
|
+
* approximation. Embed data comes from the public /pub endpoints (published
|
|
33
|
+
* APIs render live; unpublished show placeholders).
|
|
34
|
+
*/
|
|
35
|
+
export async function devCommand({ dir, port, apiOrigin }) {
|
|
36
|
+
await writeTailwindSources();
|
|
37
|
+
let state = await compileDir(dir);
|
|
38
|
+
const handle = typeof state.rawConfig?.handle === 'string' ? state.rawConfig.handle : null;
|
|
39
|
+
|
|
40
|
+
const docsPlugin = {
|
|
41
|
+
name: 'reapi-docs-data',
|
|
42
|
+
configureServer(server) {
|
|
43
|
+
const json = (res, status, data) => {
|
|
44
|
+
res.statusCode = status;
|
|
45
|
+
res.setHeader('Content-Type', 'application/json');
|
|
46
|
+
res.end(JSON.stringify(data));
|
|
47
|
+
};
|
|
48
|
+
server.middlewares.use(async (req, res, next) => {
|
|
49
|
+
const url = new URL(req.url, 'http://x');
|
|
50
|
+
if (url.pathname === '/__docs/site') {
|
|
51
|
+
return json(res, 200, {
|
|
52
|
+
config: state.config,
|
|
53
|
+
handle,
|
|
54
|
+
pages: [...state.pages.entries()].map(([slug, page]) => ({
|
|
55
|
+
path: slug,
|
|
56
|
+
title: page.frontmatter?.title,
|
|
57
|
+
})),
|
|
58
|
+
errors: state.errors,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (url.pathname === '/__docs/page') {
|
|
62
|
+
const page = state.pages.get(url.searchParams.get('path') ?? '');
|
|
63
|
+
return page ? json(res, 200, page) : json(res, 404, { error: 'not found' });
|
|
64
|
+
}
|
|
65
|
+
if (url.pathname === '/__docs/spec') {
|
|
66
|
+
// proxy published specs for embeds (avoids CORS + local TLS trust)
|
|
67
|
+
const api = url.searchParams.get('api');
|
|
68
|
+
if (!handle || !api) return json(res, 404, { error: 'no handle/api' });
|
|
69
|
+
try {
|
|
70
|
+
const r = await fetch(`${apiOrigin}/pub/${handle}/${api}`);
|
|
71
|
+
if (!r.ok) return json(res, r.status, { error: 'not published' });
|
|
72
|
+
return json(res, 200, await r.json());
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return json(res, 502, { error: String(err) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
next();
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const server = await createServer({
|
|
83
|
+
root: previewRoot,
|
|
84
|
+
configFile: false,
|
|
85
|
+
plugins: [react(), tailwindcss(), docsPlugin],
|
|
86
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
87
|
+
server: { port },
|
|
88
|
+
// the preview app lives inside this package; user content is data, not code
|
|
89
|
+
optimizeDeps: { include: ['react', 'react-dom/client'] },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const recompile = async (reason) => {
|
|
93
|
+
state = await compileDir(dir);
|
|
94
|
+
server.ws.send({ type: 'custom', event: 'docs:update', data: { reason } });
|
|
95
|
+
const status = state.errors.length ? `${state.errors.length} problem(s)` : 'ok';
|
|
96
|
+
console.log(`↻ recompiled (${reason}) — ${state.pages.size} pages, ${status}`);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
watch(dir, {
|
|
100
|
+
ignored: (p) => /node_modules|\.git/.test(p),
|
|
101
|
+
ignoreInitial: true,
|
|
102
|
+
}).on('all', (event, file) => {
|
|
103
|
+
if (/\.(mdx?|markdown)$|docs\.json$/i.test(file)) void recompile(path.basename(file));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await server.listen();
|
|
107
|
+
console.log(`\n ReAPI docs preview → http://localhost:${port}`);
|
|
108
|
+
console.log(` Watching ${dir}\n`);
|
|
109
|
+
if (state.errors.length) {
|
|
110
|
+
console.log(` ⚠ ${state.errors.length} compile problem(s) — shown in the preview.\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/push.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { compileDir, listPageFiles, printErrors } from './compile-dir.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sync the docs dir to the server (same endpoint the GitHub webhook uses).
|
|
7
|
+
* The server re-compiles with the identical package — the local check is a
|
|
8
|
+
* convenience, the server gate is the authority.
|
|
9
|
+
*/
|
|
10
|
+
export async function pushCommand({ dir, apiOrigin }) {
|
|
11
|
+
const session = process.env.REAPI_SESSION;
|
|
12
|
+
if (!session) {
|
|
13
|
+
throw new Error('Set REAPI_SESSION to your session token (better-auth session cookie value).');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// fail fast locally with the same compiler
|
|
17
|
+
const { errors } = await compileDir(dir);
|
|
18
|
+
if (errors.length) {
|
|
19
|
+
printErrors(errors);
|
|
20
|
+
throw new Error(`${errors.length} problem(s) — fix them before pushing.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const config = await fs.readFile(path.join(dir, 'docs.json'), 'utf8');
|
|
24
|
+
const files = await listPageFiles(dir);
|
|
25
|
+
const pages = await Promise.all(
|
|
26
|
+
files.map(async (file) => ({
|
|
27
|
+
path: file,
|
|
28
|
+
content: await fs.readFile(path.join(dir, file), 'utf8'),
|
|
29
|
+
})),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const res = await fetch(`${apiOrigin}/docs-site`, {
|
|
33
|
+
method: 'PUT',
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
Cookie: `__Secure-better-auth.session_token=${session}`,
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({ config, pages }),
|
|
39
|
+
});
|
|
40
|
+
const body = await res.json().catch(() => ({}));
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
for (const f of body.files ?? []) {
|
|
43
|
+
printErrors(f.errors.map((e) => ({ path: f.path, ...e })));
|
|
44
|
+
}
|
|
45
|
+
throw new Error(body.message ?? `push failed: ${res.status}`);
|
|
46
|
+
}
|
|
47
|
+
console.log(`✓ Synced ${body.pages} page(s) → ${body.url}`);
|
|
48
|
+
}
|