@lukoweb/apitogo 0.2.47 → 0.2.50
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/cli.js +1034 -761
- package/docs/cli/shape.md +11 -10
- package/docs/cli/walkthrough.md +14 -25
- package/docs/configuration/make-config.mdx +16 -12
- package/docs/configuration/navigation.mdx +1 -1
- package/docs/guides/custom-pages.md +33 -4
- package/package.json +1 -1
- package/src/config/v2/__fixtures__/scenario-a-docs-only.json +6 -6
- package/src/config/v2/__fixtures__/scenario-b-managed-origin.json +14 -14
- package/src/config/v2/__fixtures__/scenario-c-proxy-oidc.json +15 -15
- package/src/config/v2/__fixtures__/scenario-h-full-stack.json +28 -28
- package/src/config/v2/billing.ts +59 -0
- package/src/config/v2/docs-navigation.ts +95 -72
- package/src/config/v2/errors.ts +42 -42
- package/src/config/v2/gateway-reference.ts +220 -220
- package/src/config/v2/gateway-request.ts +123 -123
- package/src/config/v2/index.ts +8 -0
- package/src/config/v2/layout.ts +237 -237
- package/src/config/v2/load.ts +70 -70
- package/src/config/v2/parse.ts +214 -214
- package/src/config/v2/portal-profile.ts +88 -88
- package/src/config/v2/resolve.ts +6 -1
- package/src/config/v2/rules.ts +31 -16
- package/src/config/v2/services.ts +5 -20
- package/src/config/v2/tsx-pages.ts +100 -0
- package/src/config/v2/workspace.ts +5 -0
- package/src/config/v2/wrapper.ts +88 -9
package/src/config/v2/layout.ts
CHANGED
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
import type { Dirent } from "node:fs";
|
|
2
|
-
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import type { StatePaths } from "./paths.js";
|
|
5
|
-
import type { Layout } from "./schema.js";
|
|
6
|
-
|
|
7
|
-
export type LayoutDetection = { layout: Layout; reason: string };
|
|
8
|
-
|
|
9
|
-
/** Folders never searched for source files or docs: dependencies, build output, VCS and our own folders. */
|
|
10
|
-
const IGNORED_DIRS = new Set([
|
|
11
|
-
"node_modules",
|
|
12
|
-
".git",
|
|
13
|
-
"dist",
|
|
14
|
-
"build",
|
|
15
|
-
"bin",
|
|
16
|
-
"obj",
|
|
17
|
-
"out",
|
|
18
|
-
"coverage",
|
|
19
|
-
".next",
|
|
20
|
-
".turbo",
|
|
21
|
-
".cache",
|
|
22
|
-
".apitogo",
|
|
23
|
-
"apitogo",
|
|
24
|
-
"vendor",
|
|
25
|
-
"__pycache__",
|
|
26
|
-
".venv",
|
|
27
|
-
"venv",
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
/** Manifests that identify a project (Reference, section 3.5). */
|
|
31
|
-
const PROJECT_MANIFESTS = new Set([
|
|
32
|
-
"package.json",
|
|
33
|
-
"go.mod",
|
|
34
|
-
"pyproject.toml",
|
|
35
|
-
"requirements.txt",
|
|
36
|
-
"Pipfile",
|
|
37
|
-
"pom.xml",
|
|
38
|
-
"build.gradle",
|
|
39
|
-
"build.gradle.kts",
|
|
40
|
-
"Gemfile",
|
|
41
|
-
"composer.json",
|
|
42
|
-
"Cargo.toml",
|
|
43
|
-
"mix.exs",
|
|
44
|
-
"deno.json",
|
|
45
|
-
]);
|
|
46
|
-
const PROJECT_MANIFEST_SUFFIXES = [".csproj", ".fsproj", ".sln"];
|
|
47
|
-
|
|
48
|
-
const SOURCE_EXTENSIONS = new Set([
|
|
49
|
-
".ts",
|
|
50
|
-
".tsx",
|
|
51
|
-
".js",
|
|
52
|
-
".jsx",
|
|
53
|
-
".mjs",
|
|
54
|
-
".cjs",
|
|
55
|
-
".py",
|
|
56
|
-
".cs",
|
|
57
|
-
".go",
|
|
58
|
-
".java",
|
|
59
|
-
".kt",
|
|
60
|
-
".rb",
|
|
61
|
-
".php",
|
|
62
|
-
".rs",
|
|
63
|
-
".ex",
|
|
64
|
-
".swift",
|
|
65
|
-
]);
|
|
66
|
-
|
|
67
|
-
/** How deep, and how many entries, the source-file search may look. Detection is a hint, not a crawl. */
|
|
68
|
-
const SOURCE_SEARCH_DEPTH = 3;
|
|
69
|
-
const SOURCE_SEARCH_BUDGET = 3000;
|
|
70
|
-
|
|
71
|
-
async function readEntries(dir: string): Promise<Dirent[]> {
|
|
72
|
-
try {
|
|
73
|
-
return await readdir(dir, { withFileTypes: true });
|
|
74
|
-
} catch {
|
|
75
|
-
return [];
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const toPosix = (value: string) => value.split(path.sep).join("/");
|
|
80
|
-
|
|
81
|
-
async function findSourceFile(root: string): Promise<string | undefined> {
|
|
82
|
-
let budget = SOURCE_SEARCH_BUDGET;
|
|
83
|
-
|
|
84
|
-
async function search(
|
|
85
|
-
dir: string,
|
|
86
|
-
depth: number,
|
|
87
|
-
): Promise<string | undefined> {
|
|
88
|
-
const entries = await readEntries(dir);
|
|
89
|
-
for (const entry of entries) {
|
|
90
|
-
if (budget-- <= 0) return undefined;
|
|
91
|
-
if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
92
|
-
return toPosix(path.relative(root, path.join(dir, entry.name)));
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
if (depth >= SOURCE_SEARCH_DEPTH) return undefined;
|
|
96
|
-
for (const entry of entries) {
|
|
97
|
-
if (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) continue;
|
|
98
|
-
const found = await search(path.join(dir, entry.name), depth + 1);
|
|
99
|
-
if (found) return found;
|
|
100
|
-
}
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return search(root, 0);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Chooses the layout for `init` (Reference, section 3.5): an empty directory, or one with only VCS files, trivial
|
|
109
|
-
* files and documentation, is `standalone`; a recognisable project (a manifest or source files) is `embedded`.
|
|
110
|
-
*/
|
|
111
|
-
export async function detectLayout(dir: string): Promise<LayoutDetection> {
|
|
112
|
-
const entries = await readEntries(dir);
|
|
113
|
-
if (entries.length === 0) {
|
|
114
|
-
return { layout: "standalone", reason: "the directory is empty" };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const manifest = entries.find(
|
|
118
|
-
(entry) =>
|
|
119
|
-
entry.isFile() &&
|
|
120
|
-
(PROJECT_MANIFESTS.has(entry.name) ||
|
|
121
|
-
PROJECT_MANIFEST_SUFFIXES.some((suffix) =>
|
|
122
|
-
entry.name.endsWith(suffix),
|
|
123
|
-
)),
|
|
124
|
-
);
|
|
125
|
-
if (manifest) {
|
|
126
|
-
return {
|
|
127
|
-
layout: "embedded",
|
|
128
|
-
reason: `it contains a project (${manifest.name})`,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const source = await findSourceFile(dir);
|
|
133
|
-
if (source) {
|
|
134
|
-
return {
|
|
135
|
-
layout: "embedded",
|
|
136
|
-
reason: `it contains source code (${source})`,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
layout: "standalone",
|
|
142
|
-
reason: "it holds only documentation and trivial files, no project",
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export type DocsCandidate = {
|
|
147
|
-
kind: "folder" | "file";
|
|
148
|
-
/** Path relative to the project directory, POSIX separators. */
|
|
149
|
-
path: string;
|
|
150
|
-
/** What goes into `portal.docs.content`: the docs are referenced in place, never moved or copied. */
|
|
151
|
-
glob: string;
|
|
152
|
-
fileCount: number;
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const DOCS_FOLDER_NAMES = new Set(["docs", "doc", "documentation", "pages"]);
|
|
156
|
-
const DOCS_EXTENSIONS = new Set([".md", ".mdx"]);
|
|
157
|
-
/** Root markdown files that are project metadata, not documentation. */
|
|
158
|
-
const NOT_DOCS = /^(readme|license|licence|changelog)(\.|$)/i;
|
|
159
|
-
const DOCS_SEARCH_DEPTH = 2;
|
|
160
|
-
const OWN_PAGES_FOLDER = "portal/pages";
|
|
161
|
-
|
|
162
|
-
async function countDocs(dir: string): Promise<number> {
|
|
163
|
-
let count = 0;
|
|
164
|
-
for (const entry of await readEntries(dir)) {
|
|
165
|
-
if (entry.isDirectory()) {
|
|
166
|
-
if (!IGNORED_DIRS.has(entry.name))
|
|
167
|
-
count += await countDocs(path.join(dir, entry.name));
|
|
168
|
-
} else if (
|
|
169
|
-
entry.isFile() &&
|
|
170
|
-
DOCS_EXTENSIONS.has(path.extname(entry.name))
|
|
171
|
-
) {
|
|
172
|
-
count += 1;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return count;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Finds existing documentation: folders named `docs`, `doc`, `documentation` or `pages` (at the top or one level
|
|
180
|
-
* down) that hold `.md` or `.mdx` files, and loose markdown files at the top that are not README, LICENSE or
|
|
181
|
-
* CHANGELOG. Nothing is moved: each candidate becomes a glob.
|
|
182
|
-
*/
|
|
183
|
-
export async function detectExistingDocs(
|
|
184
|
-
dir: string,
|
|
185
|
-
): Promise<DocsCandidate[]> {
|
|
186
|
-
const candidates: DocsCandidate[] = [];
|
|
187
|
-
|
|
188
|
-
async function scan(current: string, depth: number) {
|
|
189
|
-
for (const entry of await readEntries(current)) {
|
|
190
|
-
const relative = toPosix(
|
|
191
|
-
path.relative(dir, path.join(current, entry.name)),
|
|
192
|
-
);
|
|
193
|
-
if (entry.isDirectory()) {
|
|
194
|
-
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
195
|
-
if (relative === OWN_PAGES_FOLDER) continue; // the portal's own default pages folder is already covered by the defaults
|
|
196
|
-
if (DOCS_FOLDER_NAMES.has(entry.name.toLowerCase())) {
|
|
197
|
-
const fileCount = await countDocs(path.join(current, entry.name));
|
|
198
|
-
if (fileCount > 0) {
|
|
199
|
-
candidates.push({
|
|
200
|
-
kind: "folder",
|
|
201
|
-
path: relative,
|
|
202
|
-
glob: `${relative}/**/*.{md,mdx}`,
|
|
203
|
-
fileCount,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
} else if (depth < DOCS_SEARCH_DEPTH - 1) {
|
|
207
|
-
await scan(path.join(current, entry.name), depth + 1);
|
|
208
|
-
}
|
|
209
|
-
} else if (
|
|
210
|
-
depth === 0 &&
|
|
211
|
-
entry.isFile() &&
|
|
212
|
-
DOCS_EXTENSIONS.has(path.extname(entry.name)) &&
|
|
213
|
-
!NOT_DOCS.test(entry.name)
|
|
214
|
-
) {
|
|
215
|
-
candidates.push({
|
|
216
|
-
kind: "file",
|
|
217
|
-
path: relative,
|
|
218
|
-
glob: relative,
|
|
219
|
-
fileCount: 1,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
await scan(dir, 0);
|
|
226
|
-
return candidates.sort((a, b) => a.path.localeCompare(b.path));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Creates the generated-state directory: itself, its own `.gitignore` containing `*`, and the `generated/` and
|
|
231
|
-
* `build/` folders. Never reads or edits the tenant's root `.gitignore`. Safe to call again.
|
|
232
|
-
*/
|
|
233
|
-
export async function ensureStateDir(state: StatePaths): Promise<void> {
|
|
234
|
-
await mkdir(state.generatedDir, { recursive: true });
|
|
235
|
-
await mkdir(state.buildDir, { recursive: true });
|
|
236
|
-
await writeFile(state.gitignore, "*\n");
|
|
237
|
-
}
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { StatePaths } from "./paths.js";
|
|
5
|
+
import type { Layout } from "./schema.js";
|
|
6
|
+
|
|
7
|
+
export type LayoutDetection = { layout: Layout; reason: string };
|
|
8
|
+
|
|
9
|
+
/** Folders never searched for source files or docs: dependencies, build output, VCS and our own folders. */
|
|
10
|
+
const IGNORED_DIRS = new Set([
|
|
11
|
+
"node_modules",
|
|
12
|
+
".git",
|
|
13
|
+
"dist",
|
|
14
|
+
"build",
|
|
15
|
+
"bin",
|
|
16
|
+
"obj",
|
|
17
|
+
"out",
|
|
18
|
+
"coverage",
|
|
19
|
+
".next",
|
|
20
|
+
".turbo",
|
|
21
|
+
".cache",
|
|
22
|
+
".apitogo",
|
|
23
|
+
"apitogo",
|
|
24
|
+
"vendor",
|
|
25
|
+
"__pycache__",
|
|
26
|
+
".venv",
|
|
27
|
+
"venv",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** Manifests that identify a project (Reference, section 3.5). */
|
|
31
|
+
const PROJECT_MANIFESTS = new Set([
|
|
32
|
+
"package.json",
|
|
33
|
+
"go.mod",
|
|
34
|
+
"pyproject.toml",
|
|
35
|
+
"requirements.txt",
|
|
36
|
+
"Pipfile",
|
|
37
|
+
"pom.xml",
|
|
38
|
+
"build.gradle",
|
|
39
|
+
"build.gradle.kts",
|
|
40
|
+
"Gemfile",
|
|
41
|
+
"composer.json",
|
|
42
|
+
"Cargo.toml",
|
|
43
|
+
"mix.exs",
|
|
44
|
+
"deno.json",
|
|
45
|
+
]);
|
|
46
|
+
const PROJECT_MANIFEST_SUFFIXES = [".csproj", ".fsproj", ".sln"];
|
|
47
|
+
|
|
48
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
49
|
+
".ts",
|
|
50
|
+
".tsx",
|
|
51
|
+
".js",
|
|
52
|
+
".jsx",
|
|
53
|
+
".mjs",
|
|
54
|
+
".cjs",
|
|
55
|
+
".py",
|
|
56
|
+
".cs",
|
|
57
|
+
".go",
|
|
58
|
+
".java",
|
|
59
|
+
".kt",
|
|
60
|
+
".rb",
|
|
61
|
+
".php",
|
|
62
|
+
".rs",
|
|
63
|
+
".ex",
|
|
64
|
+
".swift",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** How deep, and how many entries, the source-file search may look. Detection is a hint, not a crawl. */
|
|
68
|
+
const SOURCE_SEARCH_DEPTH = 3;
|
|
69
|
+
const SOURCE_SEARCH_BUDGET = 3000;
|
|
70
|
+
|
|
71
|
+
async function readEntries(dir: string): Promise<Dirent[]> {
|
|
72
|
+
try {
|
|
73
|
+
return await readdir(dir, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const toPosix = (value: string) => value.split(path.sep).join("/");
|
|
80
|
+
|
|
81
|
+
async function findSourceFile(root: string): Promise<string | undefined> {
|
|
82
|
+
let budget = SOURCE_SEARCH_BUDGET;
|
|
83
|
+
|
|
84
|
+
async function search(
|
|
85
|
+
dir: string,
|
|
86
|
+
depth: number,
|
|
87
|
+
): Promise<string | undefined> {
|
|
88
|
+
const entries = await readEntries(dir);
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (budget-- <= 0) return undefined;
|
|
91
|
+
if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
92
|
+
return toPosix(path.relative(root, path.join(dir, entry.name)));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (depth >= SOURCE_SEARCH_DEPTH) return undefined;
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) continue;
|
|
98
|
+
const found = await search(path.join(dir, entry.name), depth + 1);
|
|
99
|
+
if (found) return found;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return search(root, 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Chooses the layout for `init` (Reference, section 3.5): an empty directory, or one with only VCS files, trivial
|
|
109
|
+
* files and documentation, is `standalone`; a recognisable project (a manifest or source files) is `embedded`.
|
|
110
|
+
*/
|
|
111
|
+
export async function detectLayout(dir: string): Promise<LayoutDetection> {
|
|
112
|
+
const entries = await readEntries(dir);
|
|
113
|
+
if (entries.length === 0) {
|
|
114
|
+
return { layout: "standalone", reason: "the directory is empty" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const manifest = entries.find(
|
|
118
|
+
(entry) =>
|
|
119
|
+
entry.isFile() &&
|
|
120
|
+
(PROJECT_MANIFESTS.has(entry.name) ||
|
|
121
|
+
PROJECT_MANIFEST_SUFFIXES.some((suffix) =>
|
|
122
|
+
entry.name.endsWith(suffix),
|
|
123
|
+
)),
|
|
124
|
+
);
|
|
125
|
+
if (manifest) {
|
|
126
|
+
return {
|
|
127
|
+
layout: "embedded",
|
|
128
|
+
reason: `it contains a project (${manifest.name})`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const source = await findSourceFile(dir);
|
|
133
|
+
if (source) {
|
|
134
|
+
return {
|
|
135
|
+
layout: "embedded",
|
|
136
|
+
reason: `it contains source code (${source})`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
layout: "standalone",
|
|
142
|
+
reason: "it holds only documentation and trivial files, no project",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type DocsCandidate = {
|
|
147
|
+
kind: "folder" | "file";
|
|
148
|
+
/** Path relative to the project directory, POSIX separators. */
|
|
149
|
+
path: string;
|
|
150
|
+
/** What goes into `portal.docs.content`: the docs are referenced in place, never moved or copied. */
|
|
151
|
+
glob: string;
|
|
152
|
+
fileCount: number;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const DOCS_FOLDER_NAMES = new Set(["docs", "doc", "documentation", "pages"]);
|
|
156
|
+
const DOCS_EXTENSIONS = new Set([".md", ".mdx"]);
|
|
157
|
+
/** Root markdown files that are project metadata, not documentation. */
|
|
158
|
+
const NOT_DOCS = /^(readme|license|licence|changelog)(\.|$)/i;
|
|
159
|
+
const DOCS_SEARCH_DEPTH = 2;
|
|
160
|
+
const OWN_PAGES_FOLDER = "portal/pages";
|
|
161
|
+
|
|
162
|
+
async function countDocs(dir: string): Promise<number> {
|
|
163
|
+
let count = 0;
|
|
164
|
+
for (const entry of await readEntries(dir)) {
|
|
165
|
+
if (entry.isDirectory()) {
|
|
166
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
167
|
+
count += await countDocs(path.join(dir, entry.name));
|
|
168
|
+
} else if (
|
|
169
|
+
entry.isFile() &&
|
|
170
|
+
DOCS_EXTENSIONS.has(path.extname(entry.name))
|
|
171
|
+
) {
|
|
172
|
+
count += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return count;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Finds existing documentation: folders named `docs`, `doc`, `documentation` or `pages` (at the top or one level
|
|
180
|
+
* down) that hold `.md` or `.mdx` files, and loose markdown files at the top that are not README, LICENSE or
|
|
181
|
+
* CHANGELOG. Nothing is moved: each candidate becomes a glob.
|
|
182
|
+
*/
|
|
183
|
+
export async function detectExistingDocs(
|
|
184
|
+
dir: string,
|
|
185
|
+
): Promise<DocsCandidate[]> {
|
|
186
|
+
const candidates: DocsCandidate[] = [];
|
|
187
|
+
|
|
188
|
+
async function scan(current: string, depth: number) {
|
|
189
|
+
for (const entry of await readEntries(current)) {
|
|
190
|
+
const relative = toPosix(
|
|
191
|
+
path.relative(dir, path.join(current, entry.name)),
|
|
192
|
+
);
|
|
193
|
+
if (entry.isDirectory()) {
|
|
194
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
195
|
+
if (relative === OWN_PAGES_FOLDER) continue; // the portal's own default pages folder is already covered by the defaults
|
|
196
|
+
if (DOCS_FOLDER_NAMES.has(entry.name.toLowerCase())) {
|
|
197
|
+
const fileCount = await countDocs(path.join(current, entry.name));
|
|
198
|
+
if (fileCount > 0) {
|
|
199
|
+
candidates.push({
|
|
200
|
+
kind: "folder",
|
|
201
|
+
path: relative,
|
|
202
|
+
glob: `${relative}/**/*.{md,mdx}`,
|
|
203
|
+
fileCount,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} else if (depth < DOCS_SEARCH_DEPTH - 1) {
|
|
207
|
+
await scan(path.join(current, entry.name), depth + 1);
|
|
208
|
+
}
|
|
209
|
+
} else if (
|
|
210
|
+
depth === 0 &&
|
|
211
|
+
entry.isFile() &&
|
|
212
|
+
DOCS_EXTENSIONS.has(path.extname(entry.name)) &&
|
|
213
|
+
!NOT_DOCS.test(entry.name)
|
|
214
|
+
) {
|
|
215
|
+
candidates.push({
|
|
216
|
+
kind: "file",
|
|
217
|
+
path: relative,
|
|
218
|
+
glob: relative,
|
|
219
|
+
fileCount: 1,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await scan(dir, 0);
|
|
226
|
+
return candidates.sort((a, b) => a.path.localeCompare(b.path));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Creates the generated-state directory: itself, its own `.gitignore` containing `*`, and the `generated/` and
|
|
231
|
+
* `build/` folders. Never reads or edits the tenant's root `.gitignore`. Safe to call again.
|
|
232
|
+
*/
|
|
233
|
+
export async function ensureStateDir(state: StatePaths): Promise<void> {
|
|
234
|
+
await mkdir(state.generatedDir, { recursive: true });
|
|
235
|
+
await mkdir(state.buildDir, { recursive: true });
|
|
236
|
+
await writeFile(state.gitignore, "*\n");
|
|
237
|
+
}
|
package/src/config/v2/load.ts
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { APITOGO_CONFIG_FILENAME } from "../apitogo-config-core.js";
|
|
4
|
-
import { ConfigError } from "./errors.js";
|
|
5
|
-
import { parseConfigV2 } from "./parse.js";
|
|
6
|
-
import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
|
|
7
|
-
import type { ConfigV2 } from "./schema.js";
|
|
8
|
-
|
|
9
|
-
export type LoadedConfigV2 = {
|
|
10
|
-
config: ConfigV2;
|
|
11
|
-
resolved: ResolvedConfigV2;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
/** `JSON.parse` failures carry a position in different shapes across Node versions; turn it into line and column. */
|
|
15
|
-
function describeJsonError(error: unknown, text: string): string {
|
|
16
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
-
const lineColumn = /line (\d+) column (\d+)/.exec(message);
|
|
18
|
-
if (lineColumn) return `${message}`;
|
|
19
|
-
|
|
20
|
-
const position = /position (\d+)/.exec(message);
|
|
21
|
-
if (!position) return message;
|
|
22
|
-
|
|
23
|
-
const offset = Number(position[1]);
|
|
24
|
-
const before = text.slice(0, offset);
|
|
25
|
-
const line = before.split("\n").length;
|
|
26
|
-
const column = offset - before.lastIndexOf("\n");
|
|
27
|
-
return `${message} (line ${line} column ${column})`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Reads, validates and resolves a v2 config. `pathOrDir` is either the config file or the directory that
|
|
32
|
-
* contains `apitogo.config.json`. Invalid JSON, unknown keys, inline secrets and every other schema problem
|
|
33
|
-
* raise a {@link ConfigError}; nothing is silently dropped.
|
|
34
|
-
*/
|
|
35
|
-
export function loadConfigV2(pathOrDir: string): LoadedConfigV2 {
|
|
36
|
-
const resolvedPath = path.resolve(pathOrDir);
|
|
37
|
-
const configPath =
|
|
38
|
-
existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()
|
|
39
|
-
? path.join(resolvedPath, APITOGO_CONFIG_FILENAME)
|
|
40
|
-
: resolvedPath;
|
|
41
|
-
|
|
42
|
-
if (!existsSync(configPath)) {
|
|
43
|
-
throw new ConfigError(path.basename(configPath), [
|
|
44
|
-
{
|
|
45
|
-
code: "CONFIG_NOT_FOUND",
|
|
46
|
-
path: "",
|
|
47
|
-
message: `No config file at ${configPath}.`,
|
|
48
|
-
hint: "Run `apitogo init` to create one.",
|
|
49
|
-
},
|
|
50
|
-
]);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const text = readFileSync(configPath, "utf8");
|
|
54
|
-
let raw: unknown;
|
|
55
|
-
try {
|
|
56
|
-
raw = JSON.parse(text);
|
|
57
|
-
} catch (error) {
|
|
58
|
-
throw new ConfigError(path.basename(configPath), [
|
|
59
|
-
{
|
|
60
|
-
code: "CONFIG_INVALID_JSON",
|
|
61
|
-
path: "",
|
|
62
|
-
message: describeJsonError(error, text),
|
|
63
|
-
hint: "Fix the JSON syntax; comments and trailing commas are not allowed.",
|
|
64
|
-
},
|
|
65
|
-
]);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const config = parseConfigV2(raw, path.basename(configPath));
|
|
69
|
-
return { config, resolved: resolveConfigV2(config, configPath) };
|
|
70
|
-
}
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { APITOGO_CONFIG_FILENAME } from "../apitogo-config-core.js";
|
|
4
|
+
import { ConfigError } from "./errors.js";
|
|
5
|
+
import { parseConfigV2 } from "./parse.js";
|
|
6
|
+
import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
|
|
7
|
+
import type { ConfigV2 } from "./schema.js";
|
|
8
|
+
|
|
9
|
+
export type LoadedConfigV2 = {
|
|
10
|
+
config: ConfigV2;
|
|
11
|
+
resolved: ResolvedConfigV2;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** `JSON.parse` failures carry a position in different shapes across Node versions; turn it into line and column. */
|
|
15
|
+
function describeJsonError(error: unknown, text: string): string {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
const lineColumn = /line (\d+) column (\d+)/.exec(message);
|
|
18
|
+
if (lineColumn) return `${message}`;
|
|
19
|
+
|
|
20
|
+
const position = /position (\d+)/.exec(message);
|
|
21
|
+
if (!position) return message;
|
|
22
|
+
|
|
23
|
+
const offset = Number(position[1]);
|
|
24
|
+
const before = text.slice(0, offset);
|
|
25
|
+
const line = before.split("\n").length;
|
|
26
|
+
const column = offset - before.lastIndexOf("\n");
|
|
27
|
+
return `${message} (line ${line} column ${column})`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reads, validates and resolves a v2 config. `pathOrDir` is either the config file or the directory that
|
|
32
|
+
* contains `apitogo.config.json`. Invalid JSON, unknown keys, inline secrets and every other schema problem
|
|
33
|
+
* raise a {@link ConfigError}; nothing is silently dropped.
|
|
34
|
+
*/
|
|
35
|
+
export function loadConfigV2(pathOrDir: string): LoadedConfigV2 {
|
|
36
|
+
const resolvedPath = path.resolve(pathOrDir);
|
|
37
|
+
const configPath =
|
|
38
|
+
existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()
|
|
39
|
+
? path.join(resolvedPath, APITOGO_CONFIG_FILENAME)
|
|
40
|
+
: resolvedPath;
|
|
41
|
+
|
|
42
|
+
if (!existsSync(configPath)) {
|
|
43
|
+
throw new ConfigError(path.basename(configPath), [
|
|
44
|
+
{
|
|
45
|
+
code: "CONFIG_NOT_FOUND",
|
|
46
|
+
path: "",
|
|
47
|
+
message: `No config file at ${configPath}.`,
|
|
48
|
+
hint: "Run `apitogo init` to create one.",
|
|
49
|
+
},
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = readFileSync(configPath, "utf8");
|
|
54
|
+
let raw: unknown;
|
|
55
|
+
try {
|
|
56
|
+
raw = JSON.parse(text);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new ConfigError(path.basename(configPath), [
|
|
59
|
+
{
|
|
60
|
+
code: "CONFIG_INVALID_JSON",
|
|
61
|
+
path: "",
|
|
62
|
+
message: describeJsonError(error, text),
|
|
63
|
+
hint: "Fix the JSON syntax; comments and trailing commas are not allowed.",
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const config = parseConfigV2(raw, path.basename(configPath));
|
|
69
|
+
return { config, resolved: resolveConfigV2(config, configPath) };
|
|
70
|
+
}
|