@outfitter/docs-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/cli-sync.js +84 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +760 -0
- package/dist/shared/@outfitter/docs-core-d4ex21rp.js +756 -0
- package/package.json +51 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/docs-core/src/index.ts
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
5
|
+
import {
|
|
6
|
+
dirname,
|
|
7
|
+
extname,
|
|
8
|
+
isAbsolute,
|
|
9
|
+
join,
|
|
10
|
+
relative,
|
|
11
|
+
resolve
|
|
12
|
+
} from "path";
|
|
13
|
+
import { Result } from "better-result";
|
|
14
|
+
|
|
15
|
+
class DocsCoreError extends Error {
|
|
16
|
+
_tag = "DocsCoreError";
|
|
17
|
+
category;
|
|
18
|
+
context;
|
|
19
|
+
constructor(input) {
|
|
20
|
+
super(input.message);
|
|
21
|
+
this.name = "DocsCoreError";
|
|
22
|
+
this.category = input.category;
|
|
23
|
+
this.context = input.context;
|
|
24
|
+
}
|
|
25
|
+
static validation(message, context) {
|
|
26
|
+
return new DocsCoreError({
|
|
27
|
+
message,
|
|
28
|
+
category: "validation",
|
|
29
|
+
...context ? { context } : {}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
static internal(message, context) {
|
|
33
|
+
return new DocsCoreError({
|
|
34
|
+
message,
|
|
35
|
+
category: "internal",
|
|
36
|
+
...context ? { context } : {}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
var DEFAULT_PACKAGES_DIR = "packages";
|
|
41
|
+
var DEFAULT_OUTPUT_DIR = "docs/packages";
|
|
42
|
+
var DEFAULT_EXCLUDED_FILES = ["CHANGELOG.md"];
|
|
43
|
+
var DEFAULT_LLMS_FILE = "docs/llms.txt";
|
|
44
|
+
var DEFAULT_LLMS_FULL_FILE = "docs/llms-full.txt";
|
|
45
|
+
var DEFAULT_LLMS_TARGETS = ["llms", "llms-full"];
|
|
46
|
+
var DEFAULT_MDX_MODE = "lossy";
|
|
47
|
+
function toPosixPath(path) {
|
|
48
|
+
return path.split("\\").join("/");
|
|
49
|
+
}
|
|
50
|
+
function relativeToWorkspace(workspaceRoot, absolutePath) {
|
|
51
|
+
return toPosixPath(relative(workspaceRoot, absolutePath));
|
|
52
|
+
}
|
|
53
|
+
function isPathInsideWorkspace(workspaceRoot, absolutePath) {
|
|
54
|
+
const rel = relative(workspaceRoot, absolutePath);
|
|
55
|
+
return rel === "" || !(rel.startsWith("..") || isAbsolute(rel));
|
|
56
|
+
}
|
|
57
|
+
function isSamePathOrDescendant(parentPath, candidatePath) {
|
|
58
|
+
const rel = relative(parentPath, candidatePath);
|
|
59
|
+
return rel === "" || !(rel.startsWith("..") || isAbsolute(rel));
|
|
60
|
+
}
|
|
61
|
+
function pathsOverlap(pathA, pathB) {
|
|
62
|
+
return isSamePathOrDescendant(pathA, pathB) || isSamePathOrDescendant(pathB, pathA);
|
|
63
|
+
}
|
|
64
|
+
function normalizeExcludedNames(names) {
|
|
65
|
+
return new Set((names ?? DEFAULT_EXCLUDED_FILES).map((name) => name.toLowerCase()));
|
|
66
|
+
}
|
|
67
|
+
function isMdxMode(value) {
|
|
68
|
+
return value === "strict" || value === "lossy";
|
|
69
|
+
}
|
|
70
|
+
function resolveOptions(options) {
|
|
71
|
+
const workspaceRoot = resolve(options?.workspaceRoot ?? process.cwd());
|
|
72
|
+
const packagesRoot = resolve(workspaceRoot, options?.packagesDir ?? DEFAULT_PACKAGES_DIR);
|
|
73
|
+
const outputRoot = resolve(workspaceRoot, options?.outputDir ?? DEFAULT_OUTPUT_DIR);
|
|
74
|
+
const excludedLowercaseNames = normalizeExcludedNames(options?.excludedFilenames);
|
|
75
|
+
const mdxMode = options?.mdxMode ?? DEFAULT_MDX_MODE;
|
|
76
|
+
if (!isMdxMode(mdxMode)) {
|
|
77
|
+
return Result.err(DocsCoreError.validation("Invalid MDX mode", {
|
|
78
|
+
mdxMode
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
if (!existsSync(workspaceRoot)) {
|
|
82
|
+
return Result.err(DocsCoreError.validation("workspaceRoot does not exist", {
|
|
83
|
+
workspaceRoot
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
if (!existsSync(packagesRoot)) {
|
|
87
|
+
return Result.err(DocsCoreError.validation("packages directory does not exist", {
|
|
88
|
+
packagesRoot
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
if (!isPathInsideWorkspace(workspaceRoot, outputRoot)) {
|
|
92
|
+
return Result.err(DocsCoreError.validation("outputDir must resolve inside workspace", {
|
|
93
|
+
outputRoot
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
if (pathsOverlap(outputRoot, packagesRoot)) {
|
|
97
|
+
return Result.err(DocsCoreError.validation("outputDir must not overlap packages directory", {
|
|
98
|
+
outputRoot,
|
|
99
|
+
packagesRoot
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
return Result.ok({
|
|
103
|
+
workspaceRoot,
|
|
104
|
+
packagesRoot,
|
|
105
|
+
outputRoot,
|
|
106
|
+
excludedLowercaseNames,
|
|
107
|
+
mdxMode
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function isLlmsTarget(value) {
|
|
111
|
+
return value === "llms" || value === "llms-full";
|
|
112
|
+
}
|
|
113
|
+
function resolveLlmsOptions(workspaceRoot, options) {
|
|
114
|
+
const llmsPath = resolve(workspaceRoot, options?.llmsFile ?? DEFAULT_LLMS_FILE);
|
|
115
|
+
const llmsFullPath = resolve(workspaceRoot, options?.llmsFullFile ?? DEFAULT_LLMS_FULL_FILE);
|
|
116
|
+
const rawTargets = options?.targets ?? DEFAULT_LLMS_TARGETS;
|
|
117
|
+
const targets = [...new Set(rawTargets)];
|
|
118
|
+
for (const target of targets) {
|
|
119
|
+
if (!isLlmsTarget(target)) {
|
|
120
|
+
return Result.err(DocsCoreError.validation("Invalid LLM export target", {
|
|
121
|
+
target
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!isPathInsideWorkspace(workspaceRoot, llmsPath)) {
|
|
126
|
+
return Result.err(DocsCoreError.validation("llmsFile must resolve inside workspace", {
|
|
127
|
+
llmsPath
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
if (!isPathInsideWorkspace(workspaceRoot, llmsFullPath)) {
|
|
131
|
+
return Result.err(DocsCoreError.validation("llmsFullFile must resolve inside workspace", {
|
|
132
|
+
llmsFullPath
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
if (llmsPath === llmsFullPath) {
|
|
136
|
+
return Result.err(DocsCoreError.validation("llmsFile and llmsFullFile must resolve to distinct paths", {
|
|
137
|
+
llmsPath,
|
|
138
|
+
llmsFullPath
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
return Result.ok({
|
|
142
|
+
llmsPath,
|
|
143
|
+
llmsFullPath,
|
|
144
|
+
targets
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async function discoverPackageDirectories(packagesRoot) {
|
|
148
|
+
const entries = await readdir(packagesRoot, { withFileTypes: true });
|
|
149
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => ({
|
|
150
|
+
packageDirName: entry.name,
|
|
151
|
+
packageRoot: join(packagesRoot, entry.name)
|
|
152
|
+
})).sort((a, b) => a.packageDirName.localeCompare(b.packageDirName));
|
|
153
|
+
}
|
|
154
|
+
async function isPublishablePackage(packageRoot) {
|
|
155
|
+
const packageJsonPath = join(packageRoot, "package.json");
|
|
156
|
+
if (!existsSync(packageJsonPath)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const content = await readFile(packageJsonPath, "utf8");
|
|
161
|
+
const parsed = JSON.parse(content);
|
|
162
|
+
return parsed.private !== true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function isDocsSourceFile(path) {
|
|
168
|
+
const extension = extname(path).toLowerCase();
|
|
169
|
+
return extension === ".md" || extension === ".mdx";
|
|
170
|
+
}
|
|
171
|
+
function isExcludedFileName(path, excludedLowercaseNames) {
|
|
172
|
+
const fileName = path.split(/[\\/]/).at(-1) ?? "";
|
|
173
|
+
return excludedLowercaseNames.has(fileName.toLowerCase());
|
|
174
|
+
}
|
|
175
|
+
async function collectDocsSubtreeSourceFiles(docsRoot, excludedLowercaseNames) {
|
|
176
|
+
if (!existsSync(docsRoot)) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const files = [];
|
|
180
|
+
const directories = [docsRoot];
|
|
181
|
+
while (directories.length > 0) {
|
|
182
|
+
const currentDir = directories.pop();
|
|
183
|
+
if (!currentDir) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
const fullPath = join(currentDir, entry.name);
|
|
189
|
+
if (entry.isDirectory()) {
|
|
190
|
+
directories.push(fullPath);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!entry.isFile()) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!isDocsSourceFile(entry.name)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (isExcludedFileName(entry.name, excludedLowercaseNames)) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
files.push(fullPath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return files.sort((a, b) => toPosixPath(a).localeCompare(toPosixPath(b)));
|
|
206
|
+
}
|
|
207
|
+
async function collectPackageSourceFiles(packageRoot, excludedLowercaseNames) {
|
|
208
|
+
const rootEntries = await readdir(packageRoot, { withFileTypes: true });
|
|
209
|
+
const rootDocsFiles = rootEntries.filter((entry) => entry.isFile()).map((entry) => entry.name).filter((entryName) => isDocsSourceFile(entryName)).filter((entryName) => !isExcludedFileName(entryName, excludedLowercaseNames)).map((entryName) => join(packageRoot, entryName));
|
|
210
|
+
const docsSubtreeDocsFiles = await collectDocsSubtreeSourceFiles(join(packageRoot, "docs"), excludedLowercaseNames);
|
|
211
|
+
return [...rootDocsFiles, ...docsSubtreeDocsFiles].sort((a, b) => toPosixPath(a).localeCompare(toPosixPath(b)));
|
|
212
|
+
}
|
|
213
|
+
function splitMarkdownTarget(target) {
|
|
214
|
+
const trimmed = target.trim();
|
|
215
|
+
if (trimmed.length === 0) {
|
|
216
|
+
return { pathPart: target, suffix: "", wrappedInAngles: false };
|
|
217
|
+
}
|
|
218
|
+
const splitAt = trimmed.search(/\s/);
|
|
219
|
+
const firstToken = splitAt >= 0 ? trimmed.slice(0, splitAt) : trimmed;
|
|
220
|
+
const suffix = splitAt >= 0 ? trimmed.slice(splitAt) : "";
|
|
221
|
+
const wrappedInAngles = firstToken.startsWith("<") && firstToken.endsWith(">") && firstToken.length > 2;
|
|
222
|
+
const pathPart = wrappedInAngles ? firstToken.slice(1, -1) : firstToken;
|
|
223
|
+
return { pathPart, suffix, wrappedInAngles };
|
|
224
|
+
}
|
|
225
|
+
function isRewritableRelativeTarget(pathPart) {
|
|
226
|
+
return !(pathPart.length === 0 || pathPart.startsWith("#") || pathPart.startsWith("/") || pathPart.startsWith("http://") || pathPart.startsWith("https://") || pathPart.startsWith("mailto:") || pathPart.startsWith("tel:") || pathPart.startsWith("data:") || pathPart.startsWith("//"));
|
|
227
|
+
}
|
|
228
|
+
function splitPathQueryAndHash(pathPart) {
|
|
229
|
+
const hashIndex = pathPart.indexOf("#");
|
|
230
|
+
const withNoHash = hashIndex >= 0 ? pathPart.slice(0, hashIndex) : pathPart;
|
|
231
|
+
const hash = hashIndex >= 0 ? pathPart.slice(hashIndex) : "";
|
|
232
|
+
const queryIndex = withNoHash.indexOf("?");
|
|
233
|
+
const pathname = queryIndex >= 0 ? withNoHash.slice(0, queryIndex) : withNoHash;
|
|
234
|
+
const query = queryIndex >= 0 ? withNoHash.slice(queryIndex) : "";
|
|
235
|
+
return { pathname, query, hash };
|
|
236
|
+
}
|
|
237
|
+
function rewriteMarkdownLinkTarget(target, sourceAbsolutePath, destinationAbsolutePath, workspaceRoot, mirrorTargetBySourcePath) {
|
|
238
|
+
const { pathPart, suffix, wrappedInAngles } = splitMarkdownTarget(target);
|
|
239
|
+
if (!isRewritableRelativeTarget(pathPart)) {
|
|
240
|
+
return target;
|
|
241
|
+
}
|
|
242
|
+
const { pathname, query, hash } = splitPathQueryAndHash(pathPart);
|
|
243
|
+
if (pathname.length === 0) {
|
|
244
|
+
return target;
|
|
245
|
+
}
|
|
246
|
+
const absoluteTarget = resolve(dirname(sourceAbsolutePath), pathname);
|
|
247
|
+
if (!isPathInsideWorkspace(workspaceRoot, absoluteTarget)) {
|
|
248
|
+
return target;
|
|
249
|
+
}
|
|
250
|
+
const rewrittenAbsoluteTarget = mirrorTargetBySourcePath.get(absoluteTarget) ?? absoluteTarget;
|
|
251
|
+
let rewrittenPath = toPosixPath(relative(dirname(destinationAbsolutePath), rewrittenAbsoluteTarget));
|
|
252
|
+
if (rewrittenPath.length === 0) {
|
|
253
|
+
rewrittenPath = "./";
|
|
254
|
+
} else if (!rewrittenPath.startsWith(".")) {
|
|
255
|
+
rewrittenPath = `./${rewrittenPath}`;
|
|
256
|
+
}
|
|
257
|
+
const rewritten = `${rewrittenPath}${query}${hash}`;
|
|
258
|
+
const maybeWrapped = wrappedInAngles ? `<${rewritten}>` : rewritten;
|
|
259
|
+
return `${maybeWrapped}${suffix}`;
|
|
260
|
+
}
|
|
261
|
+
function rewriteMarkdownLinks(markdown, sourceAbsolutePath, destinationAbsolutePath, workspaceRoot, mirrorTargetBySourcePath) {
|
|
262
|
+
return markdown.replace(/(!?\[[^\]]*]\()([^)]+)(\))/g, (_match, prefix, target, suffix) => `${prefix}${rewriteMarkdownLinkTarget(target, sourceAbsolutePath, destinationAbsolutePath, workspaceRoot, mirrorTargetBySourcePath)}${suffix}`);
|
|
263
|
+
}
|
|
264
|
+
function toOutputRelativePath(relativePath) {
|
|
265
|
+
const extension = extname(relativePath).toLowerCase();
|
|
266
|
+
if (extension !== ".mdx") {
|
|
267
|
+
return relativePath;
|
|
268
|
+
}
|
|
269
|
+
return `${relativePath.slice(0, -".mdx".length)}.md`;
|
|
270
|
+
}
|
|
271
|
+
function getCodeFenceDelimiter(line) {
|
|
272
|
+
const fenceMatch = /^\s*(```+|~~~+)/u.exec(line);
|
|
273
|
+
return fenceMatch?.at(1) ?? null;
|
|
274
|
+
}
|
|
275
|
+
function strictMdxError(input) {
|
|
276
|
+
return DocsCoreError.validation(`Unsupported MDX syntax in strict mode: ${input.syntax}`, {
|
|
277
|
+
line: input.lineNumber,
|
|
278
|
+
path: relativeToWorkspace(input.workspaceRoot, input.sourceAbsolutePath),
|
|
279
|
+
syntax: input.syntax
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function processDocsSourceContent(input) {
|
|
283
|
+
if (extname(input.sourceAbsolutePath).toLowerCase() !== ".mdx") {
|
|
284
|
+
return Result.ok({ content: input.content, warnings: [] });
|
|
285
|
+
}
|
|
286
|
+
const warningPath = relativeToWorkspace(input.workspaceRoot, input.sourceAbsolutePath);
|
|
287
|
+
const outputLines = [];
|
|
288
|
+
const warnings = [];
|
|
289
|
+
const sourceLines = input.content.split(/\r?\n/u);
|
|
290
|
+
let activeFenceDelimiter = null;
|
|
291
|
+
for (let index = 0;index < sourceLines.length; index += 1) {
|
|
292
|
+
const line = sourceLines[index] ?? "";
|
|
293
|
+
const lineNumber = index + 1;
|
|
294
|
+
const fenceDelimiter = getCodeFenceDelimiter(line);
|
|
295
|
+
if (activeFenceDelimiter) {
|
|
296
|
+
outputLines.push(line);
|
|
297
|
+
if (fenceDelimiter && fenceDelimiter[0] === activeFenceDelimiter[0] && fenceDelimiter.length >= activeFenceDelimiter.length) {
|
|
298
|
+
activeFenceDelimiter = null;
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (fenceDelimiter) {
|
|
303
|
+
activeFenceDelimiter = fenceDelimiter;
|
|
304
|
+
outputLines.push(line);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (/^\s*(import|export)\s/u.test(line)) {
|
|
308
|
+
if (input.mdxMode === "strict") {
|
|
309
|
+
return Result.err(strictMdxError({
|
|
310
|
+
workspaceRoot: input.workspaceRoot,
|
|
311
|
+
sourceAbsolutePath: input.sourceAbsolutePath,
|
|
312
|
+
lineNumber,
|
|
313
|
+
syntax: "import/export statement"
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
warnings.push({
|
|
317
|
+
message: `Removed import/export statement on line ${lineNumber}`,
|
|
318
|
+
path: warningPath
|
|
319
|
+
});
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (/^\s*<\/?[A-Z][\w.]*\b[^>]*>\s*$/u.test(line)) {
|
|
323
|
+
if (input.mdxMode === "strict") {
|
|
324
|
+
return Result.err(strictMdxError({
|
|
325
|
+
workspaceRoot: input.workspaceRoot,
|
|
326
|
+
sourceAbsolutePath: input.sourceAbsolutePath,
|
|
327
|
+
lineNumber,
|
|
328
|
+
syntax: "JSX component tag"
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
warnings.push({
|
|
332
|
+
message: `Removed JSX component tag on line ${lineNumber}`,
|
|
333
|
+
path: warningPath
|
|
334
|
+
});
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (/^\s*\{.*\}\s*$/u.test(line)) {
|
|
338
|
+
if (input.mdxMode === "strict") {
|
|
339
|
+
return Result.err(strictMdxError({
|
|
340
|
+
workspaceRoot: input.workspaceRoot,
|
|
341
|
+
sourceAbsolutePath: input.sourceAbsolutePath,
|
|
342
|
+
lineNumber,
|
|
343
|
+
syntax: "expression block"
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
warnings.push({
|
|
347
|
+
message: `Removed expression block on line ${lineNumber}`,
|
|
348
|
+
path: warningPath
|
|
349
|
+
});
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (/\{[^{}]+\}/u.test(line)) {
|
|
353
|
+
if (input.mdxMode === "strict") {
|
|
354
|
+
return Result.err(strictMdxError({
|
|
355
|
+
workspaceRoot: input.workspaceRoot,
|
|
356
|
+
sourceAbsolutePath: input.sourceAbsolutePath,
|
|
357
|
+
lineNumber,
|
|
358
|
+
syntax: "inline expression"
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
warnings.push({
|
|
362
|
+
message: `Removed inline expression on line ${lineNumber}`,
|
|
363
|
+
path: warningPath
|
|
364
|
+
});
|
|
365
|
+
outputLines.push(line.replace(/\{[^{}]+\}/gu, "").replace(/[ \t]+$/u, ""));
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
outputLines.push(line);
|
|
369
|
+
}
|
|
370
|
+
return Result.ok({
|
|
371
|
+
content: outputLines.join(`
|
|
372
|
+
`),
|
|
373
|
+
warnings
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
async function buildExpectedOutput(options) {
|
|
377
|
+
const discoveredPackages = await discoverPackageDirectories(options.packagesRoot);
|
|
378
|
+
const packageNames = [];
|
|
379
|
+
const collectedFiles = [];
|
|
380
|
+
const files = new Map;
|
|
381
|
+
const warnings = [];
|
|
382
|
+
const sourceByDestinationPath = new Map;
|
|
383
|
+
for (const discoveredPackage of discoveredPackages) {
|
|
384
|
+
const publishable = await isPublishablePackage(discoveredPackage.packageRoot);
|
|
385
|
+
if (!publishable) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const markdownFiles = await collectPackageSourceFiles(discoveredPackage.packageRoot, options.excludedLowercaseNames);
|
|
389
|
+
if (markdownFiles.length === 0) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
packageNames.push(discoveredPackage.packageDirName);
|
|
393
|
+
for (const sourceAbsolutePath of markdownFiles) {
|
|
394
|
+
const relativeFromPackageRoot = relative(discoveredPackage.packageRoot, sourceAbsolutePath);
|
|
395
|
+
const destinationAbsolutePath = join(options.outputRoot, discoveredPackage.packageDirName, toOutputRelativePath(relativeFromPackageRoot));
|
|
396
|
+
const existingSourceAbsolutePath = sourceByDestinationPath.get(destinationAbsolutePath);
|
|
397
|
+
if (existingSourceAbsolutePath) {
|
|
398
|
+
throw DocsCoreError.validation("Multiple source docs files resolve to the same output path", {
|
|
399
|
+
outputPath: relativeToWorkspace(options.workspaceRoot, destinationAbsolutePath),
|
|
400
|
+
firstSourcePath: relativeToWorkspace(options.workspaceRoot, existingSourceAbsolutePath),
|
|
401
|
+
secondSourcePath: relativeToWorkspace(options.workspaceRoot, sourceAbsolutePath)
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
sourceByDestinationPath.set(destinationAbsolutePath, sourceAbsolutePath);
|
|
405
|
+
collectedFiles.push({
|
|
406
|
+
packageName: discoveredPackage.packageDirName,
|
|
407
|
+
sourceAbsolutePath,
|
|
408
|
+
destinationAbsolutePath
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const mirrorTargetBySourcePath = new Map(collectedFiles.map((file) => [
|
|
413
|
+
file.sourceAbsolutePath,
|
|
414
|
+
file.destinationAbsolutePath
|
|
415
|
+
]));
|
|
416
|
+
const sortedCollectedFiles = [...collectedFiles].sort((a, b) => toPosixPath(a.destinationAbsolutePath).localeCompare(toPosixPath(b.destinationAbsolutePath)));
|
|
417
|
+
const entries = [];
|
|
418
|
+
for (const collectedFile of sortedCollectedFiles) {
|
|
419
|
+
const sourceContent = await readFile(collectedFile.sourceAbsolutePath, "utf8");
|
|
420
|
+
const processedContentResult = processDocsSourceContent({
|
|
421
|
+
content: sourceContent,
|
|
422
|
+
sourceAbsolutePath: collectedFile.sourceAbsolutePath,
|
|
423
|
+
workspaceRoot: options.workspaceRoot,
|
|
424
|
+
mdxMode: options.mdxMode
|
|
425
|
+
});
|
|
426
|
+
if (processedContentResult.isErr()) {
|
|
427
|
+
throw processedContentResult.error;
|
|
428
|
+
}
|
|
429
|
+
const rewrittenContent = rewriteMarkdownLinks(processedContentResult.value.content, collectedFile.sourceAbsolutePath, collectedFile.destinationAbsolutePath, options.workspaceRoot, mirrorTargetBySourcePath);
|
|
430
|
+
warnings.push(...processedContentResult.value.warnings);
|
|
431
|
+
files.set(collectedFile.destinationAbsolutePath, rewrittenContent);
|
|
432
|
+
entries.push({
|
|
433
|
+
packageName: collectedFile.packageName,
|
|
434
|
+
destinationAbsolutePath: collectedFile.destinationAbsolutePath,
|
|
435
|
+
content: rewrittenContent
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
packageNames: packageNames.sort((a, b) => a.localeCompare(b)),
|
|
440
|
+
files,
|
|
441
|
+
entries,
|
|
442
|
+
warnings
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function trimTrailingWhitespace(value) {
|
|
446
|
+
return value.replace(/[ \t]+$/gm, "").trimEnd();
|
|
447
|
+
}
|
|
448
|
+
function extractFirstHeading(content) {
|
|
449
|
+
for (const line of content.split(/\r?\n/u)) {
|
|
450
|
+
const headingMatch = /^\s*#{1,6}\s+(.+)$/u.exec(line);
|
|
451
|
+
if (!headingMatch) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const heading = headingMatch.at(1);
|
|
455
|
+
if (heading) {
|
|
456
|
+
return heading.trim();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
function renderLlmsIndex(expectedOutput, workspaceRoot) {
|
|
462
|
+
const lines = [
|
|
463
|
+
"# llms.txt",
|
|
464
|
+
"",
|
|
465
|
+
"Outfitter package docs index for LLM retrieval.",
|
|
466
|
+
""
|
|
467
|
+
];
|
|
468
|
+
for (const packageName of expectedOutput.packageNames) {
|
|
469
|
+
lines.push(`## ${packageName}`);
|
|
470
|
+
const packageEntries = expectedOutput.entries.filter((entry) => entry.packageName === packageName).sort((a, b) => toPosixPath(a.destinationAbsolutePath).localeCompare(toPosixPath(b.destinationAbsolutePath)));
|
|
471
|
+
for (const entry of packageEntries) {
|
|
472
|
+
const relativePath = relativeToWorkspace(workspaceRoot, entry.destinationAbsolutePath);
|
|
473
|
+
const heading = extractFirstHeading(entry.content);
|
|
474
|
+
lines.push(`- ${relativePath}${heading ? ` \u2014 ${heading}` : ""}`);
|
|
475
|
+
}
|
|
476
|
+
lines.push("");
|
|
477
|
+
}
|
|
478
|
+
return `${lines.join(`
|
|
479
|
+
`).trimEnd()}
|
|
480
|
+
`;
|
|
481
|
+
}
|
|
482
|
+
function renderLlmsFull(expectedOutput, workspaceRoot) {
|
|
483
|
+
const lines = [
|
|
484
|
+
"# llms-full.txt",
|
|
485
|
+
"",
|
|
486
|
+
"Outfitter package docs corpus for LLM retrieval.",
|
|
487
|
+
""
|
|
488
|
+
];
|
|
489
|
+
const entries = [...expectedOutput.entries].sort((a, b) => toPosixPath(a.destinationAbsolutePath).localeCompare(toPosixPath(b.destinationAbsolutePath)));
|
|
490
|
+
for (const entry of entries) {
|
|
491
|
+
const relativePath = relativeToWorkspace(workspaceRoot, entry.destinationAbsolutePath);
|
|
492
|
+
const heading = extractFirstHeading(entry.content);
|
|
493
|
+
lines.push("---");
|
|
494
|
+
lines.push(`path: ${relativePath}`);
|
|
495
|
+
lines.push(`package: ${entry.packageName}`);
|
|
496
|
+
if (heading) {
|
|
497
|
+
lines.push(`title: ${heading}`);
|
|
498
|
+
}
|
|
499
|
+
lines.push("---");
|
|
500
|
+
lines.push("");
|
|
501
|
+
lines.push(trimTrailingWhitespace(entry.content));
|
|
502
|
+
lines.push("");
|
|
503
|
+
}
|
|
504
|
+
return `${lines.join(`
|
|
505
|
+
`).trimEnd()}
|
|
506
|
+
`;
|
|
507
|
+
}
|
|
508
|
+
function buildLlmsExpectedFiles(expectedOutput, workspaceRoot, llmsOptions) {
|
|
509
|
+
const files = new Map;
|
|
510
|
+
for (const target of llmsOptions.targets) {
|
|
511
|
+
if (target === "llms") {
|
|
512
|
+
files.set(llmsOptions.llmsPath, renderLlmsIndex(expectedOutput, workspaceRoot));
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
files.set(llmsOptions.llmsFullPath, renderLlmsFull(expectedOutput, workspaceRoot));
|
|
516
|
+
}
|
|
517
|
+
return files;
|
|
518
|
+
}
|
|
519
|
+
async function computeExplicitFileDrift(workspaceRoot, expectedFiles) {
|
|
520
|
+
const drift = [];
|
|
521
|
+
const expectedEntries = [...expectedFiles.entries()].sort(([a], [b]) => toPosixPath(a).localeCompare(toPosixPath(b)));
|
|
522
|
+
for (const [expectedPath, expectedContent] of expectedEntries) {
|
|
523
|
+
if (!existsSync(expectedPath)) {
|
|
524
|
+
drift.push({
|
|
525
|
+
kind: "missing",
|
|
526
|
+
path: relativeToWorkspace(workspaceRoot, expectedPath)
|
|
527
|
+
});
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const existingContent = await readFile(expectedPath, "utf8");
|
|
531
|
+
if (existingContent !== expectedContent) {
|
|
532
|
+
drift.push({
|
|
533
|
+
kind: "changed",
|
|
534
|
+
path: relativeToWorkspace(workspaceRoot, expectedPath)
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return sortDrift(drift);
|
|
539
|
+
}
|
|
540
|
+
async function listFilesRecursively(rootPath) {
|
|
541
|
+
if (!existsSync(rootPath)) {
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
const files = [];
|
|
545
|
+
const directories = [rootPath];
|
|
546
|
+
while (directories.length > 0) {
|
|
547
|
+
const currentDir = directories.pop();
|
|
548
|
+
if (!currentDir) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
552
|
+
for (const entry of entries) {
|
|
553
|
+
const fullPath = join(currentDir, entry.name);
|
|
554
|
+
if (entry.isDirectory()) {
|
|
555
|
+
directories.push(fullPath);
|
|
556
|
+
} else if (entry.isFile()) {
|
|
557
|
+
files.push(fullPath);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return files.sort((a, b) => toPosixPath(a).localeCompare(toPosixPath(b)));
|
|
562
|
+
}
|
|
563
|
+
async function pruneEmptyDirectories(rootPath) {
|
|
564
|
+
if (!existsSync(rootPath)) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
async function prune(currentDir) {
|
|
568
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
569
|
+
let hasFiles = false;
|
|
570
|
+
for (const entry of entries) {
|
|
571
|
+
const fullPath = join(currentDir, entry.name);
|
|
572
|
+
if (entry.isDirectory()) {
|
|
573
|
+
const childHasFiles = await prune(fullPath);
|
|
574
|
+
if (childHasFiles) {
|
|
575
|
+
hasFiles = true;
|
|
576
|
+
} else {
|
|
577
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
hasFiles = true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return hasFiles;
|
|
584
|
+
}
|
|
585
|
+
await prune(rootPath);
|
|
586
|
+
}
|
|
587
|
+
function sortDrift(drift) {
|
|
588
|
+
const kindPriority = {
|
|
589
|
+
changed: 0,
|
|
590
|
+
missing: 1,
|
|
591
|
+
unexpected: 2
|
|
592
|
+
};
|
|
593
|
+
return drift.sort((a, b) => {
|
|
594
|
+
const byKind = kindPriority[a.kind] - kindPriority[b.kind];
|
|
595
|
+
if (byKind !== 0) {
|
|
596
|
+
return byKind;
|
|
597
|
+
}
|
|
598
|
+
return a.path.localeCompare(b.path);
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
async function computeDrift(options, expectedFiles) {
|
|
602
|
+
const expectedPaths = new Set(expectedFiles.keys());
|
|
603
|
+
const existingFiles = await listFilesRecursively(options.outputRoot);
|
|
604
|
+
const drift = [];
|
|
605
|
+
const expectedEntries = [...expectedFiles.entries()].sort(([a], [b]) => toPosixPath(a).localeCompare(toPosixPath(b)));
|
|
606
|
+
for (const [expectedPath, expectedContent] of expectedEntries) {
|
|
607
|
+
if (!existsSync(expectedPath)) {
|
|
608
|
+
drift.push({
|
|
609
|
+
kind: "missing",
|
|
610
|
+
path: relativeToWorkspace(options.workspaceRoot, expectedPath)
|
|
611
|
+
});
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const existingContent = await readFile(expectedPath, "utf8");
|
|
615
|
+
if (existingContent !== expectedContent) {
|
|
616
|
+
drift.push({
|
|
617
|
+
kind: "changed",
|
|
618
|
+
path: relativeToWorkspace(options.workspaceRoot, expectedPath)
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const existingPath of existingFiles) {
|
|
623
|
+
if (expectedPaths.has(existingPath)) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
drift.push({
|
|
627
|
+
kind: "unexpected",
|
|
628
|
+
path: relativeToWorkspace(options.workspaceRoot, existingPath)
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return sortDrift(drift);
|
|
632
|
+
}
|
|
633
|
+
async function syncPackageDocs(options) {
|
|
634
|
+
const resolvedOptionsResult = resolveOptions(options);
|
|
635
|
+
if (resolvedOptionsResult.isErr()) {
|
|
636
|
+
return resolvedOptionsResult;
|
|
637
|
+
}
|
|
638
|
+
const resolvedOptions = resolvedOptionsResult.value;
|
|
639
|
+
try {
|
|
640
|
+
const expectedOutput = await buildExpectedOutput(resolvedOptions);
|
|
641
|
+
const existingFiles = await listFilesRecursively(resolvedOptions.outputRoot);
|
|
642
|
+
const expectedPaths = new Set(expectedOutput.files.keys());
|
|
643
|
+
const unexpectedFiles = existingFiles.filter((existingPath) => !expectedPaths.has(existingPath));
|
|
644
|
+
for (const filePath of unexpectedFiles) {
|
|
645
|
+
await rm(filePath, { force: true });
|
|
646
|
+
}
|
|
647
|
+
for (const [outputPath, content] of expectedOutput.files.entries()) {
|
|
648
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
649
|
+
await writeFile(outputPath, content, "utf8");
|
|
650
|
+
}
|
|
651
|
+
await pruneEmptyDirectories(resolvedOptions.outputRoot);
|
|
652
|
+
return Result.ok({
|
|
653
|
+
packageNames: expectedOutput.packageNames,
|
|
654
|
+
writtenFiles: [...expectedOutput.files.keys()].map((filePath) => relativeToWorkspace(resolvedOptions.workspaceRoot, filePath)).sort((a, b) => a.localeCompare(b)),
|
|
655
|
+
removedFiles: unexpectedFiles.map((filePath) => relativeToWorkspace(resolvedOptions.workspaceRoot, filePath)).sort((a, b) => a.localeCompare(b)),
|
|
656
|
+
warnings: expectedOutput.warnings
|
|
657
|
+
});
|
|
658
|
+
} catch (error) {
|
|
659
|
+
if (error instanceof DocsCoreError) {
|
|
660
|
+
return Result.err(error);
|
|
661
|
+
}
|
|
662
|
+
return Result.err(DocsCoreError.internal("Failed to sync package docs", {
|
|
663
|
+
errorMessage: error instanceof Error ? error.message : "Unknown error"
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async function checkPackageDocs(options) {
|
|
668
|
+
const resolvedOptionsResult = resolveOptions(options);
|
|
669
|
+
if (resolvedOptionsResult.isErr()) {
|
|
670
|
+
return resolvedOptionsResult;
|
|
671
|
+
}
|
|
672
|
+
const resolvedOptions = resolvedOptionsResult.value;
|
|
673
|
+
try {
|
|
674
|
+
const expectedOutput = await buildExpectedOutput(resolvedOptions);
|
|
675
|
+
const drift = await computeDrift(resolvedOptions, expectedOutput.files);
|
|
676
|
+
return Result.ok({
|
|
677
|
+
packageNames: expectedOutput.packageNames,
|
|
678
|
+
expectedFiles: [...expectedOutput.files.keys()].map((filePath) => relativeToWorkspace(resolvedOptions.workspaceRoot, filePath)).sort((a, b) => a.localeCompare(b)),
|
|
679
|
+
drift,
|
|
680
|
+
isUpToDate: drift.length === 0,
|
|
681
|
+
warnings: expectedOutput.warnings
|
|
682
|
+
});
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error instanceof DocsCoreError) {
|
|
685
|
+
return Result.err(error);
|
|
686
|
+
}
|
|
687
|
+
return Result.err(DocsCoreError.internal("Failed to check package docs", {
|
|
688
|
+
errorMessage: error instanceof Error ? error.message : "Unknown error"
|
|
689
|
+
}));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async function syncLlmsDocs(options) {
|
|
693
|
+
const resolvedOptionsResult = resolveOptions(options);
|
|
694
|
+
if (resolvedOptionsResult.isErr()) {
|
|
695
|
+
return resolvedOptionsResult;
|
|
696
|
+
}
|
|
697
|
+
const resolvedOptions = resolvedOptionsResult.value;
|
|
698
|
+
const resolvedLlmsOptionsResult = resolveLlmsOptions(resolvedOptions.workspaceRoot, options);
|
|
699
|
+
if (resolvedLlmsOptionsResult.isErr()) {
|
|
700
|
+
return resolvedLlmsOptionsResult;
|
|
701
|
+
}
|
|
702
|
+
const resolvedLlmsOptions = resolvedLlmsOptionsResult.value;
|
|
703
|
+
try {
|
|
704
|
+
const expectedOutput = await buildExpectedOutput(resolvedOptions);
|
|
705
|
+
const expectedFiles = buildLlmsExpectedFiles(expectedOutput, resolvedOptions.workspaceRoot, resolvedLlmsOptions);
|
|
706
|
+
for (const [outputPath, content] of expectedFiles.entries()) {
|
|
707
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
708
|
+
await writeFile(outputPath, content, "utf8");
|
|
709
|
+
}
|
|
710
|
+
return Result.ok({
|
|
711
|
+
packageNames: expectedOutput.packageNames,
|
|
712
|
+
writtenFiles: [...expectedFiles.keys()].map((filePath) => relativeToWorkspace(resolvedOptions.workspaceRoot, filePath)).sort((a, b) => a.localeCompare(b)),
|
|
713
|
+
warnings: expectedOutput.warnings
|
|
714
|
+
});
|
|
715
|
+
} catch (error) {
|
|
716
|
+
if (error instanceof DocsCoreError) {
|
|
717
|
+
return Result.err(error);
|
|
718
|
+
}
|
|
719
|
+
return Result.err(DocsCoreError.internal("Failed to sync LLM docs outputs", {
|
|
720
|
+
errorMessage: error instanceof Error ? error.message : "Unknown error"
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async function checkLlmsDocs(options) {
|
|
725
|
+
const resolvedOptionsResult = resolveOptions(options);
|
|
726
|
+
if (resolvedOptionsResult.isErr()) {
|
|
727
|
+
return resolvedOptionsResult;
|
|
728
|
+
}
|
|
729
|
+
const resolvedOptions = resolvedOptionsResult.value;
|
|
730
|
+
const resolvedLlmsOptionsResult = resolveLlmsOptions(resolvedOptions.workspaceRoot, options);
|
|
731
|
+
if (resolvedLlmsOptionsResult.isErr()) {
|
|
732
|
+
return resolvedLlmsOptionsResult;
|
|
733
|
+
}
|
|
734
|
+
const resolvedLlmsOptions = resolvedLlmsOptionsResult.value;
|
|
735
|
+
try {
|
|
736
|
+
const expectedOutput = await buildExpectedOutput(resolvedOptions);
|
|
737
|
+
const expectedFiles = buildLlmsExpectedFiles(expectedOutput, resolvedOptions.workspaceRoot, resolvedLlmsOptions);
|
|
738
|
+
const drift = await computeExplicitFileDrift(resolvedOptions.workspaceRoot, expectedFiles);
|
|
739
|
+
return Result.ok({
|
|
740
|
+
packageNames: expectedOutput.packageNames,
|
|
741
|
+
expectedFiles: [...expectedFiles.keys()].map((filePath) => relativeToWorkspace(resolvedOptions.workspaceRoot, filePath)).sort((a, b) => a.localeCompare(b)),
|
|
742
|
+
drift,
|
|
743
|
+
isUpToDate: drift.length === 0,
|
|
744
|
+
warnings: expectedOutput.warnings
|
|
745
|
+
});
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (error instanceof DocsCoreError) {
|
|
748
|
+
return Result.err(error);
|
|
749
|
+
}
|
|
750
|
+
return Result.err(DocsCoreError.internal("Failed to check LLM docs outputs", {
|
|
751
|
+
errorMessage: error instanceof Error ? error.message : "Unknown error"
|
|
752
|
+
}));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export { DocsCoreError, syncPackageDocs, checkPackageDocs, syncLlmsDocs, checkLlmsDocs };
|