@smitejs/snippets 2.0.0-SNAPSHOT

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.
@@ -0,0 +1,232 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildSnippetIndex,
4
+ collectTestSnippets,
5
+ expandExamples,
6
+ expandMarkdown,
7
+ expandTemplate,
8
+ extractSnippetExpected,
9
+ extractSnippets,
10
+ transformExpectedToResult,
11
+ } from "./index.js";
12
+
13
+ describe("extractSnippets", () => {
14
+ it("collects a section block with a slug and dedented code", () => {
15
+ const source = [
16
+ "it('works', () => {",
17
+ " // #section - Extract a block",
18
+ " const value = 1 + 1;",
19
+ " // #endsection",
20
+ "});",
21
+ ].join("\n");
22
+
23
+ const snippets = extractSnippets(source, "fixture.test.ts");
24
+ expect(snippets).toHaveLength(1);
25
+ expect(snippets[0]?.title).toBe("Extract a block");
26
+ expect(snippets[0]?.slug).toBe("extract-a-block");
27
+ expect(snippets[0]?.code).toBe("const value = 1 + 1;");
28
+ });
29
+
30
+ it("throws on a nested section", () => {
31
+ const source = [
32
+ "// #section - A",
33
+ "// #section - B",
34
+ "// #endsection",
35
+ ].join("\n");
36
+
37
+ expect(() => extractSnippets(source, "a.test.ts")).toThrow(/Nested/u);
38
+ });
39
+
40
+ it("throws on an unclosed section", () => {
41
+ const source = "// #section - A\nconst x = 1;";
42
+ expect(() => extractSnippets(source, "a.test.ts")).toThrow(/Unclosed/u);
43
+ });
44
+ });
45
+
46
+ describe("extractSnippetExpected", () => {
47
+ it("reads the assertions that follow the section as expected values", () => {
48
+ const source = [
49
+ " // #section - Compute a value",
50
+ " const value = 2 + 2;",
51
+ " // #endsection",
52
+ " expect(value).toBe(4);",
53
+ " expect(value).toMatchObject([4]);",
54
+ ].join("\n");
55
+
56
+ const snippets = extractSnippets(source, "a.test.ts");
57
+ expect(extractSnippetExpected(source, snippets[0] as never)).toContain(
58
+ "expect(value).toMatchObject([4]);",
59
+ );
60
+ });
61
+
62
+ it("reduces expect chains to the expected results", () => {
63
+ expect(
64
+ transformExpectedToResult("expect(x).toBe(1); expect(y).toBe(2)"),
65
+ ).toBe("1\n2");
66
+ });
67
+ });
68
+
69
+ describe("collectTestSnippets", () => {
70
+ it("builds a title index keyed on the lower-cased title", async () => {
71
+ const { snippetIndex, snippets } = await collectTestSnippets({
72
+ packageName: "@smitejs/snippets",
73
+ rootDir: process.cwd(),
74
+ srcDir: new URL("..", import.meta.url).pathname,
75
+ });
76
+ expect(snippets.length).toBeGreaterThan(0);
77
+ expect(snippetIndex.has("expand @example declarations")).toBe(true);
78
+ });
79
+
80
+ it("rejects duplicate titles", () => {
81
+ const snippets = [
82
+ {
83
+ title: "Same",
84
+ code: "a",
85
+ slug: "same",
86
+ filePath: "a",
87
+ startLine: 1,
88
+ expected: null,
89
+ },
90
+ {
91
+ title: "same",
92
+ code: "b",
93
+ slug: "same",
94
+ filePath: "b",
95
+ startLine: 1,
96
+ expected: null,
97
+ },
98
+ ];
99
+
100
+ expect(() => buildSnippetIndex(snippets, "pkg")).toThrow(/Duplicate/u);
101
+ });
102
+ });
103
+
104
+ describe("expandExamples", () => {
105
+ it("renders a referenced snippet into a fenced block", () => {
106
+ const snippets = [
107
+ {
108
+ title: "Serve an app",
109
+ code: "const server = serve(app);",
110
+ slug: "serve-an-app",
111
+ filePath: "a",
112
+ startLine: 1,
113
+ expected: null,
114
+ },
115
+ ];
116
+ const index = buildSnippetIndex(snippets, "pkg");
117
+ const expanded = expandExamples(
118
+ " * @example Serve an app\n",
119
+ index,
120
+ "pkg",
121
+ "index.d.ts",
122
+ );
123
+
124
+ expect(expanded).toContain("```ts");
125
+ expect(expanded).toContain("const server = serve(app);");
126
+ });
127
+
128
+ it("throws when a referenced title is missing", () => {
129
+ expect(() =>
130
+ expandExamples(" * @example Nope\n", new Map(), "pkg", "x.d.ts"),
131
+ ).toThrow(/Missing tested snippet: Nope/u);
132
+ });
133
+ });
134
+
135
+ describe("expandTemplate", () => {
136
+ it("replaces a // @snippet marker with the tested code", () => {
137
+ const snippets = [
138
+ {
139
+ title: "Bootstrap the app",
140
+ code: 'export const app = http.app("store");',
141
+ slug: "bootstrap",
142
+ filePath: "a",
143
+ startLine: 1,
144
+ expected: null,
145
+ },
146
+ ];
147
+ const index = buildSnippetIndex(snippets, "pkg");
148
+ const source =
149
+ 'import { http } from "@smitejs/http";\n// @snippet Bootstrap the app\n';
150
+ const expanded = expandTemplate(source, index, "pkg", "app.ts");
151
+ expect(expanded).toBe(
152
+ 'import { http } from "@smitejs/http";\nexport const app = http.app("store");\n',
153
+ );
154
+ });
155
+
156
+ it("throws when a marker has no tested snippet", () => {
157
+ expect(() =>
158
+ expandTemplate("// @snippet Nobody\n", new Map(), "pkg", "a.ts"),
159
+ ).toThrow(/Missing tested snippet: Nobody/u);
160
+ });
161
+ });
162
+
163
+ describe("expandMarkdown", () => {
164
+ it("replaces @example lines with placeholders and returns injectables", () => {
165
+ const snippet = {
166
+ title: "Bootstrap the app",
167
+ code: 'const app = http.app("store");',
168
+ slug: "bootstrap",
169
+ filePath: "x",
170
+ startLine: 1,
171
+ expected: null,
172
+ };
173
+ const index = buildSnippetIndex([snippet], "pkg");
174
+ const { body, examples } = expandMarkdown(
175
+ "# Serving\n\n@example Bootstrap the app\n\nDone",
176
+ index,
177
+ "pkg",
178
+ "concept.md",
179
+ );
180
+
181
+ expect(body).not.toContain("@example");
182
+ expect(examples).toHaveLength(1);
183
+ expect(examples[0]?.snippet.code).toBe('const app = http.app("store");');
184
+ });
185
+ });
186
+
187
+ describe("documentation examples", () => {
188
+ it("expands @example declarations", () => {
189
+ // #section - Expand @example declarations
190
+ const source = " * @example Expand @example declarations";
191
+ const snippet = buildSnippetIndex(
192
+ [
193
+ {
194
+ title: "Expand @example declarations",
195
+ code: "x()",
196
+ slug: "x",
197
+ filePath: "self.test.ts",
198
+ startLine: 1,
199
+ expected: null,
200
+ },
201
+ ],
202
+ "self",
203
+ );
204
+ const expanded = expandExamples(source, snippet, "self", "self.d.ts");
205
+ // #endsection
206
+
207
+ expect(expanded).toContain("```ts");
208
+ });
209
+
210
+ it("expands @snippet into a source file", () => {
211
+ // #section - Expand @snippet into a source file
212
+ const snippet = buildSnippetIndex([
213
+ {
214
+ title: "Expand @snippet into a source file",
215
+ code: "line = 1",
216
+ slug: "x",
217
+ filePath: "self.test.ts",
218
+ startLine: 1,
219
+ expected: null,
220
+ },
221
+ ]);
222
+ const expanded = expandTemplate(
223
+ "// @snippet Expand @snippet into a source file\n",
224
+ snippet,
225
+ "self",
226
+ "x.ts",
227
+ );
228
+ // #endsection
229
+
230
+ expect(expanded).toContain("line = 1");
231
+ });
232
+ });
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export {
2
+ buildSnippetIndex,
3
+ collectFiles,
4
+ collectTestSnippets,
5
+ extractSnippetExpected,
6
+ extractSnippets,
7
+ normalizeExampleName,
8
+ slugify,
9
+ transformExpectedToResult,
10
+ } from "./snippets.js";
11
+ export type { Snippet } from "./snippets.js";
12
+ export {
13
+ expandExamples,
14
+ expandMarkdown,
15
+ expandTemplate,
16
+ renderExample,
17
+ } from "./expand.js";
18
+ export type { MarkdownInjectable } from "./expand.js";
19
+ export { injectIntoFiles } from "./inject.js";
package/src/inject.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { expandExamples } from "./expand.js";
4
+ import { collectFiles } from "./snippets.js";
5
+ import type { Snippet } from "./snippets.js";
6
+
7
+ /**
8
+ * Walks `targetDir` for `.d.ts`/`.js`/`.ts` files and expands `@example`
9
+ * declarations in place against the snippet index. Returns the written paths.
10
+ *
11
+ * @group Inline
12
+ */
13
+ export const injectIntoFiles = async (input: {
14
+ readonly targetDir: string;
15
+ readonly snippetIndex: ReadonlyMap<string, Snippet>;
16
+ readonly packageName: string;
17
+ }): Promise<readonly string[]> => {
18
+ const files = await collectFiles(
19
+ input.targetDir,
20
+ (filePath) =>
21
+ filePath.endsWith(".d.ts") ||
22
+ filePath.endsWith(".js") ||
23
+ filePath.endsWith(".ts"),
24
+ );
25
+ const written: string[] = [];
26
+
27
+ for (const filePath of files) {
28
+ const source = await readFile(filePath, "utf8");
29
+ const expanded = expandExamples(
30
+ source,
31
+ input.snippetIndex,
32
+ input.packageName,
33
+ filePath,
34
+ );
35
+
36
+ if (expanded !== source) {
37
+ await writeFile(filePath, expanded);
38
+ written.push(filePath);
39
+ }
40
+ }
41
+
42
+ return written;
43
+ };
44
+
45
+ /** Default target directory when none is supplied to the inject command. */
46
+ export const DEFAULT_INJECT_TARGET = join("dist");
@@ -0,0 +1,326 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * A tested documentation snippet extracted from a `#section ... #endsection`
6
+ * block. The snippet code is exercised by the test file it lives in, so it is
7
+ * safe to publish into docs, JSDoc declarations, or generated code.
8
+ *
9
+ * @group Snippets
10
+ */
11
+ export interface Snippet {
12
+ /** The tested code between `#section` and `#endsection`, dedented. */
13
+ readonly code: string;
14
+ /** Absolute or root-relative path of the test file hosting the block. */
15
+ readonly filePath: string;
16
+ /** 1-based line of the `#section` marker. */
17
+ readonly startLine: number;
18
+ /** The human-readable title from the `#section` marker. */
19
+ readonly title: string;
20
+ /** URL-safe slug derived from {@link Snippet.title}. */
21
+ readonly slug: string;
22
+ /** The expected-value lines extracted from the assertions that follow. */
23
+ readonly expected: string | null;
24
+ }
25
+
26
+ /** A folder of files matching a predicate, sorted by path. */
27
+ export const collectFiles = async (
28
+ directory: string,
29
+ predicate: (filePath: string) => boolean,
30
+ ): Promise<readonly string[]> => {
31
+ const entries = await readdir(directory, { withFileTypes: true }).catch(
32
+ (error: unknown) => {
33
+ if (
34
+ error instanceof Error &&
35
+ "code" in error &&
36
+ error.code === "ENOENT"
37
+ ) {
38
+ return [];
39
+ }
40
+
41
+ throw error;
42
+ },
43
+ );
44
+ const files: string[] = [];
45
+
46
+ for (const entry of entries) {
47
+ const entryPath = path.join(directory, entry.name);
48
+
49
+ if (entry.isDirectory()) {
50
+ files.push(...(await collectFiles(entryPath, predicate)));
51
+ continue;
52
+ }
53
+
54
+ if (entry.isFile() && predicate(entryPath)) {
55
+ files.push(entryPath);
56
+ }
57
+ }
58
+
59
+ return files.sort();
60
+ };
61
+
62
+ const sectionPattern = /^\s*\/\/\s*#section\s*-\s*(.+)\s*$/u;
63
+ const endsectionPattern = /^\s*\/\/\s*#endsection\s*$/u;
64
+
65
+ /**
66
+ * Extracts every `#section ... #endsection` snippet from a test source. Nested
67
+ * or unclosed sections throw.
68
+ *
69
+ * @group Snippets
70
+ */
71
+ export const extractSnippets = (
72
+ source: string,
73
+ filePath: string,
74
+ ): readonly Snippet[] => {
75
+ const snippets: Snippet[] = [];
76
+ const lines = source.split("\n");
77
+ let current:
78
+ | {
79
+ code: string[];
80
+ filePath: string;
81
+ startLine: number;
82
+ title: string;
83
+ }
84
+ | undefined;
85
+
86
+ lines.forEach((line, index) => {
87
+ const section = line.match(sectionPattern);
88
+
89
+ if (section) {
90
+ if (current) {
91
+ throw new Error(
92
+ `Nested documentation snippet in ${filePath}:${index + 1}`,
93
+ );
94
+ }
95
+
96
+ current = {
97
+ code: [],
98
+ filePath,
99
+ startLine: index + 1,
100
+ title: section[1]?.trim() ?? "",
101
+ };
102
+ return;
103
+ }
104
+
105
+ if (endsectionPattern.test(line)) {
106
+ if (current) {
107
+ snippets.push({
108
+ ...current,
109
+ code: dedent(current.code.join("\n")).trim(),
110
+ expected: null,
111
+ slug: slugify(current.title),
112
+ });
113
+ }
114
+
115
+ current = undefined;
116
+ return;
117
+ }
118
+
119
+ if (current) {
120
+ current.code.push(line);
121
+ }
122
+ });
123
+
124
+ if (current) {
125
+ throw new Error(
126
+ `Unclosed documentation snippet in ${filePath}:${current.startLine}`,
127
+ );
128
+ }
129
+
130
+ return snippets;
131
+ };
132
+
133
+ /**
134
+ * Extracts the expected-result lines that immediately follow a snippet's
135
+ * `#endsection` marker (the assertions testing it), as plain code. Returns
136
+ * `null` when there is nothing to show.
137
+ *
138
+ * @group Snippets
139
+ */
140
+ export const extractSnippetExpected = (
141
+ testSource: string,
142
+ snippet: Snippet,
143
+ ): string | null => {
144
+ const lines = testSource.split("\n");
145
+ const sectionLine = lines.findIndex(
146
+ (line, idx) => idx >= snippet.startLine - 1 && endsectionPattern.test(line),
147
+ );
148
+
149
+ if (sectionLine === -1) {
150
+ return null;
151
+ }
152
+
153
+ const expectedLines: string[] = [];
154
+ const sectionIndent = lines[sectionLine]?.match(/^\s*/u)?.[0] ?? "";
155
+ let i = sectionLine + 1;
156
+
157
+ for (; i < lines.length; i++) {
158
+ const line = lines[i] ?? "";
159
+
160
+ if (line.trim().length === 0) {
161
+ continue;
162
+ }
163
+
164
+ const lineIndent = line.match(/^\s*/u)?.[0] ?? "";
165
+
166
+ if (lineIndent.length < sectionIndent.length) {
167
+ break;
168
+ }
169
+
170
+ expectedLines.push(line);
171
+ }
172
+
173
+ if (expectedLines.length === 0) {
174
+ return null;
175
+ }
176
+
177
+ const code = dedent(expectedLines.join("\n")).trim();
178
+ return code.length > 0 ? code : null;
179
+ };
180
+
181
+ /**
182
+ * Collects every tested snippet from the `*.test.ts` files under `srcDir`,
183
+ * returning both the flat list and a title index. Duplicate titles throw.
184
+ *
185
+ * @group Snippets
186
+ */
187
+ export const collectTestSnippets = async (input: {
188
+ readonly packageName: string;
189
+ readonly rootDir: string;
190
+ readonly srcDir: string;
191
+ }): Promise<{
192
+ snippetIndex: ReadonlyMap<string, Snippet>;
193
+ snippets: readonly Snippet[];
194
+ }> => {
195
+ const testFiles = await collectFiles(input.srcDir, (filePath) =>
196
+ filePath.endsWith(".test.ts"),
197
+ );
198
+ const snippets: Snippet[] = [];
199
+
200
+ for (const filePath of testFiles) {
201
+ const source = await readFile(filePath, "utf8");
202
+ const relativePath = path.relative(input.rootDir, filePath);
203
+ const extracted = extractSnippets(source, relativePath);
204
+
205
+ for (const snippet of extracted) {
206
+ const raw = extractSnippetExpected(source, snippet);
207
+ snippets.push({
208
+ ...snippet,
209
+ expected: raw ? transformExpectedToResult(raw) : null,
210
+ });
211
+ }
212
+ }
213
+
214
+ return {
215
+ snippetIndex: buildSnippetIndex(snippets, input.packageName),
216
+ snippets,
217
+ };
218
+ };
219
+
220
+ /**
221
+ * Builds the title → snippet index, throwing on duplicate or empty titles.
222
+ *
223
+ * @group Snippets
224
+ */
225
+ export const buildSnippetIndex = (
226
+ snippets: readonly Snippet[],
227
+ packageName: string,
228
+ ): ReadonlyMap<string, Snippet> => {
229
+ const index = new Map<string, Snippet>();
230
+
231
+ for (const snippet of snippets) {
232
+ const key = normalizeExampleName(snippet.title);
233
+
234
+ if (index.has(key)) {
235
+ throw new Error(
236
+ `Duplicate tested snippet "${snippet.title}" in ${packageName}.`,
237
+ );
238
+ }
239
+
240
+ index.set(key, snippet);
241
+ }
242
+
243
+ return index;
244
+ };
245
+
246
+ /** Normalizes a snippet title into its lookup key. */
247
+ export const normalizeExampleName = (value: string): string =>
248
+ value.trim().toLowerCase();
249
+
250
+ /** Converts a snippet title into a URL-safe slug. */
251
+ export const slugify = (value: string): string =>
252
+ value
253
+ .replace(/^@/u, "")
254
+ .replaceAll("/", "-")
255
+ .replace(/[^a-zA-Z0-9-]+/gu, "-")
256
+ .toLowerCase();
257
+
258
+ const findMatchingCloseParen = (code: string, openIndex: number): number => {
259
+ let depth = 1;
260
+
261
+ for (let i = openIndex + 1; i < code.length; i++) {
262
+ if (code[i] === "(") {
263
+ depth++;
264
+ } else if (code[i] === ")") {
265
+ depth--;
266
+
267
+ if (depth === 0) {
268
+ return i;
269
+ }
270
+ }
271
+ }
272
+
273
+ return -1;
274
+ };
275
+
276
+ /**
277
+ * Reduces a block of `expect(...).toBe(...)` assertions into just the expected
278
+ * values. Falls back to the raw code when no `expect` calls are present.
279
+ */
280
+ export const transformExpectedToResult = (code: string): string => {
281
+ const results: string[] = [];
282
+ let idx = 0;
283
+
284
+ while (idx < code.length) {
285
+ const expectIdx = code.indexOf("expect(", idx);
286
+
287
+ if (expectIdx === -1) {
288
+ break;
289
+ }
290
+
291
+ const expectOpen = expectIdx + "expect(".length;
292
+ const expectClose = findMatchingCloseParen(code, expectOpen);
293
+
294
+ if (expectClose === -1) {
295
+ break;
296
+ }
297
+
298
+ const matcherOpen = code.indexOf("(", expectClose + 1);
299
+
300
+ if (matcherOpen === -1) {
301
+ break;
302
+ }
303
+
304
+ const matcherClose = findMatchingCloseParen(code, matcherOpen);
305
+
306
+ if (matcherClose === -1) {
307
+ break;
308
+ }
309
+
310
+ const value = code.slice(matcherOpen + 1, matcherClose).trim();
311
+ results.push(value);
312
+ idx = matcherClose + 1;
313
+ }
314
+
315
+ return results.length > 0 ? results.join("\n") : code;
316
+ };
317
+
318
+ const dedent = (value: string): string => {
319
+ const lines = value.replace(/\s+$/u, "").split("\n");
320
+ const indentation = lines
321
+ .filter((line) => line.trim().length > 0)
322
+ .map((line) => line.match(/^\s*/u)?.[0].length ?? 0);
323
+ const size = indentation.length === 0 ? 0 : Math.min(...indentation);
324
+
325
+ return lines.map((line) => line.slice(size)).join("\n");
326
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "tsBuildInfoFile": "dist/.tsbuildinfo"
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["src/**/*.test.ts"]
10
+ }