docspack 0.0.1 → 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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/docspack.js +25 -0
  4. package/dist/build.d.ts +31 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +435 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +763 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/config.d.ts +41 -0
  13. package/dist/config.d.ts.map +1 -0
  14. package/dist/config.js +118 -0
  15. package/dist/config.js.map +1 -0
  16. package/dist/db.d.ts +60 -0
  17. package/dist/db.d.ts.map +1 -0
  18. package/dist/db.js +204 -0
  19. package/dist/db.js.map +1 -0
  20. package/dist/discovery.d.ts +31 -0
  21. package/dist/discovery.d.ts.map +1 -0
  22. package/dist/discovery.js +126 -0
  23. package/dist/discovery.js.map +1 -0
  24. package/dist/doctor.d.ts +25 -0
  25. package/dist/doctor.d.ts.map +1 -0
  26. package/dist/doctor.js +276 -0
  27. package/dist/doctor.js.map +1 -0
  28. package/dist/document.d.ts +13 -0
  29. package/dist/document.d.ts.map +1 -0
  30. package/dist/document.js +47 -0
  31. package/dist/document.js.map +1 -0
  32. package/dist/errors.d.ts +9 -0
  33. package/dist/errors.d.ts.map +1 -0
  34. package/dist/errors.js +10 -0
  35. package/dist/errors.js.map +1 -0
  36. package/dist/exports.d.ts +20 -0
  37. package/dist/exports.d.ts.map +1 -0
  38. package/dist/exports.js +100 -0
  39. package/dist/exports.js.map +1 -0
  40. package/dist/feedback.d.ts +68 -0
  41. package/dist/feedback.d.ts.map +1 -0
  42. package/dist/feedback.js +0 -0
  43. package/dist/feedback.js.map +1 -0
  44. package/dist/html.d.ts +4 -0
  45. package/dist/html.d.ts.map +1 -0
  46. package/dist/html.js +23 -0
  47. package/dist/html.js.map +1 -0
  48. package/dist/http.d.ts +30 -0
  49. package/dist/http.d.ts.map +1 -0
  50. package/dist/http.js +144 -0
  51. package/dist/http.js.map +1 -0
  52. package/dist/index.d.ts +25 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +25 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/init/detect.d.ts +16 -0
  57. package/dist/init/detect.d.ts.map +1 -0
  58. package/dist/init/detect.js +120 -0
  59. package/dist/init/detect.js.map +1 -0
  60. package/dist/init/plan.d.ts +43 -0
  61. package/dist/init/plan.d.ts.map +1 -0
  62. package/dist/init/plan.js +145 -0
  63. package/dist/init/plan.js.map +1 -0
  64. package/dist/init/run.d.ts +28 -0
  65. package/dist/init/run.d.ts.map +1 -0
  66. package/dist/init/run.js +96 -0
  67. package/dist/init/run.js.map +1 -0
  68. package/dist/init/templates.d.ts +24 -0
  69. package/dist/init/templates.d.ts.map +1 -0
  70. package/dist/init/templates.js +181 -0
  71. package/dist/init/templates.js.map +1 -0
  72. package/dist/init/write.d.ts +20 -0
  73. package/dist/init/write.d.ts.map +1 -0
  74. package/dist/init/write.js +56 -0
  75. package/dist/init/write.js.map +1 -0
  76. package/dist/kinds.d.ts +14 -0
  77. package/dist/kinds.d.ts.map +1 -0
  78. package/dist/kinds.js +15 -0
  79. package/dist/kinds.js.map +1 -0
  80. package/dist/llms-txt.d.ts +25 -0
  81. package/dist/llms-txt.d.ts.map +1 -0
  82. package/dist/llms-txt.js +94 -0
  83. package/dist/llms-txt.js.map +1 -0
  84. package/dist/mcp.d.ts +15 -0
  85. package/dist/mcp.d.ts.map +1 -0
  86. package/dist/mcp.js +158 -0
  87. package/dist/mcp.js.map +1 -0
  88. package/dist/preview.d.ts +18 -0
  89. package/dist/preview.d.ts.map +1 -0
  90. package/dist/preview.js +72 -0
  91. package/dist/preview.js.map +1 -0
  92. package/dist/prompt.d.ts +27 -0
  93. package/dist/prompt.d.ts.map +1 -0
  94. package/dist/prompt.js +79 -0
  95. package/dist/prompt.js.map +1 -0
  96. package/dist/search.d.ts +41 -0
  97. package/dist/search.d.ts.map +1 -0
  98. package/dist/search.js +60 -0
  99. package/dist/search.js.map +1 -0
  100. package/dist/snippet.d.ts +20 -0
  101. package/dist/snippet.d.ts.map +1 -0
  102. package/dist/snippet.js +29 -0
  103. package/dist/snippet.js.map +1 -0
  104. package/dist/spec.d.ts +38 -0
  105. package/dist/spec.d.ts.map +1 -0
  106. package/dist/spec.js +105 -0
  107. package/dist/spec.js.map +1 -0
  108. package/dist/style.d.ts +33 -0
  109. package/dist/style.d.ts.map +1 -0
  110. package/dist/style.js +94 -0
  111. package/dist/style.js.map +1 -0
  112. package/dist/submit.d.ts +61 -0
  113. package/dist/submit.d.ts.map +1 -0
  114. package/dist/submit.js +111 -0
  115. package/dist/submit.js.map +1 -0
  116. package/dist/sync.d.ts +29 -0
  117. package/dist/sync.d.ts.map +1 -0
  118. package/dist/sync.js +73 -0
  119. package/dist/sync.js.map +1 -0
  120. package/dist/verify.d.ts +44 -0
  121. package/dist/verify.d.ts.map +1 -0
  122. package/dist/verify.js +291 -0
  123. package/dist/verify.js.map +1 -0
  124. package/package.json +60 -5
  125. package/src/build.ts +572 -0
  126. package/src/cli.ts +883 -0
  127. package/src/config.ts +158 -0
  128. package/src/db.ts +261 -0
  129. package/src/discovery.ts +161 -0
  130. package/src/doctor.ts +344 -0
  131. package/src/document.ts +59 -0
  132. package/src/errors.ts +10 -0
  133. package/src/exports.ts +120 -0
  134. package/src/feedback.ts +0 -0
  135. package/src/html.ts +24 -0
  136. package/src/http.ts +190 -0
  137. package/src/index.ts +132 -0
  138. package/src/init/detect.ts +142 -0
  139. package/src/init/plan.ts +215 -0
  140. package/src/init/run.ts +142 -0
  141. package/src/init/templates.ts +200 -0
  142. package/src/init/write.ts +83 -0
  143. package/src/kinds.ts +17 -0
  144. package/src/llms-txt.ts +116 -0
  145. package/src/mcp.ts +196 -0
  146. package/src/preview.ts +98 -0
  147. package/src/prompt.ts +103 -0
  148. package/src/search.ts +96 -0
  149. package/src/snippet.ts +30 -0
  150. package/src/spec.ts +138 -0
  151. package/src/style.ts +111 -0
  152. package/src/submit.ts +189 -0
  153. package/src/sync.ts +112 -0
  154. package/src/verify.ts +355 -0
  155. package/bin/cli.js +0 -2
package/src/doctor.ts ADDED
@@ -0,0 +1,344 @@
1
+ import type { Dirent } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { readBuildConfig } from "./config.js";
5
+ import { PLACEHOLDER } from "./init/templates.js";
6
+ import {
7
+ estimateTokens,
8
+ LLMS_DIR,
9
+ MANIFEST_FILE,
10
+ type PackageManifest,
11
+ parseManifest,
12
+ resolveChunkFile,
13
+ } from "./spec.js";
14
+ import {
15
+ contentFingerprint,
16
+ findFiller,
17
+ findLongSentences,
18
+ hasConcreteContent,
19
+ withoutCode,
20
+ } from "./style.js";
21
+
22
+ export type Severity = "error" | "warn" | "info";
23
+
24
+ export interface Finding {
25
+ readonly check: string;
26
+ readonly severity: Severity;
27
+ readonly message: string;
28
+ readonly where?: string;
29
+ readonly fix?: string;
30
+ }
31
+
32
+ export interface DoctorReport {
33
+ readonly ok: boolean;
34
+ readonly findings: readonly Finding[];
35
+ readonly chunks: number;
36
+ readonly tokens: number;
37
+ }
38
+
39
+ export interface DoctorOptions {
40
+ readonly dir: string;
41
+ /** Treat warnings as failures. Used by prepublishOnly and CI. */
42
+ readonly strict?: boolean;
43
+ }
44
+
45
+ /** Over this, a chunk crowds out the response budget; under it, a chunk answers nothing. */
46
+ const MAX_CHUNK_TOKENS = 1500;
47
+ const MIN_CHUNK_TOKENS = 30;
48
+
49
+ /**
50
+ * Checks a documentation package the way the indexer and a reviewer would. A broken docs package
51
+ * still installs and still indexes, so every problem here is one that would otherwise be silent.
52
+ */
53
+ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
54
+ const findings: Finding[] = [];
55
+ const llmsDir = join(options.dir, LLMS_DIR);
56
+
57
+ const pkg = await readJson(join(options.dir, "package.json"));
58
+ const manifest = await loadManifest(llmsDir, findings);
59
+ if (manifest === undefined) {
60
+ return { ok: false, findings, chunks: 0, tokens: 0 };
61
+ }
62
+
63
+ checkPackageJson(pkg, manifest, findings);
64
+
65
+ let tokens = 0;
66
+ let newestSource = 0;
67
+ const seen = new Map<string, string>();
68
+ const abstract: string[] = [];
69
+ for (const chunk of manifest.chunks) {
70
+ let contents: string;
71
+ try {
72
+ contents = await readFile(resolveChunkFile(llmsDir, chunk.file), "utf8");
73
+ } catch (error) {
74
+ findings.push({
75
+ check: /outside of/.test(String(error)) ? "chunk-escapes" : "chunk-missing",
76
+ severity: "error",
77
+ message: /outside of/.test(String(error))
78
+ ? `chunk "${chunk.id}" points outside ${LLMS_DIR}/`
79
+ : `chunk "${chunk.id}" is missing its file`,
80
+ where: chunk.file,
81
+ fix: "Rebuild with `docspack build`.",
82
+ });
83
+ continue;
84
+ }
85
+
86
+ const text = contents.trim();
87
+ if (text.length === 0) {
88
+ findings.push({
89
+ check: "chunk-empty",
90
+ severity: "error",
91
+ message: `chunk "${chunk.id}" is empty`,
92
+ where: chunk.file,
93
+ fix: "Write the section, or remove it from the manifest.",
94
+ });
95
+ continue;
96
+ }
97
+
98
+ const size = chunk.tokens > 0 ? chunk.tokens : estimateTokens(text);
99
+ tokens += size;
100
+
101
+ if (size > MAX_CHUNK_TOKENS) {
102
+ findings.push({
103
+ check: "chunk-too-large",
104
+ severity: "warn",
105
+ message: `chunk "${chunk.id}" is ~${size} tokens; it will crowd out a response`,
106
+ where: chunk.file,
107
+ fix: "Split the section with more headings, or lower maxChunkTokens.",
108
+ });
109
+ } else if (size < MIN_CHUNK_TOKENS) {
110
+ findings.push({
111
+ check: "chunk-too-small",
112
+ severity: "warn",
113
+ message: `chunk "${chunk.id}" is ~${size} tokens; too small to answer anything`,
114
+ where: chunk.file,
115
+ fix: "Merge it into a neighbouring section.",
116
+ });
117
+ }
118
+
119
+ if (chunk.tags.length === 0 && chunk.entities.length === 0) {
120
+ findings.push({
121
+ check: "untagged",
122
+ severity: "warn",
123
+ message: `chunk "${chunk.id}" has no tags or entities; it is findable only by its prose`,
124
+ where: chunk.file,
125
+ fix: "Name the API in `inline code`, or add tags to the manifest.",
126
+ });
127
+ }
128
+
129
+ // Prose that mentions the marker inside `code` is documenting it, not leaving one behind.
130
+ if (withoutCode(text).includes(PLACEHOLDER)) {
131
+ findings.push({
132
+ check: "placeholder",
133
+ severity: "warn",
134
+ message: `chunk "${chunk.id}" still contains ${PLACEHOLDER} markers from the template`,
135
+ where: chunk.file,
136
+ fix: "Replace the template text with real documentation.",
137
+ });
138
+ }
139
+
140
+ const filler = findFiller(text);
141
+ if (filler.length > 0) {
142
+ const listed = filler
143
+ .slice(0, 3)
144
+ .map((hit) => `"${hit.phrase}"`)
145
+ .join(", ");
146
+ findings.push({
147
+ check: "filler",
148
+ severity: "warn",
149
+ message: `chunk "${chunk.id}" contains narration a model does not need: ${listed}`,
150
+ where: chunk.file,
151
+ fix: "Delete it. Nobody searches for these words and they carry no fact.",
152
+ });
153
+ }
154
+
155
+ const long = findLongSentences(text);
156
+ if (long.length > 0) {
157
+ findings.push({
158
+ check: "long-sentence",
159
+ severity: "warn",
160
+ message: `chunk "${chunk.id}" has ${plural(long.length, "sentence")} over 40 words`,
161
+ where: chunk.file,
162
+ fix: "Split them. One claim per sentence retrieves and reads better.",
163
+ });
164
+ }
165
+
166
+ if (!hasConcreteContent(text)) abstract.push(chunk.id);
167
+
168
+ const fingerprint = contentFingerprint(text);
169
+ const twin = seen.get(fingerprint);
170
+ if (twin === undefined) seen.set(fingerprint, chunk.id);
171
+ else {
172
+ findings.push({
173
+ check: "duplicate-chunk",
174
+ severity: "warn",
175
+ message: `chunk "${chunk.id}" says the same thing as "${twin}"`,
176
+ where: chunk.file,
177
+ fix: "Remove one. Duplicates compete for the same query and waste the response budget.",
178
+ });
179
+ }
180
+ }
181
+
182
+ if (abstract.length > 0) {
183
+ findings.push({
184
+ check: "no-example",
185
+ severity: "info",
186
+ message: `${plural(abstract.length, "chunk")} of ${manifest.chunks.length} have no code, list or table: ${abstract.slice(0, 3).join(", ")}${abstract.length > 3 ? ", …" : ""}`,
187
+ fix: "Agents act on concrete text. Add the call, the signature or the values.",
188
+ });
189
+ }
190
+
191
+ if (manifest.chunks.every((chunk) => chunk.entities.length === 0)) {
192
+ findings.push({
193
+ check: "no-entities",
194
+ severity: "info",
195
+ message:
196
+ "no chunk names an identifier; agents searching for an API name may miss this package",
197
+ fix: "Put API names in `inline code` in the source documentation.",
198
+ });
199
+ }
200
+
201
+ newestSource = await newestSourceMtime(options.dir);
202
+ const manifestMtime = await mtime(join(llmsDir, MANIFEST_FILE));
203
+ if (newestSource > 0 && manifestMtime > 0 && newestSource > manifestMtime) {
204
+ findings.push({
205
+ check: "payload-stale",
206
+ severity: "warn",
207
+ message: "the documentation source is newer than the generated payload",
208
+ fix: "Run `docspack build`.",
209
+ });
210
+ }
211
+
212
+ const failed = findings.some(
213
+ (finding) =>
214
+ finding.severity === "error" || (options.strict === true && finding.severity === "warn"),
215
+ );
216
+
217
+ return { ok: !failed, findings, chunks: manifest.chunks.length, tokens };
218
+ }
219
+
220
+ function plural(count: number, noun: string): string {
221
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
222
+ }
223
+
224
+ async function loadManifest(
225
+ llmsDir: string,
226
+ findings: Finding[],
227
+ ): Promise<PackageManifest | undefined> {
228
+ let raw: string;
229
+ try {
230
+ raw = await readFile(join(llmsDir, MANIFEST_FILE), "utf8");
231
+ } catch {
232
+ findings.push({
233
+ check: "manifest-invalid",
234
+ severity: "error",
235
+ message: `no ${LLMS_DIR}/${MANIFEST_FILE}`,
236
+ fix: "Run `docspack build` to generate the payload.",
237
+ });
238
+ return undefined;
239
+ }
240
+
241
+ try {
242
+ return parseManifest(JSON.parse(raw), `${LLMS_DIR}/${MANIFEST_FILE}`);
243
+ } catch (error) {
244
+ findings.push({
245
+ check: "manifest-invalid",
246
+ severity: "error",
247
+ message: error instanceof Error ? error.message : String(error),
248
+ where: `${LLMS_DIR}/${MANIFEST_FILE}`,
249
+ fix: "Run `docspack build` to regenerate it.",
250
+ });
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ function checkPackageJson(
256
+ pkg: Record<string, unknown> | undefined,
257
+ manifest: PackageManifest,
258
+ findings: Finding[],
259
+ ): void {
260
+ if (pkg === undefined) {
261
+ findings.push({
262
+ check: "manifest-invalid",
263
+ severity: "error",
264
+ message: "no package.json next to the payload",
265
+ fix: "Run `docspack init` or add a package.json.",
266
+ });
267
+ return;
268
+ }
269
+
270
+ if (typeof pkg.version === "string" && pkg.version !== manifest.version) {
271
+ findings.push({
272
+ check: "version-mismatch",
273
+ severity: "error",
274
+ message: `package.json is ${pkg.version} but the manifest says ${manifest.version}`,
275
+ fix: "Run `docspack build` after bumping the version.",
276
+ });
277
+ }
278
+ if (typeof pkg.name === "string" && pkg.name !== manifest.name) {
279
+ findings.push({
280
+ check: "version-mismatch",
281
+ severity: "error",
282
+ message: `package.json is ${pkg.name} but the manifest says ${manifest.name}`,
283
+ fix: "Run `docspack build` to regenerate the manifest.",
284
+ });
285
+ }
286
+
287
+ const files = Array.isArray(pkg.files) ? pkg.files.map(String) : undefined;
288
+ if (
289
+ files !== undefined &&
290
+ !files.some((entry) => entry.replace(/^\.\//, "").startsWith(LLMS_DIR))
291
+ ) {
292
+ findings.push({
293
+ check: "files-missing-payload",
294
+ severity: "error",
295
+ message: `"files" does not include ${LLMS_DIR}; publishing would ship a package with no documentation`,
296
+ where: "package.json",
297
+ fix: `Add "${LLMS_DIR}" to the files array.`,
298
+ });
299
+ }
300
+ }
301
+
302
+ /** Newest mtime across the configured documentation source, used to spot a stale payload. */
303
+ async function newestSourceMtime(dir: string): Promise<number> {
304
+ const config = await readBuildConfig(dir);
305
+ const source = config.from ?? config.openapi;
306
+ if (source === undefined) return 0;
307
+
308
+ const target = join(dir, source);
309
+ const single = await mtime(target);
310
+ if (single > 0 && config.openapi !== undefined) return single;
311
+
312
+ let found: Dirent[];
313
+ try {
314
+ found = await readdir(target, { recursive: true, withFileTypes: true });
315
+ } catch {
316
+ return single;
317
+ }
318
+
319
+ let newest = 0;
320
+ for (const entry of found) {
321
+ if (!entry.isFile()) continue;
322
+ newest = Math.max(newest, await mtime(join(entry.parentPath, entry.name)));
323
+ }
324
+ return newest;
325
+ }
326
+
327
+ async function mtime(path: string): Promise<number> {
328
+ try {
329
+ return (await stat(path)).mtimeMs;
330
+ } catch {
331
+ return 0;
332
+ }
333
+ }
334
+
335
+ async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
336
+ try {
337
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
338
+ return typeof parsed === "object" && parsed !== null
339
+ ? (parsed as Record<string, unknown>)
340
+ : undefined;
341
+ } catch {
342
+ return undefined;
343
+ }
344
+ }
@@ -0,0 +1,59 @@
1
+ import { stripBoilerplate } from "./style.js";
2
+
3
+ export interface CleanedDocument {
4
+ readonly text: string;
5
+ /** Title taken from front matter, when the document declares one. */
6
+ readonly title?: string;
7
+ /** Keywords taken from front matter, indexed alongside the prose. */
8
+ readonly tags: readonly string[];
9
+ }
10
+
11
+ const FRONT_MATTER = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
12
+
13
+ /**
14
+ * Prepares a source document for chunking: lifts front matter into metadata the index can use,
15
+ * and drops site boilerplate. Prose is left alone — the index matches on it.
16
+ */
17
+ export function cleanDocument(raw: string): CleanedDocument {
18
+ const match = FRONT_MATTER.exec(raw);
19
+ const body = stripBoilerplate(match === null ? raw : raw.slice(match[0].length)).trim();
20
+ if (match?.[1] === undefined) return { text: body, tags: [] };
21
+
22
+ const meta = parseFrontMatter(match[1]);
23
+ return {
24
+ text: body,
25
+ ...(meta.title === undefined ? {} : { title: meta.title }),
26
+ tags: meta.tags,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Reads the two front matter fields worth indexing. This is deliberately not a YAML parser:
32
+ * anything more complex is not metadata a retrieval index can use.
33
+ */
34
+ function parseFrontMatter(block: string): { title?: string; tags: string[] } {
35
+ let title: string | undefined;
36
+ const tags: string[] = [];
37
+
38
+ for (const line of block.split("\n")) {
39
+ const entry = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
40
+ if (entry?.[1] === undefined || entry[2] === undefined) continue;
41
+
42
+ const key = entry[1].toLowerCase();
43
+ const value = entry[2].trim().replace(/^["']|["']$/g, "");
44
+ if (key === "title" && value.length > 0) title = value;
45
+ if ((key === "tags" || key === "keywords") && value.startsWith("[")) {
46
+ tags.push(...splitList(value));
47
+ }
48
+ }
49
+
50
+ return { ...(title === undefined ? {} : { title }), tags };
51
+ }
52
+
53
+ function splitList(value: string): string[] {
54
+ return value
55
+ .replace(/^\[|\]$/g, "")
56
+ .split(",")
57
+ .map((item) => item.trim().replace(/^["']|["']$/g, ""))
58
+ .filter((item) => item.length > 0);
59
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** An error with a message safe to show to the user, plus an optional next step. */
2
+ export class DocspackError extends Error {
3
+ readonly hint: string | undefined;
4
+
5
+ constructor(message: string, options: { hint?: string; cause?: unknown } = {}) {
6
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
7
+ this.name = "DocspackError";
8
+ this.hint = options.hint;
9
+ }
10
+ }
package/src/exports.ts ADDED
@@ -0,0 +1,120 @@
1
+ import type { Dirent } from "node:fs";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+
5
+ /**
6
+ * The names a package declares, read statically from its type declarations.
7
+ *
8
+ * Importing the package and reading its keys would be more accurate and would execute
9
+ * third-party code, which is not acceptable for a check that runs over every dependency.
10
+ */
11
+ export interface ExportSurface {
12
+ readonly name: string;
13
+ readonly version: string;
14
+ /** Every identifier declared anywhere in the package's `.d.ts` files. */
15
+ readonly names: ReadonlySet<string>;
16
+ readonly files: number;
17
+ }
18
+
19
+ /** Declaration forms that introduce a name a caller could write. */
20
+ const DECLARATIONS = [
21
+ /(?:^|\s)(?:export\s+)?(?:declare\s+)?(?:async\s+)?(?:function|const|let|var|class|interface|type|enum|namespace|module)\s+([A-Za-z_$][\w$]*)/g,
22
+ /(?:^|\s)(?:readonly\s+)?(?:get|set)\s+([A-Za-z_$][\w$]*)\s*\(/g,
23
+ /^\s*(?:readonly\s+)?([A-Za-z_$][\w$]*)\??\s*[(<:]/gm,
24
+ ];
25
+ const EXPORT_LIST = /export\s*(?:type\s*)?\{([^}]*)\}/g;
26
+
27
+ const MAX_FILES = 400;
28
+ const MAX_BYTES = 8_000_000;
29
+
30
+ /**
31
+ * Reads every `.d.ts` in an installed package. Reading all of them rather than following the
32
+ * entry points is deliberate: a wider set of known names means fewer false accusations, which
33
+ * matters more here than catching every last rename.
34
+ */
35
+ export async function readExportSurface(dir: string): Promise<ExportSurface | undefined> {
36
+ const manifest = await readJson(join(dir, "package.json"));
37
+ if (manifest === undefined) return undefined;
38
+
39
+ const declarations = await findDeclarationFiles(dir);
40
+ if (declarations.length === 0) return undefined;
41
+
42
+ const names = new Set<string>();
43
+ let budget = MAX_BYTES;
44
+
45
+ for (const file of declarations) {
46
+ if (budget <= 0) break;
47
+ let source: string;
48
+ try {
49
+ source = await readFile(file, "utf8");
50
+ } catch {
51
+ continue;
52
+ }
53
+ budget -= source.length;
54
+ collectNames(stripComments(source), names);
55
+ }
56
+
57
+ return {
58
+ name: typeof manifest.name === "string" ? manifest.name : "",
59
+ version: typeof manifest.version === "string" ? manifest.version : "",
60
+ names,
61
+ files: declarations.length,
62
+ };
63
+ }
64
+
65
+ function collectNames(source: string, into: Set<string>): void {
66
+ for (const pattern of DECLARATIONS) {
67
+ pattern.lastIndex = 0;
68
+ for (const match of source.matchAll(pattern)) {
69
+ if (match[1] !== undefined) into.add(match[1]);
70
+ }
71
+ }
72
+
73
+ EXPORT_LIST.lastIndex = 0;
74
+ for (const match of source.matchAll(EXPORT_LIST)) {
75
+ for (const entry of (match[1] ?? "").split(",")) {
76
+ // `export { internalName as publicName }` — both names are worth knowing.
77
+ for (const part of entry.split(/\s+as\s+/)) {
78
+ const name = part.trim().replace(/^type\s+/, "");
79
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) into.add(name);
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ function stripComments(source: string): string {
86
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|\s)\/\/[^\n]*/g, "$1");
87
+ }
88
+
89
+ async function findDeclarationFiles(dir: string): Promise<string[]> {
90
+ let found: Dirent[];
91
+ try {
92
+ found = await readdir(dir, { recursive: true, withFileTypes: true });
93
+ } catch {
94
+ return [];
95
+ }
96
+
97
+ return found
98
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".d.ts") && !isVendored(dir, entry))
99
+ .map((entry) => join(entry.parentPath, entry.name))
100
+ .slice(0, MAX_FILES);
101
+ }
102
+
103
+ /**
104
+ * True for a file under a nested `node_modules`. The package's own path is almost always inside
105
+ * one, so the check has to be relative to the package root rather than absolute.
106
+ */
107
+ function isVendored(dir: string, entry: Dirent): boolean {
108
+ return relative(dir, entry.parentPath).split(sep).includes("node_modules");
109
+ }
110
+
111
+ async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
112
+ try {
113
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
114
+ return typeof parsed === "object" && parsed !== null
115
+ ? (parsed as Record<string, unknown>)
116
+ : undefined;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
Binary file
package/src/html.ts ADDED
@@ -0,0 +1,24 @@
1
+ import TurndownService from "turndown";
2
+
3
+ // Documentation pages wrap their prose in <main> or <article>; everything outside is chrome.
4
+ const MAIN_CONTENT = /<(main|article)\b[^>]*>([\s\S]*)<\/\1>/i;
5
+
6
+ const turndown = new TurndownService({
7
+ headingStyle: "atx",
8
+ codeBlockStyle: "fenced",
9
+ bulletListMarker: "-",
10
+ hr: "---",
11
+ });
12
+ turndown.remove(["script", "style", "noscript", "nav", "header", "footer", "aside", "form", "svg"]);
13
+
14
+ export function isHtml(contentType: string, body: string): boolean {
15
+ if (/\btext\/html\b|\bapplication\/xhtml\+xml\b/i.test(contentType)) return true;
16
+ if (/\btext\/(markdown|plain)\b/i.test(contentType)) return false;
17
+ return /^\s*(?:<!doctype html|<html\b)/i.test(body);
18
+ }
19
+
20
+ /** Best-effort HTML to Markdown conversion for sources that do not serve Markdown. */
21
+ export function htmlToMarkdown(html: string): string {
22
+ const match = MAIN_CONTENT.exec(html);
23
+ return turndown.turndown(match?.[2] ?? html).trim();
24
+ }