skrypt-ai 0.5.0 → 0.6.1

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 (120) hide show
  1. package/dist/auth/index.js +8 -1
  2. package/dist/autofix/index.d.ts +0 -4
  3. package/dist/autofix/index.js +0 -21
  4. package/dist/capture/browser.d.ts +11 -0
  5. package/dist/capture/browser.js +173 -0
  6. package/dist/capture/diff.d.ts +23 -0
  7. package/dist/capture/diff.js +52 -0
  8. package/dist/capture/index.d.ts +23 -0
  9. package/dist/capture/index.js +210 -0
  10. package/dist/capture/naming.d.ts +17 -0
  11. package/dist/capture/naming.js +45 -0
  12. package/dist/capture/parser.d.ts +15 -0
  13. package/dist/capture/parser.js +80 -0
  14. package/dist/capture/types.d.ts +57 -0
  15. package/dist/capture/types.js +1 -0
  16. package/dist/cli.js +4 -0
  17. package/dist/commands/autofix.js +136 -120
  18. package/dist/commands/cron.js +58 -47
  19. package/dist/commands/deploy.js +123 -102
  20. package/dist/commands/generate.js +88 -6
  21. package/dist/commands/heal.d.ts +10 -0
  22. package/dist/commands/heal.js +201 -0
  23. package/dist/commands/i18n.js +146 -111
  24. package/dist/commands/lint.js +50 -44
  25. package/dist/commands/llms-txt.js +59 -49
  26. package/dist/commands/login.js +61 -43
  27. package/dist/commands/mcp.js +6 -0
  28. package/dist/commands/monitor.js +13 -8
  29. package/dist/commands/qa.d.ts +2 -0
  30. package/dist/commands/qa.js +43 -0
  31. package/dist/commands/review-pr.js +114 -103
  32. package/dist/commands/sdk.js +128 -122
  33. package/dist/commands/security.js +86 -80
  34. package/dist/commands/test.js +91 -92
  35. package/dist/commands/version.js +104 -75
  36. package/dist/commands/watch.js +130 -114
  37. package/dist/config/types.js +2 -2
  38. package/dist/context-hub/index.d.ts +23 -0
  39. package/dist/context-hub/index.js +179 -0
  40. package/dist/context-hub/mappings.d.ts +8 -0
  41. package/dist/context-hub/mappings.js +55 -0
  42. package/dist/context-hub/types.d.ts +33 -0
  43. package/dist/context-hub/types.js +1 -0
  44. package/dist/generator/generator.js +39 -6
  45. package/dist/generator/types.d.ts +7 -0
  46. package/dist/generator/writer.d.ts +3 -1
  47. package/dist/generator/writer.js +24 -4
  48. package/dist/llm/anthropic-client.d.ts +1 -0
  49. package/dist/llm/anthropic-client.js +3 -1
  50. package/dist/llm/index.d.ts +6 -4
  51. package/dist/llm/index.js +76 -261
  52. package/dist/llm/openai-client.d.ts +1 -0
  53. package/dist/llm/openai-client.js +7 -2
  54. package/dist/qa/checks.d.ts +10 -0
  55. package/dist/qa/checks.js +492 -0
  56. package/dist/qa/fixes.d.ts +30 -0
  57. package/dist/qa/fixes.js +277 -0
  58. package/dist/qa/index.d.ts +29 -0
  59. package/dist/qa/index.js +187 -0
  60. package/dist/qa/types.d.ts +24 -0
  61. package/dist/qa/types.js +1 -0
  62. package/dist/scanner/csharp.d.ts +23 -0
  63. package/dist/scanner/csharp.js +421 -0
  64. package/dist/scanner/index.js +16 -2
  65. package/dist/scanner/java.d.ts +39 -0
  66. package/dist/scanner/java.js +318 -0
  67. package/dist/scanner/kotlin.d.ts +23 -0
  68. package/dist/scanner/kotlin.js +389 -0
  69. package/dist/scanner/php.d.ts +57 -0
  70. package/dist/scanner/php.js +351 -0
  71. package/dist/scanner/ruby.d.ts +36 -0
  72. package/dist/scanner/ruby.js +431 -0
  73. package/dist/scanner/swift.d.ts +25 -0
  74. package/dist/scanner/swift.js +392 -0
  75. package/dist/scanner/types.d.ts +1 -1
  76. package/dist/template/content/docs/_navigation.json +46 -0
  77. package/dist/template/content/docs/_sidebars.json +684 -0
  78. package/dist/template/content/docs/core.md +4544 -0
  79. package/dist/template/content/docs/index.mdx +89 -0
  80. package/dist/template/content/docs/integrations.md +1158 -0
  81. package/dist/template/content/docs/llms-full.md +403 -0
  82. package/dist/template/content/docs/llms.txt +4588 -0
  83. package/dist/template/content/docs/other.md +10379 -0
  84. package/dist/template/content/docs/tools.md +746 -0
  85. package/dist/template/content/docs/types.md +531 -0
  86. package/dist/template/docs.json +13 -11
  87. package/dist/template/mdx-components.tsx +27 -2
  88. package/dist/template/package.json +6 -0
  89. package/dist/template/public/search-index.json +1 -1
  90. package/dist/template/scripts/build-search-index.mjs +84 -6
  91. package/dist/template/src/app/api/chat/route.ts +83 -128
  92. package/dist/template/src/app/docs/[...slug]/page.tsx +75 -20
  93. package/dist/template/src/app/docs/llms-full.md +151 -4
  94. package/dist/template/src/app/docs/llms.txt +2464 -847
  95. package/dist/template/src/app/docs/page.mdx +48 -38
  96. package/dist/template/src/app/layout.tsx +3 -1
  97. package/dist/template/src/app/page.tsx +22 -8
  98. package/dist/template/src/components/ai-chat.tsx +73 -64
  99. package/dist/template/src/components/breadcrumbs.tsx +21 -23
  100. package/dist/template/src/components/copy-button.tsx +13 -9
  101. package/dist/template/src/components/copy-page-button.tsx +54 -0
  102. package/dist/template/src/components/docs-layout.tsx +37 -25
  103. package/dist/template/src/components/header.tsx +51 -10
  104. package/dist/template/src/components/mdx/card.tsx +17 -3
  105. package/dist/template/src/components/mdx/code-block.tsx +13 -9
  106. package/dist/template/src/components/mdx/code-group.tsx +13 -8
  107. package/dist/template/src/components/mdx/heading.tsx +15 -2
  108. package/dist/template/src/components/mdx/highlighted-code.tsx +13 -8
  109. package/dist/template/src/components/mdx/index.tsx +2 -0
  110. package/dist/template/src/components/mdx/mermaid.tsx +110 -0
  111. package/dist/template/src/components/mdx/screenshot.tsx +150 -0
  112. package/dist/template/src/components/scroll-to-hash.tsx +48 -0
  113. package/dist/template/src/components/sidebar.tsx +12 -18
  114. package/dist/template/src/components/table-of-contents.tsx +9 -0
  115. package/dist/template/src/lib/highlight.ts +3 -88
  116. package/dist/template/src/lib/navigation.ts +159 -0
  117. package/dist/template/src/styles/globals.css +17 -6
  118. package/dist/utils/validation.d.ts +0 -3
  119. package/dist/utils/validation.js +0 -26
  120. package/package.json +3 -2
@@ -121,7 +121,14 @@ export async function checkPlan(apiKey) {
121
121
  if (typeof data.valid !== 'boolean' || typeof data.email !== 'string') {
122
122
  return { valid: false, plan: 'free', email: '', error: 'Invalid API response' };
123
123
  }
124
- return data;
124
+ const plan = data.plan === 'pro' ? 'pro' : 'free';
125
+ return {
126
+ valid: data.valid,
127
+ plan,
128
+ email: data.email,
129
+ expiresAt: typeof data.expiresAt === 'string' ? data.expiresAt : undefined,
130
+ error: typeof data.error === 'string' ? data.error : undefined,
131
+ };
125
132
  }
126
133
  catch {
127
134
  return { valid: false, plan: 'free', email: '', error: 'Failed to connect to API' };
@@ -32,10 +32,6 @@ export interface ValidationResult {
32
32
  * Auto-fix a code example using LLM
33
33
  */
34
34
  export declare function autoFixExample(example: CodeExample, client: LLMClient, options?: AutoFixOptions): Promise<FixResult>;
35
- /**
36
- * Batch auto-fix multiple examples
37
- */
38
- export declare function autoFixBatch(examples: CodeExample[], client: LLMClient, options?: AutoFixOptions): Promise<Map<number, FixResult>>;
39
35
  /**
40
36
  * Create a TypeScript validator using tsc
41
37
  */
@@ -152,27 +152,6 @@ function extractCode(response, language) {
152
152
  return null;
153
153
  return joined;
154
154
  }
155
- /**
156
- * Batch auto-fix multiple examples
157
- */
158
- export async function autoFixBatch(examples, client, options = {}) {
159
- const results = new Map();
160
- for (let i = 0; i < examples.length; i++) {
161
- const example = examples[i];
162
- if (!example)
163
- continue;
164
- console.log(` [${i + 1}/${examples.length}] Fixing ${example.language} example...`);
165
- const result = await autoFixExample(example, client, options);
166
- results.set(i, result);
167
- if (result.success) {
168
- console.log(` ✓ Fixed in ${result.iterations} iteration(s)`);
169
- }
170
- else {
171
- console.log(` ✗ Could not fix after ${result.iterations} iteration(s)`);
172
- }
173
- }
174
- return results;
175
- }
176
155
  /**
177
156
  * Create a TypeScript validator using tsc
178
157
  */
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Playwright wrapper for screenshot capture.
3
+ *
4
+ * Dynamically imports Playwright (optional dependency).
5
+ * Only allows localhost URLs by default for security.
6
+ */
7
+ import type { ScreenshotDirective, ScreenshotResult, CaptureOptions } from './types.js';
8
+ /**
9
+ * Capture screenshots for a batch of directives.
10
+ */
11
+ export declare function captureScreenshots(directives: ScreenshotDirective[], options: CaptureOptions): Promise<ScreenshotResult[]>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Playwright wrapper for screenshot capture.
3
+ *
4
+ * Dynamically imports Playwright (optional dependency).
5
+ * Only allows localhost URLs by default for security.
6
+ */
7
+ import { screenshotFilename, darkVariantFilename } from './naming.js';
8
+ import { computeHash } from './diff.js';
9
+ const ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
10
+ /**
11
+ * Dynamically import Playwright. Exits with clear instructions if not installed.
12
+ */
13
+ async function ensurePlaywright() {
14
+ try {
15
+ // Dynamic import — playwright is optional
16
+ const mod = await Function('return import("playwright")')();
17
+ return mod;
18
+ }
19
+ catch {
20
+ throw new Error('Playwright is required for screenshot capture. Install it: npx playwright install chromium');
21
+ }
22
+ }
23
+ /**
24
+ * Validate that a URL is on an allowed host (localhost by default).
25
+ */
26
+ function validateUrl(url) {
27
+ try {
28
+ const parsed = new URL(url);
29
+ return { valid: ALLOWED_HOSTS.has(parsed.hostname), host: parsed.hostname };
30
+ }
31
+ catch {
32
+ return { valid: false, host: '' };
33
+ }
34
+ }
35
+ /**
36
+ * Capture screenshots for a batch of directives.
37
+ */
38
+ export async function captureScreenshots(directives, options) {
39
+ const pw = await ensurePlaywright();
40
+ const results = [];
41
+ const browser = await pw.chromium.launch({ headless: true });
42
+ try {
43
+ // Process with concurrency limit
44
+ const chunks = [];
45
+ for (let i = 0; i < directives.length; i += options.concurrency) {
46
+ chunks.push(directives.slice(i, i + options.concurrency));
47
+ }
48
+ for (const chunk of chunks) {
49
+ const chunkResults = await Promise.all(chunk.map(directive => captureOne(browser, directive, options, pw)));
50
+ results.push(...chunkResults);
51
+ }
52
+ }
53
+ finally {
54
+ await browser.close();
55
+ }
56
+ return results;
57
+ }
58
+ /**
59
+ * Capture a single screenshot.
60
+ */
61
+ async function captureOne(browser, directive, options, pw) {
62
+ const start = Date.now();
63
+ const filename = screenshotFilename(directive.url, directive.selector);
64
+ // Security check
65
+ const { valid, host } = validateUrl(directive.url);
66
+ if (!valid) {
67
+ return {
68
+ directive,
69
+ filename,
70
+ status: 'failed',
71
+ error: `URL host "${host}" not allowed. Only localhost, 127.0.0.1, and ::1 are permitted.`,
72
+ duration: Date.now() - start,
73
+ };
74
+ }
75
+ // Build context options
76
+ const contextOpts = {
77
+ viewport: options.viewport,
78
+ };
79
+ // Device emulation
80
+ if (options.device) {
81
+ const device = pw.devices[options.device];
82
+ if (device) {
83
+ contextOpts.viewport = device.viewport;
84
+ contextOpts.userAgent = device.userAgent;
85
+ contextOpts.deviceScaleFactor = device.deviceScaleFactor;
86
+ contextOpts.isMobile = device.isMobile;
87
+ contextOpts.hasTouch = device.hasTouch;
88
+ }
89
+ }
90
+ const context = await browser.newContext(contextOpts);
91
+ const page = await context.newPage();
92
+ try {
93
+ // Navigate
94
+ await page.goto(directive.url, {
95
+ waitUntil: 'networkidle',
96
+ timeout: options.timeout,
97
+ });
98
+ // Wait for settle time
99
+ await page.waitForTimeout(options.wait);
100
+ // Capture light mode screenshot
101
+ let buffer;
102
+ if (directive.selector) {
103
+ const locator = page.locator(directive.selector);
104
+ const visible = await locator.isVisible();
105
+ if (!visible) {
106
+ return {
107
+ directive,
108
+ filename,
109
+ status: 'failed',
110
+ error: `Selector "${directive.selector}" not found or not visible on ${directive.url}`,
111
+ duration: Date.now() - start,
112
+ };
113
+ }
114
+ buffer = await locator.screenshot({ type: 'png' });
115
+ }
116
+ else {
117
+ buffer = await page.screenshot({ fullPage: true, type: 'png' });
118
+ }
119
+ const hash = computeHash(buffer);
120
+ const result = {
121
+ directive,
122
+ filename,
123
+ status: 'captured',
124
+ hash,
125
+ duration: Date.now() - start,
126
+ };
127
+ // Dark mode capture
128
+ if (options.dark) {
129
+ try {
130
+ await page.emulateMedia({ colorScheme: 'dark' });
131
+ await page.evaluate(() => {
132
+ document.documentElement.classList.add('dark');
133
+ });
134
+ await page.waitForTimeout(500);
135
+ let darkBuffer;
136
+ if (directive.selector) {
137
+ darkBuffer = await page.locator(directive.selector).screenshot({ type: 'png' });
138
+ }
139
+ else {
140
+ darkBuffer = await page.screenshot({ fullPage: true, type: 'png' });
141
+ }
142
+ result.darkFilename = darkVariantFilename(filename);
143
+ result._buffers = {
144
+ light: buffer,
145
+ dark: darkBuffer,
146
+ };
147
+ }
148
+ catch {
149
+ // Dark mode capture failed — continue with light only
150
+ }
151
+ }
152
+ // Store light buffer for the orchestrator
153
+ if (!result._buffers) {
154
+ ;
155
+ result._buffer = buffer;
156
+ }
157
+ return result;
158
+ }
159
+ catch (err) {
160
+ const message = err instanceof Error ? err.message : String(err);
161
+ return {
162
+ directive,
163
+ filename,
164
+ status: 'failed',
165
+ error: message,
166
+ duration: Date.now() - start,
167
+ };
168
+ }
169
+ finally {
170
+ await page.close();
171
+ await context.close();
172
+ }
173
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Hash-based change detection for screenshots.
3
+ *
4
+ * Stores SHA-256 hashes in .skrypt/screenshot-hashes.json (project root).
5
+ * Used with --diff flag to skip re-capturing unchanged screenshots.
6
+ */
7
+ export type HashStore = Record<string, string>;
8
+ /**
9
+ * Compute SHA-256 hash of a buffer.
10
+ */
11
+ export declare function computeHash(buffer: Buffer): string;
12
+ /**
13
+ * Load the hash store from .skrypt/screenshot-hashes.json in the project directory.
14
+ */
15
+ export declare function loadHashStore(projectDir: string): HashStore;
16
+ /**
17
+ * Save the hash store to .skrypt/screenshot-hashes.json in the project directory.
18
+ */
19
+ export declare function saveHashStore(projectDir: string, store: HashStore): void;
20
+ /**
21
+ * Check if a screenshot has changed based on its hash.
22
+ */
23
+ export declare function hasChanged(filename: string, newHash: string, store: HashStore): boolean;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Hash-based change detection for screenshots.
3
+ *
4
+ * Stores SHA-256 hashes in .skrypt/screenshot-hashes.json (project root).
5
+ * Used with --diff flag to skip re-capturing unchanged screenshots.
6
+ */
7
+ import { createHash } from 'crypto';
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ const HASH_FILE = 'screenshot-hashes.json';
11
+ const STORE_DIR = '.skrypt';
12
+ /**
13
+ * Compute SHA-256 hash of a buffer.
14
+ */
15
+ export function computeHash(buffer) {
16
+ return createHash('sha256').update(buffer).digest('hex');
17
+ }
18
+ /**
19
+ * Load the hash store from .skrypt/screenshot-hashes.json in the project directory.
20
+ */
21
+ export function loadHashStore(projectDir) {
22
+ const storePath = join(projectDir, STORE_DIR, HASH_FILE);
23
+ if (!existsSync(storePath)) {
24
+ return {};
25
+ }
26
+ try {
27
+ const content = readFileSync(storePath, 'utf-8');
28
+ const parsed = JSON.parse(content);
29
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
30
+ return parsed;
31
+ }
32
+ return {};
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ }
38
+ /**
39
+ * Save the hash store to .skrypt/screenshot-hashes.json in the project directory.
40
+ */
41
+ export function saveHashStore(projectDir, store) {
42
+ const storeDir = join(projectDir, STORE_DIR);
43
+ mkdirSync(storeDir, { recursive: true });
44
+ const storePath = join(storeDir, HASH_FILE);
45
+ writeFileSync(storePath, JSON.stringify(store, null, 2), 'utf-8');
46
+ }
47
+ /**
48
+ * Check if a screenshot has changed based on its hash.
49
+ */
50
+ export function hasChanged(filename, newHash, store) {
51
+ return store[filename] !== newHash;
52
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Screenshot capture orchestrator.
3
+ *
4
+ * Coordinates MDX parsing, browser capture, diff detection, and file output.
5
+ */
6
+ import type { CaptureOptions, CaptureReport } from './types.js';
7
+ export type { CaptureOptions, CaptureReport } from './types.js';
8
+ /**
9
+ * Run the screenshot capture pipeline.
10
+ *
11
+ * 1. Find all MDX files in docsDir
12
+ * 2. Parse <Screenshot> directives from each
13
+ * 3. Validate base URL reachability
14
+ * 4. Capture screenshots via headless browser
15
+ * 5. Compare hashes for --diff mode
16
+ * 6. Write changed files to outputDir
17
+ * 7. Write manifest and hash store
18
+ */
19
+ export declare function runCapture(docsDir: string, options: CaptureOptions): Promise<CaptureReport>;
20
+ /**
21
+ * Print a capture report to stdout.
22
+ */
23
+ export declare function printCaptureReport(report: CaptureReport): void;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Screenshot capture orchestrator.
3
+ *
4
+ * Coordinates MDX parsing, browser capture, diff detection, and file output.
5
+ */
6
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
7
+ import { join, resolve } from 'path';
8
+ import { findMdxFiles } from '../utils/files.js';
9
+ import { parseScreenshotDirectives } from './parser.js';
10
+ import { captureScreenshots } from './browser.js';
11
+ import { loadHashStore, saveHashStore, computeHash, hasChanged } from './diff.js';
12
+ /**
13
+ * Run the screenshot capture pipeline.
14
+ *
15
+ * 1. Find all MDX files in docsDir
16
+ * 2. Parse <Screenshot> directives from each
17
+ * 3. Validate base URL reachability
18
+ * 4. Capture screenshots via headless browser
19
+ * 5. Compare hashes for --diff mode
20
+ * 6. Write changed files to outputDir
21
+ * 7. Write manifest and hash store
22
+ */
23
+ export async function runCapture(docsDir, options) {
24
+ const startTime = Date.now();
25
+ const resolvedDocsDir = resolve(docsDir);
26
+ const resolvedOutputDir = resolve(options.outputDir);
27
+ // 1. Find MDX files
28
+ const mdxFiles = findMdxFiles(resolvedDocsDir);
29
+ // 2. Parse directives
30
+ const allDirectives = mdxFiles.flatMap(filePath => {
31
+ const content = readFileSync(filePath, 'utf-8');
32
+ return parseScreenshotDirectives(content, filePath, options.baseUrl);
33
+ });
34
+ if (allDirectives.length === 0) {
35
+ return {
36
+ directives: 0,
37
+ captured: 0,
38
+ unchanged: 0,
39
+ failed: 0,
40
+ duration: Date.now() - startTime,
41
+ results: [],
42
+ };
43
+ }
44
+ // 3. Dry run
45
+ if (options.dryRun) {
46
+ console.log(`\n Found ${allDirectives.length} screenshot directive(s):`);
47
+ for (const d of allDirectives) {
48
+ const sel = d.selector ? ` (${d.selector})` : ' (full page)';
49
+ console.log(` ${d.url}${sel} → ${d.alt}`);
50
+ }
51
+ console.log('\n [dry run — no files written]\n');
52
+ return {
53
+ directives: allDirectives.length,
54
+ captured: 0,
55
+ unchanged: 0,
56
+ failed: 0,
57
+ duration: Date.now() - startTime,
58
+ results: [],
59
+ };
60
+ }
61
+ // 4. Validate base URL reachability
62
+ try {
63
+ const response = await fetch(options.baseUrl, {
64
+ method: 'HEAD',
65
+ signal: AbortSignal.timeout(5000),
66
+ });
67
+ if (!response.ok) {
68
+ console.error(`\n Base URL ${options.baseUrl} returned ${response.status}`);
69
+ console.error(' Make sure your dev server is running.\n');
70
+ return {
71
+ directives: allDirectives.length,
72
+ captured: 0,
73
+ unchanged: 0,
74
+ failed: allDirectives.length,
75
+ duration: Date.now() - startTime,
76
+ results: [],
77
+ };
78
+ }
79
+ }
80
+ catch {
81
+ console.error(`\n Cannot reach ${options.baseUrl}`);
82
+ console.error(' Start your dev server first, then run heal again.\n');
83
+ return {
84
+ directives: allDirectives.length,
85
+ captured: 0,
86
+ unchanged: 0,
87
+ failed: allDirectives.length,
88
+ duration: Date.now() - startTime,
89
+ results: [],
90
+ };
91
+ }
92
+ // 5. Load hash store for diff mode
93
+ const projectDir = resolvedDocsDir;
94
+ const hashStore = options.diff ? loadHashStore(projectDir) : {};
95
+ // 6. Capture all screenshots
96
+ console.log(`\n Capturing ${allDirectives.length} screenshot(s)...`);
97
+ const results = await captureScreenshots(allDirectives, options);
98
+ // 7. Process results — write files, check diffs
99
+ mkdirSync(resolvedOutputDir, { recursive: true });
100
+ let captured = 0;
101
+ let unchanged = 0;
102
+ let failed = 0;
103
+ const newHashStore = { ...hashStore };
104
+ const manifestEntries = {};
105
+ for (const result of results) {
106
+ if (result.status === 'failed') {
107
+ failed++;
108
+ continue;
109
+ }
110
+ const lightBuffer = getBuffer(result, 'light');
111
+ if (!lightBuffer) {
112
+ failed++;
113
+ result.status = 'failed';
114
+ result.error = 'No screenshot data captured';
115
+ continue;
116
+ }
117
+ const hash = computeHash(lightBuffer);
118
+ // Diff check
119
+ if (options.diff && !hasChanged(result.filename, hash, hashStore)) {
120
+ unchanged++;
121
+ result.status = 'unchanged';
122
+ continue;
123
+ }
124
+ // Write light screenshot
125
+ const outputPath = join(resolvedOutputDir, result.filename);
126
+ writeFileSync(outputPath, lightBuffer);
127
+ newHashStore[result.filename] = hash;
128
+ captured++;
129
+ // Write dark variant if present
130
+ if (result.darkFilename) {
131
+ const darkBuffer = getBuffer(result, 'dark');
132
+ if (darkBuffer) {
133
+ const darkPath = join(resolvedOutputDir, result.darkFilename);
134
+ writeFileSync(darkPath, darkBuffer);
135
+ newHashStore[result.darkFilename] = computeHash(darkBuffer);
136
+ }
137
+ }
138
+ // Build manifest entry
139
+ manifestEntries[result.filename] = {
140
+ url: result.directive.url,
141
+ selector: result.directive.selector,
142
+ filename: result.filename,
143
+ darkFilename: result.darkFilename,
144
+ capturedAt: new Date().toISOString(),
145
+ hash,
146
+ };
147
+ }
148
+ // 8. Write manifest
149
+ const manifest = {
150
+ version: 1,
151
+ capturedAt: new Date().toISOString(),
152
+ baseUrl: options.baseUrl,
153
+ viewport: options.viewport,
154
+ screenshots: manifestEntries,
155
+ };
156
+ writeFileSync(join(resolvedOutputDir, '_manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8');
157
+ // 9. Save hash store
158
+ if (options.diff) {
159
+ saveHashStore(projectDir, newHashStore);
160
+ }
161
+ return {
162
+ directives: allDirectives.length,
163
+ captured,
164
+ unchanged,
165
+ failed,
166
+ duration: Date.now() - startTime,
167
+ results,
168
+ };
169
+ }
170
+ /**
171
+ * Extract buffer from result (stored as internal _buffer or _buffers property).
172
+ */
173
+ function getBuffer(result, variant) {
174
+ const r = result;
175
+ if (r._buffers) {
176
+ return variant === 'light' ? r._buffers.light : r._buffers.dark;
177
+ }
178
+ if (variant === 'light' && r._buffer) {
179
+ return r._buffer;
180
+ }
181
+ return null;
182
+ }
183
+ /**
184
+ * Print a capture report to stdout.
185
+ */
186
+ export function printCaptureReport(report) {
187
+ if (report.directives === 0) {
188
+ console.log('\n No <Screenshot> directives found in docs.\n');
189
+ return;
190
+ }
191
+ const parts = [];
192
+ if (report.captured > 0)
193
+ parts.push(`${report.captured} captured`);
194
+ if (report.unchanged > 0)
195
+ parts.push(`${report.unchanged} unchanged`);
196
+ if (report.failed > 0)
197
+ parts.push(`${report.failed} failed`);
198
+ console.log(`\n Screenshots: ${parts.join(', ')} (${report.duration}ms)`);
199
+ // Show failures
200
+ const failures = report.results.filter(r => r.status === 'failed');
201
+ for (const f of failures) {
202
+ const loc = f.directive.selector
203
+ ? `${f.directive.url} (${f.directive.selector})`
204
+ : f.directive.url;
205
+ console.log(` ✗ ${loc}: ${f.error}`);
206
+ }
207
+ if (report.captured > 0) {
208
+ console.log('');
209
+ }
210
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Deterministic screenshot filename generation.
3
+ *
4
+ * Derives stable, human-readable filenames from URL + optional CSS selector.
5
+ *
6
+ * Examples:
7
+ * /dashboard + .billing-panel → dashboard--billing-panel.png
8
+ * /settings/account + #profile → settings-account--profile.png
9
+ * /dashboard (no selector) → dashboard.png
10
+ */
11
+ export declare function screenshotFilename(url: string, selector?: string): string;
12
+ /**
13
+ * Dark mode variant filename.
14
+ * dashboard.png → dashboard-dark.png
15
+ * dashboard--panel.png → dashboard--panel-dark.png
16
+ */
17
+ export declare function darkVariantFilename(filename: string): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Deterministic screenshot filename generation.
3
+ *
4
+ * Derives stable, human-readable filenames from URL + optional CSS selector.
5
+ *
6
+ * Examples:
7
+ * /dashboard + .billing-panel → dashboard--billing-panel.png
8
+ * /settings/account + #profile → settings-account--profile.png
9
+ * /dashboard (no selector) → dashboard.png
10
+ */
11
+ export function screenshotFilename(url, selector) {
12
+ // Extract pathname from full URL or use as-is if just a path
13
+ let pathname;
14
+ try {
15
+ pathname = new URL(url).pathname;
16
+ }
17
+ catch {
18
+ pathname = url;
19
+ }
20
+ // Strip leading /
21
+ let slug = pathname.replace(/^\/+/, '');
22
+ // Replace / with -
23
+ slug = slug.replace(/\//g, '-');
24
+ // If empty (root path), use "index"
25
+ if (!slug)
26
+ slug = 'index';
27
+ // Sanitize: only keep alphanumeric and hyphens
28
+ slug = slug.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
29
+ if (selector) {
30
+ // Strip CSS selector prefix (. or #)
31
+ let selectorSlug = selector.replace(/^[.#]/, '');
32
+ // Sanitize selector the same way
33
+ selectorSlug = selectorSlug.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
34
+ slug = `${slug}--${selectorSlug}`;
35
+ }
36
+ return `${slug}.png`;
37
+ }
38
+ /**
39
+ * Dark mode variant filename.
40
+ * dashboard.png → dashboard-dark.png
41
+ * dashboard--panel.png → dashboard--panel-dark.png
42
+ */
43
+ export function darkVariantFilename(filename) {
44
+ return filename.replace(/\.png$/, '-dark.png');
45
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Extract <Screenshot> directives from MDX files.
3
+ *
4
+ * Scans MDX content for <Screenshot> tags outside code blocks.
5
+ * Uses the same code-block-aware scanning pattern as src/qa/checks.ts.
6
+ */
7
+ import type { ScreenshotDirective } from './types.js';
8
+ /**
9
+ * Parse all <Screenshot> directives from MDX content.
10
+ *
11
+ * Supports both self-closing (<Screenshot ... />) and open/close tags.
12
+ * Extracts url, selector, alt, caption props via regex.
13
+ * Resolves relative URLs against baseUrl.
14
+ */
15
+ export declare function parseScreenshotDirectives(content: string, filePath: string, baseUrl: string): ScreenshotDirective[];