@gitpagedocs/cli 0.0.1
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/README.md +193 -0
- package/ai/application/ai-command.ts +24 -0
- package/ai/application/ai-provider-factory.ts +22 -0
- package/ai/application/docs-pattern.ts +72 -0
- package/ai/application/resolve-chat-credentials.ts +69 -0
- package/ai/application/run-ai-cli-command.ts +167 -0
- package/ai/config.ts +50 -0
- package/ai/core/chat-session.ts +44 -0
- package/ai/core/models/ai-cli-config.ts +33 -0
- package/ai/core/models/parse-ai-cli-config.ts +76 -0
- package/ai/core/ports/illm-provider.ts +32 -0
- package/ai/infrastructure/ai-config-file.ts +101 -0
- package/ai/infrastructure/file-system-adapter.ts +85 -0
- package/ai/infrastructure/llm/tools-llm-provider.ts +54 -0
- package/ai/infrastructure/version-docs-writer.ts +143 -0
- package/ai/presentation/ai-prompts.ts +239 -0
- package/application/config-only/handler.mjs +57 -0
- package/application/home/handler.mjs +87 -0
- package/application/ports/cli-runtime-ports.ts +93 -0
- package/application/report/config-only-reporter.mjs +68 -0
- package/application/use-cases/dispatch-mode.ts +18 -0
- package/builders/config-orchestrator.mjs +35 -0
- package/builders/language-bundles-builder.mjs +24 -0
- package/builders/project-links.mjs +50 -0
- package/builders/root-config-builder.d.mts +16 -0
- package/builders/root-config-builder.mjs +56 -0
- package/builders/route-builders.mjs +136 -0
- package/builders/theme-template.mjs +76 -0
- package/builders/version-config-builder.mjs +186 -0
- package/content/docs-en.mjs +556 -0
- package/content/docs-es.mjs +469 -0
- package/content/docs-pt.mjs +469 -0
- package/content/docs.mjs +9 -0
- package/contracts/doc-versions.mjs +3 -0
- package/contracts/index.mjs +11 -0
- package/contracts/langs-paths.mjs +28 -0
- package/contracts/languages.mjs +6 -0
- package/contracts/layouts-paths.d.mts +17 -0
- package/contracts/layouts-paths.mjs +68 -0
- package/contracts/path-segments.d.mts +2 -0
- package/contracts/path-segments.mjs +15 -0
- package/data/default-site-config.mjs +328 -0
- package/data/i18n-langmenu.mjs +350 -0
- package/data/i18n-translations.mjs +20 -0
- package/data/layouts.mjs +437 -0
- package/data/path-mappings.mjs +26 -0
- package/data/route-metas.mjs +93 -0
- package/data/theme-colors.mjs +444 -0
- package/data/urls.mjs +12 -0
- package/data/version-constants.mjs +3 -0
- package/domain/models/cli-options.ts +33 -0
- package/domain/services/layouts-migration.d.mts +15 -0
- package/domain/services/layouts-migration.mjs +74 -0
- package/domain/services/sanitize-segment.mjs +7 -0
- package/home/constants.mjs +4 -0
- package/home/home-file-writer.mjs +19 -0
- package/home/templates.mjs +51 -0
- package/index.mjs +38 -0
- package/infrastructure/config-only/runtime.mjs +17 -0
- package/infrastructure/gitpagedocs-config-file.ts +47 -0
- package/infrastructure/home/runtime.mjs +37 -0
- package/package.json +60 -0
- package/presentation/commands/ai-info.ts +57 -0
- package/presentation/commands/chat.ts +259 -0
- package/presentation/commands/config-clear.ts +23 -0
- package/presentation/commands/config-info.ts +72 -0
- package/presentation/commands/diagnostics.ts +174 -0
- package/presentation/commands/docs.ts +42 -0
- package/presentation/commands/mcp.ts +20 -0
- package/presentation/commands/pages.ts +103 -0
- package/presentation/commands/password.ts +62 -0
- package/presentation/commands/run-command.ts +72 -0
- package/presentation/index.ts +125 -0
- package/presentation/options/parser.ts +120 -0
- package/presentation/options/resolver.ts +46 -0
- package/presentation/options/schema.ts +14 -0
- package/presentation/ui/banner.ts +13 -0
- package/presentation/ui/clack.ts +91 -0
- package/presentation/ui/layouts-prompts.ts +124 -0
- package/presentation/ui/prompts.ts +147 -0
- package/presentation/ui/terminal-stream.ts +97 -0
- package/presentation/ui/tty.ts +37 -0
- package/runtime/doc-path-resolver.mjs +59 -0
- package/runtime/exec.mjs +168 -0
- package/runtime/git-ops.mjs +112 -0
- package/runtime/layouts-migrator.d.mts +11 -0
- package/runtime/layouts-migrator.mjs +117 -0
- package/runtime/output.mjs +201 -0
- package/runtime/workflow.mjs +131 -0
- package/start.mjs +14 -0
- package/ui/logger.mjs +9 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { AI_MODEL_DEFAULTS, type AiProviderId } from "../../config";
|
|
2
|
+
import type { AiCliConfig } from "./ai-cli-config";
|
|
3
|
+
|
|
4
|
+
type SupportedLanguage = AiCliConfig["ai"]["languages"][number];
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_PROVIDERS: ReadonlySet<string> = new Set(Object.keys(AI_MODEL_DEFAULTS));
|
|
7
|
+
const SUPPORTED_LANGUAGES: ReadonlySet<string> = new Set<SupportedLanguage>(["pt", "en", "es"]);
|
|
8
|
+
|
|
9
|
+
/** Fallbacks for optional fields, mirroring the interactive prompt defaults. */
|
|
10
|
+
const DEFAULT_LANGUAGES: AiCliConfig["ai"]["languages"] = ["en", "pt", "es"];
|
|
11
|
+
const DEFAULT_OUTPUT_DIR = "gitpagedocs/docs";
|
|
12
|
+
const DEFAULT_FILE_PREFIX = "ai-generated";
|
|
13
|
+
|
|
14
|
+
function isString(value: unknown): value is string {
|
|
15
|
+
return typeof value === "string";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function asOptionalString(value: unknown): string | undefined {
|
|
19
|
+
return isString(value) ? value : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function asStringWithDefault(value: unknown, fallback: string): string {
|
|
23
|
+
return isString(value) ? value : fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function asPaths(value: unknown): string[] {
|
|
27
|
+
return Array.isArray(value) ? value.filter(isString) : [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function asLanguages(value: unknown): AiCliConfig["ai"]["languages"] {
|
|
31
|
+
const languages = Array.isArray(value)
|
|
32
|
+
? value.filter((language): language is SupportedLanguage => isString(language) && SUPPORTED_LANGUAGES.has(language))
|
|
33
|
+
: [];
|
|
34
|
+
return languages.length > 0 ? languages : [...DEFAULT_LANGUAGES];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function asProviderId(value: unknown): AiProviderId | null {
|
|
38
|
+
return isString(value) && SUPPORTED_PROVIDERS.has(value) ? (value as AiProviderId) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate an untrusted `.gitpagedocsconfig` payload. Tolerant by design so
|
|
43
|
+
* hand-edited configs keep working: only the version-1 envelope and a known
|
|
44
|
+
* provider are required; every other field falls back to the interactive
|
|
45
|
+
* prompt defaults. Returns a sanitized {@link AiCliConfig} with only known
|
|
46
|
+
* fields, or `null` when the payload cannot identify a provider.
|
|
47
|
+
*/
|
|
48
|
+
export function parseAiCliConfig(raw: unknown): AiCliConfig | null {
|
|
49
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
50
|
+
const candidate = raw as Partial<AiCliConfig>;
|
|
51
|
+
if (candidate.version !== 1) return null;
|
|
52
|
+
|
|
53
|
+
const ai = candidate.ai;
|
|
54
|
+
if (typeof ai !== "object" || ai === null) return null;
|
|
55
|
+
const provider = asProviderId(ai.provider);
|
|
56
|
+
if (!provider) return null;
|
|
57
|
+
|
|
58
|
+
const model = asOptionalString(ai.model)?.trim() || AI_MODEL_DEFAULTS[provider];
|
|
59
|
+
const apiKey = asOptionalString(ai.apiKey);
|
|
60
|
+
const baseUrl = asOptionalString(ai.baseUrl);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
version: 1,
|
|
64
|
+
ai: {
|
|
65
|
+
provider,
|
|
66
|
+
model,
|
|
67
|
+
...(apiKey !== undefined ? { apiKey } : {}),
|
|
68
|
+
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
69
|
+
paths: asPaths(ai.paths),
|
|
70
|
+
languages: asLanguages(ai.languages),
|
|
71
|
+
outputDir: asStringWithDefault(ai.outputDir, DEFAULT_OUTPUT_DIR),
|
|
72
|
+
filePrefix: asStringWithDefault(ai.filePrefix, DEFAULT_FILE_PREFIX),
|
|
73
|
+
contextPrompt: asStringWithDefault(ai.contextPrompt, ""),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file illm-provider.ts
|
|
3
|
+
* @description Contract for all LLM implementations ensuring OCP and DIP.
|
|
4
|
+
* Isolates the AI logic from the specific LLM providers (OpenAI, Claude, Ollama, etc.).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ChatMessage {
|
|
8
|
+
role: 'user' | 'assistant' | 'system';
|
|
9
|
+
content: string;
|
|
10
|
+
type?: 'text' | 'image' | 'audio'; // Supporting multimedia as requested
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ILLMProviderConfig {
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
model?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ILLMProvider {
|
|
20
|
+
/**
|
|
21
|
+
* Generates markdown documentation based on file content.
|
|
22
|
+
* @param fileContent Raw content of the source file
|
|
23
|
+
* @param contextPrompt Optional instructions to guide the generation (e.g., FSD standard)
|
|
24
|
+
*/
|
|
25
|
+
generateDocumentation(fileContent: string, contextPrompt?: string): Promise<string>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Conversational chat support.
|
|
29
|
+
* @param messages Array of chat messages (system context, user history, etc.)
|
|
30
|
+
*/
|
|
31
|
+
chat(messages: ChatMessage[]): Promise<string>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveUserConfigDir } from "@gitpagedocs/tools";
|
|
4
|
+
import {
|
|
5
|
+
AI_CLI_CONFIG_FILENAME,
|
|
6
|
+
type AiCliConfig,
|
|
7
|
+
} from "../core/models/ai-cli-config";
|
|
8
|
+
import { parseAiCliConfig } from "../core/models/parse-ai-cli-config";
|
|
9
|
+
|
|
10
|
+
/** Owner-only directory/file permissions (no-op on Windows, enforced on POSIX). */
|
|
11
|
+
const CONFIG_DIR_MODE = 0o700;
|
|
12
|
+
const CONFIG_FILE_MODE = 0o600;
|
|
13
|
+
|
|
14
|
+
export interface AiConfigFileRepositoryOptions {
|
|
15
|
+
/** Working directory searched for a legacy `<cwd>/.gitpagedocsconfig`. */
|
|
16
|
+
cwd?: string;
|
|
17
|
+
/** Destination directory; defaults to the per-user OS config directory. */
|
|
18
|
+
configDir?: string;
|
|
19
|
+
/** Notified after a legacy config is moved into the user config directory. */
|
|
20
|
+
onMigrate?: (fromPath: string, toPath: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stores `.gitpagedocsconfig` in the per-user OS config directory
|
|
25
|
+
* (`%APPDATA%\gitpagedocs` on Windows, `~/Library/Application Support/gitpagedocs`
|
|
26
|
+
* on macOS, `$XDG_CONFIG_HOME/gitpagedocs` elsewhere) with owner-only
|
|
27
|
+
* permissions, so the API key never lives inside a repository checkout.
|
|
28
|
+
* A legacy `<cwd>/.gitpagedocsconfig` is migrated automatically on first read.
|
|
29
|
+
*/
|
|
30
|
+
export class AiConfigFileRepository {
|
|
31
|
+
private readonly cwd: string;
|
|
32
|
+
private readonly configDir: string;
|
|
33
|
+
private readonly onMigrate?: (fromPath: string, toPath: string) => void;
|
|
34
|
+
|
|
35
|
+
constructor(options: AiConfigFileRepositoryOptions = {}) {
|
|
36
|
+
this.cwd = options.cwd ?? process.cwd();
|
|
37
|
+
this.configDir = options.configDir ?? resolveUserConfigDir();
|
|
38
|
+
this.onMigrate = options.onMigrate;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getConfigPath(): string {
|
|
42
|
+
return path.join(this.configDir, AI_CLI_CONFIG_FILENAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getLegacyConfigPath(): string {
|
|
46
|
+
return path.join(this.cwd, AI_CLI_CONFIG_FILENAME);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async read(): Promise<AiCliConfig | null> {
|
|
50
|
+
const current = await this.readFrom(this.getConfigPath());
|
|
51
|
+
if (current) return current;
|
|
52
|
+
return this.migrateLegacyConfig();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Delete the stored configuration (secure and legacy locations), wiping
|
|
56
|
+
* any saved credentials. Returns the paths that were actually removed. */
|
|
57
|
+
async clear(): Promise<string[]> {
|
|
58
|
+
const removed: string[] = [];
|
|
59
|
+
const candidates = new Set([this.getConfigPath(), this.getLegacyConfigPath()].map((p) => path.resolve(p)));
|
|
60
|
+
for (const configPath of candidates) {
|
|
61
|
+
try {
|
|
62
|
+
await fs.rm(configPath);
|
|
63
|
+
removed.push(configPath);
|
|
64
|
+
} catch {
|
|
65
|
+
// Missing file: nothing to remove at this location.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return removed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async write(config: AiCliConfig): Promise<void> {
|
|
72
|
+
const configPath = this.getConfigPath();
|
|
73
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: CONFIG_DIR_MODE });
|
|
74
|
+
const data = `${JSON.stringify(config, null, 2)}\n`;
|
|
75
|
+
await fs.writeFile(configPath, data, { encoding: "utf-8", mode: CONFIG_FILE_MODE });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async readFrom(configPath: string): Promise<AiCliConfig | null> {
|
|
79
|
+
try {
|
|
80
|
+
const content = await fs.readFile(configPath, "utf-8");
|
|
81
|
+
return parseAiCliConfig(JSON.parse(content));
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async migrateLegacyConfig(): Promise<AiCliConfig | null> {
|
|
88
|
+
const legacyPath = this.getLegacyConfigPath();
|
|
89
|
+
if (path.resolve(legacyPath) === path.resolve(this.getConfigPath())) return null;
|
|
90
|
+
const legacy = await this.readFrom(legacyPath);
|
|
91
|
+
if (!legacy) return null;
|
|
92
|
+
try {
|
|
93
|
+
await this.write(legacy);
|
|
94
|
+
await fs.rm(legacyPath, { force: true });
|
|
95
|
+
this.onMigrate?.(legacyPath, this.getConfigPath());
|
|
96
|
+
} catch {
|
|
97
|
+
// Migration is best-effort: keep serving the legacy config on failure.
|
|
98
|
+
}
|
|
99
|
+
return legacy;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { statSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export interface FilePayload {
|
|
6
|
+
filePath: string;
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class FileSystemAdapter {
|
|
11
|
+
constructor(private readonly baseDir: string = process.cwd()) {}
|
|
12
|
+
|
|
13
|
+
splitExistingDirectories(targetDirectories: string[]): { existing: string[]; missing: string[] } {
|
|
14
|
+
const existing: string[] = [];
|
|
15
|
+
const missing: string[] = [];
|
|
16
|
+
|
|
17
|
+
for (const targetDirectory of targetDirectories) {
|
|
18
|
+
const absolutePath = path.resolve(this.baseDir, targetDirectory);
|
|
19
|
+
try {
|
|
20
|
+
const stat = statSync(absolutePath);
|
|
21
|
+
if (stat.isDirectory()) {
|
|
22
|
+
existing.push(targetDirectory);
|
|
23
|
+
} else {
|
|
24
|
+
missing.push(targetDirectory);
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
missing.push(targetDirectory);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { existing, missing };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async readDirectoryRecursively(targetDirectory: string): Promise<FilePayload[]> {
|
|
35
|
+
const absolutePath = path.resolve(this.baseDir, targetDirectory);
|
|
36
|
+
const resolvedFiles: FilePayload[] = [];
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await this.traverse(absolutePath, resolvedFiles);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return resolvedFiles;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async traverse(currentPath: string, payloadList: FilePayload[]): Promise<void> {
|
|
47
|
+
let stats;
|
|
48
|
+
try {
|
|
49
|
+
stats = await fs.stat(currentPath);
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (stats.isDirectory()) {
|
|
55
|
+
const basename = path.basename(currentPath);
|
|
56
|
+
if (['node_modules', '.git', '.next', 'out', 'dist'].includes(basename)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const entries = await fs.readdir(currentPath);
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
await this.traverse(path.join(currentPath, entry), payloadList);
|
|
63
|
+
}
|
|
64
|
+
} else if (stats.isFile()) {
|
|
65
|
+
const ext = path.extname(currentPath);
|
|
66
|
+
const validExtensions = ['.ts', '.tsx', '.js', '.jsx', '.json', '.md', '.css'];
|
|
67
|
+
if (validExtensions.includes(ext)) {
|
|
68
|
+
try {
|
|
69
|
+
const content = await fs.readFile(currentPath, 'utf-8');
|
|
70
|
+
payloadList.push({
|
|
71
|
+
filePath: currentPath,
|
|
72
|
+
content: content
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async writeDocumentationOutput(outputContent: string, targetPath: string = "documentation-output.md"): Promise<void> {
|
|
81
|
+
const absoluteDest = path.resolve(this.baseDir, targetPath);
|
|
82
|
+
await fs.mkdir(path.dirname(absoluteDest), { recursive: true });
|
|
83
|
+
await fs.writeFile(absoluteDest, outputContent, 'utf-8');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createDefaultFactory, legacyProviderToCatalogId } from "@gitpagedocs/tools/ai";
|
|
2
|
+
import type { AiMessage, ProviderConfig } from "@gitpagedocs/tools/ports";
|
|
3
|
+
import type { ILLMProvider, ChatMessage, ILLMProviderConfig } from "../../core/ports/illm-provider";
|
|
4
|
+
import { DEFAULT_AI_DOC_PROMPT, type AiProviderId } from "../../config";
|
|
5
|
+
|
|
6
|
+
const factory = createDefaultFactory();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Single CLI LLM provider backed by the shared @gitpagedocs/tools AI core.
|
|
10
|
+
* Replaces the four duplicated CLI provider classes (OpenAI/Claude/Gemini/
|
|
11
|
+
* Ollama) with one adapter over the registry/factory, preserving the
|
|
12
|
+
* ILLMProvider contract used by the AI documentation flow.
|
|
13
|
+
*/
|
|
14
|
+
export class ToolsLlmProvider implements ILLMProvider {
|
|
15
|
+
private readonly providerId;
|
|
16
|
+
private readonly model: string;
|
|
17
|
+
private readonly apiKey?: string;
|
|
18
|
+
private readonly baseUrl?: string;
|
|
19
|
+
|
|
20
|
+
constructor(legacyProvider: AiProviderId, config: ILLMProviderConfig) {
|
|
21
|
+
this.providerId = legacyProviderToCatalogId(legacyProvider);
|
|
22
|
+
this.model = config.model ?? "";
|
|
23
|
+
this.apiKey = config.apiKey;
|
|
24
|
+
this.baseUrl = config.baseUrl;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async generateDocumentation(fileContent: string, contextPrompt?: string): Promise<string> {
|
|
28
|
+
return this.chat([
|
|
29
|
+
{ role: "system", content: contextPrompt || DEFAULT_AI_DOC_PROMPT },
|
|
30
|
+
{ role: "user", content: fileContent },
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async chat(messages: ChatMessage[]): Promise<string> {
|
|
35
|
+
const system = messages
|
|
36
|
+
.filter((m) => m.role === "system")
|
|
37
|
+
.map((m) => m.content)
|
|
38
|
+
.join("\n");
|
|
39
|
+
const aiMessages: AiMessage[] = messages
|
|
40
|
+
.filter((m) => m.role !== "system")
|
|
41
|
+
.map((m) => ({ role: m.role as AiMessage["role"], content: m.content }));
|
|
42
|
+
|
|
43
|
+
const config: ProviderConfig = {
|
|
44
|
+
providerId: this.providerId,
|
|
45
|
+
model: this.model,
|
|
46
|
+
apiKey: this.apiKey,
|
|
47
|
+
baseUrl: this.baseUrl,
|
|
48
|
+
};
|
|
49
|
+
const response = await factory
|
|
50
|
+
.create(this.providerId)
|
|
51
|
+
.generate({ messages: aiMessages, system: system || undefined, maxTokens: 4000 }, config);
|
|
52
|
+
return response.text;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// @ts-expect-error .mjs runtime module is type-less in this package.
|
|
4
|
+
import { buildMdRoute } from "../../builders/route-builders.mjs";
|
|
5
|
+
import type { AiDocPage } from "../application/docs-pattern";
|
|
6
|
+
|
|
7
|
+
const CANONICAL_LANGS = ["pt", "en", "es"] as const;
|
|
8
|
+
type Lang = (typeof CANONICAL_LANGS)[number];
|
|
9
|
+
|
|
10
|
+
/** AI routes/menus use a high, dedicated id range so they never clash with the
|
|
11
|
+
* deterministic base routes (ids 1..N) and can be identified for idempotency. */
|
|
12
|
+
const AI_ROUTE_ID_BASE = 1000;
|
|
13
|
+
|
|
14
|
+
export interface VersionDocsWriteInput {
|
|
15
|
+
cwd: string;
|
|
16
|
+
versionId: string;
|
|
17
|
+
/** Pages generated per language (only the languages the user selected). */
|
|
18
|
+
pagesByLang: Partial<Record<Lang, AiDocPage[]>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface VersionDocsWriteResult {
|
|
22
|
+
configPath: string;
|
|
23
|
+
slugs: string[];
|
|
24
|
+
files: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface LangContent {
|
|
28
|
+
title: string;
|
|
29
|
+
body: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function indexBySlug(pages: AiDocPage[] | undefined): Map<string, AiDocPage> {
|
|
33
|
+
const map = new Map<string, AiDocPage>();
|
|
34
|
+
for (const page of pages ?? []) {
|
|
35
|
+
if (!map.has(page.slug)) map.set(page.slug, page);
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write AI-generated pages into the gitpagedocs versioned structure and wire
|
|
42
|
+
* them into the version config.json (routes-md + menus-header-md). Pages are
|
|
43
|
+
* written for ALL three canonical languages (missing languages reuse an
|
|
44
|
+
* available translation) so every route path stays valid. Idempotent: any prior
|
|
45
|
+
* `aiGenerated` routes/menus are replaced, never duplicated. The base config and
|
|
46
|
+
* its deterministic routes are preserved untouched.
|
|
47
|
+
*/
|
|
48
|
+
export async function writeVersionDocs(input: VersionDocsWriteInput): Promise<VersionDocsWriteResult> {
|
|
49
|
+
const { cwd, versionId, pagesByLang } = input;
|
|
50
|
+
const base = `gitpagedocs/docs/versions/${versionId}`;
|
|
51
|
+
|
|
52
|
+
const perLang: Record<Lang, Map<string, AiDocPage>> = {
|
|
53
|
+
pt: indexBySlug(pagesByLang.pt),
|
|
54
|
+
en: indexBySlug(pagesByLang.en),
|
|
55
|
+
es: indexBySlug(pagesByLang.es),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Ordered unique slug list (prefer en order, then pt, then es).
|
|
59
|
+
const order: string[] = [];
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
for (const lang of ["en", "pt", "es"] as Lang[]) {
|
|
62
|
+
for (const page of pagesByLang[lang] ?? []) {
|
|
63
|
+
if (!seen.has(page.slug)) {
|
|
64
|
+
seen.add(page.slug);
|
|
65
|
+
order.push(page.slug);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (order.length === 0) {
|
|
70
|
+
throw new Error("No AI pages to write.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const resolve = (slug: string): Record<Lang, LangContent> => {
|
|
74
|
+
const fallback = CANONICAL_LANGS.map((lang) => perLang[lang].get(slug)).find(Boolean);
|
|
75
|
+
const out = {} as Record<Lang, LangContent>;
|
|
76
|
+
for (const lang of CANONICAL_LANGS) {
|
|
77
|
+
const page = perLang[lang].get(slug) ?? fallback!;
|
|
78
|
+
out[lang] = { title: page.title, body: page.body };
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// 1) Write the markdown files (all 3 canonical languages).
|
|
84
|
+
const writtenFiles: string[] = [];
|
|
85
|
+
for (const slug of order) {
|
|
86
|
+
const content = resolve(slug);
|
|
87
|
+
for (const lang of CANONICAL_LANGS) {
|
|
88
|
+
const rel = `${base}/${lang}/${slug}.md`;
|
|
89
|
+
const abs = path.resolve(cwd, rel);
|
|
90
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
91
|
+
await fs.writeFile(abs, `${content[lang].body}\n`, "utf-8");
|
|
92
|
+
writtenFiles.push(rel);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2) Patch the version config.json (raw JSON round-trip, preserve all keys).
|
|
97
|
+
const configRel = `${base}/config.json`;
|
|
98
|
+
const configAbs = path.resolve(cwd, configRel);
|
|
99
|
+
let raw: string;
|
|
100
|
+
try {
|
|
101
|
+
raw = await fs.readFile(configAbs, "utf-8");
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Version config not found at ${configRel}. Run the base scaffold (\`gitpagedocs\`) first.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const config = JSON.parse(raw) as Record<string, unknown>;
|
|
108
|
+
|
|
109
|
+
const routesMd = Array.isArray(config["routes-md"]) ? (config["routes-md"] as Array<Record<string, unknown>>) : [];
|
|
110
|
+
const menusMd = Array.isArray(config["menus-header-md"]) ? (config["menus-header-md"] as Array<Record<string, unknown>>) : [];
|
|
111
|
+
const keptRoutes = routesMd.filter((route) => route?.aiGenerated !== true);
|
|
112
|
+
const keptMenus = menusMd.filter((menu) => menu?.aiGenerated !== true);
|
|
113
|
+
|
|
114
|
+
const newRoutes: Array<Record<string, unknown>> = [];
|
|
115
|
+
const newMenus: Array<Record<string, unknown>> = [];
|
|
116
|
+
order.forEach((slug, index) => {
|
|
117
|
+
const content = resolve(slug);
|
|
118
|
+
const id = AI_ROUTE_ID_BASE + index + 1;
|
|
119
|
+
const pathByLang = {
|
|
120
|
+
pt: `${base}/pt/${slug}.md`,
|
|
121
|
+
en: `${base}/en/${slug}.md`,
|
|
122
|
+
es: `${base}/es/${slug}.md`,
|
|
123
|
+
};
|
|
124
|
+
const titles = { pt: content.pt.title, en: content.en.title, es: content.es.title };
|
|
125
|
+
const descriptions = { pt: "", en: "", es: "" };
|
|
126
|
+
const route = buildMdRoute(versionId, id, pathByLang, titles, descriptions, {}) as Record<string, unknown>;
|
|
127
|
+
route.aiGenerated = true;
|
|
128
|
+
newRoutes.push(route);
|
|
129
|
+
newMenus.push({
|
|
130
|
+
id,
|
|
131
|
+
aiGenerated: true,
|
|
132
|
+
pt: { title: content.pt.title, "path-click": pathByLang.pt },
|
|
133
|
+
en: { title: content.en.title, "path-click": pathByLang.en },
|
|
134
|
+
es: { title: content.es.title, "path-click": pathByLang.es },
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
config["routes-md"] = [...keptRoutes, ...newRoutes];
|
|
139
|
+
config["menus-header-md"] = [...keptMenus, ...newMenus];
|
|
140
|
+
await fs.writeFile(configAbs, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
141
|
+
|
|
142
|
+
return { configPath: configRel, slugs: order, files: writtenFiles };
|
|
143
|
+
}
|