doxla 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/dist/cli/index.js +239 -0
- package/dist/cli/index.js.map +1 -0
- package/package.json +54 -0
- package/src/app/index.html +12 -0
- package/src/app/package.json +30 -0
- package/src/app/src/App.tsx +76 -0
- package/src/app/src/components/DocPage.tsx +41 -0
- package/src/app/src/components/FileTree.tsx +113 -0
- package/src/app/src/components/IndexPage.tsx +88 -0
- package/src/app/src/components/MarkdownRenderer.tsx +49 -0
- package/src/app/src/components/SearchResults.tsx +94 -0
- package/src/app/src/components/layout/Header.tsx +44 -0
- package/src/app/src/components/layout/Layout.tsx +23 -0
- package/src/app/src/components/layout/Sidebar.tsx +22 -0
- package/src/app/src/components/ui/Badge.tsx +26 -0
- package/src/app/src/components/ui/Button.tsx +37 -0
- package/src/app/src/components/ui/Card.tsx +56 -0
- package/src/app/src/components/ui/Input.tsx +19 -0
- package/src/app/src/components/ui/ScrollArea.tsx +16 -0
- package/src/app/src/components/ui/Separator.tsx +22 -0
- package/src/app/src/globals.css +25 -0
- package/src/app/src/lib/utils.ts +6 -0
- package/src/app/src/main.tsx +10 -0
- package/src/app/src/types/manifest.ts +13 -0
- package/src/app/tsconfig.json +15 -0
- package/src/app/vite.config.ts +11 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/cli/commands/init.ts
|
|
7
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
|
|
12
|
+
// src/cli/templates/doxla-workflow.ts
|
|
13
|
+
var workflowTemplate = `name: Deploy Doxla Docs
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
push:
|
|
17
|
+
branches: [main]
|
|
18
|
+
workflow_dispatch:
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
pages: write
|
|
23
|
+
id-token: write
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: "pages"
|
|
27
|
+
cancel-in-progress: false
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
build-and-deploy:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
environment:
|
|
33
|
+
name: github-pages
|
|
34
|
+
url: \${{ steps.deployment.outputs.page_url }}
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@v4
|
|
37
|
+
- uses: actions/setup-node@v4
|
|
38
|
+
with:
|
|
39
|
+
node-version: "20"
|
|
40
|
+
- run: npx doxla@latest build --base-path "/\${{ github.event.repository.name }}"
|
|
41
|
+
- uses: actions/upload-pages-artifact@v3
|
|
42
|
+
with:
|
|
43
|
+
path: ./doxla-dist
|
|
44
|
+
- id: deployment
|
|
45
|
+
uses: actions/deploy-pages@v4
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
// src/cli/commands/init.ts
|
|
49
|
+
async function initCommand() {
|
|
50
|
+
const workflowDir = join(process.cwd(), ".github", "workflows");
|
|
51
|
+
const workflowPath = join(workflowDir, "doxla.yml");
|
|
52
|
+
if (existsSync(workflowPath)) {
|
|
53
|
+
console.log(
|
|
54
|
+
chalk.yellow("\u26A0 Workflow file already exists at .github/workflows/doxla.yml")
|
|
55
|
+
);
|
|
56
|
+
console.log(chalk.yellow(" Overwriting with latest template..."));
|
|
57
|
+
}
|
|
58
|
+
await mkdir(workflowDir, { recursive: true });
|
|
59
|
+
await writeFile(workflowPath, workflowTemplate, "utf-8");
|
|
60
|
+
console.log(chalk.green("\u2713 Created .github/workflows/doxla.yml"));
|
|
61
|
+
console.log();
|
|
62
|
+
console.log("Next steps:");
|
|
63
|
+
console.log(
|
|
64
|
+
` 1. Enable GitHub Pages in your repo settings (Settings \u2192 Pages \u2192 Source: ${chalk.bold("GitHub Actions")})`
|
|
65
|
+
);
|
|
66
|
+
console.log(" 2. Commit and push the workflow file");
|
|
67
|
+
console.log(
|
|
68
|
+
" 3. Your docs will be built and deployed on every push to main"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/cli/commands/build.ts
|
|
73
|
+
import { resolve as resolve2 } from "path";
|
|
74
|
+
import chalk2 from "chalk";
|
|
75
|
+
import ora from "ora";
|
|
76
|
+
|
|
77
|
+
// src/cli/lib/discover.ts
|
|
78
|
+
import fg from "fast-glob";
|
|
79
|
+
var IGNORE_PATTERNS = [
|
|
80
|
+
"**/node_modules/**",
|
|
81
|
+
"**/.git/**",
|
|
82
|
+
"**/dist/**",
|
|
83
|
+
"**/build/**",
|
|
84
|
+
"**/doxla-dist/**",
|
|
85
|
+
"**/.next/**",
|
|
86
|
+
"**/coverage/**"
|
|
87
|
+
];
|
|
88
|
+
async function discoverMarkdownFiles(rootDir) {
|
|
89
|
+
const files = await fg("**/*.md", {
|
|
90
|
+
cwd: rootDir,
|
|
91
|
+
ignore: IGNORE_PATTERNS,
|
|
92
|
+
onlyFiles: true
|
|
93
|
+
});
|
|
94
|
+
return files.sort((a, b) => {
|
|
95
|
+
const aIsReadme = a.toLowerCase() === "readme.md";
|
|
96
|
+
const bIsReadme = b.toLowerCase() === "readme.md";
|
|
97
|
+
if (aIsReadme && !bIsReadme) return -1;
|
|
98
|
+
if (!aIsReadme && bIsReadme) return 1;
|
|
99
|
+
return a.localeCompare(b);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/cli/lib/manifest.ts
|
|
104
|
+
import { readFile } from "fs/promises";
|
|
105
|
+
import { join as join2, basename, dirname } from "path";
|
|
106
|
+
function extractTitle(content, filePath) {
|
|
107
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
108
|
+
if (match) {
|
|
109
|
+
return match[1].trim();
|
|
110
|
+
}
|
|
111
|
+
const name = basename(filePath, ".md");
|
|
112
|
+
if (name.toLowerCase() === "readme") {
|
|
113
|
+
const dir = dirname(filePath);
|
|
114
|
+
if (dir === ".") return "README";
|
|
115
|
+
return `${dir} - README`;
|
|
116
|
+
}
|
|
117
|
+
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
118
|
+
}
|
|
119
|
+
function createSlug(filePath) {
|
|
120
|
+
return filePath.replace(/\.md$/i, "").replace(/\\/g, "/").toLowerCase().replace(/[^a-z0-9/.-]/g, "-");
|
|
121
|
+
}
|
|
122
|
+
async function generateManifest(rootDir, files) {
|
|
123
|
+
const docs = await Promise.all(
|
|
124
|
+
files.map(async (filePath) => {
|
|
125
|
+
const fullPath = join2(rootDir, filePath);
|
|
126
|
+
const content = await readFile(fullPath, "utf-8");
|
|
127
|
+
return {
|
|
128
|
+
slug: createSlug(filePath),
|
|
129
|
+
path: filePath,
|
|
130
|
+
title: extractTitle(content, filePath),
|
|
131
|
+
content
|
|
132
|
+
};
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
const repoName = basename(rootDir);
|
|
136
|
+
return {
|
|
137
|
+
repoName,
|
|
138
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
139
|
+
totalDocs: docs.length,
|
|
140
|
+
docs
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/cli/lib/build-app.ts
|
|
145
|
+
import { cp, writeFile as writeFile2, rm, mkdir as mkdir2 } from "fs/promises";
|
|
146
|
+
import { join as join3 } from "path";
|
|
147
|
+
import { tmpdir } from "os";
|
|
148
|
+
import { execSync } from "child_process";
|
|
149
|
+
import { randomBytes } from "crypto";
|
|
150
|
+
|
|
151
|
+
// src/cli/lib/template.ts
|
|
152
|
+
import { fileURLToPath } from "url";
|
|
153
|
+
import { dirname as dirname2, resolve } from "path";
|
|
154
|
+
import { existsSync as existsSync2 } from "fs";
|
|
155
|
+
function getTemplatePath() {
|
|
156
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
157
|
+
let dir = dirname2(__filename);
|
|
158
|
+
for (let i = 0; i < 5; i++) {
|
|
159
|
+
const candidate = resolve(dir, "src", "app");
|
|
160
|
+
if (existsSync2(resolve(candidate, "package.json"))) {
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
dir = dirname2(dir);
|
|
164
|
+
}
|
|
165
|
+
const fallback = resolve(dirname2(__filename), "..", "..", "src", "app");
|
|
166
|
+
return fallback;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/cli/lib/build-app.ts
|
|
170
|
+
async function buildApp(manifest, options) {
|
|
171
|
+
const templateDir = getTemplatePath();
|
|
172
|
+
const tempDir = join3(
|
|
173
|
+
tmpdir(),
|
|
174
|
+
`doxla-build-${randomBytes(6).toString("hex")}`
|
|
175
|
+
);
|
|
176
|
+
try {
|
|
177
|
+
await cp(templateDir, tempDir, { recursive: true });
|
|
178
|
+
await writeFile2(
|
|
179
|
+
join3(tempDir, "src", "manifest.json"),
|
|
180
|
+
JSON.stringify(manifest, null, 2),
|
|
181
|
+
"utf-8"
|
|
182
|
+
);
|
|
183
|
+
const envContent = `VITE_BASE_PATH=${options.basePath}
|
|
184
|
+
`;
|
|
185
|
+
await writeFile2(join3(tempDir, ".env"), envContent, "utf-8");
|
|
186
|
+
execSync("npm install --no-audit --no-fund", {
|
|
187
|
+
cwd: tempDir,
|
|
188
|
+
stdio: "pipe"
|
|
189
|
+
});
|
|
190
|
+
execSync("npx vite build", {
|
|
191
|
+
cwd: tempDir,
|
|
192
|
+
stdio: "pipe"
|
|
193
|
+
});
|
|
194
|
+
await rm(options.output, { recursive: true, force: true });
|
|
195
|
+
await mkdir2(options.output, { recursive: true });
|
|
196
|
+
await cp(join3(tempDir, "dist"), options.output, { recursive: true });
|
|
197
|
+
} finally {
|
|
198
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/cli/commands/build.ts
|
|
204
|
+
async function buildCommand(options) {
|
|
205
|
+
const rootDir = resolve2(options.root);
|
|
206
|
+
const outputDir = resolve2(options.output);
|
|
207
|
+
const basePath = options.basePath;
|
|
208
|
+
console.log(chalk2.bold("doxla build"));
|
|
209
|
+
console.log();
|
|
210
|
+
const discoverSpinner = ora("Discovering markdown files...").start();
|
|
211
|
+
const files = await discoverMarkdownFiles(rootDir);
|
|
212
|
+
if (files.length === 0) {
|
|
213
|
+
discoverSpinner.fail("No markdown files found");
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
discoverSpinner.succeed(`Found ${files.length} markdown file${files.length === 1 ? "" : "s"}`);
|
|
217
|
+
const manifestSpinner = ora("Generating manifest...").start();
|
|
218
|
+
const manifest = await generateManifest(rootDir, files);
|
|
219
|
+
manifestSpinner.succeed("Manifest generated");
|
|
220
|
+
const buildSpinner = ora("Building docs viewer...").start();
|
|
221
|
+
try {
|
|
222
|
+
await buildApp(manifest, { output: outputDir, basePath });
|
|
223
|
+
buildSpinner.succeed("Docs viewer built");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
buildSpinner.fail("Build failed");
|
|
226
|
+
console.error(chalk2.red(error.message));
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
console.log();
|
|
230
|
+
console.log(chalk2.green(`\u2713 Output written to ${options.output}/`));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/cli/index.ts
|
|
234
|
+
var program = new Command();
|
|
235
|
+
program.name("doxla").description("Improve documentation discoverability within repos").version("0.1.0");
|
|
236
|
+
program.command("init").description("Set up GitHub Actions workflow for Doxla docs deployment").action(initCommand);
|
|
237
|
+
program.command("build").description("Discover markdown files and build the docs viewer").option("-o, --output <dir>", "Output directory", "doxla-dist").option("-r, --root <dir>", "Root directory to scan for markdown files", ".").option("--base-path <path>", "Base path for GitHub Pages deployment", "/").action(buildCommand);
|
|
238
|
+
program.parse();
|
|
239
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/cli/index.ts","../../src/cli/commands/init.ts","../../src/cli/templates/doxla-workflow.ts","../../src/cli/commands/build.ts","../../src/cli/lib/discover.ts","../../src/cli/lib/manifest.ts","../../src/cli/lib/build-app.ts","../../src/cli/lib/template.ts"],"sourcesContent":["import { Command } from \"commander\";\nimport { initCommand } from \"./commands/init.js\";\nimport { buildCommand } from \"./commands/build.js\";\n\nconst program = new Command();\n\nprogram\n .name(\"doxla\")\n .description(\"Improve documentation discoverability within repos\")\n .version(\"0.1.0\");\n\nprogram\n .command(\"init\")\n .description(\"Set up GitHub Actions workflow for Doxla docs deployment\")\n .action(initCommand);\n\nprogram\n .command(\"build\")\n .description(\"Discover markdown files and build the docs viewer\")\n .option(\"-o, --output <dir>\", \"Output directory\", \"doxla-dist\")\n .option(\"-r, --root <dir>\", \"Root directory to scan for markdown files\", \".\")\n .option(\"--base-path <path>\", \"Base path for GitHub Pages deployment\", \"/\")\n .action(buildCommand);\n\nprogram.parse();\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport chalk from \"chalk\";\nimport { workflowTemplate } from \"../templates/doxla-workflow.js\";\n\nexport async function initCommand() {\n const workflowDir = join(process.cwd(), \".github\", \"workflows\");\n const workflowPath = join(workflowDir, \"doxla.yml\");\n\n if (existsSync(workflowPath)) {\n console.log(\n chalk.yellow(\"⚠ Workflow file already exists at .github/workflows/doxla.yml\")\n );\n console.log(chalk.yellow(\" Overwriting with latest template...\"));\n }\n\n await mkdir(workflowDir, { recursive: true });\n await writeFile(workflowPath, workflowTemplate, \"utf-8\");\n\n console.log(chalk.green(\"✓ Created .github/workflows/doxla.yml\"));\n console.log();\n console.log(\"Next steps:\");\n console.log(\n ` 1. Enable GitHub Pages in your repo settings (Settings → Pages → Source: ${chalk.bold(\"GitHub Actions\")})`\n );\n console.log(\" 2. Commit and push the workflow file\");\n console.log(\n \" 3. Your docs will be built and deployed on every push to main\"\n );\n}\n","export const workflowTemplate = `name: Deploy Doxla Docs\n\non:\n push:\n branches: [main]\n workflow_dispatch:\n\npermissions:\n contents: read\n pages: write\n id-token: write\n\nconcurrency:\n group: \"pages\"\n cancel-in-progress: false\n\njobs:\n build-and-deploy:\n runs-on: ubuntu-latest\n environment:\n name: github-pages\n url: \\${{ steps.deployment.outputs.page_url }}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: \"20\"\n - run: npx doxla@latest build --base-path \"/\\${{ github.event.repository.name }}\"\n - uses: actions/upload-pages-artifact@v3\n with:\n path: ./doxla-dist\n - id: deployment\n uses: actions/deploy-pages@v4\n`;\n","import { resolve } from \"node:path\";\nimport chalk from \"chalk\";\nimport ora from \"ora\";\nimport { discoverMarkdownFiles } from \"../lib/discover.js\";\nimport { generateManifest } from \"../lib/manifest.js\";\nimport { buildApp } from \"../lib/build-app.js\";\n\ninterface BuildOptions {\n output: string;\n root: string;\n basePath: string;\n}\n\nexport async function buildCommand(options: BuildOptions) {\n const rootDir = resolve(options.root);\n const outputDir = resolve(options.output);\n const basePath = options.basePath;\n\n console.log(chalk.bold(\"doxla build\"));\n console.log();\n\n // Step 1: Discover markdown files\n const discoverSpinner = ora(\"Discovering markdown files...\").start();\n const files = await discoverMarkdownFiles(rootDir);\n\n if (files.length === 0) {\n discoverSpinner.fail(\"No markdown files found\");\n process.exit(1);\n }\n\n discoverSpinner.succeed(`Found ${files.length} markdown file${files.length === 1 ? \"\" : \"s\"}`);\n\n // Step 2: Generate manifest\n const manifestSpinner = ora(\"Generating manifest...\").start();\n const manifest = await generateManifest(rootDir, files);\n manifestSpinner.succeed(\"Manifest generated\");\n\n // Step 3: Build docs viewer\n const buildSpinner = ora(\"Building docs viewer...\").start();\n try {\n await buildApp(manifest, { output: outputDir, basePath });\n buildSpinner.succeed(\"Docs viewer built\");\n } catch (error) {\n buildSpinner.fail(\"Build failed\");\n console.error(chalk.red((error as Error).message));\n process.exit(1);\n }\n\n console.log();\n console.log(chalk.green(`✓ Output written to ${options.output}/`));\n}\n","import fg from \"fast-glob\";\n\nconst IGNORE_PATTERNS = [\n \"**/node_modules/**\",\n \"**/.git/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/doxla-dist/**\",\n \"**/.next/**\",\n \"**/coverage/**\",\n];\n\nexport async function discoverMarkdownFiles(rootDir: string): Promise<string[]> {\n const files = await fg(\"**/*.md\", {\n cwd: rootDir,\n ignore: IGNORE_PATTERNS,\n onlyFiles: true,\n });\n\n // Sort: README.md first, then alphabetical\n return files.sort((a, b) => {\n const aIsReadme = a.toLowerCase() === \"readme.md\";\n const bIsReadme = b.toLowerCase() === \"readme.md\";\n if (aIsReadme && !bIsReadme) return -1;\n if (!aIsReadme && bIsReadme) return 1;\n return a.localeCompare(b);\n });\n}\n","import { readFile } from \"node:fs/promises\";\nimport { join, basename, dirname } from \"node:path\";\nimport type { Manifest, DocFile } from \"../../app/src/types/manifest.js\";\n\nfunction extractTitle(content: string, filePath: string): string {\n // Try to extract from first # heading\n const match = content.match(/^#\\s+(.+)$/m);\n if (match) {\n return match[1].trim();\n }\n\n // Fallback to filename without extension\n const name = basename(filePath, \".md\");\n if (name.toLowerCase() === \"readme\") {\n const dir = dirname(filePath);\n if (dir === \".\") return \"README\";\n return `${dir} - README`;\n }\n return name.replace(/[-_]/g, \" \").replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\nfunction createSlug(filePath: string): string {\n return filePath\n .replace(/\\.md$/i, \"\")\n .replace(/\\\\/g, \"/\")\n .toLowerCase()\n .replace(/[^a-z0-9/.-]/g, \"-\");\n}\n\nexport async function generateManifest(\n rootDir: string,\n files: string[]\n): Promise<Manifest> {\n const docs: DocFile[] = await Promise.all(\n files.map(async (filePath) => {\n const fullPath = join(rootDir, filePath);\n const content = await readFile(fullPath, \"utf-8\");\n return {\n slug: createSlug(filePath),\n path: filePath,\n title: extractTitle(content, filePath),\n content,\n };\n })\n );\n\n const repoName = basename(rootDir);\n\n return {\n repoName,\n generatedAt: new Date().toISOString(),\n totalDocs: docs.length,\n docs,\n };\n}\n","import { cp, writeFile, rm, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { tmpdir } from \"node:os\";\nimport { execSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport type { Manifest } from \"../../app/src/types/manifest.js\";\nimport { getTemplatePath } from \"./template.js\";\n\ninterface BuildOptions {\n output: string;\n basePath: string;\n}\n\nexport async function buildApp(\n manifest: Manifest,\n options: BuildOptions\n): Promise<void> {\n const templateDir = getTemplatePath();\n const tempDir = join(\n tmpdir(),\n `doxla-build-${randomBytes(6).toString(\"hex\")}`\n );\n\n try {\n // Copy template to temp dir\n await cp(templateDir, tempDir, { recursive: true });\n\n // Write manifest.json into the app's src directory\n await writeFile(\n join(tempDir, \"src\", \"manifest.json\"),\n JSON.stringify(manifest, null, 2),\n \"utf-8\"\n );\n\n // Write .env with base path\n const envContent = `VITE_BASE_PATH=${options.basePath}\\n`;\n await writeFile(join(tempDir, \".env\"), envContent, \"utf-8\");\n\n // Install dependencies\n execSync(\"npm install --no-audit --no-fund\", {\n cwd: tempDir,\n stdio: \"pipe\",\n });\n\n // Build with Vite\n execSync(\"npx vite build\", {\n cwd: tempDir,\n stdio: \"pipe\",\n });\n\n // Copy output\n await rm(options.output, { recursive: true, force: true });\n await mkdir(options.output, { recursive: true });\n await cp(join(tempDir, \"dist\"), options.output, { recursive: true });\n } finally {\n // Clean up temp dir\n await rm(tempDir, { recursive: true, force: true }).catch(() => {});\n }\n}\n","import { fileURLToPath } from \"node:url\";\nimport { dirname, resolve } from \"node:path\";\nimport { existsSync } from \"node:fs\";\n\nexport function getTemplatePath(): string {\n const __filename = fileURLToPath(import.meta.url);\n let dir = dirname(__filename);\n\n // Walk up from the current file location until we find src/app\n // Works both from source (src/cli/lib/) and built (dist/cli/)\n for (let i = 0; i < 5; i++) {\n const candidate = resolve(dir, \"src\", \"app\");\n if (existsSync(resolve(candidate, \"package.json\"))) {\n return candidate;\n }\n dir = dirname(dir);\n }\n\n // Fallback: assume built location dist/cli/ -> ../../src/app\n const fallback = resolve(dirname(__filename), \"..\", \"..\", \"src\", \"app\");\n return fallback;\n}\n"],"mappings":"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","names":["resolve","chalk","join","writeFile","mkdir","join","dirname","existsSync","join","writeFile","mkdir","resolve","chalk"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "doxla",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Improve documentation discoverability within repos",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"doxla": "dist/cli/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"src/app"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"dev": "tsup --watch",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"documentation",
|
|
21
|
+
"docs",
|
|
22
|
+
"github-pages",
|
|
23
|
+
"markdown",
|
|
24
|
+
"viewer"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"chalk": "^5.4.1",
|
|
29
|
+
"commander": "^13.1.0",
|
|
30
|
+
"fast-glob": "^3.3.3",
|
|
31
|
+
"ora": "^8.2.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
35
|
+
"@testing-library/react": "^16.1.0",
|
|
36
|
+
"@types/node": "^22.12.0",
|
|
37
|
+
"@types/react": "^19.0.8",
|
|
38
|
+
"@types/react-dom": "^19.0.3",
|
|
39
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
40
|
+
"@vitejs/plugin-react": "^5.1.4",
|
|
41
|
+
"clsx": "^2.1.1",
|
|
42
|
+
"jsdom": "^26.0.0",
|
|
43
|
+
"lucide-react": "^0.563.0",
|
|
44
|
+
"react": "^19.2.4",
|
|
45
|
+
"react-dom": "^19.2.4",
|
|
46
|
+
"react-markdown": "^10.1.0",
|
|
47
|
+
"react-syntax-highlighter": "^16.1.0",
|
|
48
|
+
"remark-gfm": "^4.0.1",
|
|
49
|
+
"tailwind-merge": "^3.4.0",
|
|
50
|
+
"tsup": "^8.3.6",
|
|
51
|
+
"typescript": "^5.7.3",
|
|
52
|
+
"vitest": "^3.0.4"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -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>Doxla Docs</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "doxla-viewer",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"react": "^19.0.0",
|
|
11
|
+
"react-dom": "^19.0.0",
|
|
12
|
+
"react-markdown": "^10.1.0",
|
|
13
|
+
"remark-gfm": "^4.0.0",
|
|
14
|
+
"react-syntax-highlighter": "^15.6.1",
|
|
15
|
+
"lucide-react": "^0.474.0",
|
|
16
|
+
"clsx": "^2.1.1",
|
|
17
|
+
"tailwind-merge": "^3.0.2"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
21
|
+
"@tailwindcss/vite": "^4.0.6",
|
|
22
|
+
"@tailwindcss/typography": "^0.5.16",
|
|
23
|
+
"tailwindcss": "^4.0.6",
|
|
24
|
+
"@types/react": "^19.0.8",
|
|
25
|
+
"@types/react-dom": "^19.0.3",
|
|
26
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
27
|
+
"typescript": "^5.7.3",
|
|
28
|
+
"vite": "^6.1.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import manifest from "./manifest.json";
|
|
3
|
+
import type { Manifest } from "./types/manifest";
|
|
4
|
+
import { Layout } from "./components/layout/Layout";
|
|
5
|
+
import { IndexPage } from "./components/IndexPage";
|
|
6
|
+
import { DocPage } from "./components/DocPage";
|
|
7
|
+
import { SearchResults } from "./components/SearchResults";
|
|
8
|
+
|
|
9
|
+
const data = manifest as Manifest;
|
|
10
|
+
|
|
11
|
+
type Route =
|
|
12
|
+
| { type: "index" }
|
|
13
|
+
| { type: "doc"; slug: string }
|
|
14
|
+
| { type: "search"; query: string };
|
|
15
|
+
|
|
16
|
+
function parseHash(): Route {
|
|
17
|
+
const hash = window.location.hash.slice(1) || "/";
|
|
18
|
+
|
|
19
|
+
if (hash === "/" || hash === "") {
|
|
20
|
+
return { type: "index" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (hash.startsWith("/doc/")) {
|
|
24
|
+
return { type: "doc", slug: hash.slice(5) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (hash.startsWith("/search")) {
|
|
28
|
+
const params = new URLSearchParams(hash.split("?")[1] || "");
|
|
29
|
+
return { type: "search", query: params.get("q") || "" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { type: "index" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function App() {
|
|
36
|
+
const [route, setRoute] = useState<Route>(parseHash);
|
|
37
|
+
|
|
38
|
+
const handleHashChange = useCallback(() => {
|
|
39
|
+
setRoute(parseHash());
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
window.addEventListener("hashchange", handleHashChange);
|
|
44
|
+
return () => window.removeEventListener("hashchange", handleHashChange);
|
|
45
|
+
}, [handleHashChange]);
|
|
46
|
+
|
|
47
|
+
const renderContent = () => {
|
|
48
|
+
switch (route.type) {
|
|
49
|
+
case "index":
|
|
50
|
+
return <IndexPage docs={data.docs} />;
|
|
51
|
+
case "doc": {
|
|
52
|
+
const doc = data.docs.find((d) => d.slug === route.slug);
|
|
53
|
+
if (!doc) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="p-8">
|
|
56
|
+
<h1 className="text-2xl font-bold">Document not found</h1>
|
|
57
|
+
<p className="mt-2 text-muted-foreground">
|
|
58
|
+
The document "{route.slug}" could not be found.
|
|
59
|
+
</p>
|
|
60
|
+
<a href="#/" className="mt-4 inline-block text-primary underline">
|
|
61
|
+
Back to index
|
|
62
|
+
</a>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return <DocPage doc={doc} />;
|
|
67
|
+
}
|
|
68
|
+
case "search":
|
|
69
|
+
return <SearchResults docs={data.docs} query={route.query} />;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Layout manifest={data}>{renderContent()}</Layout>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { DocFile } from "../types/manifest";
|
|
2
|
+
import { MarkdownRenderer } from "./MarkdownRenderer";
|
|
3
|
+
import { Badge } from "./ui/Badge";
|
|
4
|
+
import { Separator } from "./ui/Separator";
|
|
5
|
+
|
|
6
|
+
interface DocPageProps {
|
|
7
|
+
doc: DocFile;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function DocPage({ doc }: DocPageProps) {
|
|
11
|
+
const pathParts = doc.path.split("/");
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div>
|
|
15
|
+
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
|
16
|
+
<a href="#/" className="hover:text-foreground">
|
|
17
|
+
Home
|
|
18
|
+
</a>
|
|
19
|
+
{pathParts.map((part, i) => (
|
|
20
|
+
<span key={i} className="flex items-center gap-2">
|
|
21
|
+
<span>/</span>
|
|
22
|
+
{i === pathParts.length - 1 ? (
|
|
23
|
+
<span className="text-foreground">{part}</span>
|
|
24
|
+
) : (
|
|
25
|
+
<span>{part}</span>
|
|
26
|
+
)}
|
|
27
|
+
</span>
|
|
28
|
+
))}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div className="mb-4 flex items-center gap-3">
|
|
32
|
+
<h1 className="text-3xl font-bold">{doc.title}</h1>
|
|
33
|
+
<Badge variant="secondary">.md</Badge>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<Separator className="mb-6" />
|
|
37
|
+
|
|
38
|
+
<MarkdownRenderer content={doc.content} />
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ChevronRight, ChevronDown, FileText, Folder } from "lucide-react";
|
|
3
|
+
import type { DocFile } from "../types/manifest";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
interface TreeNode {
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
doc?: DocFile;
|
|
10
|
+
children: Map<string, TreeNode>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildTree(docs: DocFile[]): TreeNode {
|
|
14
|
+
const root: TreeNode = { name: "", path: "", children: new Map() };
|
|
15
|
+
|
|
16
|
+
for (const doc of docs) {
|
|
17
|
+
const parts = doc.path.split("/");
|
|
18
|
+
let current = root;
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < parts.length; i++) {
|
|
21
|
+
const part = parts[i];
|
|
22
|
+
if (!current.children.has(part)) {
|
|
23
|
+
current.children.set(part, {
|
|
24
|
+
name: part,
|
|
25
|
+
path: parts.slice(0, i + 1).join("/"),
|
|
26
|
+
children: new Map(),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
current = current.children.get(part)!;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
current.doc = doc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function TreeItem({
|
|
39
|
+
node,
|
|
40
|
+
depth,
|
|
41
|
+
}: {
|
|
42
|
+
node: TreeNode;
|
|
43
|
+
depth: number;
|
|
44
|
+
}) {
|
|
45
|
+
const [expanded, setExpanded] = useState(depth < 2);
|
|
46
|
+
const isFolder = node.children.size > 0 && !node.doc;
|
|
47
|
+
const sortedChildren = Array.from(node.children.values()).sort((a, b) => {
|
|
48
|
+
// Folders first, then files
|
|
49
|
+
const aIsFolder = a.children.size > 0 && !a.doc;
|
|
50
|
+
const bIsFolder = b.children.size > 0 && !b.doc;
|
|
51
|
+
if (aIsFolder && !bIsFolder) return -1;
|
|
52
|
+
if (!aIsFolder && bIsFolder) return 1;
|
|
53
|
+
return a.name.localeCompare(b.name);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (isFolder) {
|
|
57
|
+
return (
|
|
58
|
+
<div>
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => setExpanded(!expanded)}
|
|
61
|
+
className={cn(
|
|
62
|
+
"flex w-full items-center gap-1 rounded-md px-2 py-1 text-sm hover:bg-accent",
|
|
63
|
+
)}
|
|
64
|
+
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
65
|
+
>
|
|
66
|
+
{expanded ? (
|
|
67
|
+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
68
|
+
) : (
|
|
69
|
+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
70
|
+
)}
|
|
71
|
+
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
72
|
+
<span className="truncate">{node.name}</span>
|
|
73
|
+
</button>
|
|
74
|
+
{expanded && (
|
|
75
|
+
<div>
|
|
76
|
+
{sortedChildren.map((child) => (
|
|
77
|
+
<TreeItem key={child.path} node={child} depth={depth + 1} />
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (node.doc) {
|
|
86
|
+
return (
|
|
87
|
+
<a
|
|
88
|
+
href={`#/doc/${node.doc.slug}`}
|
|
89
|
+
className={cn(
|
|
90
|
+
"flex items-center gap-1 rounded-md px-2 py-1 text-sm hover:bg-accent",
|
|
91
|
+
)}
|
|
92
|
+
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
93
|
+
>
|
|
94
|
+
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
95
|
+
<span className="truncate">{node.name}</span>
|
|
96
|
+
</a>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function FileTree({ docs }: { docs: DocFile[] }) {
|
|
104
|
+
const tree = buildTree(docs);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<nav className="space-y-0.5">
|
|
108
|
+
{Array.from(tree.children.values()).map((node) => (
|
|
109
|
+
<TreeItem key={node.path} node={node} depth={0} />
|
|
110
|
+
))}
|
|
111
|
+
</nav>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { DocFile } from "../types/manifest";
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardHeader,
|
|
5
|
+
CardTitle,
|
|
6
|
+
CardDescription,
|
|
7
|
+
CardContent,
|
|
8
|
+
} from "./ui/Card";
|
|
9
|
+
import { Badge } from "./ui/Badge";
|
|
10
|
+
import { FileText } from "lucide-react";
|
|
11
|
+
|
|
12
|
+
interface IndexPageProps {
|
|
13
|
+
docs: DocFile[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getPreview(content: string): string {
|
|
17
|
+
// Strip the first heading and get the first non-empty paragraph
|
|
18
|
+
const lines = content.split("\n");
|
|
19
|
+
let foundHeading = false;
|
|
20
|
+
const previewLines: string[] = [];
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (!foundHeading && line.startsWith("#")) {
|
|
24
|
+
foundHeading = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (trimmed === "") {
|
|
29
|
+
if (previewLines.length > 0) break;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
previewLines.push(trimmed);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const preview = previewLines.join(" ");
|
|
36
|
+
if (preview.length > 150) {
|
|
37
|
+
return preview.slice(0, 150) + "...";
|
|
38
|
+
}
|
|
39
|
+
return preview || "No preview available";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getDirLabel(path: string): string | null {
|
|
43
|
+
const parts = path.split("/");
|
|
44
|
+
if (parts.length <= 1) return null;
|
|
45
|
+
return parts.slice(0, -1).join("/");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function IndexPage({ docs }: IndexPageProps) {
|
|
49
|
+
return (
|
|
50
|
+
<div>
|
|
51
|
+
<div className="mb-8">
|
|
52
|
+
<h1 className="text-3xl font-bold">Documentation</h1>
|
|
53
|
+
<p className="mt-2 text-muted-foreground">
|
|
54
|
+
{docs.length} document{docs.length === 1 ? "" : "s"} found in this
|
|
55
|
+
repository
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
60
|
+
{docs.map((doc) => {
|
|
61
|
+
const dirLabel = getDirLabel(doc.path);
|
|
62
|
+
return (
|
|
63
|
+
<a key={doc.slug} href={`#/doc/${doc.slug}`} className="group">
|
|
64
|
+
<Card className="h-full transition-shadow group-hover:shadow-md">
|
|
65
|
+
<CardHeader>
|
|
66
|
+
<div className="flex items-center gap-2">
|
|
67
|
+
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
68
|
+
<CardTitle className="group-hover:text-primary">
|
|
69
|
+
{doc.title}
|
|
70
|
+
</CardTitle>
|
|
71
|
+
</div>
|
|
72
|
+
{dirLabel && (
|
|
73
|
+
<Badge variant="outline" className="w-fit">
|
|
74
|
+
{dirLabel}
|
|
75
|
+
</Badge>
|
|
76
|
+
)}
|
|
77
|
+
</CardHeader>
|
|
78
|
+
<CardContent>
|
|
79
|
+
<CardDescription>{getPreview(doc.content)}</CardDescription>
|
|
80
|
+
</CardContent>
|
|
81
|
+
</Card>
|
|
82
|
+
</a>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Markdown from "react-markdown";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
4
|
+
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
5
|
+
import type { Components } from "react-markdown";
|
|
6
|
+
|
|
7
|
+
interface MarkdownRendererProps {
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const components: Components = {
|
|
12
|
+
code(props) {
|
|
13
|
+
const { children, className, ...rest } = props;
|
|
14
|
+
const match = /language-(\w+)/.exec(className || "");
|
|
15
|
+
const isInline = !match;
|
|
16
|
+
|
|
17
|
+
if (isInline) {
|
|
18
|
+
return (
|
|
19
|
+
<code
|
|
20
|
+
className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono"
|
|
21
|
+
{...rest}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</code>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<SyntaxHighlighter
|
|
30
|
+
style={oneLight}
|
|
31
|
+
language={match[1]}
|
|
32
|
+
PreTag="div"
|
|
33
|
+
className="rounded-md text-sm"
|
|
34
|
+
>
|
|
35
|
+
{String(children).replace(/\n$/, "")}
|
|
36
|
+
</SyntaxHighlighter>
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="prose prose-neutral max-w-none">
|
|
44
|
+
<Markdown remarkPlugins={[remarkGfm]} components={components}>
|
|
45
|
+
{content}
|
|
46
|
+
</Markdown>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { DocFile } from "../types/manifest";
|
|
2
|
+
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "./ui/Card";
|
|
3
|
+
import { Badge } from "./ui/Badge";
|
|
4
|
+
import { FileText, SearchX } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface SearchResultsProps {
|
|
7
|
+
docs: DocFile[];
|
|
8
|
+
query: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SearchHit {
|
|
12
|
+
doc: DocFile;
|
|
13
|
+
snippet: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function search(docs: DocFile[], query: string): SearchHit[] {
|
|
17
|
+
if (!query.trim()) return [];
|
|
18
|
+
|
|
19
|
+
const lower = query.toLowerCase();
|
|
20
|
+
const results: SearchHit[] = [];
|
|
21
|
+
|
|
22
|
+
for (const doc of docs) {
|
|
23
|
+
const titleMatch = doc.title.toLowerCase().includes(lower);
|
|
24
|
+
const contentLower = doc.content.toLowerCase();
|
|
25
|
+
const contentIdx = contentLower.indexOf(lower);
|
|
26
|
+
|
|
27
|
+
if (titleMatch || contentIdx !== -1) {
|
|
28
|
+
let snippet = "";
|
|
29
|
+
if (contentIdx !== -1) {
|
|
30
|
+
const start = Math.max(0, contentIdx - 60);
|
|
31
|
+
const end = Math.min(doc.content.length, contentIdx + query.length + 60);
|
|
32
|
+
snippet =
|
|
33
|
+
(start > 0 ? "..." : "") +
|
|
34
|
+
doc.content.slice(start, end).replace(/\n/g, " ") +
|
|
35
|
+
(end < doc.content.length ? "..." : "");
|
|
36
|
+
} else {
|
|
37
|
+
// Title match only - take first line of content as snippet
|
|
38
|
+
const firstLine = doc.content.split("\n").find((l) => l.trim() && !l.startsWith("#"));
|
|
39
|
+
snippet = firstLine?.trim().slice(0, 120) || "";
|
|
40
|
+
}
|
|
41
|
+
results.push({ doc, snippet });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function SearchResults({ docs, query }: SearchResultsProps) {
|
|
49
|
+
const results = search(docs, query);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div>
|
|
53
|
+
<div className="mb-8">
|
|
54
|
+
<h1 className="text-3xl font-bold">Search Results</h1>
|
|
55
|
+
<p className="mt-2 text-muted-foreground">
|
|
56
|
+
{results.length} result{results.length === 1 ? "" : "s"} for "{query}"
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{results.length === 0 ? (
|
|
61
|
+
<div className="flex flex-col items-center gap-4 py-12 text-muted-foreground">
|
|
62
|
+
<SearchX className="h-12 w-12" />
|
|
63
|
+
<p>No documents matched your search.</p>
|
|
64
|
+
<a href="#/" className="text-primary underline">
|
|
65
|
+
Back to index
|
|
66
|
+
</a>
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
<div className="space-y-4">
|
|
70
|
+
{results.map((hit) => (
|
|
71
|
+
<a key={hit.doc.slug} href={`#/doc/${hit.doc.slug}`} className="group block">
|
|
72
|
+
<Card className="transition-shadow group-hover:shadow-md">
|
|
73
|
+
<CardHeader>
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
76
|
+
<CardTitle className="group-hover:text-primary">
|
|
77
|
+
{hit.doc.title}
|
|
78
|
+
</CardTitle>
|
|
79
|
+
<Badge variant="outline" className="ml-auto">
|
|
80
|
+
{hit.doc.path}
|
|
81
|
+
</Badge>
|
|
82
|
+
</div>
|
|
83
|
+
</CardHeader>
|
|
84
|
+
<CardContent>
|
|
85
|
+
<CardDescription>{hit.snippet}</CardDescription>
|
|
86
|
+
</CardContent>
|
|
87
|
+
</Card>
|
|
88
|
+
</a>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Search, BookOpen } from "lucide-react";
|
|
3
|
+
import { Input } from "../ui/Input";
|
|
4
|
+
import { Button } from "../ui/Button";
|
|
5
|
+
|
|
6
|
+
interface HeaderProps {
|
|
7
|
+
repoName: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Header({ repoName }: HeaderProps) {
|
|
11
|
+
const [searchInput, setSearchInput] = useState("");
|
|
12
|
+
|
|
13
|
+
const handleSearch = (e: React.FormEvent) => {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
if (searchInput.trim()) {
|
|
16
|
+
window.location.hash = `/search?q=${encodeURIComponent(searchInput.trim())}`;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<header className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b border-border bg-background px-6">
|
|
22
|
+
<a href="#/" className="flex items-center gap-2 font-semibold">
|
|
23
|
+
<BookOpen className="h-5 w-5" />
|
|
24
|
+
<span>{repoName}</span>
|
|
25
|
+
</a>
|
|
26
|
+
|
|
27
|
+
<form onSubmit={handleSearch} className="ml-auto flex items-center gap-2">
|
|
28
|
+
<div className="relative">
|
|
29
|
+
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
30
|
+
<Input
|
|
31
|
+
type="search"
|
|
32
|
+
placeholder="Search docs..."
|
|
33
|
+
value={searchInput}
|
|
34
|
+
onChange={(e) => setSearchInput(e.target.value)}
|
|
35
|
+
className="w-64 pl-9"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
<Button type="submit" size="sm">
|
|
39
|
+
Search
|
|
40
|
+
</Button>
|
|
41
|
+
</form>
|
|
42
|
+
</header>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Manifest } from "../../types/manifest";
|
|
3
|
+
import { Header } from "./Header";
|
|
4
|
+
import { Sidebar } from "./Sidebar";
|
|
5
|
+
|
|
6
|
+
interface LayoutProps {
|
|
7
|
+
manifest: Manifest;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Layout({ manifest, children }: LayoutProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="min-h-screen">
|
|
14
|
+
<Header repoName={manifest.repoName} />
|
|
15
|
+
<div className="flex">
|
|
16
|
+
<Sidebar docs={manifest.docs} />
|
|
17
|
+
<main className="flex-1 overflow-auto">
|
|
18
|
+
<div className="mx-auto max-w-4xl p-6">{children}</div>
|
|
19
|
+
</main>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ScrollArea } from "../ui/ScrollArea";
|
|
2
|
+
import { FileTree } from "../FileTree";
|
|
3
|
+
import type { DocFile } from "../../types/manifest";
|
|
4
|
+
|
|
5
|
+
interface SidebarProps {
|
|
6
|
+
docs: DocFile[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Sidebar({ docs }: SidebarProps) {
|
|
10
|
+
return (
|
|
11
|
+
<aside className="hidden w-64 shrink-0 border-r border-border md:block">
|
|
12
|
+
<ScrollArea className="h-[calc(100vh-3.5rem)] py-4">
|
|
13
|
+
<div className="px-3">
|
|
14
|
+
<h2 className="mb-2 px-2 text-sm font-semibold text-muted-foreground">
|
|
15
|
+
Documents
|
|
16
|
+
</h2>
|
|
17
|
+
<FileTree docs={docs} />
|
|
18
|
+
</div>
|
|
19
|
+
</ScrollArea>
|
|
20
|
+
</aside>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cn } from "../../lib/utils";
|
|
2
|
+
|
|
3
|
+
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
4
|
+
variant?: "default" | "secondary" | "outline";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Badge({
|
|
8
|
+
className,
|
|
9
|
+
variant = "default",
|
|
10
|
+
...props
|
|
11
|
+
}: BadgeProps) {
|
|
12
|
+
return (
|
|
13
|
+
<span
|
|
14
|
+
className={cn(
|
|
15
|
+
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
|
16
|
+
{
|
|
17
|
+
"bg-primary text-primary-foreground": variant === "default",
|
|
18
|
+
"bg-muted text-muted-foreground": variant === "secondary",
|
|
19
|
+
"border border-border text-foreground": variant === "outline",
|
|
20
|
+
},
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { cn } from "../../lib/utils";
|
|
2
|
+
|
|
3
|
+
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
4
|
+
variant?: "default" | "ghost" | "outline";
|
|
5
|
+
size?: "default" | "sm" | "icon";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Button({
|
|
9
|
+
className,
|
|
10
|
+
variant = "default",
|
|
11
|
+
size = "default",
|
|
12
|
+
...props
|
|
13
|
+
}: ButtonProps) {
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
className={cn(
|
|
17
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors",
|
|
18
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary",
|
|
19
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
20
|
+
{
|
|
21
|
+
"bg-primary text-primary-foreground shadow hover:bg-primary/90":
|
|
22
|
+
variant === "default",
|
|
23
|
+
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
|
|
24
|
+
"border border-border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground":
|
|
25
|
+
variant === "outline",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"h-9 px-4 py-2": size === "default",
|
|
29
|
+
"h-8 rounded-md px-3 text-xs": size === "sm",
|
|
30
|
+
"h-9 w-9": size === "icon",
|
|
31
|
+
},
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { cn } from "../../lib/utils";
|
|
2
|
+
|
|
3
|
+
export function Card({
|
|
4
|
+
className,
|
|
5
|
+
...props
|
|
6
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
className={cn(
|
|
10
|
+
"rounded-lg border border-border bg-background shadow-sm",
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CardHeader({
|
|
19
|
+
className,
|
|
20
|
+
...props
|
|
21
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
22
|
+
return (
|
|
23
|
+
<div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CardTitle({
|
|
28
|
+
className,
|
|
29
|
+
...props
|
|
30
|
+
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
|
31
|
+
return (
|
|
32
|
+
<h3
|
|
33
|
+
className={cn("text-lg font-semibold leading-none", className)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function CardDescription({
|
|
40
|
+
className,
|
|
41
|
+
...props
|
|
42
|
+
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
|
43
|
+
return (
|
|
44
|
+
<p
|
|
45
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
46
|
+
{...props}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function CardContent({
|
|
52
|
+
className,
|
|
53
|
+
...props
|
|
54
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
55
|
+
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cn } from "../../lib/utils";
|
|
2
|
+
|
|
3
|
+
export function Input({
|
|
4
|
+
className,
|
|
5
|
+
...props
|
|
6
|
+
}: React.InputHTMLAttributes<HTMLInputElement>) {
|
|
7
|
+
return (
|
|
8
|
+
<input
|
|
9
|
+
className={cn(
|
|
10
|
+
"flex h-9 w-full rounded-md border border-border bg-background px-3 py-1 text-sm shadow-sm transition-colors",
|
|
11
|
+
"placeholder:text-muted-foreground",
|
|
12
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary",
|
|
13
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { cn } from "../../lib/utils";
|
|
2
|
+
|
|
3
|
+
export function ScrollArea({
|
|
4
|
+
className,
|
|
5
|
+
children,
|
|
6
|
+
...props
|
|
7
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
className={cn("overflow-auto", className)}
|
|
11
|
+
{...props}
|
|
12
|
+
>
|
|
13
|
+
{children}
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cn } from "../../lib/utils";
|
|
2
|
+
|
|
3
|
+
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
orientation?: "horizontal" | "vertical";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Separator({
|
|
8
|
+
className,
|
|
9
|
+
orientation = "horizontal",
|
|
10
|
+
...props
|
|
11
|
+
}: SeparatorProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className={cn(
|
|
15
|
+
"shrink-0 bg-border",
|
|
16
|
+
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
|
17
|
+
className
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@plugin "@tailwindcss/typography";
|
|
3
|
+
|
|
4
|
+
@theme {
|
|
5
|
+
--color-background: #ffffff;
|
|
6
|
+
--color-foreground: #0a0a0a;
|
|
7
|
+
--color-muted: #f5f5f5;
|
|
8
|
+
--color-muted-foreground: #737373;
|
|
9
|
+
--color-border: #e5e5e5;
|
|
10
|
+
--color-primary: #171717;
|
|
11
|
+
--color-primary-foreground: #fafafa;
|
|
12
|
+
--color-accent: #f5f5f5;
|
|
13
|
+
--color-accent-foreground: #171717;
|
|
14
|
+
--radius-sm: 0.25rem;
|
|
15
|
+
--radius-md: 0.375rem;
|
|
16
|
+
--radius-lg: 0.5rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
font-family:
|
|
21
|
+
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
|
22
|
+
Arial, sans-serif;
|
|
23
|
+
background-color: var(--color-background);
|
|
24
|
+
color: var(--color-foreground);
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noEmit": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [react(), tailwindcss()],
|
|
7
|
+
base: process.env.VITE_BASE_PATH || "/",
|
|
8
|
+
build: {
|
|
9
|
+
outDir: "dist",
|
|
10
|
+
},
|
|
11
|
+
});
|