@specglass/cli 0.0.5 → 0.0.7
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.js +60 -7
- package/dist/commands/check.d.ts +7 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/migrate.d.ts +16 -0
- package/dist/commands/migrate.js +172 -0
- package/dist/migration/converter.d.ts +51 -0
- package/dist/migration/converter.js +110 -0
- package/dist/migration/detector.d.ts +45 -0
- package/dist/migration/detector.js +196 -0
- package/dist/migration/planner.d.ts +55 -0
- package/dist/migration/planner.js +129 -0
- package/dist/migration/writer.d.ts +42 -0
- package/dist/migration/writer.js +150 -0
- package/dist/ui/check-results-display.d.ts +6 -0
- package/dist/ui/check-results-display.js +50 -0
- package/dist/ui/consent-prompt.d.ts +24 -0
- package/dist/ui/consent-prompt.js +40 -0
- package/dist/utils/context-builder.d.ts +9 -0
- package/dist/utils/context-builder.js +63 -0
- package/package.json +2 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source platform detection for migration.
|
|
3
|
+
*
|
|
4
|
+
* Detects which documentation platform is being used in a project
|
|
5
|
+
* by looking for platform-specific configuration files and content.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
8
|
+
import { join, resolve } from "path";
|
|
9
|
+
/** Supported source platforms for migration. */
|
|
10
|
+
export const SUPPORTED_PLATFORMS = [
|
|
11
|
+
"mintlify",
|
|
12
|
+
"docusaurus",
|
|
13
|
+
"readme",
|
|
14
|
+
"gitbook",
|
|
15
|
+
];
|
|
16
|
+
// ─── Platform-specific detectors ────────────────────────────────────
|
|
17
|
+
function collectContentFiles(dir, extensions = [".md", ".mdx"], maxDepth = 10) {
|
|
18
|
+
const files = [];
|
|
19
|
+
if (!existsSync(dir) || maxDepth <= 0)
|
|
20
|
+
return files;
|
|
21
|
+
try {
|
|
22
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const fullPath = join(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
26
|
+
files.push(...collectContentFiles(fullPath, extensions, maxDepth - 1));
|
|
27
|
+
}
|
|
28
|
+
else if (entry.isFile() &&
|
|
29
|
+
extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
30
|
+
files.push(fullPath);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Permission errors, etc. — skip silently
|
|
36
|
+
}
|
|
37
|
+
return files;
|
|
38
|
+
}
|
|
39
|
+
function detectMintlify(projectRoot) {
|
|
40
|
+
const mintJsonPath = join(projectRoot, "mint.json");
|
|
41
|
+
if (!existsSync(mintJsonPath)) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
failure: {
|
|
45
|
+
expectedFiles: ["mint.json"],
|
|
46
|
+
hint: "Mintlify projects have a mint.json configuration file in the project root.",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Read mint.json to find the content directory
|
|
51
|
+
let docsDir = projectRoot;
|
|
52
|
+
try {
|
|
53
|
+
const mintConfig = JSON.parse(readFileSync(mintJsonPath, "utf-8"));
|
|
54
|
+
// Mintlify's navigation references page paths — content is usually in root or a subdirectory
|
|
55
|
+
if (mintConfig.pages) {
|
|
56
|
+
// pages array or navigation structure — content relative to root
|
|
57
|
+
docsDir = projectRoot;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Malformed mint.json — still proceed with root as content dir
|
|
62
|
+
}
|
|
63
|
+
const contentFiles = collectContentFiles(docsDir);
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
detected: {
|
|
67
|
+
platform: "mintlify",
|
|
68
|
+
sourceDir: docsDir,
|
|
69
|
+
configPath: mintJsonPath,
|
|
70
|
+
navConfigPath: mintJsonPath, // Navigation is embedded in mint.json
|
|
71
|
+
contentFiles,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function detectDocusaurus(projectRoot) {
|
|
76
|
+
const configJsPath = join(projectRoot, "docusaurus.config.js");
|
|
77
|
+
const configTsPath = join(projectRoot, "docusaurus.config.ts");
|
|
78
|
+
const configPath = existsSync(configTsPath)
|
|
79
|
+
? configTsPath
|
|
80
|
+
: existsSync(configJsPath)
|
|
81
|
+
? configJsPath
|
|
82
|
+
: null;
|
|
83
|
+
if (!configPath) {
|
|
84
|
+
return {
|
|
85
|
+
success: false,
|
|
86
|
+
failure: {
|
|
87
|
+
expectedFiles: ["docusaurus.config.js", "docusaurus.config.ts"],
|
|
88
|
+
hint: "Docusaurus projects have a docusaurus.config.js or docusaurus.config.ts in the project root.",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const docsDir = join(projectRoot, "docs");
|
|
93
|
+
const sidebarsJs = join(projectRoot, "sidebars.js");
|
|
94
|
+
const sidebarsTs = join(projectRoot, "sidebars.ts");
|
|
95
|
+
const navConfigPath = existsSync(sidebarsTs)
|
|
96
|
+
? sidebarsTs
|
|
97
|
+
: existsSync(sidebarsJs)
|
|
98
|
+
? sidebarsJs
|
|
99
|
+
: null;
|
|
100
|
+
const contentFiles = collectContentFiles(docsDir);
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
detected: {
|
|
104
|
+
platform: "docusaurus",
|
|
105
|
+
sourceDir: docsDir,
|
|
106
|
+
configPath,
|
|
107
|
+
navConfigPath,
|
|
108
|
+
contentFiles,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function detectReadme(projectRoot) {
|
|
113
|
+
const readmeYmlPath = join(projectRoot, ".readme.yml");
|
|
114
|
+
const readmeYamlPath = join(projectRoot, ".readme.yaml");
|
|
115
|
+
const orderYamlPath = join(projectRoot, "_order.yaml");
|
|
116
|
+
// ReadMe projects can be identified by:
|
|
117
|
+
// 1. .readme.yml / .readme.yaml (ReadMe config file)
|
|
118
|
+
// 2. _order.yaml (rdme-cli docs-as-code sync format)
|
|
119
|
+
const configPath = existsSync(readmeYmlPath)
|
|
120
|
+
? readmeYmlPath
|
|
121
|
+
: existsSync(readmeYamlPath)
|
|
122
|
+
? readmeYamlPath
|
|
123
|
+
: existsSync(orderYamlPath)
|
|
124
|
+
? orderYamlPath
|
|
125
|
+
: null;
|
|
126
|
+
if (!configPath) {
|
|
127
|
+
return {
|
|
128
|
+
success: false,
|
|
129
|
+
failure: {
|
|
130
|
+
expectedFiles: [".readme.yml", ".readme.yaml", "_order.yaml"],
|
|
131
|
+
hint: "ReadMe projects have a .readme.yml/.readme.yaml config file or an _order.yaml navigation file in the project root.",
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// ReadMe content is typically in a docs/ directory or root
|
|
136
|
+
const docsDir = existsSync(join(projectRoot, "docs"))
|
|
137
|
+
? join(projectRoot, "docs")
|
|
138
|
+
: projectRoot;
|
|
139
|
+
const contentFiles = collectContentFiles(docsDir);
|
|
140
|
+
// _order.yaml serves as both config and nav when it's the identifier
|
|
141
|
+
const navConfigPath = existsSync(orderYamlPath) ? orderYamlPath : null;
|
|
142
|
+
return {
|
|
143
|
+
success: true,
|
|
144
|
+
detected: {
|
|
145
|
+
platform: "readme",
|
|
146
|
+
sourceDir: docsDir,
|
|
147
|
+
configPath,
|
|
148
|
+
navConfigPath,
|
|
149
|
+
contentFiles,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function detectGitbook(projectRoot) {
|
|
154
|
+
const summaryPath = join(projectRoot, "SUMMARY.md");
|
|
155
|
+
const gitbookYaml = join(projectRoot, ".gitbook.yaml");
|
|
156
|
+
// GitBook requires SUMMARY.md (defines navigation), .gitbook.yaml is optional
|
|
157
|
+
if (!existsSync(summaryPath)) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
failure: {
|
|
161
|
+
expectedFiles: ["SUMMARY.md", ".gitbook.yaml"],
|
|
162
|
+
hint: "GitBook projects have a SUMMARY.md navigation file (required) and optionally a .gitbook.yaml config file.",
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const configPath = existsSync(gitbookYaml) ? gitbookYaml : null;
|
|
167
|
+
const contentFiles = collectContentFiles(projectRoot);
|
|
168
|
+
return {
|
|
169
|
+
success: true,
|
|
170
|
+
detected: {
|
|
171
|
+
platform: "gitbook",
|
|
172
|
+
sourceDir: projectRoot,
|
|
173
|
+
configPath,
|
|
174
|
+
navConfigPath: summaryPath,
|
|
175
|
+
contentFiles,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// ─── Public API ─────────────────────────────────────────────────────
|
|
180
|
+
const DETECTORS = {
|
|
181
|
+
mintlify: detectMintlify,
|
|
182
|
+
docusaurus: detectDocusaurus,
|
|
183
|
+
readme: detectReadme,
|
|
184
|
+
gitbook: detectGitbook,
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Detect a source platform's content in the given project root.
|
|
188
|
+
*
|
|
189
|
+
* @param platform - Which platform to detect
|
|
190
|
+
* @param projectRoot - Absolute path to the project root (defaults to cwd)
|
|
191
|
+
* @returns Detection result — success with detected platform info, or failure with hints
|
|
192
|
+
*/
|
|
193
|
+
export function detectPlatform(platform, projectRoot = process.cwd()) {
|
|
194
|
+
const root = resolve(projectRoot);
|
|
195
|
+
return DETECTORS[platform](root);
|
|
196
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration planner — builds a plan for migrating content from a source platform
|
|
3
|
+
* to the specglass/ndocs format without executing the actual conversion.
|
|
4
|
+
*
|
|
5
|
+
* Used by both dry-run mode and the actual migration to preview planned changes.
|
|
6
|
+
*/
|
|
7
|
+
import type { DetectedPlatform, SupportedPlatform } from "./detector.js";
|
|
8
|
+
/** A single file that will be migrated. */
|
|
9
|
+
export interface PlannedFile {
|
|
10
|
+
/** Absolute path to the source file. */
|
|
11
|
+
sourcePath: string;
|
|
12
|
+
/** Relative target path within src/content/docs/ */
|
|
13
|
+
targetRelativePath: string;
|
|
14
|
+
/** Whether this file needs AI conversion (true) or is a simple copy (false). */
|
|
15
|
+
needsConversion: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** A component mapping from source to ndocs equivalent. */
|
|
18
|
+
export interface ComponentMapping {
|
|
19
|
+
/** Source platform component (e.g., "<Tip>") */
|
|
20
|
+
source: string;
|
|
21
|
+
/** ndocs equivalent component (e.g., "<Callout type=\"tip\">") */
|
|
22
|
+
target: string;
|
|
23
|
+
}
|
|
24
|
+
/** A navigation mapping from source config to ndocs _meta.json entries. */
|
|
25
|
+
export interface NavigationMapping {
|
|
26
|
+
/** Source navigation label or path */
|
|
27
|
+
sourceLabel: string;
|
|
28
|
+
/** Target directory in ndocs content structure */
|
|
29
|
+
targetDir: string;
|
|
30
|
+
}
|
|
31
|
+
/** Complete migration plan. */
|
|
32
|
+
export interface MigrationPlan {
|
|
33
|
+
/** Source platform. */
|
|
34
|
+
platform: SupportedPlatform;
|
|
35
|
+
/** Files to migrate. */
|
|
36
|
+
files: PlannedFile[];
|
|
37
|
+
/** Component conversions that will be applied. */
|
|
38
|
+
componentMappings: ComponentMapping[];
|
|
39
|
+
/** Navigation structure mapping. */
|
|
40
|
+
navigationMappings: NavigationMapping[];
|
|
41
|
+
/** Target content directory (absolute path). */
|
|
42
|
+
targetContentDir: string;
|
|
43
|
+
/** Number of files that need AI conversion. */
|
|
44
|
+
aiConversionCount: number;
|
|
45
|
+
/** Number of files that are simple copies (images, assets). */
|
|
46
|
+
simpleCopyCount: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build a complete migration plan from a detected platform.
|
|
50
|
+
*
|
|
51
|
+
* @param detected - The detected platform information
|
|
52
|
+
* @param targetContentDir - Absolute path to the target content directory (default: src/content/docs)
|
|
53
|
+
* @returns Complete migration plan
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildMigrationPlan(detected: DetectedPlatform, targetContentDir: string): MigrationPlan;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration planner — builds a plan for migrating content from a source platform
|
|
3
|
+
* to the specglass/ndocs format without executing the actual conversion.
|
|
4
|
+
*
|
|
5
|
+
* Used by both dry-run mode and the actual migration to preview planned changes.
|
|
6
|
+
*/
|
|
7
|
+
import { dirname, extname, join, relative } from "path";
|
|
8
|
+
// ─── Component mappings per platform ────────────────────────────────
|
|
9
|
+
const PLATFORM_COMPONENT_MAPPINGS = {
|
|
10
|
+
mintlify: [
|
|
11
|
+
{ source: "<Tip>", target: '<Callout type="tip">' },
|
|
12
|
+
{ source: "<Warning>", target: '<Callout type="warning">' },
|
|
13
|
+
{ source: "<Note>", target: '<Callout>' },
|
|
14
|
+
{ source: "<Info>", target: '<Callout>' },
|
|
15
|
+
{ source: "<Check>", target: '<Callout type="tip">' },
|
|
16
|
+
{ source: "<AccordionGroup>", target: "<Tabs>" },
|
|
17
|
+
{ source: "<Accordion>", target: "<TabItem>" },
|
|
18
|
+
{ source: "<CodeGroup>", target: "<Tabs>" },
|
|
19
|
+
{ source: "<Card>", target: "<Card>" },
|
|
20
|
+
{ source: "<CardGroup>", target: "div (grid container)" },
|
|
21
|
+
],
|
|
22
|
+
docusaurus: [
|
|
23
|
+
{ source: ":::note", target: "<Callout>" },
|
|
24
|
+
{ source: ":::tip", target: '<Callout type="tip">' },
|
|
25
|
+
{ source: ":::info", target: "<Callout>" },
|
|
26
|
+
{ source: ":::caution", target: '<Callout type="warning">' },
|
|
27
|
+
{ source: ":::danger", target: '<Callout type="danger">' },
|
|
28
|
+
{ source: ":::warning", target: '<Callout type="warning">' },
|
|
29
|
+
{ source: "<Tabs>", target: "<Tabs>" },
|
|
30
|
+
{ source: "<TabItem>", target: "<TabItem>" },
|
|
31
|
+
{
|
|
32
|
+
source: "import CodeBlock",
|
|
33
|
+
target: "<CodeBlock> (auto-injected, no import needed)",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
readme: [
|
|
37
|
+
{ source: "> 📘", target: "<Callout>" },
|
|
38
|
+
{ source: "> ⚠️", target: '<Callout type="warning">' },
|
|
39
|
+
{ source: "> 🚧", target: '<Callout type="danger">' },
|
|
40
|
+
{ source: "> 👍", target: '<Callout type="tip">' },
|
|
41
|
+
{ source: "[block:code]", target: "<CodeBlock>" },
|
|
42
|
+
{ source: "[block:callout]", target: "<Callout>" },
|
|
43
|
+
{ source: "[block:parameters]", target: "markdown table" },
|
|
44
|
+
],
|
|
45
|
+
gitbook: [
|
|
46
|
+
{ source: "{% hint style=\"info\" %}", target: "<Callout>" },
|
|
47
|
+
{ source: "{% hint style=\"warning\" %}", target: "<Callout type=\"warning\">" },
|
|
48
|
+
{ source: "{% hint style=\"danger\" %}", target: "<Callout type=\"danger\">" },
|
|
49
|
+
{ source: "{% hint style=\"success\" %}", target: "<Callout type=\"tip\">" },
|
|
50
|
+
{ source: "{% tabs %}", target: "<Tabs>" },
|
|
51
|
+
{ source: "{% tab title=... %}", target: "<TabItem>" },
|
|
52
|
+
{ source: "{% code ... %}", target: "<CodeBlock>" },
|
|
53
|
+
{ source: "{% embed ... %}", target: "iframe or link" },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
// ─── Planning logic ─────────────────────────────────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* Determine the target relative path for a source file.
|
|
59
|
+
*
|
|
60
|
+
* Maps the source file path relative to the source content directory
|
|
61
|
+
* into the ndocs content structure, normalizing extensions to .mdx.
|
|
62
|
+
*/
|
|
63
|
+
function mapTargetPath(sourcePath, sourceDir) {
|
|
64
|
+
const rel = relative(sourceDir, sourcePath);
|
|
65
|
+
const ext = extname(rel);
|
|
66
|
+
// Convert .md to .mdx, keep .mdx as-is
|
|
67
|
+
if (ext === ".md") {
|
|
68
|
+
return rel.slice(0, -3) + ".mdx";
|
|
69
|
+
}
|
|
70
|
+
return rel;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Generate navigation mappings from directory structure.
|
|
74
|
+
*
|
|
75
|
+
* Each directory in the source content corresponds to a potential
|
|
76
|
+
* _meta.json in the ndocs target — we map these as navigation entries.
|
|
77
|
+
*/
|
|
78
|
+
function buildNavigationMappings(detected) {
|
|
79
|
+
const dirs = new Set();
|
|
80
|
+
for (const file of detected.contentFiles) {
|
|
81
|
+
const rel = relative(detected.sourceDir, file);
|
|
82
|
+
const dir = dirname(rel);
|
|
83
|
+
if (dir !== ".") {
|
|
84
|
+
dirs.add(dir);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Root always gets a mapping
|
|
88
|
+
const mappings = [
|
|
89
|
+
{ sourceLabel: "(root)", targetDir: "src/content/docs" },
|
|
90
|
+
];
|
|
91
|
+
for (const dir of Array.from(dirs).sort()) {
|
|
92
|
+
mappings.push({
|
|
93
|
+
sourceLabel: dir,
|
|
94
|
+
targetDir: join("src/content/docs", dir),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return mappings;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build a complete migration plan from a detected platform.
|
|
101
|
+
*
|
|
102
|
+
* @param detected - The detected platform information
|
|
103
|
+
* @param targetContentDir - Absolute path to the target content directory (default: src/content/docs)
|
|
104
|
+
* @returns Complete migration plan
|
|
105
|
+
*/
|
|
106
|
+
export function buildMigrationPlan(detected, targetContentDir) {
|
|
107
|
+
const markdownExts = [".md", ".mdx"];
|
|
108
|
+
const files = detected.contentFiles.map((sourcePath) => {
|
|
109
|
+
const ext = extname(sourcePath);
|
|
110
|
+
const needsConversion = markdownExts.includes(ext);
|
|
111
|
+
const targetRelativePath = mapTargetPath(sourcePath, detected.sourceDir);
|
|
112
|
+
return {
|
|
113
|
+
sourcePath,
|
|
114
|
+
targetRelativePath,
|
|
115
|
+
needsConversion,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
const aiConversionCount = files.filter((f) => f.needsConversion).length;
|
|
119
|
+
const simpleCopyCount = files.filter((f) => !f.needsConversion).length;
|
|
120
|
+
return {
|
|
121
|
+
platform: detected.platform,
|
|
122
|
+
files,
|
|
123
|
+
componentMappings: PLATFORM_COMPONENT_MAPPINGS[detected.platform],
|
|
124
|
+
navigationMappings: buildNavigationMappings(detected),
|
|
125
|
+
targetContentDir,
|
|
126
|
+
aiConversionCount,
|
|
127
|
+
simpleCopyCount,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration writer — handles writing converted content, copying assets,
|
|
3
|
+
* and generating navigation config to the target ndocs content directory.
|
|
4
|
+
*/
|
|
5
|
+
import type { NavigationMapping } from "./planner.js";
|
|
6
|
+
import type { ConversionResult } from "./converter.js";
|
|
7
|
+
import type { PlannedFile } from "./planner.js";
|
|
8
|
+
/** Result of writing files to the target directory. */
|
|
9
|
+
export interface WriteResult {
|
|
10
|
+
/** Total files written successfully. */
|
|
11
|
+
filesWritten: number;
|
|
12
|
+
/** Total files that failed to write. */
|
|
13
|
+
filesFailed: number;
|
|
14
|
+
/** Total files copied (non-conversion assets). */
|
|
15
|
+
filesCopied: number;
|
|
16
|
+
/** Paths of successfully written files. */
|
|
17
|
+
writtenPaths: string[];
|
|
18
|
+
/** Errors from failed writes. */
|
|
19
|
+
errors: Array<{
|
|
20
|
+
path: string;
|
|
21
|
+
error: string;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Write a single converted file to the target content directory.
|
|
26
|
+
* Creates parent directories as needed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function writeConvertedFile(targetContentDir: string, relativePath: string, content: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Copy a non-conversion file (e.g. image, asset) to the target content directory.
|
|
31
|
+
* Creates parent directories as needed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function copyAssetFile(targetContentDir: string, file: PlannedFile): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Generate and write `_meta.json` files based on navigation mappings.
|
|
36
|
+
* Creates one `_meta.json` per directory level that has navigation entries.
|
|
37
|
+
*/
|
|
38
|
+
export declare function writeMetaJson(targetContentDir: string, navigationMappings: NavigationMapping[]): Promise<string[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Write all successfully converted files, copy assets, and generate navigation config.
|
|
41
|
+
*/
|
|
42
|
+
export declare function writeAll(targetContentDir: string, results: ConversionResult[], navigationMappings: NavigationMapping[], allFiles?: PlannedFile[]): Promise<WriteResult>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration writer — handles writing converted content, copying assets,
|
|
3
|
+
* and generating navigation config to the target ndocs content directory.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, writeFile, copyFile } from "fs/promises";
|
|
6
|
+
import { dirname, join, relative } from "path";
|
|
7
|
+
/**
|
|
8
|
+
* Write a single converted file to the target content directory.
|
|
9
|
+
* Creates parent directories as needed.
|
|
10
|
+
*/
|
|
11
|
+
export async function writeConvertedFile(targetContentDir, relativePath, content) {
|
|
12
|
+
const fullPath = join(targetContentDir, relativePath);
|
|
13
|
+
const dir = dirname(fullPath);
|
|
14
|
+
// Create parent directories recursively
|
|
15
|
+
await mkdir(dir, { recursive: true });
|
|
16
|
+
// Write the converted content
|
|
17
|
+
await writeFile(fullPath, content, "utf-8");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Copy a non-conversion file (e.g. image, asset) to the target content directory.
|
|
21
|
+
* Creates parent directories as needed.
|
|
22
|
+
*/
|
|
23
|
+
export async function copyAssetFile(targetContentDir, file) {
|
|
24
|
+
const fullPath = join(targetContentDir, file.targetRelativePath);
|
|
25
|
+
const dir = dirname(fullPath);
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
await copyFile(file.sourcePath, fullPath);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Extract the relative path within the content directory from a NavigationMapping targetDir.
|
|
31
|
+
* Handles both absolute and src/content/docs/-relative paths dynamically.
|
|
32
|
+
*/
|
|
33
|
+
function resolveNavRelativePath(targetContentDir, targetDir) {
|
|
34
|
+
// If targetDir is absolute, compute relative to targetContentDir
|
|
35
|
+
if (targetDir.startsWith("/")) {
|
|
36
|
+
return relative(targetContentDir, targetDir);
|
|
37
|
+
}
|
|
38
|
+
// If it starts with the known prefix pattern, strip it
|
|
39
|
+
// Compute the expected prefix from targetContentDir (last 3 segments typically)
|
|
40
|
+
const contentDirSuffix = targetContentDir
|
|
41
|
+
.replace(/\/$/, "")
|
|
42
|
+
.split("/")
|
|
43
|
+
.slice(-3)
|
|
44
|
+
.join("/");
|
|
45
|
+
const regex = new RegExp(`^${contentDirSuffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/?`);
|
|
46
|
+
if (regex.test(targetDir)) {
|
|
47
|
+
return targetDir.replace(regex, "");
|
|
48
|
+
}
|
|
49
|
+
// Fallback: strip common src/content/docs prefix
|
|
50
|
+
return targetDir.replace(/^src\/content\/docs\/?/, "");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Generate and write `_meta.json` files based on navigation mappings.
|
|
54
|
+
* Creates one `_meta.json` per directory level that has navigation entries.
|
|
55
|
+
*/
|
|
56
|
+
export async function writeMetaJson(targetContentDir, navigationMappings) {
|
|
57
|
+
const writtenPaths = [];
|
|
58
|
+
// Group entries by their parent directory
|
|
59
|
+
const dirEntries = new Map();
|
|
60
|
+
for (const mapping of navigationMappings) {
|
|
61
|
+
const relativeDir = resolveNavRelativePath(targetContentDir, mapping.targetDir);
|
|
62
|
+
const cleanRel = relativeDir.replace(/\/$/, "");
|
|
63
|
+
if (!cleanRel)
|
|
64
|
+
continue;
|
|
65
|
+
// Determine parent directory and entry name
|
|
66
|
+
const segments = cleanRel.split("/");
|
|
67
|
+
const entryName = segments[segments.length - 1];
|
|
68
|
+
const parentDir = segments.length > 1 ? segments.slice(0, -1).join("/") : ".";
|
|
69
|
+
if (!dirEntries.has(parentDir)) {
|
|
70
|
+
dirEntries.set(parentDir, {});
|
|
71
|
+
}
|
|
72
|
+
dirEntries.get(parentDir)[entryName] = mapping.sourceLabel;
|
|
73
|
+
}
|
|
74
|
+
// Write a _meta.json for each parent directory that has entries
|
|
75
|
+
for (const [dirKey, entries] of dirEntries) {
|
|
76
|
+
if (Object.keys(entries).length === 0)
|
|
77
|
+
continue;
|
|
78
|
+
const metaDir = dirKey === "." ? targetContentDir : join(targetContentDir, dirKey);
|
|
79
|
+
const metaPath = join(metaDir, "_meta.json");
|
|
80
|
+
await mkdir(metaDir, { recursive: true });
|
|
81
|
+
await writeFile(metaPath, JSON.stringify(entries, null, 2) + "\n", "utf-8");
|
|
82
|
+
writtenPaths.push(metaPath);
|
|
83
|
+
}
|
|
84
|
+
return writtenPaths;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Write all successfully converted files, copy assets, and generate navigation config.
|
|
88
|
+
*/
|
|
89
|
+
export async function writeAll(targetContentDir, results, navigationMappings, allFiles) {
|
|
90
|
+
const writeResult = {
|
|
91
|
+
filesWritten: 0,
|
|
92
|
+
filesFailed: 0,
|
|
93
|
+
filesCopied: 0,
|
|
94
|
+
writtenPaths: [],
|
|
95
|
+
errors: [],
|
|
96
|
+
};
|
|
97
|
+
// Write each successfully converted file
|
|
98
|
+
for (const result of results) {
|
|
99
|
+
if (!result.success) {
|
|
100
|
+
writeResult.filesFailed++;
|
|
101
|
+
writeResult.errors.push({
|
|
102
|
+
path: result.file.targetRelativePath,
|
|
103
|
+
error: result.error || "Conversion failed",
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await writeConvertedFile(targetContentDir, result.file.targetRelativePath, result.content);
|
|
109
|
+
writeResult.filesWritten++;
|
|
110
|
+
writeResult.writtenPaths.push(result.file.targetRelativePath);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
writeResult.filesFailed++;
|
|
114
|
+
writeResult.errors.push({
|
|
115
|
+
path: result.file.targetRelativePath,
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Copy non-conversion files (images, assets)
|
|
121
|
+
if (allFiles) {
|
|
122
|
+
const filesToCopy = allFiles.filter((f) => !f.needsConversion);
|
|
123
|
+
for (const file of filesToCopy) {
|
|
124
|
+
try {
|
|
125
|
+
await copyAssetFile(targetContentDir, file);
|
|
126
|
+
writeResult.filesCopied++;
|
|
127
|
+
writeResult.writtenPaths.push(file.targetRelativePath);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
writeResult.filesFailed++;
|
|
131
|
+
writeResult.errors.push({
|
|
132
|
+
path: file.targetRelativePath,
|
|
133
|
+
error: err instanceof Error ? err.message : String(err),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Generate _meta.json navigation files
|
|
139
|
+
try {
|
|
140
|
+
const metaPaths = await writeMetaJson(targetContentDir, navigationMappings);
|
|
141
|
+
writeResult.writtenPaths.push(...metaPaths.map((p) => relative(targetContentDir, p)));
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
writeResult.errors.push({
|
|
145
|
+
path: "_meta.json",
|
|
146
|
+
error: err instanceof Error ? err.message : String(err),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return writeResult;
|
|
150
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ValidationResult } from "@specglass/core";
|
|
2
|
+
interface CheckResultsDisplayProps {
|
|
3
|
+
results: ValidationResult[];
|
|
4
|
+
}
|
|
5
|
+
export declare function CheckResultsDisplay({ results }: CheckResultsDisplayProps): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
const CATEGORIES = [
|
|
4
|
+
{ label: "Links", icon: "🔗", prefix: "LINK_" },
|
|
5
|
+
{ label: "Frontmatter", icon: "📝", prefix: "FRONTMATTER_" },
|
|
6
|
+
{ label: "Spec Drift", icon: "📊", prefix: "SPEC_DRIFT_" },
|
|
7
|
+
];
|
|
8
|
+
/**
|
|
9
|
+
* Group validation results by category (code prefix).
|
|
10
|
+
* Results that don't match any known category are placed in "Other".
|
|
11
|
+
*/
|
|
12
|
+
function groupByCategory(results) {
|
|
13
|
+
const grouped = new Map();
|
|
14
|
+
for (const result of results) {
|
|
15
|
+
const category = CATEGORIES.find((c) => result.code.startsWith(c.prefix));
|
|
16
|
+
const key = category?.prefix ?? "OTHER_";
|
|
17
|
+
const def = category ?? { label: "Other", icon: "❓", prefix: "OTHER_" };
|
|
18
|
+
if (!grouped.has(key)) {
|
|
19
|
+
grouped.set(key, { def, items: [] });
|
|
20
|
+
}
|
|
21
|
+
grouped.get(key).items.push(result);
|
|
22
|
+
}
|
|
23
|
+
return grouped;
|
|
24
|
+
}
|
|
25
|
+
function ResultItem({ result }) {
|
|
26
|
+
const icon = result.severity === "error" ? "❌" : "⚠️";
|
|
27
|
+
const color = result.severity === "error" ? "red" : "yellow";
|
|
28
|
+
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsxs(Text, { color: color, children: [icon, " [", result.code, "]"] }), _jsxs(Text, { children: [" ", result.message] })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [result.filePath, result.line ? `:${result.line}` : ""] }) }), result.hint && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "cyan", children: ["Hint: ", result.hint] }) }))] }));
|
|
29
|
+
}
|
|
30
|
+
function CategorySection({ def, items }) {
|
|
31
|
+
const errorCount = items.filter((i) => i.severity === "error").length;
|
|
32
|
+
const warningCount = items.filter((i) => i.severity === "warning").length;
|
|
33
|
+
const parts = [];
|
|
34
|
+
if (errorCount > 0)
|
|
35
|
+
parts.push(`${errorCount} error${errorCount !== 1 ? "s" : ""}`);
|
|
36
|
+
if (warningCount > 0)
|
|
37
|
+
parts.push(`${warningCount} warning${warningCount !== 1 ? "s" : ""}`);
|
|
38
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [def.icon, " ", def.label] }), _jsxs(Text, { dimColor: true, children: [" (", parts.join(", "), ")"] })] }), items.map((item, i) => (_jsx(ResultItem, { result: item }, `${item.code}-${item.filePath}-${i}`)))] }));
|
|
39
|
+
}
|
|
40
|
+
export function CheckResultsDisplay({ results }) {
|
|
41
|
+
if (results.length === 0) {
|
|
42
|
+
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2705 All checks passed" }) }));
|
|
43
|
+
}
|
|
44
|
+
const grouped = groupByCategory(results);
|
|
45
|
+
const totalErrors = results.filter((r) => r.severity === "error").length;
|
|
46
|
+
const totalWarnings = results.filter((r) => r.severity === "warning").length;
|
|
47
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Specglass Check Results" }), Array.from(grouped.values()).map(({ def, items }) => (_jsx(CategorySection, { def: def, items: items }, def.prefix))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: totalErrors > 0 ? "red" : "yellow", children: totalErrors > 0
|
|
48
|
+
? `✗ ${totalErrors} error${totalErrors !== 1 ? "s" : ""}${totalWarnings > 0 ? `, ${totalWarnings} warning${totalWarnings !== 1 ? "s" : ""}` : ""}`
|
|
49
|
+
: `⚠ ${totalWarnings} warning${totalWarnings !== 1 ? "s" : ""} (no errors)` }) })] }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent prompt component for the migration command.
|
|
3
|
+
*
|
|
4
|
+
* Warns the user that content will be sent to an external AI API
|
|
5
|
+
* and requires explicit consent before proceeding.
|
|
6
|
+
*/
|
|
7
|
+
import type { MigrationPlan } from "../migration/planner.js";
|
|
8
|
+
export interface ConsentPromptProps {
|
|
9
|
+
/** The migration plan to display. */
|
|
10
|
+
plan: MigrationPlan;
|
|
11
|
+
/** Whether consent was already given via --yes flag. */
|
|
12
|
+
autoConsent: boolean;
|
|
13
|
+
/** Called when user makes a consent decision. */
|
|
14
|
+
onDecision: (consented: boolean) => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Displays the AI consent warning and waits for user input.
|
|
18
|
+
*
|
|
19
|
+
* Shows:
|
|
20
|
+
* - Warning about external API usage
|
|
21
|
+
* - Summary of what will be sent (file count)
|
|
22
|
+
* - Y/N prompt
|
|
23
|
+
*/
|
|
24
|
+
export declare function ConsentPrompt({ plan, autoConsent, onDecision, }: ConsentPromptProps): import("react/jsx-runtime").JSX.Element;
|