docspack 0.0.1 → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/docspack.js +25 -0
- package/dist/build.d.ts +31 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +435 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +763 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +118 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +60 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +204 -0
- package/dist/db.js.map +1 -0
- package/dist/discovery.d.ts +31 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +126 -0
- package/dist/discovery.js.map +1 -0
- package/dist/doctor.d.ts +25 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +276 -0
- package/dist/doctor.js.map +1 -0
- package/dist/document.d.ts +13 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +47 -0
- package/dist/document.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -0
- package/dist/exports.d.ts +20 -0
- package/dist/exports.d.ts.map +1 -0
- package/dist/exports.js +100 -0
- package/dist/exports.js.map +1 -0
- package/dist/feedback.d.ts +68 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +0 -0
- package/dist/feedback.js.map +1 -0
- package/dist/html.d.ts +4 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +23 -0
- package/dist/html.js.map +1 -0
- package/dist/http.d.ts +30 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +144 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/init/detect.d.ts +16 -0
- package/dist/init/detect.d.ts.map +1 -0
- package/dist/init/detect.js +120 -0
- package/dist/init/detect.js.map +1 -0
- package/dist/init/plan.d.ts +43 -0
- package/dist/init/plan.d.ts.map +1 -0
- package/dist/init/plan.js +145 -0
- package/dist/init/plan.js.map +1 -0
- package/dist/init/run.d.ts +28 -0
- package/dist/init/run.d.ts.map +1 -0
- package/dist/init/run.js +96 -0
- package/dist/init/run.js.map +1 -0
- package/dist/init/templates.d.ts +24 -0
- package/dist/init/templates.d.ts.map +1 -0
- package/dist/init/templates.js +181 -0
- package/dist/init/templates.js.map +1 -0
- package/dist/init/write.d.ts +20 -0
- package/dist/init/write.d.ts.map +1 -0
- package/dist/init/write.js +56 -0
- package/dist/init/write.js.map +1 -0
- package/dist/kinds.d.ts +14 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/kinds.js +15 -0
- package/dist/kinds.js.map +1 -0
- package/dist/llms-txt.d.ts +25 -0
- package/dist/llms-txt.d.ts.map +1 -0
- package/dist/llms-txt.js +94 -0
- package/dist/llms-txt.js.map +1 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +158 -0
- package/dist/mcp.js.map +1 -0
- package/dist/preview.d.ts +18 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +72 -0
- package/dist/preview.js.map +1 -0
- package/dist/prompt.d.ts +27 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +79 -0
- package/dist/prompt.js.map +1 -0
- package/dist/search.d.ts +41 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +60 -0
- package/dist/search.js.map +1 -0
- package/dist/snippet.d.ts +20 -0
- package/dist/snippet.d.ts.map +1 -0
- package/dist/snippet.js +29 -0
- package/dist/snippet.js.map +1 -0
- package/dist/spec.d.ts +38 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +105 -0
- package/dist/spec.js.map +1 -0
- package/dist/style.d.ts +33 -0
- package/dist/style.d.ts.map +1 -0
- package/dist/style.js +94 -0
- package/dist/style.js.map +1 -0
- package/dist/submit.d.ts +61 -0
- package/dist/submit.d.ts.map +1 -0
- package/dist/submit.js +111 -0
- package/dist/submit.js.map +1 -0
- package/dist/sync.d.ts +29 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +73 -0
- package/dist/sync.js.map +1 -0
- package/dist/verify.d.ts +44 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +291 -0
- package/dist/verify.js.map +1 -0
- package/package.json +61 -5
- package/src/build.ts +572 -0
- package/src/cli.ts +883 -0
- package/src/config.ts +158 -0
- package/src/db.ts +261 -0
- package/src/discovery.ts +161 -0
- package/src/doctor.ts +344 -0
- package/src/document.ts +59 -0
- package/src/errors.ts +10 -0
- package/src/exports.ts +120 -0
- package/src/feedback.ts +0 -0
- package/src/html.ts +24 -0
- package/src/http.ts +190 -0
- package/src/index.ts +132 -0
- package/src/init/detect.ts +142 -0
- package/src/init/plan.ts +215 -0
- package/src/init/run.ts +142 -0
- package/src/init/templates.ts +200 -0
- package/src/init/write.ts +83 -0
- package/src/kinds.ts +17 -0
- package/src/llms-txt.ts +116 -0
- package/src/mcp.ts +196 -0
- package/src/preview.ts +98 -0
- package/src/prompt.ts +103 -0
- package/src/search.ts +96 -0
- package/src/snippet.ts +30 -0
- package/src/spec.ts +138 -0
- package/src/style.ts +111 -0
- package/src/submit.ts +189 -0
- package/src/sync.ts +112 -0
- package/src/verify.ts +355 -0
- package/bin/cli.js +0 -2
package/src/http.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { DocspackError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
4
|
+
|
|
5
|
+
export interface HttpResponse {
|
|
6
|
+
/** Final URL after redirects, when the runtime reports one. */
|
|
7
|
+
readonly url: string;
|
|
8
|
+
readonly contentType: string;
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly bytes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GetOptions {
|
|
14
|
+
readonly maxBytes?: number;
|
|
15
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HttpClientOptions {
|
|
19
|
+
readonly fetchImpl?: FetchLike;
|
|
20
|
+
readonly timeoutMs?: number;
|
|
21
|
+
readonly maxBytes?: number;
|
|
22
|
+
readonly retries?: number;
|
|
23
|
+
readonly userAgent?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RETRY_DELAYS_MS = [250, 1000];
|
|
27
|
+
const DEFAULT_ACCEPT = "text/markdown, text/plain, text/html;q=0.8, */*;q=0.1";
|
|
28
|
+
|
|
29
|
+
/** Signals a transport-level failure worth retrying. */
|
|
30
|
+
class RetryableHttpError extends Error {}
|
|
31
|
+
|
|
32
|
+
const delay = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
|
|
33
|
+
|
|
34
|
+
/** Parses a URL and rejects anything that is not http(s), including `file:` and `data:`. */
|
|
35
|
+
export function assertHttpUrl(url: string): URL {
|
|
36
|
+
let parsed: URL;
|
|
37
|
+
try {
|
|
38
|
+
parsed = new URL(url);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new DocspackError(`"${url}" is not a valid URL`);
|
|
41
|
+
}
|
|
42
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
43
|
+
throw new DocspackError(`Refusing to fetch "${url}": only http and https URLs are supported`);
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Small HTTP client with byte caps, timeouts and retries. The fetch implementation is injectable. */
|
|
49
|
+
export class HttpClient {
|
|
50
|
+
readonly #fetch: FetchLike;
|
|
51
|
+
readonly #timeoutMs: number;
|
|
52
|
+
readonly #maxBytes: number;
|
|
53
|
+
readonly #retries: number;
|
|
54
|
+
readonly #userAgent: string;
|
|
55
|
+
|
|
56
|
+
constructor(options: HttpClientOptions = {}) {
|
|
57
|
+
this.#fetch = options.fetchImpl ?? globalThis.fetch;
|
|
58
|
+
this.#timeoutMs = options.timeoutMs ?? 30_000;
|
|
59
|
+
this.#maxBytes = options.maxBytes ?? 2_000_000;
|
|
60
|
+
this.#retries = options.retries ?? 2;
|
|
61
|
+
this.#userAgent = options.userAgent ?? "docspack";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async get(url: string, options: GetOptions = {}): Promise<HttpResponse> {
|
|
65
|
+
const maxBytes = options.maxBytes ?? this.#maxBytes;
|
|
66
|
+
let lastError: unknown;
|
|
67
|
+
|
|
68
|
+
for (let attempt = 0; attempt <= this.#retries; attempt += 1) {
|
|
69
|
+
if (attempt > 0) await delay(RETRY_DELAYS_MS[attempt - 1] ?? 1000);
|
|
70
|
+
try {
|
|
71
|
+
return await this.#getOnce(url, maxBytes, options.headers);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!(error instanceof RetryableHttpError)) throw error;
|
|
74
|
+
lastError = error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new DocspackError(
|
|
79
|
+
`Could not fetch ${url}: ${lastError instanceof Error ? lastError.message : "unknown error"}`,
|
|
80
|
+
{
|
|
81
|
+
hint: "Check your network connection and whether the documentation host is reachable.",
|
|
82
|
+
cause: lastError,
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True when a HEAD request succeeds. Used to probe for optional documents. */
|
|
88
|
+
async exists(url: string, headers?: Readonly<Record<string, string>>): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = assertHttpUrl(url);
|
|
91
|
+
const response = await this.#fetch(parsed.toString(), {
|
|
92
|
+
method: "HEAD",
|
|
93
|
+
redirect: "follow",
|
|
94
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
95
|
+
headers: { "user-agent": this.#userAgent, ...headers },
|
|
96
|
+
});
|
|
97
|
+
return response.ok;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async #getOnce(
|
|
104
|
+
url: string,
|
|
105
|
+
maxBytes: number,
|
|
106
|
+
headers: Readonly<Record<string, string>> | undefined,
|
|
107
|
+
): Promise<HttpResponse> {
|
|
108
|
+
const parsed = assertHttpUrl(url);
|
|
109
|
+
|
|
110
|
+
let response: Response;
|
|
111
|
+
try {
|
|
112
|
+
response = await this.#fetch(parsed.toString(), {
|
|
113
|
+
redirect: "follow",
|
|
114
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
115
|
+
headers: { "user-agent": this.#userAgent, accept: DEFAULT_ACCEPT, ...headers },
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new RetryableHttpError(error instanceof Error ? error.message : String(error));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
const detail = `${response.status} ${response.statusText}`.trim();
|
|
123
|
+
if (response.status === 429 || response.status >= 500) {
|
|
124
|
+
throw new RetryableHttpError(detail);
|
|
125
|
+
}
|
|
126
|
+
throw new DocspackError(
|
|
127
|
+
`Could not fetch ${url}: ${detail}`,
|
|
128
|
+
response.status === 404
|
|
129
|
+
? {
|
|
130
|
+
hint: "The document does not exist at that URL. Check the source or pick another one.",
|
|
131
|
+
}
|
|
132
|
+
: {},
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A redirect must not be able to move the request onto a non-http scheme.
|
|
137
|
+
if (response.url.length > 0) assertHttpUrl(response.url);
|
|
138
|
+
|
|
139
|
+
const declared = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
140
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
141
|
+
throw new DocspackError(`${url} is ${declared} bytes, over the ${maxBytes} byte limit`, {
|
|
142
|
+
hint: "Raise the limit with --max-bytes, or use a smaller documentation source.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { text, bytes } = await this.#readCapped(response, url, maxBytes);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
url: response.url.length > 0 ? response.url : parsed.toString(),
|
|
150
|
+
contentType: response.headers.get("content-type") ?? "",
|
|
151
|
+
text: text.replace(/\r\n/g, "\n"),
|
|
152
|
+
bytes,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #readCapped(
|
|
157
|
+
response: Response,
|
|
158
|
+
url: string,
|
|
159
|
+
maxBytes: number,
|
|
160
|
+
): Promise<{ text: string; bytes: number }> {
|
|
161
|
+
const body = response.body;
|
|
162
|
+
if (body === null) {
|
|
163
|
+
const text = await response.text();
|
|
164
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
165
|
+
if (bytes > maxBytes) throw this.#tooLarge(url, maxBytes);
|
|
166
|
+
return { text, bytes };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const reader = body.getReader();
|
|
170
|
+
const chunks: Uint8Array[] = [];
|
|
171
|
+
let bytes = 0;
|
|
172
|
+
while (true) {
|
|
173
|
+
const { done, value } = await reader.read();
|
|
174
|
+
if (done) break;
|
|
175
|
+
bytes += value.byteLength;
|
|
176
|
+
if (bytes > maxBytes) {
|
|
177
|
+
await reader.cancel();
|
|
178
|
+
throw this.#tooLarge(url, maxBytes);
|
|
179
|
+
}
|
|
180
|
+
chunks.push(value);
|
|
181
|
+
}
|
|
182
|
+
return { text: Buffer.concat(chunks).toString("utf8"), bytes };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#tooLarge(url: string, maxBytes: number): DocspackError {
|
|
186
|
+
return new DocspackError(`${url} exceeds the ${maxBytes} byte limit`, {
|
|
187
|
+
hint: "Raise the limit with --max-bytes, or use a smaller documentation source.",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export { type BuildOptions, type BuildResult, buildPackage } from "./build.js";
|
|
2
|
+
export {
|
|
3
|
+
type BuildConfig,
|
|
4
|
+
CONFIG_KEY,
|
|
5
|
+
type FeedbackChannel,
|
|
6
|
+
parseBuildConfig,
|
|
7
|
+
parseFeedbackChannel,
|
|
8
|
+
readBuildConfig,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
export {
|
|
11
|
+
defaultStorePath,
|
|
12
|
+
type IndexedChunk,
|
|
13
|
+
type IndexedPackage,
|
|
14
|
+
type SearchHit,
|
|
15
|
+
type SearchOptions,
|
|
16
|
+
Store,
|
|
17
|
+
silenceSqliteWarning,
|
|
18
|
+
toFtsQuery,
|
|
19
|
+
} from "./db.js";
|
|
20
|
+
export {
|
|
21
|
+
type DiscoveredPackage,
|
|
22
|
+
type Discovery,
|
|
23
|
+
discoverPackages,
|
|
24
|
+
projectPackageIds,
|
|
25
|
+
resolvePackageDir,
|
|
26
|
+
} from "./discovery.js";
|
|
27
|
+
export {
|
|
28
|
+
type DoctorOptions,
|
|
29
|
+
type DoctorReport,
|
|
30
|
+
type Finding,
|
|
31
|
+
runDoctor,
|
|
32
|
+
type Severity,
|
|
33
|
+
} from "./doctor.js";
|
|
34
|
+
export { DocspackError } from "./errors.js";
|
|
35
|
+
export { type ExportSurface, readExportSurface } from "./exports.js";
|
|
36
|
+
export {
|
|
37
|
+
type AddOptions,
|
|
38
|
+
type AddResult,
|
|
39
|
+
addFinding,
|
|
40
|
+
FEEDBACK_DIR,
|
|
41
|
+
FEEDBACK_FILE,
|
|
42
|
+
type FeedbackFinding,
|
|
43
|
+
feedbackPath,
|
|
44
|
+
fingerprintOf,
|
|
45
|
+
type RemoveOptions,
|
|
46
|
+
readFeedback,
|
|
47
|
+
removeFindings,
|
|
48
|
+
} from "./feedback.js";
|
|
49
|
+
export { type FetchLike, HttpClient } from "./http.js";
|
|
50
|
+
export { type Detected, detectProject, type PackageManager } from "./init/detect.js";
|
|
51
|
+
export {
|
|
52
|
+
type InitMode,
|
|
53
|
+
type InitOptions,
|
|
54
|
+
type InitPlan,
|
|
55
|
+
type InitTemplate,
|
|
56
|
+
type InputKind,
|
|
57
|
+
type PlannedFile,
|
|
58
|
+
planInit,
|
|
59
|
+
proposePackageName,
|
|
60
|
+
} from "./init/plan.js";
|
|
61
|
+
export { type InitResult, type RunInitOptions, runInit } from "./init/run.js";
|
|
62
|
+
export {
|
|
63
|
+
type ApplyOptions,
|
|
64
|
+
applyPlan,
|
|
65
|
+
renderTree,
|
|
66
|
+
type WriteStatus,
|
|
67
|
+
type WrittenFile,
|
|
68
|
+
} from "./init/write.js";
|
|
69
|
+
export { type FindingKind, isFindingKind, KINDS } from "./kinds.js";
|
|
70
|
+
export {
|
|
71
|
+
fetchableLinks,
|
|
72
|
+
type LlmsTxtDocument,
|
|
73
|
+
type LlmsTxtLink,
|
|
74
|
+
type LlmsTxtSection,
|
|
75
|
+
parseLlmsTxt,
|
|
76
|
+
} from "./llms-txt.js";
|
|
77
|
+
export { createMcpServer, type McpOptions, startMcpServer } from "./mcp.js";
|
|
78
|
+
export { type PreviewOptions, type PreviewResult, previewPackage } from "./preview.js";
|
|
79
|
+
export { type Choice, defaultStreams, Prompter, type PromptStreams } from "./prompt.js";
|
|
80
|
+
export {
|
|
81
|
+
DEFAULT_LIMIT,
|
|
82
|
+
DEFAULT_MAX_TOKENS,
|
|
83
|
+
type QueryHit,
|
|
84
|
+
type QueryOptions,
|
|
85
|
+
type QueryResult,
|
|
86
|
+
queryDocs,
|
|
87
|
+
renderAnswer,
|
|
88
|
+
splitPackageId,
|
|
89
|
+
} from "./search.js";
|
|
90
|
+
export { AGENTS_SNIPPET, FEEDBACK_SNIPPET } from "./snippet.js";
|
|
91
|
+
export {
|
|
92
|
+
CHUNKS_DIR,
|
|
93
|
+
type ChunkSpec,
|
|
94
|
+
chunkId,
|
|
95
|
+
estimateTokens,
|
|
96
|
+
isCommunityPackage,
|
|
97
|
+
isDocsPackage,
|
|
98
|
+
isVendorPackage,
|
|
99
|
+
LLMS_DIR,
|
|
100
|
+
MANIFEST_FILE,
|
|
101
|
+
type PackageManifest,
|
|
102
|
+
packageId,
|
|
103
|
+
parseManifest,
|
|
104
|
+
resolveChunkFile,
|
|
105
|
+
SCHEMA_URL,
|
|
106
|
+
serializeManifest,
|
|
107
|
+
} from "./spec.js";
|
|
108
|
+
export {
|
|
109
|
+
type BlockedFinding,
|
|
110
|
+
type BlockedReason,
|
|
111
|
+
issueUrl,
|
|
112
|
+
prepareSubmission,
|
|
113
|
+
type RoutedFinding,
|
|
114
|
+
renderBody,
|
|
115
|
+
type SubmitOptions,
|
|
116
|
+
type SubmitReport,
|
|
117
|
+
} from "./submit.js";
|
|
118
|
+
export {
|
|
119
|
+
type SyncedPackage,
|
|
120
|
+
type SyncOptions,
|
|
121
|
+
type SyncResult,
|
|
122
|
+
type SyncStatus,
|
|
123
|
+
syncProject,
|
|
124
|
+
} from "./sync.js";
|
|
125
|
+
export {
|
|
126
|
+
type DriftFinding,
|
|
127
|
+
type VerifiedPackage,
|
|
128
|
+
type VerifyOptions,
|
|
129
|
+
type VerifyReport,
|
|
130
|
+
type VerifyStatus,
|
|
131
|
+
verifyProject,
|
|
132
|
+
} from "./verify.js";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type PackageManager = "pnpm" | "npm" | "yarn" | "bun";
|
|
6
|
+
|
|
7
|
+
/** What `init` learned about the project it was run in. Everything is a proposal, never a decision. */
|
|
8
|
+
export interface Detected {
|
|
9
|
+
readonly libraryName?: string;
|
|
10
|
+
readonly libraryVersion?: string;
|
|
11
|
+
readonly docsDir?: string;
|
|
12
|
+
readonly docsFiles?: number;
|
|
13
|
+
readonly openapi?: string;
|
|
14
|
+
readonly llmsTxt?: string;
|
|
15
|
+
readonly repository?: string;
|
|
16
|
+
readonly license?: string;
|
|
17
|
+
readonly packageManager: PackageManager;
|
|
18
|
+
readonly workspace: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DOC_DIRS = ["docs", "documentation", "website/docs", "content/docs", "src/content/docs"];
|
|
22
|
+
const OPENAPI_FILES = [
|
|
23
|
+
"openapi.json",
|
|
24
|
+
"api/openapi.json",
|
|
25
|
+
"spec/openapi.json",
|
|
26
|
+
"docs/openapi.json",
|
|
27
|
+
];
|
|
28
|
+
const LLMS_TXT_FILES = ["llms.txt", "public/llms.txt", "static/llms.txt"];
|
|
29
|
+
const MARKDOWN = /\.(md|mdx|markdown)$/i;
|
|
30
|
+
|
|
31
|
+
export async function detectProject(cwd: string): Promise<Detected> {
|
|
32
|
+
const manifest = await readJson(join(cwd, "package.json"));
|
|
33
|
+
const name = typeof manifest?.name === "string" ? manifest.name : undefined;
|
|
34
|
+
const version = typeof manifest?.version === "string" ? manifest.version : undefined;
|
|
35
|
+
const license = typeof manifest?.license === "string" ? manifest.license : undefined;
|
|
36
|
+
|
|
37
|
+
const docs = await findDocsDir(cwd);
|
|
38
|
+
const openapi = await findOpenApi(cwd);
|
|
39
|
+
const llmsTxt = await findFirstFile(cwd, LLMS_TXT_FILES);
|
|
40
|
+
const repository = await gitRemote(cwd);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
...(name === undefined ? {} : { libraryName: name }),
|
|
44
|
+
...(version === undefined ? {} : { libraryVersion: version }),
|
|
45
|
+
...(docs === undefined ? {} : { docsDir: docs.dir, docsFiles: docs.files }),
|
|
46
|
+
...(openapi === undefined ? {} : { openapi }),
|
|
47
|
+
...(llmsTxt === undefined ? {} : { llmsTxt }),
|
|
48
|
+
...(repository === undefined ? {} : { repository }),
|
|
49
|
+
...(license === undefined ? {} : { license: await detectLicense(cwd, license) }),
|
|
50
|
+
packageManager: await detectPackageManager(cwd),
|
|
51
|
+
workspace:
|
|
52
|
+
manifest?.workspaces !== undefined || (await exists(join(cwd, "pnpm-workspace.yaml"))),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function findDocsDir(cwd: string): Promise<{ dir: string; files: number } | undefined> {
|
|
57
|
+
for (const candidate of DOC_DIRS) {
|
|
58
|
+
const dir = join(cwd, candidate);
|
|
59
|
+
const files = await countMarkdown(dir);
|
|
60
|
+
if (files > 0) return { dir: candidate, files };
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function countMarkdown(dir: string): Promise<number> {
|
|
66
|
+
let found: Dirent[];
|
|
67
|
+
try {
|
|
68
|
+
found = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
69
|
+
} catch {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
return found.filter((entry) => entry.isFile() && MARKDOWN.test(entry.name)).length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Only counts as an OpenAPI document if it actually declares itself as one. */
|
|
76
|
+
async function findOpenApi(cwd: string): Promise<string | undefined> {
|
|
77
|
+
for (const candidate of OPENAPI_FILES) {
|
|
78
|
+
const parsed = await readJson(join(cwd, candidate));
|
|
79
|
+
if (parsed === undefined) continue;
|
|
80
|
+
if (typeof parsed.openapi === "string" || typeof parsed.swagger === "string") return candidate;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function findFirstFile(
|
|
86
|
+
cwd: string,
|
|
87
|
+
candidates: readonly string[],
|
|
88
|
+
): Promise<string | undefined> {
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
if (await exists(join(cwd, candidate))) return candidate;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function detectPackageManager(cwd: string): Promise<PackageManager> {
|
|
96
|
+
if (await exists(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
97
|
+
if ((await exists(join(cwd, "bun.lock"))) || (await exists(join(cwd, "bun.lockb")))) return "bun";
|
|
98
|
+
if (await exists(join(cwd, "yarn.lock"))) return "yarn";
|
|
99
|
+
return "npm";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function detectLicense(cwd: string, declared: string): Promise<string> {
|
|
103
|
+
return declared.length > 0 || (await exists(join(cwd, "LICENSE"))) ? declared : declared;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Reads the origin URL straight out of .git/config rather than spawning git. */
|
|
107
|
+
async function gitRemote(cwd: string): Promise<string | undefined> {
|
|
108
|
+
let config: string;
|
|
109
|
+
try {
|
|
110
|
+
config = await readFile(join(cwd, ".git", "config"), "utf8");
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const section = /\[remote "origin"\]([\s\S]*?)(?:\n\[|$)/.exec(config);
|
|
116
|
+
const url = section?.[1] === undefined ? undefined : /url\s*=\s*(\S+)/.exec(section[1])?.[1];
|
|
117
|
+
if (url === undefined) return undefined;
|
|
118
|
+
|
|
119
|
+
const ssh = /^git@([^:]+):(.+?)(?:\.git)?$/.exec(url);
|
|
120
|
+
if (ssh?.[1] !== undefined && ssh[2] !== undefined) return `https://${ssh[1]}/${ssh[2]}`;
|
|
121
|
+
return url.replace(/\.git$/, "");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
|
|
125
|
+
try {
|
|
126
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
127
|
+
return typeof parsed === "object" && parsed !== null
|
|
128
|
+
? (parsed as Record<string, unknown>)
|
|
129
|
+
: undefined;
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function exists(path: string): Promise<boolean> {
|
|
136
|
+
try {
|
|
137
|
+
await stat(path);
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/init/plan.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { DocspackError } from "../errors.js";
|
|
3
|
+
import { isCommunityPackage, isDocsPackage } from "../spec.js";
|
|
4
|
+
import type { Detected } from "./detect.js";
|
|
5
|
+
import { docSeeds, gitignore, packageJson, readme, workflow } from "./templates.js";
|
|
6
|
+
|
|
7
|
+
export type InitMode = "standalone" | "in-repo";
|
|
8
|
+
export type InitTemplate = "full" | "minimal";
|
|
9
|
+
export type InputKind = "from" | "openapi" | "mirror" | "seeded";
|
|
10
|
+
|
|
11
|
+
export interface InitOptions {
|
|
12
|
+
readonly name?: string;
|
|
13
|
+
readonly version?: string;
|
|
14
|
+
readonly out?: string;
|
|
15
|
+
readonly mode?: InitMode;
|
|
16
|
+
readonly from?: string;
|
|
17
|
+
readonly openapi?: string;
|
|
18
|
+
readonly mirror?: string;
|
|
19
|
+
readonly community?: boolean;
|
|
20
|
+
readonly workflow?: boolean;
|
|
21
|
+
readonly template?: InitTemplate;
|
|
22
|
+
/** Version of docspack recorded as the package's devDependency. */
|
|
23
|
+
readonly docspackVersion: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PlannedFile {
|
|
27
|
+
/** POSIX-style path relative to the package directory. */
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly contents: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InitPlan {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly version: string;
|
|
35
|
+
/** Package directory, relative to where init was run. */
|
|
36
|
+
readonly dir: string;
|
|
37
|
+
readonly input: { readonly kind: InputKind; readonly value: string };
|
|
38
|
+
readonly files: readonly PlannedFile[];
|
|
39
|
+
readonly notes: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SLUG = /[^a-z0-9-]+/g;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Computes the whole scaffold from options and what was detected. Pure on purpose: `--dry-run`
|
|
46
|
+
* prints exactly what a real run would write, and tests can assert the file set without a disk.
|
|
47
|
+
*/
|
|
48
|
+
export function planInit(options: InitOptions, detected: Detected): InitPlan {
|
|
49
|
+
const community =
|
|
50
|
+
options.community === true || (options.name !== undefined && isCommunityPackage(options.name));
|
|
51
|
+
const name = options.name ?? proposePackageName(detected, community);
|
|
52
|
+
assertPackageName(name);
|
|
53
|
+
|
|
54
|
+
const notes: string[] = [];
|
|
55
|
+
const version = options.version ?? detected.libraryVersion ?? "0.1.0";
|
|
56
|
+
if (options.version === undefined && detected.libraryVersion === undefined) {
|
|
57
|
+
notes.push("No library version was detected, so the package starts at 0.1.0.");
|
|
58
|
+
} else if (options.version === undefined && detected.libraryVersion !== undefined) {
|
|
59
|
+
notes.push(
|
|
60
|
+
`Version ${version} matches ${detected.libraryName ?? "the surrounding package"}; keep them in step on every release.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const mode: InitMode = options.mode ?? "standalone";
|
|
65
|
+
const dir = options.out ?? (mode === "in-repo" ? "packages/docspack" : "docspack");
|
|
66
|
+
const input = resolveInput(options, detected, dir, notes);
|
|
67
|
+
const template: InitTemplate = options.template ?? "full";
|
|
68
|
+
|
|
69
|
+
const files: PlannedFile[] = [
|
|
70
|
+
{
|
|
71
|
+
path: "package.json",
|
|
72
|
+
contents: packageJson({
|
|
73
|
+
name,
|
|
74
|
+
version,
|
|
75
|
+
...(detected.libraryName === undefined ? {} : { libraryName: detected.libraryName }),
|
|
76
|
+
...(detected.repository === undefined ? {} : { repository: detected.repository }),
|
|
77
|
+
...(detected.license === undefined ? {} : { license: detected.license }),
|
|
78
|
+
build: buildConfigFor(input),
|
|
79
|
+
docspackVersion: options.docspackVersion,
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
{ path: ".gitignore", contents: gitignore() },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
if (template === "full") {
|
|
86
|
+
files.push({
|
|
87
|
+
path: "README.md",
|
|
88
|
+
contents: readme(name, detected.libraryName),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (input.kind === "seeded") {
|
|
93
|
+
files.push(...docSeeds(detected.libraryName ?? name));
|
|
94
|
+
notes.push(
|
|
95
|
+
"docs/ was seeded with three templates. Replace the TODO markers before publishing; `docspack doctor --strict` fails while they remain.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.workflow === true) {
|
|
100
|
+
files.push({
|
|
101
|
+
path: ".github/workflows/publish-docspack.yml",
|
|
102
|
+
contents: workflow(name, workflowArgs(input)),
|
|
103
|
+
});
|
|
104
|
+
notes.push("The release workflow needs an NPM_TOKEN secret with publish rights to the scope.");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!community) {
|
|
108
|
+
const scope = name.slice(0, name.indexOf("/"));
|
|
109
|
+
notes.push(`Publishing to ${scope} requires owning that npm scope.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { name, version, dir, input, files, notes };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `@acme/sdk` becomes `@acme/docspack`; an unscoped `acme` becomes `@acme/docspack`. */
|
|
116
|
+
export function proposePackageName(detected: Detected, community: boolean): string {
|
|
117
|
+
const library = detected.libraryName;
|
|
118
|
+
if (library === undefined) {
|
|
119
|
+
throw new DocspackError("Could not work out a package name", {
|
|
120
|
+
hint: "Run inside a project with a package.json, or pass --name @acme/docspack.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const bare = library.startsWith("@") ? (library.split("/")[1] ?? library.slice(1)) : library;
|
|
125
|
+
const slug = bare
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(SLUG, "-")
|
|
128
|
+
.replace(/^-+|-+$/g, "");
|
|
129
|
+
if (community) return `@docspack-community/${slug}`;
|
|
130
|
+
|
|
131
|
+
const scope = library.startsWith("@") ? library.slice(1, library.indexOf("/")) : slug;
|
|
132
|
+
return `@${scope}/docspack`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function assertPackageName(name: string): void {
|
|
136
|
+
if (!isDocsPackage(name)) {
|
|
137
|
+
throw new DocspackError(`"${name}" is not a name docspack will discover`, {
|
|
138
|
+
hint: "Use @vendor/docspack for a package you own, or @docspack-community/<name> otherwise.",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveInput(
|
|
144
|
+
options: InitOptions,
|
|
145
|
+
detected: Detected,
|
|
146
|
+
dir: string,
|
|
147
|
+
notes: string[],
|
|
148
|
+
): { kind: InputKind; value: string } {
|
|
149
|
+
const chosen = [options.from, options.openapi, options.mirror].filter(
|
|
150
|
+
(value) => value !== undefined,
|
|
151
|
+
);
|
|
152
|
+
if (chosen.length > 1) {
|
|
153
|
+
throw new DocspackError("Choose one input: --from, --openapi or --mirror");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (options.mirror !== undefined) {
|
|
157
|
+
if (!isCommunityPackage(options.name ?? "")) {
|
|
158
|
+
notes.push(
|
|
159
|
+
"A mirror republishes someone else's documentation; publish it under @docspack-community rather than the vendor's scope.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return { kind: "mirror", value: options.mirror };
|
|
163
|
+
}
|
|
164
|
+
if (options.openapi !== undefined)
|
|
165
|
+
return { kind: "openapi", value: relativeToPackage(options.openapi, dir) };
|
|
166
|
+
if (options.from !== undefined)
|
|
167
|
+
return { kind: "from", value: relativeToPackage(options.from, dir) };
|
|
168
|
+
|
|
169
|
+
if (detected.docsDir !== undefined) {
|
|
170
|
+
notes.push(`Found ${detected.docsFiles} Markdown files in ${detected.docsDir}/.`);
|
|
171
|
+
return { kind: "from", value: relativeToPackage(detected.docsDir, dir) };
|
|
172
|
+
}
|
|
173
|
+
if (detected.openapi !== undefined) {
|
|
174
|
+
notes.push(`Found an OpenAPI document at ${detected.openapi}.`);
|
|
175
|
+
return { kind: "openapi", value: relativeToPackage(detected.openapi, dir) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { kind: "seeded", value: "./docs" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build inputs are recorded relative to the package directory, because that is where
|
|
183
|
+
* `docspack build` runs from.
|
|
184
|
+
*/
|
|
185
|
+
function relativeToPackage(target: string, dir: string): string {
|
|
186
|
+
if (/^https?:\/\//i.test(target)) return target;
|
|
187
|
+
const normalized = target.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
188
|
+
const relative = posix.relative(dir.replace(/\\/g, "/"), normalized);
|
|
189
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildConfigFor(input: {
|
|
193
|
+
kind: InputKind;
|
|
194
|
+
value: string;
|
|
195
|
+
}): Record<string, string | number> {
|
|
196
|
+
switch (input.kind) {
|
|
197
|
+
case "openapi":
|
|
198
|
+
return { openapi: input.value };
|
|
199
|
+
case "mirror":
|
|
200
|
+
return { source: input.value };
|
|
201
|
+
default:
|
|
202
|
+
return { from: input.value, maxChunkTokens: 800 };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function workflowArgs(input: { kind: InputKind; value: string }): string {
|
|
207
|
+
switch (input.kind) {
|
|
208
|
+
case "openapi":
|
|
209
|
+
return ` --openapi ${input.value}`;
|
|
210
|
+
case "mirror":
|
|
211
|
+
return ` ${input.value}`;
|
|
212
|
+
default:
|
|
213
|
+
return ` --from ${input.value}`;
|
|
214
|
+
}
|
|
215
|
+
}
|