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