1import { describe, expect, test } from "bun:test";
2import {
3 applyRuns,
4 markdownDialect,
5 markdownRuns,
6} from "../packages/vite-plugin-copy-editor/src/index.ts";
7import {
8 docBlocks,
9 docCommentLines,
10 docRegions,
11 JSDOC_LINE_PREFIX,
12 linkTagText,
13 markDocLines,
14 regionSource,
15 replaceRegion,
16} from "./jsdoc-blocks.ts";
17
18const FILE = `import x from "y";
19
20/**
21 * An S3 bucket for storing objects in AWS.
22 *
23 * A bucket name is auto-generated unless you provide one via
24 * \`bucketName\`. See {@link BucketPolicy} for access.
25 *
26 * - first item
27 * continues here
28 * - second item
29 *
30 * ### Creating a Bucket
31 * Section prose.
32 *
33 * **Example:** Basic Bucket
34 * \`\`\`typescript
35 * const bucket = yield* Bucket("b", {});
36 * \`\`\`
37 *
38 * @resource
39 */
40export const Bucket = 1;
41`;
42const START = FILE.indexOf("/**");
43const markdown = {
44 linePrefix: JSDOC_LINE_PREFIX,
45 atomics: [
46 {
47 pattern: /\{@link\s+([^}]+)\}/,
48 text: (m: RegExpExecArray) => linkTagText(m[1]!),
49 },
50 ],
51};
52
53const blockText = (b: { start: number; end: number }) =>
54 FILE.slice(b.start, b.end);
55
56describe("docBlocks", () => {
57 const blocks = docBlocks(docCommentLines(FILE, START));
58
59 test("finds rendered prose blocks and skips code and tags", () => {
60 expect(blocks.map((b) => [b.kind, blockText(b)])).toEqual([
61 ["paragraph", "An S3 bucket for storing objects in AWS."],
62 [
63 "paragraph",
64 "A bucket name is auto-generated unless you provide one via\n * `bucketName`. See {@link BucketPolicy} for access.",
65 ],
66 ["listItem", "first item\n * continues here"],
67 ["listItem", "second item"],
68 ["sectionTitle", "Creating a Bucket"],
69 ["paragraph", "Section prose."],
70 ["exampleTitle", "Basic Bucket"],
71 ]);
72 });
73
74 test("an edit rewrites the JSDoc in place", () => {
75 const block = blocks[1]!;
76 const runs = markdownRuns(FILE, block.start, block.end, markdown);
77 const before = [
78 "A bucket name is auto-generated unless you provide one via\n",
79 "bucketName",
80 ". See ",
81 "BucketPolicy",
82 " for access.",
83 ];
84 const after = [...before];
85 after[0] =
86 "A bucket name is generated for you unless you provide one via\n";
87 after[4] = " for permissions.";
88 const out = applyRuns(FILE, runs, markdownDialect(markdown), {
89 before,
90 after,
91 });
92 expect(out).toContain(
93 " * A bucket name is generated for you unless you provide one via\n * `bucketName`. See {@link BucketPolicy} for permissions.",
94 );
95 // The rest of the file is untouched.
96 expect(
97 out
98 .replace("generated for you", "auto-generated")
99 .replace("permissions.", "access."),
100 ).toBe(FILE);
101 });
102
103 test("link text is locked", () => {
104 const block = blocks[1]!;
105 const runs = markdownRuns(FILE, block.start, block.end, markdown);
106 const before = [
107 "A bucket name is auto-generated unless you provide one via\n",
108 "bucketName",
109 ". See ",
110 "BucketPolicy",
111 " for access.",
112 ];
113 const after = [...before];
114 after[3] = "OtherThing";
115 expect(() =>
116 applyRuns(FILE, runs, markdownDialect(markdown), { before, after }),
117 ).toThrow(/generated/);
118 });
119});
120
121const EMPTY_SECTION = `/**
122 * Summary.
123 *
124 * ### Empty Section
125 * **Example:** Only
126 * \`\`\`ts
127 * x();
128 * \`\`\`
129 * @resource
130 */`;
131
132describe("docRegions", () => {
133 const lines = docCommentLines(FILE, START);
134 const regions = docRegions(lines);
135
136 test("finds the summary, section descriptions, and example bodies", () => {
137 expect(regions.map((r) => [r.kind, regionSource(lines, r)])).toEqual([
138 [
139 "summary",
140 "An S3 bucket for storing objects in AWS.\n\nA bucket name is auto-generated unless you provide one via\n`bucketName`. See {@link BucketPolicy} for access.\n\n- first item\n continues here\n- second item",
141 ],
142 ["sectionDescription", "Section prose."],
143 [
144 "exampleBody",
145 '```typescript\nconst bucket = yield* Bucket("b", {});\n```',
146 ],
147 ]);
148 });
149
150 test("an empty section gets an empty region after its title", () => {
151 const empty = docCommentLines(EMPTY_SECTION, 0);
152 const [, description] = docRegions(empty);
153 expect(description?.kind).toBe("sectionDescription");
154 expect(description!.lastLine).toBeLessThan(description!.firstLine);
155 const out = replaceRegion(
156 EMPTY_SECTION,
157 empty,
158 description!,
159 "New *prose*.",
160 );
161 expect(out).toContain(
162 " * ### Empty Section\n * New *prose*.\n * **Example:** Only",
163 );
164 });
165
166 test("replacing a region writes markdown as JSDoc lines", () => {
167 const next =
168 "A new summary with `code`.\n\n```ts\nconst x = 1;\n```\n\n@mention stays prose";
169 const out = replaceRegion(FILE, lines, regions[0]!, next);
170 expect(out).toContain(
171 "/**\n * A new summary with `code`.\n *\n * ```ts\n * const x = 1;\n * ```\n *\n * \\@mention stays prose\n *\n * ### Creating a Bucket",
172 );
173 // Round trip: the new region reads back as the markdown written.
174 const reread = docCommentLines(out, START);
175 expect(regionSource(reread, docRegions(reread)[0]!)).toBe(
176 next.replace("@mention", "\\@mention"),
177 );
178 });
179
180 test("refuses to close the comment", () => {
181 expect(() => replaceRegion(FILE, lines, regions[0]!, "oops */")).toThrow(
182 /\*\//,
183 );
184 });
185
186 test("wraps regions and marks the titles outside them", () => {
187 const texts = markDocLines(lines, (t) => `id#${t}`).join("\n");
188 expect(texts).toContain(
189 '<div data-copy="id#r0" data-copy-format="markdown" data-copy-style="docs">\n\nAn S3 bucket',
190 );
191 expect(texts).toContain("### Creating a Bucket<!--copy:id#0-->");
192 expect(texts).toContain("**Example:** Basic Bucket<!--copy:id#1-->");
193 expect(texts).not.toContain("AWS.<!--copy");
194 });
195});
196
197test("replaceRegion collapses extra blank lines outside code only", () => {
198 const lines = docCommentLines(FILE, START);
199 const region = docRegions(lines)[0]!;
200 const out = replaceRegion(
201 FILE,
202 lines,
203 region,
204 "One.\n\n\n\nTwo.\n\n```ts\na\n\n\nb\n```",
205 );
206 expect(out).toContain(
207 " * One.\n *\n * Two.\n *\n * ```ts\n * a\n *\n *\n * b\n * ```",
208 );
209});