@tsdoctor/pages 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/Blocks.js +358 -0
- package/Build.js +525 -0
- package/Examples.js +237 -0
- package/LICENSE +21 -0
- package/Llms.js +282 -0
- package/Markdown.js +155 -0
- package/Nav.js +148 -0
- package/Page.js +80 -0
- package/README.md +32 -0
- package/Scope.js +56 -0
- package/TwoslashDirectives.js +63 -0
- package/WorkItems.js +162 -0
- package/index.d.ts +1193 -0
- package/index.js +12 -0
- package/package.json +54 -0
- package/tsdoc-metadata.json +11 -0
package/Examples.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { CodeText, Example } from "./Blocks.js";
|
|
2
|
+
import { classifyCutDirective, isTwoslashDirective } from "./TwoslashDirectives.js";
|
|
3
|
+
import { Effect, Schema } from "effect";
|
|
4
|
+
import { TypeReferenceExtractor } from "@tsdoctor/model";
|
|
5
|
+
import { format } from "prettier";
|
|
6
|
+
|
|
7
|
+
//#region src/Examples.ts
|
|
8
|
+
/**
|
|
9
|
+
* Prettier could not format an example.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Malformed example code is author input, so this is a typed failure rather
|
|
13
|
+
* than a defect. The original Prettier error rides in `cause` rather than
|
|
14
|
+
* being flattened to a string; an adapter that reports it (the RSPress
|
|
15
|
+
* `PrettierError` event) reads the message from there and falls back to the
|
|
16
|
+
* unformatted code.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
var ExampleFormatError = class extends Schema.TaggedError("ExampleFormatError")("ExampleFormatError", {
|
|
21
|
+
/** The fence language the parser was chosen for. */
|
|
22
|
+
language: Schema.String,
|
|
23
|
+
/** The original Prettier error. */
|
|
24
|
+
cause: Schema.Defect()
|
|
25
|
+
}) {
|
|
26
|
+
get message() {
|
|
27
|
+
return `Prettier could not format a ${this.language} example`;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const TYPESCRIPT_LANGUAGES = /* @__PURE__ */ new Set([
|
|
31
|
+
"typescript",
|
|
32
|
+
"ts",
|
|
33
|
+
"javascript",
|
|
34
|
+
"js"
|
|
35
|
+
]);
|
|
36
|
+
/**
|
|
37
|
+
* Prepare an example for Twoslash: prepend `import { name } from "pkg"`
|
|
38
|
+
* unless the example already imports the package, and prepend `// @noErrors`
|
|
39
|
+
* when errors are suppressed. Non-TypeScript examples pass through untouched.
|
|
40
|
+
*
|
|
41
|
+
* @param example - The example with language and code
|
|
42
|
+
* @param apiItemName - The documented item, imported at the top of the example
|
|
43
|
+
* @param packageName - The package to import it from
|
|
44
|
+
* @param suppressErrors - Whether to suppress TypeScript errors (default `true`)
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
function prepareExampleCode(example, apiItemName, packageName, suppressErrors = true) {
|
|
48
|
+
const { language, code } = example;
|
|
49
|
+
if (!TYPESCRIPT_LANGUAGES.has(language)) return {
|
|
50
|
+
code,
|
|
51
|
+
isTypeScript: false,
|
|
52
|
+
language
|
|
53
|
+
};
|
|
54
|
+
const importLine = `import { ${apiItemName} } from "${packageName}";`;
|
|
55
|
+
const finalCode = code.includes(`from "${packageName}"`) || code.includes(`from '${packageName}'`) ? code : `${importLine}\n${code}`;
|
|
56
|
+
return {
|
|
57
|
+
code: `${suppressErrors ? "// @noErrors\n" : ""}${finalCode}`,
|
|
58
|
+
isTypeScript: true,
|
|
59
|
+
language: "typescript"
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Strip Twoslash directives from code for display: config directives
|
|
64
|
+
* (`// @noErrors`, `// @errors: 2304`, `// @filename: …`), annotation markers
|
|
65
|
+
* (`// ^?`) and the cut directives — `// ---cut---` removes itself and every
|
|
66
|
+
* line before it, `// ---cut-after---` itself and every line after, and a
|
|
67
|
+
* `// ---cut-start---` / `// ---cut-end---` pair removes the range between.
|
|
68
|
+
*
|
|
69
|
+
* @param code - The code containing Twoslash directives
|
|
70
|
+
* @returns The code a reader sees and copies
|
|
71
|
+
* @public
|
|
72
|
+
*/
|
|
73
|
+
function stripTwoslashDirectives(code) {
|
|
74
|
+
const lines = code.split("\n");
|
|
75
|
+
let cutBeforeIndex = -1;
|
|
76
|
+
let cutAfterIndex = -1;
|
|
77
|
+
const cutRanges = [];
|
|
78
|
+
const cutStartStack = [];
|
|
79
|
+
for (let i = 0; i < lines.length; i++) {
|
|
80
|
+
const cutType = classifyCutDirective(lines[i].trim());
|
|
81
|
+
if (cutType === "cut-before") cutBeforeIndex = i;
|
|
82
|
+
else if (cutType === "cut-after") cutAfterIndex = i;
|
|
83
|
+
else if (cutType === "cut-start") cutStartStack.push(i);
|
|
84
|
+
else if (cutType === "cut-end") {
|
|
85
|
+
const startIdx = cutStartStack.pop();
|
|
86
|
+
if (startIdx !== void 0) cutRanges.push([startIdx, i]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let filteredLines = lines;
|
|
90
|
+
if (cutBeforeIndex >= 0) {
|
|
91
|
+
filteredLines = filteredLines.slice(cutBeforeIndex + 1);
|
|
92
|
+
if (cutAfterIndex >= 0) cutAfterIndex = cutAfterIndex - cutBeforeIndex - 1;
|
|
93
|
+
for (const range of cutRanges) {
|
|
94
|
+
range[0] -= cutBeforeIndex + 1;
|
|
95
|
+
range[1] -= cutBeforeIndex + 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (cutAfterIndex >= 0) filteredLines = filteredLines.slice(0, cutAfterIndex);
|
|
99
|
+
const excludedLines = /* @__PURE__ */ new Set();
|
|
100
|
+
for (const [start, end] of cutRanges) for (let i = start; i <= end; i++) if (i >= 0 && i < filteredLines.length) excludedLines.add(i);
|
|
101
|
+
return filteredLines.filter((line, i) => !excludedLines.has(i) && !isTwoslashDirective(line.trim())).join("\n").trim();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Prepend hidden imports to code using the Twoslash cut directive, so the
|
|
105
|
+
* type-checker resolves external types while the reader never sees the
|
|
106
|
+
* import lines. Returns the code unchanged when there is nothing to import.
|
|
107
|
+
*
|
|
108
|
+
* @param code - The code to prepend imports to
|
|
109
|
+
* @param imports - The import statements to add
|
|
110
|
+
* @public
|
|
111
|
+
*/
|
|
112
|
+
function prependHiddenImports(code, imports) {
|
|
113
|
+
if (imports.length === 0) return code;
|
|
114
|
+
return `${TypeReferenceExtractor.formatImports([...imports]).join("\n")}\n// ---cut---\n${code}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build both spellings of a code block from its type-check text: the
|
|
118
|
+
* `source` as given, the `display` with every directive stripped.
|
|
119
|
+
*
|
|
120
|
+
* @param source - The type-check text — hidden imports, cut marker, directives intact
|
|
121
|
+
* @public
|
|
122
|
+
*/
|
|
123
|
+
function codeText(source) {
|
|
124
|
+
return CodeText.make({
|
|
125
|
+
display: stripTwoslashDirectives(source),
|
|
126
|
+
source
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Add logical blank lines between code sections for visual clarity: after an
|
|
131
|
+
* import block, before a section comment and before a `return`.
|
|
132
|
+
*
|
|
133
|
+
* @remarks
|
|
134
|
+
* Runs after Prettier, which does not insert breathing room of its own. Lines
|
|
135
|
+
* inside a multi-line import and Twoslash directive lines never trigger a
|
|
136
|
+
* rule, so a directive stays attached to the line it annotates.
|
|
137
|
+
*
|
|
138
|
+
* @param code - Prettier-formatted code
|
|
139
|
+
* @public
|
|
140
|
+
*/
|
|
141
|
+
function addLogicalBlankLines(code) {
|
|
142
|
+
const lines = code.split("\n");
|
|
143
|
+
const result = [];
|
|
144
|
+
let inMultiLineImport = false;
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
const trimmed = line.trim();
|
|
147
|
+
const wasInMultiLineImport = inMultiLineImport;
|
|
148
|
+
if (!inMultiLineImport && trimmed.startsWith("import ") && !trimmed.endsWith(";")) inMultiLineImport = true;
|
|
149
|
+
else if (inMultiLineImport && trimmed.endsWith(";")) inMultiLineImport = false;
|
|
150
|
+
const isCurrentImport = trimmed.startsWith("import ") || wasInMultiLineImport;
|
|
151
|
+
if (result.length > 0 && !isCurrentImport) {
|
|
152
|
+
const prevTrimmed = result[result.length - 1].trim();
|
|
153
|
+
if (prevTrimmed !== "") {
|
|
154
|
+
const isDirective = isTwoslashDirective(trimmed);
|
|
155
|
+
const prevIsImportEnd = prevTrimmed.startsWith("import ") && prevTrimmed.endsWith(";") || /}\s*from\s+/.test(prevTrimmed) && prevTrimmed.endsWith(";");
|
|
156
|
+
if (prevIsImportEnd && trimmed !== "" && !isDirective) result.push("");
|
|
157
|
+
if (trimmed.startsWith("//") && !isDirective && !prevTrimmed.startsWith("//") && !prevIsImportEnd) result.push("");
|
|
158
|
+
if (/^return[\s;(]/.test(trimmed) && !prevTrimmed.startsWith("//")) result.push("");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
result.push(line);
|
|
162
|
+
}
|
|
163
|
+
return result.join("\n");
|
|
164
|
+
}
|
|
165
|
+
/** Fence languages Prettier can format, mapped to its parser name. */
|
|
166
|
+
const LANGUAGE_TO_PARSER = {
|
|
167
|
+
typescript: "typescript",
|
|
168
|
+
ts: "typescript",
|
|
169
|
+
tsx: "typescript",
|
|
170
|
+
javascript: "babel",
|
|
171
|
+
js: "babel",
|
|
172
|
+
jsx: "babel",
|
|
173
|
+
node: "babel"
|
|
174
|
+
};
|
|
175
|
+
/** The one Prettier configuration every adapter formats examples with. */
|
|
176
|
+
const PRETTIER_OPTIONS = {
|
|
177
|
+
printWidth: 80,
|
|
178
|
+
tabWidth: 2,
|
|
179
|
+
useTabs: false,
|
|
180
|
+
semi: true,
|
|
181
|
+
singleQuote: false,
|
|
182
|
+
trailingComma: "es5",
|
|
183
|
+
bracketSpacing: true,
|
|
184
|
+
arrowParens: "always"
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Format example code with Prettier, then add logical blank lines. A
|
|
188
|
+
* language Prettier has no parser for is returned unchanged.
|
|
189
|
+
*
|
|
190
|
+
* @param code - The code to format
|
|
191
|
+
* @param language - The fence language (`typescript`, `ts`, `js`, …)
|
|
192
|
+
* @public
|
|
193
|
+
*/
|
|
194
|
+
const formatExampleCode = Effect.fn("Examples.formatExampleCode")(function* (code, language) {
|
|
195
|
+
const parser = LANGUAGE_TO_PARSER[language.toLowerCase()];
|
|
196
|
+
if (!parser) return code;
|
|
197
|
+
return addLogicalBlankLines((yield* Effect.tryPromise({
|
|
198
|
+
try: () => format(code, {
|
|
199
|
+
...PRETTIER_OPTIONS,
|
|
200
|
+
parser
|
|
201
|
+
}),
|
|
202
|
+
catch: (cause) => new ExampleFormatError({
|
|
203
|
+
language,
|
|
204
|
+
cause
|
|
205
|
+
})
|
|
206
|
+
})).trim());
|
|
207
|
+
});
|
|
208
|
+
/**
|
|
209
|
+
* Build an {@link Example} block item from a raw TSDoc example: prepare it
|
|
210
|
+
* for Twoslash, format it, and produce both code spellings once.
|
|
211
|
+
*
|
|
212
|
+
* @remarks
|
|
213
|
+
* A non-TypeScript example is not type-checked, so its `display` and
|
|
214
|
+
* `source` are the same formatted text and emitters render it in a plain
|
|
215
|
+
* fence.
|
|
216
|
+
*
|
|
217
|
+
* @param example - The example with language and code
|
|
218
|
+
* @param apiItemName - The documented item, imported at the top of the example
|
|
219
|
+
* @param packageName - The package to import it from
|
|
220
|
+
* @param suppressErrors - Whether to suppress TypeScript errors (default `true`)
|
|
221
|
+
* @public
|
|
222
|
+
*/
|
|
223
|
+
const buildExample = Effect.fn("Examples.buildExample")(function* (example, apiItemName, packageName, suppressErrors = true) {
|
|
224
|
+
const prepared = prepareExampleCode(example, apiItemName, packageName, suppressErrors);
|
|
225
|
+
const formatted = yield* formatExampleCode(prepared.code, prepared.language);
|
|
226
|
+
return Example.make({
|
|
227
|
+
language: prepared.language,
|
|
228
|
+
code: prepared.isTypeScript ? codeText(formatted) : CodeText.make({
|
|
229
|
+
display: formatted,
|
|
230
|
+
source: formatted
|
|
231
|
+
}),
|
|
232
|
+
typeChecked: prepared.isTypeScript
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
//#endregion
|
|
237
|
+
export { ExampleFormatError, addLogicalBlankLines, buildExample, codeText, formatExampleCode, prepareExampleCode, prependHiddenImports, stripTwoslashDirectives };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/Llms.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
//#region src/Llms.ts
|
|
2
|
+
/** Pre-compiled regex for parsing llms.txt link lines. */
|
|
3
|
+
const LLMS_TXT_LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)(?::\s*(.+))?$/;
|
|
4
|
+
/**
|
|
5
|
+
* Parse a single line from llms.txt format.
|
|
6
|
+
*
|
|
7
|
+
* Recognizes the pattern: `- [title](url): description`
|
|
8
|
+
* The description portion (`: description`) is optional.
|
|
9
|
+
*
|
|
10
|
+
* @param line - A single line from an llms.txt file
|
|
11
|
+
* @returns Parsed entry or null for non-link lines (headers, empty lines, plain text)
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
function parseLlmsTxtLine(line) {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
if (trimmed === "") return null;
|
|
18
|
+
const match = LLMS_TXT_LINE_RE.exec(trimmed);
|
|
19
|
+
if (!match) return null;
|
|
20
|
+
const title = match[1];
|
|
21
|
+
const url = match[2];
|
|
22
|
+
const rawDescription = match[3];
|
|
23
|
+
return {
|
|
24
|
+
title,
|
|
25
|
+
url,
|
|
26
|
+
description: rawDescription ? rawDescription.trim() : void 0
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Filter API page entries from global llms.txt content.
|
|
31
|
+
*
|
|
32
|
+
* Removes lines whose parsed URL is in the `apiRoutes` set.
|
|
33
|
+
* Appends pointer lines for per-package llms files when `pointers` is non-empty.
|
|
34
|
+
*
|
|
35
|
+
* @param content - Full llms.txt content string
|
|
36
|
+
* @param apiRoutes - Set of API route paths to remove
|
|
37
|
+
* @param pointers - Per-package pointer entries to append
|
|
38
|
+
* @returns Filtered llms.txt content
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
function filterLlmsTxt(content, apiRoutes, pointers) {
|
|
43
|
+
const lines = content.split("\n");
|
|
44
|
+
const filtered = [];
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const entry = parseLlmsTxtLine(line);
|
|
47
|
+
if (entry && apiRoutes.has(entry.url)) continue;
|
|
48
|
+
filtered.push(line);
|
|
49
|
+
}
|
|
50
|
+
let result = filtered.join("\n");
|
|
51
|
+
if (pointers.length > 0) {
|
|
52
|
+
result += "\n\n";
|
|
53
|
+
for (const pointer of pointers) result += `- For ${pointer.name} API docs, see [${pointer.name} llms.txt](${pointer.llmsTxtUrl})\n`;
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Generate a structured global llms.txt that groups pages by package scope.
|
|
59
|
+
*
|
|
60
|
+
* Output format:
|
|
61
|
+
* ```
|
|
62
|
+
* # {site title}
|
|
63
|
+
*
|
|
64
|
+
* ## Others
|
|
65
|
+
* - [Blog Post](/blog/post.md)
|
|
66
|
+
*
|
|
67
|
+
* ## Packages
|
|
68
|
+
*
|
|
69
|
+
* ### {name} {version}
|
|
70
|
+
* {description}
|
|
71
|
+
* - [Guide Page](/pkg/guides/guide.md)
|
|
72
|
+
* - [API Reference](/pkg/llms-api.txt)
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @param content - Original RSPress-generated llms.txt content
|
|
76
|
+
* @param apiRoutes - Set of API route paths to exclude as individual entries
|
|
77
|
+
* @param packages - Package scope metadata
|
|
78
|
+
* @returns Restructured llms.txt content
|
|
79
|
+
*
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
function generateStructuredLlmsTxt(content, apiRoutes, packages) {
|
|
83
|
+
const lines = content.split("\n");
|
|
84
|
+
let title = "";
|
|
85
|
+
for (const line of lines) if (line.startsWith("# ")) {
|
|
86
|
+
title = line;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const allEntries = [];
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
const entry = parseLlmsTxtLine(line);
|
|
92
|
+
if (entry && !apiRoutes.has(entry.url)) allEntries.push(entry);
|
|
93
|
+
}
|
|
94
|
+
const packageEntries = /* @__PURE__ */ new Map();
|
|
95
|
+
const others = [];
|
|
96
|
+
for (const entry of allEntries) {
|
|
97
|
+
let matched = false;
|
|
98
|
+
for (const pkg of packages) {
|
|
99
|
+
const base = pkg.packageRoute.endsWith("/") ? pkg.packageRoute : `${pkg.packageRoute}/`;
|
|
100
|
+
if (entry.url.startsWith(base) || entry.url === pkg.packageRoute) {
|
|
101
|
+
const existing = packageEntries.get(pkg.packageName) ?? [];
|
|
102
|
+
existing.push(entry);
|
|
103
|
+
packageEntries.set(pkg.packageName, existing);
|
|
104
|
+
matched = true;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!matched) others.push(entry);
|
|
109
|
+
}
|
|
110
|
+
const output = [];
|
|
111
|
+
if (title) {
|
|
112
|
+
output.push(title);
|
|
113
|
+
output.push("");
|
|
114
|
+
}
|
|
115
|
+
if (others.length > 0) {
|
|
116
|
+
output.push("## Others");
|
|
117
|
+
output.push("");
|
|
118
|
+
for (const entry of others) output.push(formatEntry(entry));
|
|
119
|
+
output.push("");
|
|
120
|
+
}
|
|
121
|
+
const packagesWithEntries = packages;
|
|
122
|
+
if (packagesWithEntries.length > 0) {
|
|
123
|
+
output.push("## Packages");
|
|
124
|
+
output.push("");
|
|
125
|
+
for (const pkg of packagesWithEntries) {
|
|
126
|
+
const versionSuffix = pkg.version ? ` ${pkg.version}` : "";
|
|
127
|
+
output.push(`### ${pkg.name}${versionSuffix}`);
|
|
128
|
+
output.push("");
|
|
129
|
+
if (pkg.description) {
|
|
130
|
+
output.push(pkg.description);
|
|
131
|
+
output.push("");
|
|
132
|
+
}
|
|
133
|
+
const entries = packageEntries.get(pkg.packageName) ?? [];
|
|
134
|
+
for (const entry of entries) output.push(formatEntry(entry));
|
|
135
|
+
output.push(`- [API Reference](${pkg.llmsApiTxtUrl})`);
|
|
136
|
+
output.push("");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return output.join("\n");
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parse llms-full.txt content into sections delimited by frontmatter blocks.
|
|
143
|
+
*
|
|
144
|
+
* Each section has the format:
|
|
145
|
+
* ```
|
|
146
|
+
* ---
|
|
147
|
+
* url: /path/to/page
|
|
148
|
+
* ---
|
|
149
|
+
*
|
|
150
|
+
* Content here...
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
function parseSections(content) {
|
|
154
|
+
if (content.trim() === "") return [];
|
|
155
|
+
const sections = [];
|
|
156
|
+
const frontmatterPattern = /^---\nurl:\s*(.+)\n---$/gm;
|
|
157
|
+
let match = frontmatterPattern.exec(content);
|
|
158
|
+
const boundaries = [];
|
|
159
|
+
while (match !== null) {
|
|
160
|
+
boundaries.push({
|
|
161
|
+
url: match[1].trim(),
|
|
162
|
+
start: match.index,
|
|
163
|
+
fmEnd: match.index + match[0].length
|
|
164
|
+
});
|
|
165
|
+
match = frontmatterPattern.exec(content);
|
|
166
|
+
}
|
|
167
|
+
for (let i = 0; i < boundaries.length; i++) {
|
|
168
|
+
const boundary = boundaries[i];
|
|
169
|
+
const nextStart = i + 1 < boundaries.length ? boundaries[i + 1].start : content.length;
|
|
170
|
+
const sectionContent = content.slice(boundary.start, nextStart);
|
|
171
|
+
sections.push({
|
|
172
|
+
url: boundary.url,
|
|
173
|
+
raw: sectionContent.trimEnd()
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return sections;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Filter API page content sections from global llms-full.txt.
|
|
180
|
+
*
|
|
181
|
+
* Sections are delimited by `---\nurl: {path}\n---` frontmatter blocks.
|
|
182
|
+
* Removes entire sections whose URL matches a known API route.
|
|
183
|
+
*
|
|
184
|
+
* @param content - Full llms-full.txt content string
|
|
185
|
+
* @param apiRoutes - Set of API route paths to remove
|
|
186
|
+
* @returns Filtered llms-full.txt content
|
|
187
|
+
*
|
|
188
|
+
* @public
|
|
189
|
+
*/
|
|
190
|
+
function filterLlmsFullTxt(content, apiRoutes) {
|
|
191
|
+
if (content.trim() === "") return "";
|
|
192
|
+
const kept = parseSections(content).filter((section) => !apiRoutes.has(section.url));
|
|
193
|
+
if (kept.length === 0) return "";
|
|
194
|
+
return kept.map((section) => section.raw).join("\n\n\n");
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Format a single llms.txt link entry.
|
|
198
|
+
*/
|
|
199
|
+
function formatEntry(entry) {
|
|
200
|
+
if (entry.description) return `- [${entry.title}](${entry.url}): ${entry.description}`;
|
|
201
|
+
return `- [${entry.title}](${entry.url})`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Generate a per-package llms.txt index.
|
|
205
|
+
*
|
|
206
|
+
* Output format:
|
|
207
|
+
* ```
|
|
208
|
+
* # {name}
|
|
209
|
+
*
|
|
210
|
+
* ## Guides
|
|
211
|
+
*
|
|
212
|
+
* - [Guide Title](/path): Description
|
|
213
|
+
*
|
|
214
|
+
* ## API Reference
|
|
215
|
+
*
|
|
216
|
+
* - [ApiItem](/path): Description
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* Sections with no entries are omitted.
|
|
220
|
+
*
|
|
221
|
+
* @param input - Package name, guide pages, and API pages
|
|
222
|
+
* @returns Generated llms.txt content
|
|
223
|
+
*
|
|
224
|
+
* @public
|
|
225
|
+
*/
|
|
226
|
+
function generatePackageLlmsTxt(input) {
|
|
227
|
+
const parts = [
|
|
228
|
+
`# ${input.name}`,
|
|
229
|
+
"",
|
|
230
|
+
`> API documentation for the ${input.packageName} package`
|
|
231
|
+
];
|
|
232
|
+
if (input.guidePages.length > 0) {
|
|
233
|
+
parts.push("");
|
|
234
|
+
parts.push("## Guides");
|
|
235
|
+
parts.push("");
|
|
236
|
+
for (const page of input.guidePages) parts.push(formatEntry(page));
|
|
237
|
+
}
|
|
238
|
+
if (input.apiPages.length > 0) {
|
|
239
|
+
parts.push("");
|
|
240
|
+
parts.push("## API Reference");
|
|
241
|
+
parts.push("");
|
|
242
|
+
for (const page of input.apiPages) parts.push(formatEntry(page));
|
|
243
|
+
}
|
|
244
|
+
parts.push("");
|
|
245
|
+
return parts.join("\n");
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Concatenate page contents with frontmatter delimiters.
|
|
249
|
+
*
|
|
250
|
+
* Used for llms-full.txt, llms-docs.txt, and llms-api.txt generation
|
|
251
|
+
* (pass different page sets for each).
|
|
252
|
+
*
|
|
253
|
+
* Output format:
|
|
254
|
+
* ```
|
|
255
|
+
* ---
|
|
256
|
+
* url: /path/to/page
|
|
257
|
+
* ---
|
|
258
|
+
*
|
|
259
|
+
* Content here...
|
|
260
|
+
*
|
|
261
|
+
*
|
|
262
|
+
* ---
|
|
263
|
+
* url: /path/to/next
|
|
264
|
+
* ---
|
|
265
|
+
*
|
|
266
|
+
* More content...
|
|
267
|
+
* ```
|
|
268
|
+
*
|
|
269
|
+
* @param pages - Array of page URLs and their markdown content
|
|
270
|
+
* @returns Concatenated content with frontmatter delimiters
|
|
271
|
+
*
|
|
272
|
+
* @public
|
|
273
|
+
*/
|
|
274
|
+
function generatePackageLlmsFullTxt(pages) {
|
|
275
|
+
if (pages.length === 0) return "";
|
|
276
|
+
const sections = [];
|
|
277
|
+
for (const page of pages) sections.push(`---\nurl: ${page.url}\n---\n\n${page.content}`);
|
|
278
|
+
return sections.join("\n\n\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
//#endregion
|
|
282
|
+
export { filterLlmsFullTxt, filterLlmsTxt, generatePackageLlmsFullTxt, generatePackageLlmsTxt, generateStructuredLlmsTxt, parseLlmsTxtLine };
|
package/Markdown.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Blockquote, Code, Heading, InlineCode, Link, List, ListItem, Markdown, Paragraph, Root, Strong, Table, TableCell, TableRow, Text } from "@effected/markdown";
|
|
2
|
+
import { Effect, Result } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/Markdown.ts
|
|
5
|
+
const text = (value) => Text.make({ value });
|
|
6
|
+
const heading = (depth, value) => Heading.make({
|
|
7
|
+
depth,
|
|
8
|
+
children: [text(value)]
|
|
9
|
+
});
|
|
10
|
+
const paragraph = (children) => Paragraph.make({ children: [...children] });
|
|
11
|
+
const fence = (value, lang) => Code.make({
|
|
12
|
+
value,
|
|
13
|
+
lang
|
|
14
|
+
});
|
|
15
|
+
const cell = (children) => TableCell.make({ children: [...children] });
|
|
16
|
+
const row = (cells) => TableRow.make({ children: [...cells] });
|
|
17
|
+
const parametersTable = (rows) => Table.make({ children: [row([
|
|
18
|
+
cell([text("Name")]),
|
|
19
|
+
cell([text("Type")]),
|
|
20
|
+
cell([text("Description")])
|
|
21
|
+
]), ...rows.map((r) => row([
|
|
22
|
+
cell([InlineCode.make({ value: r.name })]),
|
|
23
|
+
cell(r.type === void 0 ? [] : [InlineCode.make({ value: r.type })]),
|
|
24
|
+
cell(r.description)
|
|
25
|
+
]))] });
|
|
26
|
+
const enumMembersTable = (rows) => Table.make({ children: [row([
|
|
27
|
+
cell([text("Name")]),
|
|
28
|
+
cell([text("Value")]),
|
|
29
|
+
cell([text("Description")])
|
|
30
|
+
]), ...rows.map((r) => row([
|
|
31
|
+
cell([InlineCode.make({ value: r.name })]),
|
|
32
|
+
cell(r.value === void 0 ? [] : [InlineCode.make({ value: r.value })]),
|
|
33
|
+
cell(r.description)
|
|
34
|
+
]))] });
|
|
35
|
+
const memberNodes = (member) => {
|
|
36
|
+
const nodes = [heading(3, member.name), fence(member.code.display, "ts")];
|
|
37
|
+
if (member.summary && member.summary.length > 0) nodes.push(paragraph(member.summary));
|
|
38
|
+
if (member.parameters && member.parameters.length > 0) nodes.push(parametersTable(member.parameters));
|
|
39
|
+
if (member.returns && member.returns.length > 0) nodes.push(paragraph([
|
|
40
|
+
Strong.make({ children: [text("Returns:")] }),
|
|
41
|
+
text(" "),
|
|
42
|
+
...member.returns
|
|
43
|
+
]));
|
|
44
|
+
return nodes;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Render one block to flow nodes.
|
|
48
|
+
*
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
function markdownBlockTree(block) {
|
|
52
|
+
switch (block.kind) {
|
|
53
|
+
case "title": {
|
|
54
|
+
const nodes = [heading(1, block.name)];
|
|
55
|
+
if (block.deprecation && block.deprecation.length > 0) nodes.push(Blockquote.make({ children: [paragraph([
|
|
56
|
+
Strong.make({ children: [text("Deprecated:")] }),
|
|
57
|
+
text(" "),
|
|
58
|
+
...block.deprecation
|
|
59
|
+
])] }));
|
|
60
|
+
if (block.releaseTag !== "Public") nodes.push(paragraph([InlineCode.make({ value: block.releaseTag })]));
|
|
61
|
+
return nodes;
|
|
62
|
+
}
|
|
63
|
+
case "available-from": {
|
|
64
|
+
const children = [text("Available from: ")];
|
|
65
|
+
block.entryPoints.forEach((entryPoint, index) => {
|
|
66
|
+
if (index > 0) children.push(text(", "));
|
|
67
|
+
const spec = entryPoint === "default" ? block.packageName : `${block.packageName}/${entryPoint}`;
|
|
68
|
+
children.push(InlineCode.make({ value: spec }));
|
|
69
|
+
});
|
|
70
|
+
return [paragraph(children)];
|
|
71
|
+
}
|
|
72
|
+
case "prose": return block.role === "summary" ? block.content : [heading(2, block.role === "remarks" ? "Remarks" : "Returns"), ...block.content];
|
|
73
|
+
case "source-link": return [paragraph([Link.make({
|
|
74
|
+
url: block.href,
|
|
75
|
+
children: [text("Source")]
|
|
76
|
+
})])];
|
|
77
|
+
case "signature": return [fence(block.code.display, "ts")];
|
|
78
|
+
case "base-class": return [
|
|
79
|
+
heading(2, "Base Class"),
|
|
80
|
+
paragraph([
|
|
81
|
+
InlineCode.make({ value: block.className }),
|
|
82
|
+
text(" extends "),
|
|
83
|
+
InlineCode.make({ value: block.baseName }),
|
|
84
|
+
text(", a compiler-generated declaration that is not exported from "),
|
|
85
|
+
InlineCode.make({ value: block.packageName }),
|
|
86
|
+
text(".")
|
|
87
|
+
]),
|
|
88
|
+
fence(block.code.display, "ts")
|
|
89
|
+
];
|
|
90
|
+
case "member-group": return [heading(2, block.title), ...block.members.flatMap(memberNodes)];
|
|
91
|
+
case "parameters": return [parametersTable(block.rows)];
|
|
92
|
+
case "enum-members": return [enumMembersTable(block.rows)];
|
|
93
|
+
case "examples": return [heading(2, "Examples"), ...block.items.map((item) => fence(item.code.display, item.language))];
|
|
94
|
+
case "see-also": return [heading(2, "See Also"), List.make({
|
|
95
|
+
ordered: false,
|
|
96
|
+
spread: false,
|
|
97
|
+
children: block.references.map((reference) => ListItem.make({
|
|
98
|
+
spread: false,
|
|
99
|
+
children: [paragraph(reference)]
|
|
100
|
+
}))
|
|
101
|
+
})];
|
|
102
|
+
case "member-index": return [heading(2, block.title), List.make({
|
|
103
|
+
ordered: false,
|
|
104
|
+
spread: false,
|
|
105
|
+
children: block.entries.map((entry) => {
|
|
106
|
+
const children = [Link.make({
|
|
107
|
+
url: entry.route,
|
|
108
|
+
children: [text(entry.name)]
|
|
109
|
+
})];
|
|
110
|
+
if (entry.summary && entry.summary.length > 0) children.push(text(" - "), ...entry.summary);
|
|
111
|
+
return ListItem.make({
|
|
112
|
+
spread: false,
|
|
113
|
+
children: [paragraph(children)]
|
|
114
|
+
});
|
|
115
|
+
})
|
|
116
|
+
})];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Render a page's body to flow nodes — the pre-serialization form of
|
|
121
|
+
* {@link renderMarkdownResult}.
|
|
122
|
+
*
|
|
123
|
+
* @public
|
|
124
|
+
*/
|
|
125
|
+
function markdownTree(page) {
|
|
126
|
+
return page.blocks.flatMap(markdownBlockTree);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Render a page's body to a markdown string. No frontmatter: that is the
|
|
130
|
+
* adapter's, built from the page facts.
|
|
131
|
+
*
|
|
132
|
+
* @remarks
|
|
133
|
+
* A stringify failure on a tree this module built itself is surfaced rather
|
|
134
|
+
* than thrown, because the prose inside a block arrived from a builder and
|
|
135
|
+
* may carry any node the kit admits; the kit's own error names what it could
|
|
136
|
+
* not serialize.
|
|
137
|
+
*
|
|
138
|
+
* @public
|
|
139
|
+
*/
|
|
140
|
+
function renderMarkdownResult(page) {
|
|
141
|
+
const root = Root.make({ children: [...markdownTree(page)] });
|
|
142
|
+
return Result.map(Markdown.stringifyResult(root), (markdown) => {
|
|
143
|
+
const trimmed = markdown.trim();
|
|
144
|
+
return trimmed ? `${trimmed}\n` : "\n";
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The Effect form of {@link renderMarkdownResult}.
|
|
149
|
+
*
|
|
150
|
+
* @public
|
|
151
|
+
*/
|
|
152
|
+
const renderMarkdown = Effect.fn("Markdown.renderMarkdown")((page) => Effect.fromResult(renderMarkdownResult(page)));
|
|
153
|
+
|
|
154
|
+
//#endregion
|
|
155
|
+
export { markdownBlockTree, markdownTree, renderMarkdown, renderMarkdownResult };
|