@lon-ask/dockit 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/LICENSE +674 -0
- package/README.md +496 -0
- package/SKILL.md +154 -0
- package/apps/client/dist/assets/index-CqOXxsEZ.js +240 -0
- package/apps/client/dist/assets/index-DwvaANnI.css +1 -0
- package/apps/client/dist/index.html +13 -0
- package/apps/server/src/core/domain/entry.ts +22 -0
- package/apps/server/src/core/domain/errors.ts +27 -0
- package/apps/server/src/core/domain/knowledge-graph.ts +51 -0
- package/apps/server/src/core/domain/types.ts +168 -0
- package/apps/server/src/core/ports/IBuildRepository.ts +7 -0
- package/apps/server/src/core/ports/IDocumentNormalizer.ts +6 -0
- package/apps/server/src/core/ports/IDocumentStore.ts +4 -0
- package/apps/server/src/core/ports/IEntryReadModel.ts +9 -0
- package/apps/server/src/core/ports/IEntryRepository.ts +11 -0
- package/apps/server/src/core/ports/IKnowledgeGraph.ts +10 -0
- package/apps/server/src/core/ports/IPathResolver.ts +3 -0
- package/apps/server/src/core/ports/ISearchEngine.ts +9 -0
- package/apps/server/src/core/ports/ISourceProcessor.ts +7 -0
- package/apps/server/src/core/ports/ISourceRepository.ts +11 -0
- package/apps/server/src/core/usecases/BuildUseCase.ts +98 -0
- package/apps/server/src/core/usecases/ConfigUseCase.ts +64 -0
- package/apps/server/src/core/usecases/SearchUseCase.ts +16 -0
- package/apps/server/src/index.ts +98 -0
- package/apps/server/src/infrastructure/filesystem/FileSystemDocumentStore.ts +27 -0
- package/apps/server/src/infrastructure/graph/GraphSearchDecorator.ts +53 -0
- package/apps/server/src/infrastructure/graph/GraphifyKnowledgeGraph.ts +172 -0
- package/apps/server/src/infrastructure/graph/index.ts +2 -0
- package/apps/server/src/infrastructure/persistence/sqlite/SqliteBuildRepository.ts +34 -0
- package/apps/server/src/infrastructure/persistence/sqlite/SqliteEntryReadModel.ts +17 -0
- package/apps/server/src/infrastructure/persistence/sqlite/SqliteEntryRepository.ts +81 -0
- package/apps/server/src/infrastructure/persistence/sqlite/SqliteSourceRepository.ts +65 -0
- package/apps/server/src/infrastructure/persistence/sqlite/connection.ts +52 -0
- package/apps/server/src/infrastructure/search/SearchEngineFactory.ts +43 -0
- package/apps/server/src/infrastructure/search/json/JsonSearchEngine.ts +164 -0
- package/apps/server/src/infrastructure/search/vector/EmbeddingService.ts +23 -0
- package/apps/server/src/infrastructure/search/vector/VectorSearchEngine.ts +480 -0
- package/apps/server/src/infrastructure/source-processors/AntoraSourceProcessor.ts +14 -0
- package/apps/server/src/infrastructure/source-processors/AsciidocSourceProcessor.ts +12 -0
- package/apps/server/src/infrastructure/source-processors/DocumentNormalizer.ts +16 -0
- package/apps/server/src/infrastructure/source-processors/GithubMarkdownSourceProcessor.ts +12 -0
- package/apps/server/src/infrastructure/source-processors/MavenSourceProcessor.ts +12 -0
- package/apps/server/src/infrastructure/source-processors/PathResolver.ts +6 -0
- package/apps/server/src/infrastructure/source-processors/SourceCodeSourceProcessor.ts +260 -0
- package/apps/server/src/infrastructure/source-processors/ZipSourceProcessor.ts +12 -0
- package/apps/server/src/mcp-http.ts +102 -0
- package/apps/server/src/mcp.ts +432 -0
- package/apps/server/src/routes/build.ts +105 -0
- package/apps/server/src/routes/entries.ts +62 -0
- package/apps/server/src/routes/graph.ts +57 -0
- package/apps/server/src/routes/search.ts +28 -0
- package/apps/server/src/routes/sources.ts +105 -0
- package/apps/server/src/routes/viewer.ts +28 -0
- package/apps/server/src/services/antora.ts +238 -0
- package/apps/server/src/services/asciidoc.ts +221 -0
- package/apps/server/src/services/configLoader.ts +207 -0
- package/apps/server/src/services/githubMarkdown.ts +236 -0
- package/apps/server/src/services/maven.ts +178 -0
- package/apps/server/src/services/normalizer.ts +63 -0
- package/apps/server/src/services/paths.ts +5 -0
- package/apps/server/src/services/textExtractor.ts +49 -0
- package/apps/server/src/services/zip.ts +84 -0
- package/bin/commands/build.ts +85 -0
- package/bin/commands/dev.ts +36 -0
- package/bin/commands/get.ts +36 -0
- package/bin/commands/graph.ts +153 -0
- package/bin/commands/init.ts +170 -0
- package/bin/commands/list.ts +47 -0
- package/bin/commands/mcp.ts +32 -0
- package/bin/commands/search.ts +185 -0
- package/bin/commands/serve.ts +23 -0
- package/bin/commands/status.ts +46 -0
- package/bin/dockit-cli.ts +92 -0
- package/bin/dockit.js +17 -0
- package/bin/utils.ts +85 -0
- package/dockit.yaml +154 -0
- package/package.json +60 -0
- package/scripts/mcp-wrapper.sh +44 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
import type { ConfigUseCase } from '../core/usecases/ConfigUseCase.js';
|
|
3
|
+
import type { SourceType } from '../core/domain/types.js';
|
|
4
|
+
|
|
5
|
+
const VALID_TYPES: SourceType[] = ['zip', 'antora', 'maven', 'asciidoc', 'github-markdown', 'source-code'];
|
|
6
|
+
|
|
7
|
+
function validateConfig(type: SourceType, config: Record<string, unknown>): string | null {
|
|
8
|
+
switch (type) {
|
|
9
|
+
case 'maven':
|
|
10
|
+
if (!config.groupId || typeof config.groupId !== 'string') return 'groupId is required';
|
|
11
|
+
if (!config.artifactId || typeof config.artifactId !== 'string') return 'artifactId is required';
|
|
12
|
+
if (!config.version || typeof config.version !== 'string') return 'version is required';
|
|
13
|
+
return null;
|
|
14
|
+
case 'zip': {
|
|
15
|
+
const hasUrl = config.url && typeof config.url === 'string';
|
|
16
|
+
const hasLocalPath = config.localPath && typeof config.localPath === 'string';
|
|
17
|
+
if (!hasUrl && !hasLocalPath) return 'url or localPath is required';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
case 'antora': {
|
|
21
|
+
const hasRepoUrl = config.repoUrl && typeof config.repoUrl === 'string';
|
|
22
|
+
const hasZipPath = config.zipPath && typeof config.zipPath === 'string';
|
|
23
|
+
const hasLocalPath = config.localPath && typeof config.localPath === 'string';
|
|
24
|
+
if (!hasRepoUrl && !hasZipPath && !hasLocalPath) return 'repoUrl, localPath, or zipPath is required';
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
case 'asciidoc': {
|
|
28
|
+
const hasRepoUrl = config.repoUrl && typeof config.repoUrl === 'string';
|
|
29
|
+
const hasZipPath = config.zipPath && typeof config.zipPath === 'string';
|
|
30
|
+
const hasLocalPath = config.localPath && typeof config.localPath === 'string';
|
|
31
|
+
if (!hasRepoUrl && !hasZipPath && !hasLocalPath) return 'repoUrl, localPath, or zipPath is required';
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
case 'github-markdown': {
|
|
35
|
+
const hasRepoUrl = config.repoUrl && typeof config.repoUrl === 'string';
|
|
36
|
+
const hasLocalPath = config.localPath && typeof config.localPath === 'string';
|
|
37
|
+
if (!hasRepoUrl && !hasLocalPath) return 'repoUrl or localPath is required';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
case 'source-code': {
|
|
41
|
+
const hasRepoUrl = config.repoUrl && typeof config.repoUrl === 'string';
|
|
42
|
+
const hasLocalPath = config.localPath && typeof config.localPath === 'string';
|
|
43
|
+
const hasZipPath = config.zipPath && typeof config.zipPath === 'string';
|
|
44
|
+
if (!hasRepoUrl && !hasLocalPath && !hasZipPath) return 'repoUrl, localPath, or zipPath is required';
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
default:
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createSourceRoutes(configUseCase: ConfigUseCase): Router {
|
|
53
|
+
const router = Router({ mergeParams: true });
|
|
54
|
+
|
|
55
|
+
router.post('/', async (req: Request, res: Response) => {
|
|
56
|
+
const entryId = req.params.entryId as string;
|
|
57
|
+
const entry = await configUseCase.getEntry(entryId);
|
|
58
|
+
if (!entry) {
|
|
59
|
+
res.status(404).json({ error: 'Entry not found' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { type, label, config } = req.body as { type: SourceType; label: string; config: Record<string, unknown> };
|
|
64
|
+
|
|
65
|
+
if (!type || !VALID_TYPES.includes(type)) {
|
|
66
|
+
res.status(400).json({ error: `type must be one of: ${VALID_TYPES.join(', ')}` });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!label) {
|
|
70
|
+
res.status(400).json({ error: 'label is required' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const configError = validateConfig(type, config);
|
|
74
|
+
if (configError) {
|
|
75
|
+
res.status(400).json({ error: configError });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const source = await configUseCase.createSource(entryId, { type, label, config: config as any });
|
|
80
|
+
res.status(201).json(source);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return router;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createSourceFlatRoutes(configUseCase: ConfigUseCase): Router {
|
|
87
|
+
const router = Router();
|
|
88
|
+
|
|
89
|
+
router.put('/:id', async (req: Request, res: Response) => {
|
|
90
|
+
const id = req.params.id as string;
|
|
91
|
+
const input = req.body as { label?: string; config?: Record<string, unknown> };
|
|
92
|
+
await configUseCase.updateSource(id, input);
|
|
93
|
+
const { SqliteSourceRepository } = await import('../infrastructure/persistence/sqlite/SqliteSourceRepository.js');
|
|
94
|
+
const source = await new SqliteSourceRepository().findById(id);
|
|
95
|
+
res.json(source);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
router.delete('/:id', async (req: Request, res: Response) => {
|
|
99
|
+
const id = req.params.id as string;
|
|
100
|
+
await configUseCase.deleteSource(id);
|
|
101
|
+
res.json({ success: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return router;
|
|
105
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { DATA_ROOT } from '../services/paths.js';
|
|
6
|
+
|
|
7
|
+
const router = Router();
|
|
8
|
+
|
|
9
|
+
router.use('/bundle/:entryId', (req: Request, res: Response) => {
|
|
10
|
+
const entryId = req.params.entryId as string;
|
|
11
|
+
const filePath = req.path.replace(/^\//, '') || 'index.html';
|
|
12
|
+
const fullPath = path.resolve(DATA_ROOT, entryId, 'bundle', filePath);
|
|
13
|
+
const dataRoot = path.resolve(DATA_ROOT);
|
|
14
|
+
|
|
15
|
+
if (!fullPath.startsWith(dataRoot)) {
|
|
16
|
+
res.status(403).json({ error: 'Invalid document path' });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!fs.existsSync(fullPath)) {
|
|
21
|
+
res.status(404).json({ error: 'Documentation not built yet' });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
res.sendFile(fullPath);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export default router;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { execSync, spawn } from 'node:child_process';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import unzipper from 'unzipper';
|
|
7
|
+
import type { AntoraSourceConfig } from '../core/domain/types.js';
|
|
8
|
+
|
|
9
|
+
async function cloneRepo(repoUrl: string, targetDir: string, log: (msg: string) => void): Promise<void> {
|
|
10
|
+
log(`Cloning repository ${repoUrl}`);
|
|
11
|
+
if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true, force: true });
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const proc = spawn('git', ['clone', '--depth', '1', repoUrl, targetDir], {
|
|
14
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
15
|
+
});
|
|
16
|
+
let stderr = '';
|
|
17
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
18
|
+
stderr += data.toString();
|
|
19
|
+
const lines = data.toString().trim().split('\n');
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (line) log(` git: ${line}`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
proc.on('close', (code) => {
|
|
25
|
+
if (code === 0) {
|
|
26
|
+
log('Repository cloned successfully');
|
|
27
|
+
resolve();
|
|
28
|
+
} else {
|
|
29
|
+
reject(new Error(`git clone failed with code ${code}: ${stderr}`));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
proc.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function extractZip(zipPath: string, targetDir: string, log: (msg: string) => void): Promise<void> {
|
|
37
|
+
log(`Extracting local ZIP from ${zipPath}`);
|
|
38
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
39
|
+
const data = fs.readFileSync(zipPath);
|
|
40
|
+
const stream = Readable.from(data);
|
|
41
|
+
await pipeline(stream, unzipper.Extract({ path: targetDir }));
|
|
42
|
+
|
|
43
|
+
const entries = fs.readdirSync(targetDir);
|
|
44
|
+
if (entries.length === 1 && fs.statSync(path.join(targetDir, entries[0])).isDirectory()) {
|
|
45
|
+
log('Detected single root directory in ZIP, flattening...');
|
|
46
|
+
const innerDir = path.join(targetDir, entries[0]);
|
|
47
|
+
const innerEntries = fs.readdirSync(innerDir);
|
|
48
|
+
for (const entry of innerEntries) {
|
|
49
|
+
const src = path.join(innerDir, entry);
|
|
50
|
+
const dest = path.join(targetDir, entry);
|
|
51
|
+
fs.renameSync(src, dest);
|
|
52
|
+
}
|
|
53
|
+
fs.rmdirSync(innerDir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
log(`Extracted ZIP to ${targetDir}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findAntoraYmlFiles(dir: string): string[] {
|
|
60
|
+
const results: string[] = [];
|
|
61
|
+
const stack = [dir];
|
|
62
|
+
while (stack.length > 0) {
|
|
63
|
+
const current = stack.pop()!;
|
|
64
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.name.startsWith('.')) continue;
|
|
67
|
+
if (entry.name === 'node_modules') continue;
|
|
68
|
+
const fullPath = path.join(current, entry.name);
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
stack.push(fullPath);
|
|
71
|
+
} else if (entry.name === 'antora.yml') {
|
|
72
|
+
results.push(fullPath);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findExistingPlaybook(contentDir: string): string | null {
|
|
80
|
+
const candidates = ['antora-playbook.yml', 'site.yml', 'antora-playbook.yaml', 'site.yaml'];
|
|
81
|
+
for (const name of candidates) {
|
|
82
|
+
const p = path.join(contentDir, name);
|
|
83
|
+
if (fs.existsSync(p)) return p;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function generatePlaybook(
|
|
89
|
+
entryId: string,
|
|
90
|
+
contentDir: string,
|
|
91
|
+
outputDir: string,
|
|
92
|
+
startPaths: string[],
|
|
93
|
+
log: (msg: string) => void
|
|
94
|
+
): string {
|
|
95
|
+
let sourcesYaml: string;
|
|
96
|
+
if (startPaths.length === 0) {
|
|
97
|
+
log(' No antora.yml files found — Antora will scan entire repository');
|
|
98
|
+
sourcesYaml = ` - url: ${contentDir}\n branches: HEAD`;
|
|
99
|
+
} else {
|
|
100
|
+
log(` Found ${startPaths.length} antora.yml file(s)`);
|
|
101
|
+
if (startPaths.length === 1) {
|
|
102
|
+
const rel = path.relative(contentDir, path.dirname(startPaths[0]));
|
|
103
|
+
log(` Using start_path: ${rel || '.'}`);
|
|
104
|
+
sourcesYaml = ` - url: ${contentDir}\n branches: HEAD\n start_path: ${rel || '.'}`;
|
|
105
|
+
} else {
|
|
106
|
+
const paths = startPaths
|
|
107
|
+
.map((f) => path.relative(contentDir, path.dirname(f)))
|
|
108
|
+
.filter((p) => p.length > 0);
|
|
109
|
+
const pathsStr = paths.length > 0
|
|
110
|
+
? `\n${paths.map((p) => ` - ${p}`).join('\n')}`
|
|
111
|
+
: '';
|
|
112
|
+
sourcesYaml = ` - url: ${contentDir}\n branches: HEAD\n start_paths:${pathsStr}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return `
|
|
117
|
+
site:
|
|
118
|
+
title: Dockit — ${entryId}
|
|
119
|
+
url: /api/bundle/${entryId}
|
|
120
|
+
content:
|
|
121
|
+
sources:
|
|
122
|
+
${sourcesYaml}
|
|
123
|
+
ui:
|
|
124
|
+
bundle:
|
|
125
|
+
url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable
|
|
126
|
+
snapshot: true
|
|
127
|
+
output:
|
|
128
|
+
dir: ${outputDir}
|
|
129
|
+
`.trim();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function generatePlaybookFromExisting(playbookPath: string, outputDir: string): string {
|
|
133
|
+
const raw = fs.readFileSync(playbookPath, 'utf-8');
|
|
134
|
+
if (raw.includes('output:') && raw.includes('dir:')) {
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
return raw + `\noutput:\n dir: ${outputDir}\n`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function runAntora(playbookPath: string, log: (msg: string) => void): Promise<void> {
|
|
141
|
+
log(`Running Antora with playbook ${playbookPath}`);
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const proc = spawn('npx', ['antora', playbookPath], {
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
cwd: path.dirname(playbookPath),
|
|
146
|
+
env: { ...process.env, CI: 'true' },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let stderr = '';
|
|
150
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
151
|
+
const lines = data.toString().trim().split('\n');
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
if (line) log(` antora: ${line}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
157
|
+
stderr += data.toString();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
proc.on('close', (code) => {
|
|
161
|
+
if (code === 0) {
|
|
162
|
+
log('Antora build completed successfully');
|
|
163
|
+
resolve();
|
|
164
|
+
} else {
|
|
165
|
+
const tail = stderr.slice(-800);
|
|
166
|
+
log(`Antora build failed (stderr: ${tail})`);
|
|
167
|
+
reject(new Error(`Antora build failed with code ${code}`));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
proc.on('error', reject);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function buildAntoraSource(
|
|
175
|
+
config: AntoraSourceConfig,
|
|
176
|
+
entryId: string,
|
|
177
|
+
workDir: string,
|
|
178
|
+
log: (msg: string) => void
|
|
179
|
+
): Promise<string> {
|
|
180
|
+
let contentDir: string;
|
|
181
|
+
let cleanupDir: string | undefined;
|
|
182
|
+
|
|
183
|
+
if (config.localPath) {
|
|
184
|
+
if (!fs.existsSync(config.localPath)) {
|
|
185
|
+
throw new Error(`Local path not found: ${config.localPath}`);
|
|
186
|
+
}
|
|
187
|
+
if (!fs.statSync(config.localPath).isDirectory()) {
|
|
188
|
+
throw new Error(`localPath must be a directory: ${config.localPath}`);
|
|
189
|
+
}
|
|
190
|
+
log(`Using local directory: ${config.localPath}`);
|
|
191
|
+
contentDir = config.localPath;
|
|
192
|
+
} else if (config.repoUrl) {
|
|
193
|
+
contentDir = path.join(workDir, 'content');
|
|
194
|
+
await cloneRepo(config.repoUrl, contentDir, log);
|
|
195
|
+
cleanupDir = contentDir;
|
|
196
|
+
} else if (config.zipPath) {
|
|
197
|
+
contentDir = path.join(workDir, 'content');
|
|
198
|
+
await extractZip(config.zipPath, contentDir, log);
|
|
199
|
+
cleanupDir = contentDir;
|
|
200
|
+
} else {
|
|
201
|
+
throw new Error('Antora source requires repoUrl, localPath, or zipPath');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const playbookPath = path.join(workDir, 'antora-playbook.yml');
|
|
205
|
+
const outputDir = path.join(workDir, 'output');
|
|
206
|
+
const existingPlaybook = findExistingPlaybook(contentDir);
|
|
207
|
+
|
|
208
|
+
if (existingPlaybook) {
|
|
209
|
+
log(`Found existing playbook at ${path.relative(contentDir, existingPlaybook)}`);
|
|
210
|
+
const playbookContent = generatePlaybookFromExisting(existingPlaybook, outputDir);
|
|
211
|
+
fs.writeFileSync(playbookPath, playbookContent, 'utf-8');
|
|
212
|
+
} else {
|
|
213
|
+
const antoraFiles = findAntoraYmlFiles(contentDir);
|
|
214
|
+
if (antoraFiles.length === 0) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`No antora.yml files found in repository. ` +
|
|
217
|
+
`This repo does not appear to be an Antora-based documentation source. ` +
|
|
218
|
+
`Use a ZIP or Maven source type instead, or provide the URL to the specific ` +
|
|
219
|
+
`Antora documentation repository (e.g. a repo containing antora.yml files).`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const playbookContent = generatePlaybook(entryId, contentDir, outputDir, antoraFiles, log);
|
|
223
|
+
fs.writeFileSync(playbookPath, playbookContent, 'utf-8');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await runAntora(playbookPath, log);
|
|
227
|
+
|
|
228
|
+
return outputDir;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function ensureAntoraInstalled(): boolean {
|
|
232
|
+
try {
|
|
233
|
+
execSync('npx antora --version', { stdio: 'pipe' });
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import unzipper from 'unzipper';
|
|
8
|
+
import asciidoctorModule from '@asciidoctor/core';
|
|
9
|
+
import type { AsciidocSourceConfig } from '../core/domain/types.js';
|
|
10
|
+
|
|
11
|
+
type Processor = {
|
|
12
|
+
convertFile(file: string, options?: Record<string, unknown>): unknown;
|
|
13
|
+
};
|
|
14
|
+
const asciidoctor = asciidoctorModule as unknown as () => Processor;
|
|
15
|
+
|
|
16
|
+
const SKIP_DIRS = new Set([
|
|
17
|
+
'node_modules', 'target', 'build', 'dist', '.git',
|
|
18
|
+
'integrations', 'extensions', 'independent-projects',
|
|
19
|
+
'test', 'tests', 'tcks',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
async function cloneRepo(repoUrl: string, targetDir: string, log: (msg: string) => void): Promise<void> {
|
|
23
|
+
log(`Cloning repository ${repoUrl}`);
|
|
24
|
+
if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true, force: true });
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const proc = spawn('git', ['clone', '--depth', '1', repoUrl, targetDir], {
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
+
});
|
|
29
|
+
let stderr = '';
|
|
30
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
31
|
+
stderr += data.toString();
|
|
32
|
+
const lines = data.toString().trim().split('\n');
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
if (line) log(` git: ${line}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
proc.on('close', (code) => {
|
|
38
|
+
if (code === 0) {
|
|
39
|
+
log('Repository cloned successfully');
|
|
40
|
+
resolve();
|
|
41
|
+
} else {
|
|
42
|
+
reject(new Error(`git clone failed with code ${code}: ${stderr}`));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
proc.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function extractZip(zipPath: string, targetDir: string, log: (msg: string) => void): Promise<void> {
|
|
50
|
+
log(`Extracting local ZIP from ${zipPath}`);
|
|
51
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
52
|
+
const data = fs.readFileSync(zipPath);
|
|
53
|
+
const stream = Readable.from(data);
|
|
54
|
+
await pipeline(stream, unzipper.Extract({ path: targetDir }));
|
|
55
|
+
|
|
56
|
+
const entries = fs.readdirSync(targetDir);
|
|
57
|
+
if (entries.length === 1 && fs.statSync(path.join(targetDir, entries[0])).isDirectory()) {
|
|
58
|
+
log('Detected single root directory, flattening...');
|
|
59
|
+
const innerDir = path.join(targetDir, entries[0]);
|
|
60
|
+
const innerEntries = fs.readdirSync(innerDir);
|
|
61
|
+
for (const entry of innerEntries) {
|
|
62
|
+
fs.renameSync(path.join(innerDir, entry), path.join(targetDir, entry));
|
|
63
|
+
}
|
|
64
|
+
fs.rmdirSync(innerDir);
|
|
65
|
+
}
|
|
66
|
+
log(`Extracted ZIP to ${targetDir}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findAdocFiles(dir: string, sourcePath: string): string[] {
|
|
70
|
+
const root = sourcePath ? path.join(dir, sourcePath) : dir;
|
|
71
|
+
if (!fs.existsSync(root)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const results: string[] = [];
|
|
75
|
+
const stack = [root];
|
|
76
|
+
while (stack.length > 0) {
|
|
77
|
+
const current = stack.pop()!;
|
|
78
|
+
let entries: fs.Dirent[];
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
|
|
86
|
+
const fullPath = path.join(current, entry.name);
|
|
87
|
+
if (entry.isDirectory()) {
|
|
88
|
+
stack.push(fullPath);
|
|
89
|
+
} else if (entry.name.endsWith('.adoc')) {
|
|
90
|
+
results.push(fullPath);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runAsciidoctor(
|
|
98
|
+
adocFiles: string[],
|
|
99
|
+
sourceRoot: string,
|
|
100
|
+
targetDir: string,
|
|
101
|
+
log: (msg: string) => void
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
log(`Converting ${adocFiles.length} .adoc files to HTML`);
|
|
104
|
+
|
|
105
|
+
const adoc = asciidoctor();
|
|
106
|
+
let filesProcessed = 0;
|
|
107
|
+
let filesSkipped = 0;
|
|
108
|
+
|
|
109
|
+
for (const adocFile of adocFiles) {
|
|
110
|
+
try {
|
|
111
|
+
const relPath = path.relative(sourceRoot, adocFile);
|
|
112
|
+
const outDir = path.join(targetDir, path.dirname(relPath));
|
|
113
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
114
|
+
|
|
115
|
+
adoc.convertFile(adocFile, {
|
|
116
|
+
to_dir: outDir,
|
|
117
|
+
base_dir: path.dirname(adocFile),
|
|
118
|
+
mkdirs: true,
|
|
119
|
+
safe: 'unsafe',
|
|
120
|
+
});
|
|
121
|
+
filesProcessed++;
|
|
122
|
+
} catch (err: any) {
|
|
123
|
+
const rel = path.relative(sourceRoot, adocFile);
|
|
124
|
+
log(` WARNING: Failed to convert ${rel}: ${err.message || err}`);
|
|
125
|
+
filesSkipped++;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const htmlCount = countHtmlFiles(targetDir);
|
|
130
|
+
log(`AsciiDoc conversion complete: ${filesProcessed} converted, ${filesSkipped} skipped, ${htmlCount} HTML files generated`);
|
|
131
|
+
|
|
132
|
+
if (htmlCount === 0 && filesProcessed === 0) {
|
|
133
|
+
throw new Error('Asciidoctor failed to produce any HTML. Check that the source files are valid.');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function countHtmlFiles(dir: string): number {
|
|
138
|
+
if (!fs.existsSync(dir)) return 0;
|
|
139
|
+
let count = 0;
|
|
140
|
+
const stack = [dir];
|
|
141
|
+
while (stack.length > 0) {
|
|
142
|
+
const current = stack.pop()!;
|
|
143
|
+
let entries: fs.Dirent[];
|
|
144
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
|
145
|
+
for (const e of entries) {
|
|
146
|
+
const p = path.join(current, e.name);
|
|
147
|
+
if (e.isDirectory() && !e.name.startsWith('.')) stack.push(p);
|
|
148
|
+
else if (e.name.endsWith('.html')) count++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return count;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function rmDir(dir: string): void {
|
|
155
|
+
try {
|
|
156
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
} catch {
|
|
158
|
+
// best-effort cleanup
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function buildAsciidocSource(
|
|
163
|
+
config: AsciidocSourceConfig,
|
|
164
|
+
targetDir: string,
|
|
165
|
+
log: (msg: string) => void
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
let repoDir: string;
|
|
168
|
+
let shouldCleanup: boolean;
|
|
169
|
+
|
|
170
|
+
if (config.localPath) {
|
|
171
|
+
if (!fs.existsSync(config.localPath)) {
|
|
172
|
+
throw new Error(`Local path not found: ${config.localPath}`);
|
|
173
|
+
}
|
|
174
|
+
if (!fs.statSync(config.localPath).isDirectory()) {
|
|
175
|
+
throw new Error(`localPath must be a directory: ${config.localPath}`);
|
|
176
|
+
}
|
|
177
|
+
log(`Using local directory: ${config.localPath}`);
|
|
178
|
+
repoDir = config.localPath;
|
|
179
|
+
shouldCleanup = false;
|
|
180
|
+
} else if (config.repoUrl) {
|
|
181
|
+
repoDir = path.join(os.tmpdir(), `dockit-asciidoc-${Date.now()}`);
|
|
182
|
+
await cloneRepo(config.repoUrl, repoDir, log);
|
|
183
|
+
shouldCleanup = true;
|
|
184
|
+
} else if (config.zipPath) {
|
|
185
|
+
repoDir = path.join(os.tmpdir(), `dockit-asciidoc-${Date.now()}`);
|
|
186
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
187
|
+
await extractZip(config.zipPath, repoDir, log);
|
|
188
|
+
shouldCleanup = true;
|
|
189
|
+
} else {
|
|
190
|
+
throw new Error('AsciiDoc source requires repoUrl, localPath, or zipPath');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const sourcePath = config.sourcePath || '';
|
|
195
|
+
const sourceRoot = sourcePath ? path.join(repoDir, sourcePath) : repoDir;
|
|
196
|
+
|
|
197
|
+
log(`Scanning for .adoc files in ${sourcePath || 'repository root'}`);
|
|
198
|
+
const adocFiles = findAdocFiles(repoDir, sourcePath);
|
|
199
|
+
|
|
200
|
+
if (adocFiles.length === 0) {
|
|
201
|
+
if (sourcePath) {
|
|
202
|
+
throw new Error(`No .adoc files found at path "${sourcePath}" in the repository`);
|
|
203
|
+
}
|
|
204
|
+
throw new Error(
|
|
205
|
+
`No .adoc files found in the repository. ` +
|
|
206
|
+
`If the docs live in a subdirectory, set the "sourcePath" field ` +
|
|
207
|
+
`(e.g. "docs/src/main/asciidoc" for Quarkus).`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
log(`Found ${adocFiles.length} .adoc file(s)`);
|
|
212
|
+
|
|
213
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
214
|
+
await runAsciidoctor(adocFiles, sourceRoot, targetDir, log);
|
|
215
|
+
} finally {
|
|
216
|
+
if (shouldCleanup && fs.existsSync(repoDir)) {
|
|
217
|
+
log('Cleaning up cloned repository...');
|
|
218
|
+
rmDir(repoDir);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|