-
Notifications
You must be signed in to change notification settings - Fork 256
Expand file tree
/
Copy pathsync-snippets.ts
More file actions
543 lines (467 loc) · 15.3 KB
/
sync-snippets.ts
File metadata and controls
543 lines (467 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
/**
* Code Snippet Sync Script
*
* This script syncs code snippets into JSDoc comments and markdown files
* containing labeled code fences.
*
* ## Supported Source Files
*
* - **Full-file inclusion**: Any file type (e.g., `.json`, `.yaml`, `.sh`, `.ts`)
* - **Region extraction**: Only `.ts` and `.tsx` files (using `//#region` markers)
*
* ## Code Fence Format
*
* Full-file inclusion (any file type):
*
* ``````typescript
* ```json source="./config.json"
* // entire file content is synced here
* ```
* ``````
*
* Region extraction (.ts/.tsx only):
*
* ``````typescript
* ```ts source="./path.examples.ts#regionName"
* // region content is synced here
* ```
* ``````
*
* Optionally, a display filename can be shown before the source reference:
*
* ``````typescript
* ```ts my-app.ts source="./path.examples.ts#regionName"
* // code is synced here
* ```
* ``````
*
* ## Region Format (in .examples.ts files)
*
* ``````typescript
* //#region regionName
* // code here
* //#endregion regionName
* ``````
*
* Run: npm run sync:snippets
*/
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PROJECT_ROOT = join(__dirname, "..");
const SRC_DIR = join(PROJECT_ROOT, "src");
const DOCS_DIR = join(PROJECT_ROOT, "docs");
/** Processing mode based on file type */
type FileMode = "jsdoc" | "markdown";
/**
* Represents a labeled code fence found in a source file.
*/
interface LabeledCodeFence {
/** Optional display filename (e.g., "my-app.ts") */
displayName?: string;
/** Relative path to the example file (e.g., "./app.examples.ts") */
examplePath: string;
/** Region name (e.g., "App_basicUsage"), or undefined for whole file */
regionName?: string;
/** Language from the code fence (e.g., "ts", "json", "yaml") */
language: string;
/** Character index of the opening fence line start */
openingFenceStart: number;
/** Character index after the opening fence line (after newline) */
openingFenceEnd: number;
/** Character index of the closing fence line start */
closingFenceStart: number;
/** The JSDoc line prefix extracted from context (e.g., " * ") */
linePrefix: string;
}
/**
* Cache for example file regions to avoid re-reading files.
* Key: `${absoluteExamplePath}#${regionName}` (empty regionName for whole file)
* Value: extracted code string
*/
type RegionCache = Map<string, string>;
/**
* Processing result for a source file.
*/
interface FileProcessingResult {
filePath: string;
modified: boolean;
snippetsProcessed: number;
errors: string[];
}
// JSDoc patterns - for code fences inside JSDoc comments with " * " prefix
// Matches: <prefix>```<lang> [displayName] source="<path>" or source="<path>#<region>"
// Example: " * ```ts my-app.ts source="./app.examples.ts#App_basicUsage""
// Example: " * ```ts source="./app.examples.ts#App_basicUsage""
// Example: " * ```ts source="./complete-example.ts"" (whole file)
const JSDOC_LABELED_FENCE_PATTERN =
/^(\s*\*\s*)```(\w+)(?:\s+(\S+))?\s+source="([^"#]+)(?:#([^"]+))?"/;
const JSDOC_CLOSING_FENCE_PATTERN = /^(\s*\*\s*)```\s*$/;
// Markdown patterns - for plain code fences in markdown files (no prefix)
// Matches: ```<lang> [displayName] source="<path>" or source="<path>#<region>"
// Example: ```tsx source="./patterns.tsx#chunkedDataServer"
// Example: ```tsx source="./complete-example.tsx" (whole file)
const MARKDOWN_LABELED_FENCE_PATTERN =
/^```(\w+)(?:\s+(\S+))?\s+source="([^"#]+)(?:#([^"]+))?"/;
const MARKDOWN_CLOSING_FENCE_PATTERN = /^```\s*$/;
/**
* Find all labeled code fences in a source file.
* @param content The file content
* @param filePath The file path (for error messages)
* @param mode The processing mode (jsdoc or markdown)
* @returns Array of labeled code fence references
*/
function findLabeledCodeFences(
content: string,
filePath: string,
mode: FileMode,
): LabeledCodeFence[] {
const results: LabeledCodeFence[] = [];
const lines = content.split("\n");
let charIndex = 0;
// Select patterns based on mode
const openPattern =
mode === "jsdoc"
? JSDOC_LABELED_FENCE_PATTERN
: MARKDOWN_LABELED_FENCE_PATTERN;
const closePattern =
mode === "jsdoc"
? JSDOC_CLOSING_FENCE_PATTERN
: MARKDOWN_CLOSING_FENCE_PATTERN;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const openMatch = line.match(openPattern);
if (openMatch) {
let linePrefix: string;
let language: string;
let displayName: string | undefined;
let examplePath: string;
let regionName: string;
if (mode === "jsdoc") {
// JSDoc: group 1=prefix, 2=lang, 3=displayName, 4=path, 5=region
[, linePrefix, language, displayName, examplePath, regionName] =
openMatch;
} else {
// Markdown: group 1=lang, 2=displayName, 3=path, 4=region (no prefix)
[, language, displayName, examplePath, regionName] = openMatch;
linePrefix = "";
}
const openingFenceStart = charIndex;
const openingFenceEnd = charIndex + line.length + 1; // +1 for newline
// Find closing fence
let closingFenceStart = -1;
let searchIndex = openingFenceEnd;
for (let j = i + 1; j < lines.length; j++) {
const closeLine = lines[j];
if (closePattern.test(closeLine)) {
closingFenceStart = searchIndex;
break;
}
searchIndex += closeLine.length + 1;
}
if (closingFenceStart === -1) {
throw new Error(
`${filePath}: No closing fence for ${examplePath}#${regionName}`,
);
}
results.push({
displayName,
examplePath,
regionName,
language,
openingFenceStart,
openingFenceEnd,
closingFenceStart,
linePrefix,
});
}
charIndex += line.length + 1;
}
return results;
}
/**
* Dedent content by removing a base indentation prefix from each line.
* @param content The content to dedent
* @param baseIndent The indentation to remove
* @returns The dedented content
*/
function dedent(content: string, baseIndent: string): string {
if (!baseIndent) return content;
const lines = content.split("\n");
const dedentedLines = lines.map((line) => {
// Preserve empty lines as-is
if (line.trim() === "") return "";
// Remove the base indentation if present
if (line.startsWith(baseIndent)) {
return line.slice(baseIndent.length);
}
// Line has less indentation than base - keep as-is
return line;
});
// Trim trailing empty lines
while (
dedentedLines.length > 0 &&
dedentedLines[dedentedLines.length - 1] === ""
) {
dedentedLines.pop();
}
return dedentedLines.join("\n");
}
/**
* Extract a region from an example file.
* @param exampleContent The content of the example file
* @param regionName The region name to extract
* @param examplePath The example file path (for error messages)
* @returns The dedented region content
*/
function extractRegion(
exampleContent: string,
regionName: string,
examplePath: string,
): string {
// Region extraction only supported for .ts/.tsx files (uses //#region syntax)
if (!examplePath.endsWith(".ts") && !examplePath.endsWith(".tsx")) {
throw new Error(
`Region extraction (#${regionName}) is only supported for .ts/.tsx files. ` +
`Use full-file inclusion (without #regionName) for: ${examplePath}`,
);
}
const lineEnding = exampleContent.includes("\r\n") ? "\r\n" : "\n";
const regionStart = `//#region ${regionName}${lineEnding}`;
const regionEnd = `//#endregion ${regionName}${lineEnding}`;
const startIndex = exampleContent.indexOf(regionStart);
if (startIndex === -1) {
throw new Error(`Region "${regionName}" not found in ${examplePath}`);
}
const endIndex = exampleContent.indexOf(regionEnd, startIndex);
if (endIndex === -1) {
throw new Error(
`Region end marker for "${regionName}" not found in ${examplePath}`,
);
}
// Get content after the region start line
const afterStart = exampleContent.indexOf("\n", startIndex);
if (afterStart === -1 || afterStart >= endIndex) {
return ""; // Empty region
}
// Extract the raw content
const rawContent = exampleContent.slice(afterStart + 1, endIndex);
// Determine base indentation from the //#region line
let lineStart = exampleContent.lastIndexOf("\n", startIndex);
lineStart = lineStart === -1 ? 0 : lineStart + 1;
const regionLine = exampleContent.slice(lineStart, startIndex);
// The base indent is the whitespace before //#region
const baseIndent = regionLine;
return dedent(rawContent, baseIndent);
}
/**
* Get or load a region from the cache.
* @param sourceFilePath The source file requesting the region
* @param examplePath The relative path to the example file
* @param regionName The region name to extract, or undefined for whole file
* @param cache The region cache
* @returns The extracted code string
*/
function getOrLoadRegion(
sourceFilePath: string,
examplePath: string,
regionName: string | undefined,
cache: RegionCache,
): string {
// Resolve the example path relative to the source file
const sourceDir = dirname(sourceFilePath);
const absoluteExamplePath = resolve(sourceDir, examplePath);
// File content is always cached with key ending in "#" (empty region)
const fileKey = `${absoluteExamplePath}#`;
let fileContent = cache.get(fileKey);
if (fileContent === undefined) {
try {
fileContent = readFileSync(absoluteExamplePath, "utf-8").trim();
} catch {
throw new Error(`Example file not found: ${absoluteExamplePath}`);
}
cache.set(fileKey, fileContent);
}
// If no region name, return whole file
if (!regionName) {
return fileContent;
}
// Extract region from cached file content, cache the result
const regionKey = `${absoluteExamplePath}#${regionName}`;
let regionContent = cache.get(regionKey);
if (regionContent === undefined) {
regionContent = extractRegion(fileContent, regionName, examplePath);
cache.set(regionKey, regionContent);
}
return regionContent;
}
/**
* Format code lines for insertion into a JSDoc comment.
* @param code The code to format
* @param linePrefix The JSDoc line prefix (e.g., " * ")
* @returns The formatted code with JSDoc prefixes
*/
function formatCodeLines(code: string, linePrefix: string): string {
const lines = code.split("\n");
return lines
.map((line) =>
line === "" ? linePrefix.trimEnd() : `${linePrefix}${line}`,
)
.join("\n");
}
/**
* Process a single source file to sync snippets.
* @param filePath The source file path
* @param cache The region cache
* @param mode The processing mode (jsdoc or markdown)
* @returns The processing result
*/
function processFile(
filePath: string,
cache: RegionCache,
mode: FileMode,
): FileProcessingResult {
const result: FileProcessingResult = {
filePath,
modified: false,
snippetsProcessed: 0,
errors: [],
};
let content: string;
try {
content = readFileSync(filePath, "utf-8");
} catch (err) {
result.errors.push(`Failed to read file: ${err}`);
return result;
}
let fences: LabeledCodeFence[];
try {
fences = findLabeledCodeFences(content, filePath, mode);
} catch (err) {
result.errors.push(err instanceof Error ? err.message : String(err));
return result;
}
if (fences.length === 0) {
return result;
}
const originalContent = content;
// Process fences in reverse order to preserve positions
for (let i = fences.length - 1; i >= 0; i--) {
const fence = fences[i];
try {
const code = getOrLoadRegion(
filePath,
fence.examplePath,
fence.regionName,
cache,
);
const formattedCode = formatCodeLines(code, fence.linePrefix);
// Replace content between opening fence end and closing fence start
content =
content.slice(0, fence.openingFenceEnd) +
formattedCode +
"\n" +
content.slice(fence.closingFenceStart);
result.snippetsProcessed++;
} catch (err) {
result.errors.push(
`${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (
result.snippetsProcessed > 0 &&
result.errors.length === 0 &&
content !== originalContent
) {
writeFileSync(filePath, content);
result.modified = true;
}
return result;
}
/**
* Find all TypeScript source files in a directory, excluding examples, tests, and generated files.
* @param dir The directory to search
* @returns Array of absolute file paths
*/
function findSourceFiles(dir: string): string[] {
const files: string[] = [];
const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const name = entry.name;
// Only process .ts and .tsx files
if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue;
// Exclude example files, test files
if (name.endsWith(".examples.ts") || name.endsWith(".examples.tsx"))
continue;
if (name.endsWith(".test.ts")) continue;
// Get the relative path from the parent directory
const parentPath = entry.parentPath;
// Exclude generated directory
if (parentPath.includes("/generated") || parentPath.includes("\\generated"))
continue;
const fullPath = join(parentPath, name);
files.push(fullPath);
}
return files;
}
/**
* Find all markdown files in a directory.
* @param dir The directory to search
* @returns Array of absolute file paths
*/
function findMarkdownFiles(dir: string): string[] {
const files: string[] = [];
const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
// Only process .md files
if (!entry.name.endsWith(".md")) continue;
const fullPath = join(entry.parentPath, entry.name);
files.push(fullPath);
}
return files;
}
async function main() {
console.log("🔧 Syncing code snippets from example files...\n");
const cache: RegionCache = new Map();
const results: FileProcessingResult[] = [];
// Process TypeScript source files (JSDoc mode)
const sourceFiles = findSourceFiles(SRC_DIR);
for (const filePath of sourceFiles) {
const result = processFile(filePath, cache, "jsdoc");
results.push(result);
}
// Process markdown documentation files
const markdownFiles = findMarkdownFiles(DOCS_DIR);
for (const filePath of markdownFiles) {
const result = processFile(filePath, cache, "markdown");
results.push(result);
}
// Report results
const modified = results.filter((r) => r.modified);
const errors = results.flatMap((r) => r.errors);
if (modified.length > 0) {
console.log(`✅ Modified ${modified.length} file(s):`);
for (const r of modified) {
console.log(` ${r.filePath} (${r.snippetsProcessed} snippet(s))`);
}
} else {
console.log("✅ No files needed modification");
}
if (errors.length > 0) {
console.error("\n❌ Errors:");
for (const error of errors) {
console.error(` ${error}`);
}
process.exit(1);
}
console.log("\n🎉 Snippet sync complete!");
}
main().catch((error) => {
console.error("❌ Snippet sync failed:", error);
process.exit(1);
});