@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.
Files changed (91) hide show
  1. package/README.md +193 -0
  2. package/ai/application/ai-command.ts +24 -0
  3. package/ai/application/ai-provider-factory.ts +22 -0
  4. package/ai/application/docs-pattern.ts +72 -0
  5. package/ai/application/resolve-chat-credentials.ts +69 -0
  6. package/ai/application/run-ai-cli-command.ts +167 -0
  7. package/ai/config.ts +50 -0
  8. package/ai/core/chat-session.ts +44 -0
  9. package/ai/core/models/ai-cli-config.ts +33 -0
  10. package/ai/core/models/parse-ai-cli-config.ts +76 -0
  11. package/ai/core/ports/illm-provider.ts +32 -0
  12. package/ai/infrastructure/ai-config-file.ts +101 -0
  13. package/ai/infrastructure/file-system-adapter.ts +85 -0
  14. package/ai/infrastructure/llm/tools-llm-provider.ts +54 -0
  15. package/ai/infrastructure/version-docs-writer.ts +143 -0
  16. package/ai/presentation/ai-prompts.ts +239 -0
  17. package/application/config-only/handler.mjs +57 -0
  18. package/application/home/handler.mjs +87 -0
  19. package/application/ports/cli-runtime-ports.ts +93 -0
  20. package/application/report/config-only-reporter.mjs +68 -0
  21. package/application/use-cases/dispatch-mode.ts +18 -0
  22. package/builders/config-orchestrator.mjs +35 -0
  23. package/builders/language-bundles-builder.mjs +24 -0
  24. package/builders/project-links.mjs +50 -0
  25. package/builders/root-config-builder.d.mts +16 -0
  26. package/builders/root-config-builder.mjs +56 -0
  27. package/builders/route-builders.mjs +136 -0
  28. package/builders/theme-template.mjs +76 -0
  29. package/builders/version-config-builder.mjs +186 -0
  30. package/content/docs-en.mjs +556 -0
  31. package/content/docs-es.mjs +469 -0
  32. package/content/docs-pt.mjs +469 -0
  33. package/content/docs.mjs +9 -0
  34. package/contracts/doc-versions.mjs +3 -0
  35. package/contracts/index.mjs +11 -0
  36. package/contracts/langs-paths.mjs +28 -0
  37. package/contracts/languages.mjs +6 -0
  38. package/contracts/layouts-paths.d.mts +17 -0
  39. package/contracts/layouts-paths.mjs +68 -0
  40. package/contracts/path-segments.d.mts +2 -0
  41. package/contracts/path-segments.mjs +15 -0
  42. package/data/default-site-config.mjs +328 -0
  43. package/data/i18n-langmenu.mjs +350 -0
  44. package/data/i18n-translations.mjs +20 -0
  45. package/data/layouts.mjs +437 -0
  46. package/data/path-mappings.mjs +26 -0
  47. package/data/route-metas.mjs +93 -0
  48. package/data/theme-colors.mjs +444 -0
  49. package/data/urls.mjs +12 -0
  50. package/data/version-constants.mjs +3 -0
  51. package/domain/models/cli-options.ts +33 -0
  52. package/domain/services/layouts-migration.d.mts +15 -0
  53. package/domain/services/layouts-migration.mjs +74 -0
  54. package/domain/services/sanitize-segment.mjs +7 -0
  55. package/home/constants.mjs +4 -0
  56. package/home/home-file-writer.mjs +19 -0
  57. package/home/templates.mjs +51 -0
  58. package/index.mjs +38 -0
  59. package/infrastructure/config-only/runtime.mjs +17 -0
  60. package/infrastructure/gitpagedocs-config-file.ts +47 -0
  61. package/infrastructure/home/runtime.mjs +37 -0
  62. package/package.json +60 -0
  63. package/presentation/commands/ai-info.ts +57 -0
  64. package/presentation/commands/chat.ts +259 -0
  65. package/presentation/commands/config-clear.ts +23 -0
  66. package/presentation/commands/config-info.ts +72 -0
  67. package/presentation/commands/diagnostics.ts +174 -0
  68. package/presentation/commands/docs.ts +42 -0
  69. package/presentation/commands/mcp.ts +20 -0
  70. package/presentation/commands/pages.ts +103 -0
  71. package/presentation/commands/password.ts +62 -0
  72. package/presentation/commands/run-command.ts +72 -0
  73. package/presentation/index.ts +125 -0
  74. package/presentation/options/parser.ts +120 -0
  75. package/presentation/options/resolver.ts +46 -0
  76. package/presentation/options/schema.ts +14 -0
  77. package/presentation/ui/banner.ts +13 -0
  78. package/presentation/ui/clack.ts +91 -0
  79. package/presentation/ui/layouts-prompts.ts +124 -0
  80. package/presentation/ui/prompts.ts +147 -0
  81. package/presentation/ui/terminal-stream.ts +97 -0
  82. package/presentation/ui/tty.ts +37 -0
  83. package/runtime/doc-path-resolver.mjs +59 -0
  84. package/runtime/exec.mjs +168 -0
  85. package/runtime/git-ops.mjs +112 -0
  86. package/runtime/layouts-migrator.d.mts +11 -0
  87. package/runtime/layouts-migrator.mjs +117 -0
  88. package/runtime/output.mjs +201 -0
  89. package/runtime/workflow.mjs +131 -0
  90. package/start.mjs +14 -0
  91. package/ui/logger.mjs +9 -0
@@ -0,0 +1,59 @@
1
+ /** Map doc paths to content keys and apply version badges */
2
+
3
+ export function parseDocFileToKey(fileName) {
4
+ if (fileName === "index.md") return "index";
5
+ if (fileName === "getting-started.md") return "gettingStarted";
6
+ if (fileName === "configuration.md") return "configuration";
7
+ if (fileName === "deployment.md") return "deployment";
8
+ if (fileName === "architecture.md") return "architecture";
9
+ if (fileName === "themes.md") return "themes";
10
+ if (fileName === "faq.md") return "faq";
11
+ if (fileName === "project-overview.md") return "projectOverview";
12
+ if (fileName === "functionalities.md") return "functionalities";
13
+ if (fileName === "github-issues-projects.md") return "githubIssuesProjects";
14
+ if (fileName === "git-introduction.md") return "gitIntroduction";
15
+ if (fileName === "authorized-routes.md") return "authorizedRoutes";
16
+ return undefined;
17
+ }
18
+
19
+ export function parseHtmlFileToKey(fileName) {
20
+ const base = fileName.replace(/\.html$/, "");
21
+ if (base === "source-viewer") return "sourceViewer";
22
+ return undefined;
23
+ }
24
+
25
+ export function extractLanguageFromPath(docPath) {
26
+ const match = docPath.match(/\/(pt|en|es)\//);
27
+ return match?.[1];
28
+ }
29
+
30
+ export function withVersionBadge(content, versionId, language) {
31
+ const normalized = typeof content === "string" ? content : "";
32
+ if (!normalized.trim()) {
33
+ return normalized;
34
+ }
35
+
36
+ const alreadyTagged =
37
+ normalized.includes(`Version: ${versionId}`) ||
38
+ normalized.includes(`Versao: ${versionId}`) ||
39
+ normalized.includes(`Version (ES): ${versionId}`);
40
+ if (alreadyTagged) {
41
+ return normalized;
42
+ }
43
+
44
+ const label = `> ${versionLabelPrefix(language)}: ${versionId}`;
45
+
46
+ return `${normalized.trimEnd()}\n\n${label}\n`;
47
+ }
48
+
49
+ /** Badge wording per language; every other language reads the English form. */
50
+ const VERSION_LABEL_PREFIXES = { pt: "Versao", es: "Version (ES)" };
51
+
52
+ function versionLabelPrefix(language) {
53
+ return VERSION_LABEL_PREFIXES[language] ?? "Version";
54
+ }
55
+
56
+ export function normalizeToOutputPath(outputDir, configPath) {
57
+ const normalized = configPath.replace(/^gitpagedocs\//, "");
58
+ return `${outputDir}/${normalized}`;
59
+ }
@@ -0,0 +1,168 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+
5
+ /**
6
+ * Child-process helpers that never rely on the OS resolving a bare command
7
+ * name through PATH at spawn time and never build a shell command string.
8
+ *
9
+ * Every entry point takes the program name (e.g. "git"), resolves it to an
10
+ * absolute path up front by walking PATH ourselves, and hands that absolute
11
+ * path plus an argument ARRAY to `execFileSync`/`spawn`. Owner, repo, branch
12
+ * and URL values therefore reach the child as discrete argv entries instead of
13
+ * being interpolated into a shell string.
14
+ */
15
+
16
+ const IS_WINDOWS = process.platform === "win32";
17
+
18
+ /** Windows extensions that CreateProcess/cmd.exe can actually launch. */
19
+ const WINDOWS_EXECUTABLE_EXTENSIONS = [".COM", ".EXE", ".BAT", ".CMD"];
20
+
21
+ /** Batch launchers are not executables: Windows runs them through cmd.exe. */
22
+ const CMD_SHELL_EXTENSIONS = new Set([".cmd", ".bat"]);
23
+
24
+ function readEnvVar(env, name) {
25
+ if (env[name] !== undefined) return env[name];
26
+ if (!IS_WINDOWS) return undefined;
27
+ // A plain object spread from `process.env` on Windows keeps the original
28
+ // casing ("Path"), so the case-insensitive lookup `process.env` offers is
29
+ // lost. Restore it for the two variables PATH resolution depends on.
30
+ const wanted = name.toUpperCase();
31
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === wanted);
32
+ return key === undefined ? undefined : env[key];
33
+ }
34
+
35
+ function pathDirectories(env) {
36
+ return (readEnvVar(env, "PATH") ?? "").split(path.delimiter).filter(Boolean);
37
+ }
38
+
39
+ function windowsExtensions(env) {
40
+ const configured = (readEnvVar(env, "PATHEXT") ?? "")
41
+ .split(";")
42
+ .map((ext) => ext.trim().toUpperCase())
43
+ .filter((ext) => WINDOWS_EXECUTABLE_EXTENSIONS.includes(ext));
44
+ return configured.length > 0 ? configured : WINDOWS_EXECUTABLE_EXTENSIONS;
45
+ }
46
+
47
+ function isExecutableFile(candidate) {
48
+ try {
49
+ if (!statSync(candidate).isFile()) return false;
50
+ if (!IS_WINDOWS) accessSync(candidate, constants.X_OK);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function candidateFileNames(name, env) {
58
+ if (!IS_WINDOWS) return [name];
59
+ const upper = name.toUpperCase();
60
+ const extensions = windowsExtensions(env);
61
+ if (extensions.some((ext) => upper.endsWith(ext))) return [name];
62
+ return extensions.map((ext) => `${name}${ext}`);
63
+ }
64
+
65
+ function usesCmdShell(file) {
66
+ return IS_WINDOWS && CMD_SHELL_EXTENSIONS.has(path.extname(file).toLowerCase());
67
+ }
68
+
69
+ function quoteForCmd(value) {
70
+ const text = String(value);
71
+ if (/["\r\n]/.test(text)) {
72
+ throw new Error(`Cannot pass ${JSON.stringify(text)} through a .cmd/.bat launcher: quotes and line breaks are not supported.`);
73
+ }
74
+ return `"${text}"`;
75
+ }
76
+
77
+ /**
78
+ * Resolve `name` to the absolute path of the executable that PATH points at.
79
+ *
80
+ * Directories are scanned in PATH order. On Windows the PATHEXT executable
81
+ * extensions are tried in their configured order (.COM, .EXE, .BAT, .CMD by
82
+ * default), so `git` resolves to `git.exe` before any `git.cmd` shim. A name
83
+ * that already contains a path separator is only checked, never searched.
84
+ *
85
+ * @param {string} name Program name such as "git", "gh" or "npx".
86
+ * @param {{ env?: NodeJS.ProcessEnv }} [options] Environment whose PATH/PATHEXT drive the lookup (defaults to `process.env`).
87
+ * @returns {string} Absolute path of the executable.
88
+ * @throws {Error} When nothing on PATH matches, with an actionable message.
89
+ */
90
+ export function resolveExecutable(name, options = {}) {
91
+ if (typeof name !== "string" || name.trim() === "") {
92
+ throw new TypeError("resolveExecutable(name) requires a non-empty program name.");
93
+ }
94
+ const env = options.env ?? process.env;
95
+
96
+ if (name.includes("/") || name.includes("\\")) {
97
+ const direct = path.resolve(name);
98
+ if (isExecutableFile(direct)) return direct;
99
+ throw new Error(`\`${name}\` is not an executable file (looked at ${direct}).`);
100
+ }
101
+
102
+ const fileNames = candidateFileNames(name, env);
103
+ for (const directory of pathDirectories(env)) {
104
+ for (const fileName of fileNames) {
105
+ const candidate = path.join(directory, fileName);
106
+ if (isExecutableFile(candidate)) return path.resolve(candidate);
107
+ }
108
+ }
109
+
110
+ throw new Error(
111
+ `\`${name}\` was not found on PATH. Install it and make sure the directory that contains it is listed in the PATH environment variable, then retry.`,
112
+ );
113
+ }
114
+
115
+ function launchOptions(file, args, options) {
116
+ if (usesCmdShell(file)) {
117
+ // Node refuses to spawn .cmd/.bat files without a shell (EINVAL, see
118
+ // CVE-2024-27980) because only cmd.exe can interpret them. The launcher path
119
+ // is already absolute and every argument is individually quoted, so cmd.exe
120
+ // receives a fixed command line rather than one assembled from user input.
121
+ return { file: quoteForCmd(file), args: args.map(quoteForCmd), options: { ...options, shell: true } };
122
+ }
123
+ return { file, args, options: { ...options, shell: false } };
124
+ }
125
+
126
+ /**
127
+ * Run `name` synchronously with an argument array and wait for it to finish.
128
+ * Same contract as `execFileSync` (throws on a non-zero exit, returns stdout).
129
+ *
130
+ * @param {string} name Program name such as "git".
131
+ * @param {readonly string[]} [args] Arguments passed verbatim, one argv entry each.
132
+ * @param {import("node:child_process").ExecFileSyncOptions} [options] Forwarded to `execFileSync` (cwd, env, stdio, ...).
133
+ * @returns {Buffer | string} stdout, as `execFileSync` returns it.
134
+ */
135
+ export function runExecutable(name, args = [], options = {}) {
136
+ const resolved = resolveExecutable(name, { env: options.env });
137
+ const launch = launchOptions(resolved, args, options);
138
+ return execFileSync(launch.file, launch.args, launch.options);
139
+ }
140
+
141
+ /**
142
+ * Run `name` synchronously and return its trimmed stdout as a string.
143
+ * Uses `stdio: "pipe"` unless the caller overrides it.
144
+ *
145
+ * @param {string} name Program name such as "git".
146
+ * @param {readonly string[]} [args] Arguments passed verbatim, one argv entry each.
147
+ * @param {import("node:child_process").ExecFileSyncOptions} [options] Forwarded to `execFileSync`.
148
+ * @returns {string} Trimmed stdout.
149
+ */
150
+ export function runExecutableCapture(name, args = [], options = {}) {
151
+ const output = runExecutable(name, args, { stdio: "pipe", ...options });
152
+ // execFileSync yields null when the caller routes stdout away from "pipe".
153
+ return output == null ? "" : output.toString().trim();
154
+ }
155
+
156
+ /**
157
+ * Start `name` asynchronously (like `spawn`) with an argument array.
158
+ *
159
+ * @param {string} name Program name such as "npx".
160
+ * @param {readonly string[]} [args] Arguments passed verbatim, one argv entry each.
161
+ * @param {import("node:child_process").SpawnOptions} [options] Forwarded to `spawn` (cwd, env, stdio, ...).
162
+ * @returns {import("node:child_process").ChildProcess} The spawned child.
163
+ */
164
+ export function spawnExecutable(name, args = [], options = {}) {
165
+ const resolved = resolveExecutable(name, { env: options.env });
166
+ const launch = launchOptions(resolved, args, options);
167
+ return spawn(launch.file, launch.args, launch.options);
168
+ }
@@ -0,0 +1,112 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { runExecutable, runExecutableCapture } from "./exec.mjs";
4
+
5
+ /** Detect { owner, repo } from the git `origin` remote URL, or null. */
6
+ export function detectRepoFromGit(root) {
7
+ try {
8
+ const url = runExecutableCapture("git", ["remote", "get-url", "origin"], { cwd: root });
9
+ const match = url.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/);
10
+ if (match) return { owner: match[1], repo: match[2] };
11
+ } catch {
12
+ // no remote / not a repo / git not installed
13
+ }
14
+ return null;
15
+ }
16
+
17
+ export function tryConfigurePagesToGitHubActions(owner, repo, branch, root) {
18
+ try {
19
+ runExecutable("gh", ["--version"], { cwd: root, stdio: "ignore" });
20
+ } catch {
21
+ console.warn(
22
+ "GitHub CLI (gh) not found. Could not auto-configure Pages source. Set GitHub Pages source to 'GitHub Actions' manually in repository settings.",
23
+ );
24
+ return;
25
+ }
26
+
27
+ // Each candidate is an argv array: owner/repo/branch travel as discrete
28
+ // arguments, never through a shell string.
29
+ const pagesEndpoint = `repos/${owner}/${repo}/pages`;
30
+ const candidates = [
31
+ ["api", "-X", "PUT", pagesEndpoint, "-f", "build_type=workflow"],
32
+ ["api", "-X", "POST", pagesEndpoint, "-f", "build_type=workflow"],
33
+ ["api", "-X", "POST", pagesEndpoint, "-f", `source[branch]=${branch}`, "-f", "source[path]=/"],
34
+ ["api", "-X", "PUT", pagesEndpoint, "-f", `source[branch]=${branch}`, "-f", "source[path]=/", "-f", "build_type=workflow"],
35
+ ];
36
+
37
+ for (const args of candidates) {
38
+ try {
39
+ runExecutable("gh", args, { cwd: root, stdio: "ignore" });
40
+ console.log("GitHub Pages source configured for GitHub Actions.");
41
+ return;
42
+ } catch {
43
+ // Try next candidate endpoint.
44
+ }
45
+ }
46
+
47
+ console.warn(
48
+ "Could not auto-configure Pages source through GitHub API. Set repository Pages source to 'GitHub Actions' manually.",
49
+ );
50
+ }
51
+
52
+ export function getCurrentGitBranch(root) {
53
+ try {
54
+ const branch = runExecutableCapture("git", ["branch", "--show-current"], { cwd: root });
55
+ return branch || "main";
56
+ } catch {
57
+ return "main";
58
+ }
59
+ }
60
+
61
+ export function runGitPushForGeneratedArtifacts(options, root, sanitizeSegment) {
62
+ const owner = sanitizeSegment(options.githubOwner);
63
+ const repo = sanitizeSegment(options.githubRepo);
64
+ if (!owner || !repo) {
65
+ throw new Error("`--push` requires owner and repo. Use `--owner <owner> --repo <repo>` or `--<owner> --<repo>`.");
66
+ }
67
+ if (!existsSync(path.join(root, ".git"))) {
68
+ throw new Error("Current directory is not a git repository. Initialize git before using --push.");
69
+ }
70
+
71
+ const repoUrl = `https://github.com/${owner}/${repo}.git`;
72
+ try {
73
+ runExecutable("git", ["remote", "get-url", "origin"], { cwd: root, stdio: "ignore" });
74
+ } catch {
75
+ runExecutable("git", ["remote", "add", "origin", repoUrl], { cwd: root, stdio: "inherit" });
76
+ }
77
+
78
+ runExecutable("git", ["add", "gitpagedocs", ".github/workflows/gitpagedocs-pages.yml"], { cwd: root, stdio: "inherit" });
79
+
80
+ let hasStagedChanges = false;
81
+ try {
82
+ runExecutable("git", ["diff", "--cached", "--quiet"], { cwd: root, stdio: "ignore" });
83
+ } catch {
84
+ hasStagedChanges = true;
85
+ }
86
+
87
+ if (!hasStagedChanges) {
88
+ return;
89
+ }
90
+
91
+ runExecutable("git", ["commit", "-m", "chore: setup gitpagedocs pages workflow"], { cwd: root, stdio: "inherit" });
92
+
93
+ const currentBranch = getCurrentGitBranch(root);
94
+ try {
95
+ runExecutable("git", ["push", "-u", "origin", currentBranch], { cwd: root, stdio: "inherit" });
96
+ tryConfigurePagesToGitHubActions(owner, repo, currentBranch, root);
97
+ return;
98
+ } catch {
99
+ console.warn("Initial push failed. Trying automatic rebase with remote branch...");
100
+ }
101
+
102
+ try {
103
+ runExecutable("git", ["pull", "--rebase", "origin", currentBranch], { cwd: root, stdio: "inherit" });
104
+ runExecutable("git", ["push", "-u", "origin", currentBranch], { cwd: root, stdio: "inherit" });
105
+ } catch {
106
+ throw new Error(
107
+ `Failed to push after automatic rebase on branch '${currentBranch}'. Resolve conflicts and run 'git push -u origin ${currentBranch}' manually.`,
108
+ );
109
+ }
110
+
111
+ tryConfigurePagesToGitHubActions(owner, repo, currentBranch, root);
112
+ }
@@ -0,0 +1,11 @@
1
+ export interface LayoutsMigrationResult {
2
+ moved: string[];
3
+ failed: Array<{ file: string; reason: string }>;
4
+ }
5
+
6
+ export declare function listFilesRecursively(absoluteDir: string): string[];
7
+
8
+ export declare function executeLayoutsMigration(
9
+ root: string,
10
+ plan: { from: string; to: string; files: string[] },
11
+ ): LayoutsMigrationResult;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Filesystem side of the legacy layouts migration.
3
+ *
4
+ * The decision of *what* moves lives in the domain planner; this module only
5
+ * reads directories and performs the move, so both halves stay testable on
6
+ * their own.
7
+ */
8
+ import {
9
+ existsSync,
10
+ readdirSync,
11
+ statSync,
12
+ mkdirSync,
13
+ renameSync,
14
+ copyFileSync,
15
+ rmSync,
16
+ } from "node:fs";
17
+ import path from "node:path";
18
+
19
+ /**
20
+ * List every file under a directory, as POSIX paths relative to it.
21
+ *
22
+ * @param {string} absoluteDir
23
+ * @returns {string[]} Empty when the directory is absent or unreadable.
24
+ */
25
+ export function listFilesRecursively(absoluteDir) {
26
+ if (!absoluteDir || !existsSync(absoluteDir)) return [];
27
+
28
+ const found = [];
29
+ const walk = (currentDir, prefix) => {
30
+ let entries;
31
+ try {
32
+ entries = readdirSync(currentDir);
33
+ } catch {
34
+ return;
35
+ }
36
+ for (const entry of entries) {
37
+ const absolute = path.join(currentDir, entry);
38
+ const relative = prefix ? `${prefix}/${entry}` : entry;
39
+ let isDirectory = false;
40
+ try {
41
+ isDirectory = statSync(absolute).isDirectory();
42
+ } catch {
43
+ continue;
44
+ }
45
+ if (isDirectory) walk(absolute, relative);
46
+ else found.push(relative);
47
+ }
48
+ };
49
+
50
+ walk(absoluteDir, "");
51
+ return found.sort(compareCodeUnits);
52
+ }
53
+
54
+ /**
55
+ * Plain UTF-16 code-unit order (what `sort()` does by default), spelled out so
56
+ * the listing never depends on the runtime locale.
57
+ *
58
+ * @param {string} a
59
+ * @param {string} b
60
+ * @returns {number}
61
+ */
62
+ function compareCodeUnits(a, b) {
63
+ if (a < b) return -1;
64
+ if (a > b) return 1;
65
+ return 0;
66
+ }
67
+
68
+ /**
69
+ * Move one file, falling back to copy + delete when rename is not possible
70
+ * (different volumes, or a locked handle on Windows).
71
+ *
72
+ * @param {string} fromAbsolute
73
+ * @param {string} toAbsolute
74
+ */
75
+ function moveFile(fromAbsolute, toAbsolute) {
76
+ mkdirSync(path.dirname(toAbsolute), { recursive: true });
77
+ try {
78
+ renameSync(fromAbsolute, toAbsolute);
79
+ } catch {
80
+ copyFileSync(fromAbsolute, toAbsolute);
81
+ rmSync(fromAbsolute, { force: true });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Execute a migration plan produced by `planLayoutsMigration`.
87
+ *
88
+ * Files are moved one by one so a single failure is reported without aborting
89
+ * the rest; the legacy folder is removed only once it is genuinely empty.
90
+ *
91
+ * @param {string} root Project root the plan's paths are relative to.
92
+ * @param {{ from: string, to: string, files: string[] }} plan
93
+ * @returns {{ moved: string[], failed: Array<{ file: string, reason: string }> }}
94
+ */
95
+ export function executeLayoutsMigration(root, plan) {
96
+ const moved = [];
97
+ const failed = [];
98
+
99
+ const fromRoot = path.join(root, ...plan.from.split("/"));
100
+ const toRoot = path.join(root, ...plan.to.split("/"));
101
+
102
+ for (const file of plan.files) {
103
+ const segments = file.split("/");
104
+ try {
105
+ moveFile(path.join(fromRoot, ...segments), path.join(toRoot, ...segments));
106
+ moved.push(file);
107
+ } catch (error) {
108
+ failed.push({ file, reason: error instanceof Error ? error.message : String(error) });
109
+ }
110
+ }
111
+
112
+ if (failed.length === 0 && listFilesRecursively(fromRoot).length === 0) {
113
+ rmSync(fromRoot, { recursive: true, force: true });
114
+ }
115
+
116
+ return { moved, failed };
117
+ }
@@ -0,0 +1,201 @@
1
+ import { existsSync, readdirSync, rmSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import {
5
+ parseDocFileToKey,
6
+ parseHtmlFileToKey,
7
+ extractLanguageFromPath,
8
+ withVersionBadge,
9
+ normalizeToOutputPath,
10
+ } from "./doc-path-resolver.mjs";
11
+ import { layoutsArtifactPaths } from "../contracts/layouts-paths.mjs";
12
+ import { languageArtifactPaths } from "../contracts/langs-paths.mjs";
13
+ import { SUPPORTED_LANGUAGES } from "../contracts/languages.mjs";
14
+
15
+ const DEFAULT_ICON_SVG = `<?xml version="1.0" encoding="utf-8"?>
16
+ <svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
17
+ <path opacity="0.1" fill-rule="evenodd" clip-rule="evenodd" d="M12 3C4.5885 3 3 4.5885 3 12C3 19.4115 4.5885 21 12 21C19.4115 21 21 19.4115 21 12C21 4.5885 19.4115 3 12 3ZM14.2695 9.03471C15.1872 8.34452 15.7204 8.25293 15.9299 8.25017C16.1326 8.86788 16.1588 9.54654 16.0042 10.1835C15.9945 10.2234 15.9842 10.263 15.9731 10.3025C15.941 10.4168 15.9647 10.5393 16.0366 10.6333L16.0377 10.6347C16.0521 10.6537 16.0664 10.6728 16.0805 10.6922C16.5194 11.2949 16.7603 12.0687 16.7497 12.8714L16.7496 12.8747C16.7496 14.5738 16.3076 15.5644 15.6994 16.1583C15.0859 16.7572 14.2627 16.9938 13.4137 17.0968L13.3898 17.0997L13.3777 17.1014C12.443 17.2601 11.5456 17.2688 10.6078 17.1286L10.604 17.1281L10.5687 17.1233C9.72415 17.0085 8.90483 16.7616 8.29455 16.1566C7.68912 15.5564 7.25036 14.5643 7.25038 12.8747L7.25034 12.8714C7.23972 12.0687 7.48056 11.2949 7.91949 10.6922C7.93482 10.6711 7.9504 10.6503 7.96621 10.6297C8.03648 10.538 8.05962 10.4184 8.02812 10.3068L8.02599 10.2993C8.01523 10.2608 8.00513 10.2222 7.99569 10.1834C7.84087 9.54684 7.86561 8.86863 8.0659 8.25016C8.27679 8.2528 8.81228 8.34432 9.73045 9.03473C9.76619 9.0616 9.80249 9.08937 9.83937 9.11807C9.93139 9.19 10.0529 9.21205 10.1644 9.17703L10.1657 9.17661C10.2268 9.15768 10.2882 9.13972 10.3496 9.12272C11.4349 8.82255 12.5651 8.82255 13.6504 9.12272C13.7088 9.13888 13.7671 9.15592 13.8253 9.17382L13.8277 9.17457C13.9431 9.21015 14.0704 9.18836 14.1678 9.11252C14.2022 9.08578 14.2361 9.05985 14.2695 9.03471Z" fill="#323232"/>
18
+ <path d="M13.717 8.88177C12.5881 8.56954 11.4119 8.56954 10.283 8.88177C10.2637 8.8871 10.2444 8.89254 10.2252 8.89808C10.1067 8.93218 9.97923 8.909 9.8807 8.83491V8.83491C8.69311 7.94193 8.07278 7.98181 7.91927 8.01161V8.01161C7.8946 8.0164 7.87585 8.03481 7.86732 8.05844C7.86516 8.06441 7.86303 8.07038 7.8609 8.07636C7.61639 8.76505 7.57904 9.52813 7.75277 10.2425C7.76289 10.2841 7.77371 10.3255 7.78525 10.3667C7.78601 10.3694 7.78677 10.3721 7.78753 10.3748C7.79754 10.4102 7.7902 10.4483 7.7678 10.4776V10.4776C7.75074 10.4998 7.73394 10.5223 7.71741 10.545C7.24479 11.1939 6.98907 12.0213 7.00036 12.8747C7.00036 16.3399 8.80396 17.1358 10.535 17.3711L10.5708 17.3758C11.5347 17.5199 12.4587 17.511 13.4195 17.3479L13.4438 17.345C15.1832 17.1339 16.9996 16.3587 16.9996 12.8747C17.0109 12.0213 16.7552 11.1939 16.2826 10.545C16.2674 10.5242 16.252 10.5035 16.2364 10.483C16.236 10.4825 16.2356 10.482 16.2352 10.4815C16.211 10.4498 16.203 10.4085 16.2138 10.3701V10.3701C16.2257 10.3277 16.2368 10.2852 16.2471 10.2425C16.421 9.52611 16.3815 8.76076 16.1329 8.07176C16.1313 8.06753 16.1298 8.06331 16.1283 8.05909C16.1195 8.03503 16.1004 8.01628 16.0752 8.01142V8.01142C15.921 7.98164 15.3041 7.94381 14.1193 8.83491V8.83491C14.0208 8.90896 13.8933 8.93197 13.7748 8.89804C13.7556 8.89252 13.7363 8.8871 13.717 8.88177Z" stroke="#323232" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
19
+ <path d="M3 12C3 4.5885 4.5885 3 12 3C19.4115 3 21 4.5885 21 12C21 19.4115 19.4115 21 12 21C4.5885 21 3 19.4115 3 12Z" stroke="#323232" stroke-width="2"/>
20
+ </svg>
21
+ `;
22
+
23
+ function listExistingVersionIds(root, outputDir) {
24
+ const versionsRoot = path.join(root, outputDir, "docs", "versions");
25
+ if (!existsSync(versionsRoot)) return [];
26
+ return readdirSync(versionsRoot, { withFileTypes: true })
27
+ .filter((entry) => entry.isDirectory())
28
+ .map((entry) => entry.name);
29
+ }
30
+
31
+ function removeLegacySourceViewerFiles(root, outputDir) {
32
+ const versionIds = listExistingVersionIds(root, outputDir);
33
+ for (const versionId of versionIds) {
34
+ for (const language of SUPPORTED_LANGUAGES) {
35
+ for (const fileName of ["source-viewer", "source-viewer.html"]) {
36
+ const legacyPath = path.join(root, outputDir, "docs", "versions", versionId, language, fileName);
37
+ if (existsSync(legacyPath)) {
38
+ rmSync(legacyPath, { force: true });
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ async function writeJson(root, relativePath, data) {
46
+ const absolutePath = path.join(root, relativePath);
47
+ await mkdir(path.dirname(absolutePath), { recursive: true });
48
+ await writeFile(absolutePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
49
+ }
50
+
51
+ export async function writeText(root, relativePath, data) {
52
+ const absolutePath = path.join(root, relativePath);
53
+ await mkdir(path.dirname(absolutePath), { recursive: true });
54
+ await writeFile(absolutePath, data, "utf-8");
55
+ }
56
+
57
+ /**
58
+ * Write the local layout artifacts into the standalone layouts home.
59
+ *
60
+ * No layouts folder is ever deleted here. The legacy `<outputDir>/layouts/`
61
+ * folder can hold hand-edited templates and the viewer still reads it, so
62
+ * relocating it is an explicit, confirmed migration rather than a side effect of
63
+ * generating. Switching back to official layouts likewise leaves an existing
64
+ * layouts home in place: `layoutsConfigPathOficial` already decides which source
65
+ * the viewer uses, so the files are inert rather than harmful.
66
+ */
67
+ async function writeLayoutArtifacts(options) {
68
+ const { root, layoutsDir, useLocalLayoutConfig, layouts, artifacts, createThemeTemplate } = options;
69
+
70
+ if (!useLocalLayoutConfig) return;
71
+
72
+ const paths = layoutsArtifactPaths(layoutsDir);
73
+ await writeJson(root, paths.config, artifacts.layoutsConfig);
74
+ await writeJson(root, paths.fallbackConfig, artifacts.fallbackLayoutsConfig);
75
+ for (const layout of layouts) {
76
+ await writeJson(root, `${paths.root}/${layout.file}`, createThemeTemplate(layout));
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Write one UI-strings bundle per shipped language. Which ones are on is
82
+ * `site.languages` in config.json, so the 1.1.68 `langs.json` manifest is
83
+ * removed: a stale copy would only mislead.
84
+ */
85
+ async function writeLanguageArtifacts(root, outputDir, artifacts) {
86
+ const paths = languageArtifactPaths(outputDir);
87
+ rmSync(path.join(root, paths.legacyManifest), { force: true });
88
+ for (const [language, bundle] of Object.entries(artifacts.languageBundles)) {
89
+ await writeJson(root, paths.bundle(language), bundle);
90
+ }
91
+ }
92
+
93
+ /** Keep only versioned docs output in docs/, removing legacy root language folders. */
94
+ function removeLegacyLanguageDirs(root, outputDir) {
95
+ for (const legacyLanguageDir of SUPPORTED_LANGUAGES) {
96
+ const legacyPath = path.join(root, outputDir, "docs", legacyLanguageDir);
97
+ if (existsSync(legacyPath)) {
98
+ rmSync(legacyPath, { recursive: true, force: true });
99
+ }
100
+ }
101
+ }
102
+
103
+ async function writeVersionConfigs(root, outputDir, versionConfigs) {
104
+ for (const [versionId, versionConfig] of Object.entries(versionConfigs)) {
105
+ await writeJson(root, `${outputDir}/docs/versions/${versionId}/config.json`, versionConfig);
106
+ }
107
+ }
108
+
109
+ async function writeMarkdownDoc(root, outputDir, versionId, docPath, docs) {
110
+ const key = parseDocFileToKey(path.basename(docPath));
111
+ const language = extractLanguageFromPath(docPath);
112
+ const content = key && language ? docs?.[language]?.[key] : undefined;
113
+ if (!content) return;
114
+ const versionedContent = withVersionBadge(content, versionId, language);
115
+ await writeText(root, normalizeToOutputPath(outputDir, docPath), versionedContent);
116
+ }
117
+
118
+ async function writeVersionMarkdownDocs(root, outputDir, versionId, versionConfig, docs) {
119
+ const versionRoutesMd = versionConfig["routes-md"] ?? versionConfig.routes ?? [];
120
+ for (const route of versionRoutesMd) {
121
+ for (const docPath of Object.values(route.path ?? {})) {
122
+ await writeMarkdownDoc(root, outputDir, versionId, docPath, docs);
123
+ }
124
+ }
125
+ }
126
+
127
+ async function writeHtmlDoc(root, outputDir, docPath, docsHtml) {
128
+ const key = parseHtmlFileToKey(path.basename(docPath));
129
+ const language = extractLanguageFromPath(docPath);
130
+ const content = key && language ? docsHtml[key]?.[language] : undefined;
131
+ if (!content) return;
132
+ const normalizedHtmlPath = normalizeToOutputPath(outputDir, docPath);
133
+ await writeText(root, normalizedHtmlPath, content);
134
+ // Backward-compatible alias for tools or users that still expect ".html".
135
+ if (!path.extname(docPath)) {
136
+ await writeText(root, `${normalizedHtmlPath}.html`, content);
137
+ }
138
+ }
139
+
140
+ async function writeVersionHtmlDocs(root, outputDir, versionConfig, docsHtml) {
141
+ const versionRoutesHtml = versionConfig["routes-html"] ?? [];
142
+ for (const route of versionRoutesHtml) {
143
+ if (route.url) continue;
144
+ for (const docPath of Object.values(route.path ?? {})) {
145
+ await writeHtmlDoc(root, outputDir, docPath, docsHtml);
146
+ }
147
+ }
148
+ }
149
+
150
+ /** Ensure version folders always include an index file. */
151
+ async function writeVersionFallbackIndexes(root, outputDir, versionId, docs) {
152
+ for (const language of SUPPORTED_LANGUAGES) {
153
+ const fallbackIndex = docs?.[language]?.index;
154
+ if (!fallbackIndex) continue;
155
+ const versionIndexPath = `${outputDir}/docs/versions/${versionId}/${language}/index.md`;
156
+ if (!existsSync(path.join(root, versionIndexPath))) {
157
+ const versionedFallbackIndex = withVersionBadge(fallbackIndex, versionId, language);
158
+ await writeText(root, versionIndexPath, versionedFallbackIndex);
159
+ }
160
+ }
161
+ }
162
+
163
+ async function writeVersionedDocs(root, outputDir, artifacts) {
164
+ const docsHtml = artifacts.docsHtml ?? {};
165
+ for (const [versionId, versionConfig] of Object.entries(artifacts.versionConfigs)) {
166
+ await writeVersionMarkdownDocs(root, outputDir, versionId, versionConfig, artifacts.docs);
167
+ await writeVersionHtmlDocs(root, outputDir, versionConfig, docsHtml);
168
+ await writeVersionFallbackIndexes(root, outputDir, versionId, artifacts.docs);
169
+ }
170
+ }
171
+
172
+ export async function writeConfigOnlyOutput(options) {
173
+ const {
174
+ root,
175
+ outputDir,
176
+ artifacts,
177
+ useLocalLayoutConfig,
178
+ layoutsDir,
179
+ layouts,
180
+ createThemeTemplate,
181
+ } = options;
182
+
183
+ removeLegacyLanguageDirs(root, outputDir);
184
+
185
+ await writeJson(root, `${outputDir}/config.json`, artifacts.rootConfig);
186
+ await writeLanguageArtifacts(root, outputDir, artifacts);
187
+ await writeText(root, `${outputDir}/icon.svg`, DEFAULT_ICON_SVG);
188
+ await writeLayoutArtifacts({
189
+ root,
190
+ layoutsDir,
191
+ useLocalLayoutConfig,
192
+ layouts,
193
+ artifacts,
194
+ createThemeTemplate,
195
+ });
196
+ await writeVersionConfigs(root, outputDir, artifacts.versionConfigs);
197
+
198
+ removeLegacySourceViewerFiles(root, outputDir);
199
+
200
+ await writeVersionedDocs(root, outputDir, artifacts);
201
+ }