@loracle-js/cli 1.0.2
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/README.md +69 -0
- package/dist/bin/loracle.js +24 -0
- package/dist/publish.js +162 -0
- package/dist/scraper.js +688 -0
- package/dist/types.js +30 -0
- package/dist/ui.js +68 -0
- package/dist/uploader.js +92 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Loracle CLI
|
|
2
|
+
|
|
3
|
+
Publishes your Storybook to Loracle so your AI tools always have up-to-date component context.
|
|
4
|
+
|
|
5
|
+
<a href="https://www.npmjs.com/package/@loracle-js/cli">
|
|
6
|
+
<img src="https://badgen.net/npm/v/@loracle-js/cli" alt="Published on npm">
|
|
7
|
+
</a>
|
|
8
|
+
|
|
9
|
+
## Documentation
|
|
10
|
+
|
|
11
|
+
👉 Read the [Loracle CLI docs](https://getloracle.com/docs/cli)
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Node.js 18+
|
|
16
|
+
- Storybook 7+
|
|
17
|
+
- A Loracle API key — get one at [getloracle.com](https://getloracle.com)
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx @loracle-js/cli publish <storybook-url> --version <semver>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Example:**
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
LORACLE_API_KEY=your_api_key npx @loracle-js/cli publish https://storybook.example.com --version 1.2.0
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Options
|
|
32
|
+
|
|
33
|
+
| Option | Description |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `-v, --version` | Semantic version for this build (required) |
|
|
36
|
+
| `--dry-run` | Validate without uploading |
|
|
37
|
+
|
|
38
|
+
### Environment variables
|
|
39
|
+
|
|
40
|
+
| Variable | Description |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `LORACLE_API_KEY` | Your Loracle API key (required) |
|
|
43
|
+
|
|
44
|
+
## CI
|
|
45
|
+
|
|
46
|
+
### GitHub Actions
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
- name: Install Playwright browsers
|
|
50
|
+
run: npx playwright install chromium --with-deps
|
|
51
|
+
|
|
52
|
+
- name: Publish to Loracle
|
|
53
|
+
run: npx @loracle-js/cli publish ${{ vars.STORYBOOK_URL }} --version ${{ github.ref_name }}
|
|
54
|
+
env:
|
|
55
|
+
LORACLE_API_KEY: ${{ secrets.LORACLE_API_KEY }}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### GitHub Actions (official Playwright image)
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
container:
|
|
62
|
+
image: mcr.microsoft.com/playwright:latest
|
|
63
|
+
|
|
64
|
+
steps:
|
|
65
|
+
- name: Publish to Loracle
|
|
66
|
+
run: npx @loracle-js/cli publish ${{ vars.STORYBOOK_URL }} --version ${{ github.ref_name }}
|
|
67
|
+
env:
|
|
68
|
+
LORACLE_API_KEY: ${{ secrets.LORACLE_API_KEY }}
|
|
69
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { publish } from '../publish.js';
|
|
4
|
+
import * as dotenv from 'dotenv';
|
|
5
|
+
dotenv.config();
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name('loracle')
|
|
9
|
+
.description('Loracle CLI - Publish your design system to Loracle')
|
|
10
|
+
.version('1.0.0', '-V, --cli-version', 'output the CLI version');
|
|
11
|
+
program
|
|
12
|
+
.command('publish')
|
|
13
|
+
.description('Scrape and publish Storybook components to Loracle')
|
|
14
|
+
.argument('<url>', 'Storybook URL to publish')
|
|
15
|
+
.requiredOption('-v, --version <version>', 'Semantic version for this build (e.g., 1.0.0)')
|
|
16
|
+
.option('--dry-run', 'Scrape and validate without uploading')
|
|
17
|
+
.action(async (url, options) => {
|
|
18
|
+
await publish({
|
|
19
|
+
url,
|
|
20
|
+
version: options.version,
|
|
21
|
+
dryRun: options.dryRun,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
program.parse();
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { StorybookScraper } from './scraper.js';
|
|
2
|
+
import { initBuild, uploadToS3 } from './uploader.js';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { intro, outro, cancel, error, success, createSpinner, link, ErrorMessages } from './ui.js';
|
|
7
|
+
// Semver validation (from Portal API)
|
|
8
|
+
const semverRegex = /^\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/;
|
|
9
|
+
const API_URL = 'https://getloracle.com';
|
|
10
|
+
export async function publish(options) {
|
|
11
|
+
const { url, version } = options;
|
|
12
|
+
const startTime = Date.now();
|
|
13
|
+
// Step 1: Validate API key (skip for dry-run)
|
|
14
|
+
const apiKey = options.apiKey || process.env.LORACLE_API_KEY;
|
|
15
|
+
if (!apiKey && !options.dryRun) {
|
|
16
|
+
error(ErrorMessages.missingApiKey.title, ErrorMessages.missingApiKey.details);
|
|
17
|
+
cancel('Missing API key');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
// Step 2: Validate version format
|
|
21
|
+
if (!semverRegex.test(version)) {
|
|
22
|
+
const err = ErrorMessages.invalidVersion(version);
|
|
23
|
+
error(err.title, err.details);
|
|
24
|
+
cancel('Invalid version');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
// Show intro
|
|
28
|
+
const dryRunLabel = options.dryRun ? ' (dry run)' : '';
|
|
29
|
+
intro('publish', `v${version}${dryRunLabel}`);
|
|
30
|
+
// Step 3: Connect to Storybook
|
|
31
|
+
const connectSpinner = createSpinner();
|
|
32
|
+
connectSpinner.start('Connecting to Storybook...');
|
|
33
|
+
let componentCount = 0;
|
|
34
|
+
try {
|
|
35
|
+
const baseUrl = url.endsWith('/') ? url : url + '/';
|
|
36
|
+
const indexUrl = baseUrl + 'index.json';
|
|
37
|
+
const response = await fetch(indexUrl, { signal: AbortSignal.timeout(10000) });
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error(`HTTP ${response.status}`);
|
|
40
|
+
}
|
|
41
|
+
const indexData = await response.json();
|
|
42
|
+
// Count unique components (group by component path)
|
|
43
|
+
const components = new Set();
|
|
44
|
+
if (indexData.entries) {
|
|
45
|
+
for (const entry of Object.values(indexData.entries)) {
|
|
46
|
+
const e = entry;
|
|
47
|
+
if (e.type === 'docs' || e.type === 'story') {
|
|
48
|
+
if (e.title) {
|
|
49
|
+
components.add(e.title);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
componentCount = components.size;
|
|
55
|
+
connectSpinner.stop(`Connected · ${componentCount} components found`);
|
|
56
|
+
}
|
|
57
|
+
catch (_error) {
|
|
58
|
+
connectSpinner.error('Could not connect to Storybook');
|
|
59
|
+
const err = ErrorMessages.connectionFailed(url);
|
|
60
|
+
error(err.title, err.details);
|
|
61
|
+
cancel('Connection failed');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
// Step 4: Scrape components
|
|
65
|
+
const extractSpinner = createSpinner();
|
|
66
|
+
extractSpinner.start('Extracting components...');
|
|
67
|
+
// Create temp directory for output
|
|
68
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loracle-'));
|
|
69
|
+
const outputDir = path.join(tempDir, 'output');
|
|
70
|
+
const componentsDir = path.join(outputDir, 'components');
|
|
71
|
+
await fs.mkdir(componentsDir, { recursive: true });
|
|
72
|
+
await fs.mkdir(path.join(outputDir, 'screenshots'), { recursive: true });
|
|
73
|
+
let scrapedComponents = [];
|
|
74
|
+
try {
|
|
75
|
+
const scraper = new StorybookScraper({
|
|
76
|
+
url,
|
|
77
|
+
outputDir,
|
|
78
|
+
headless: true,
|
|
79
|
+
verbose: false,
|
|
80
|
+
onProgress: (event) => {
|
|
81
|
+
switch (event.type) {
|
|
82
|
+
case 'component_start':
|
|
83
|
+
extractSpinner.message(`Extracting · ${event.name}`);
|
|
84
|
+
break;
|
|
85
|
+
case 'component_done':
|
|
86
|
+
extractSpinner.message(`Extracting · ${event.current}/${event.total}`);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
scrapedComponents = await scraper.scrape();
|
|
92
|
+
extractSpinner.stop(`Extracted ${scrapedComponents.length} components`);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
extractSpinner.error('Extraction failed');
|
|
96
|
+
error('Extraction failed', [err instanceof Error ? err.message : String(err)]);
|
|
97
|
+
cancel('Extraction failed');
|
|
98
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
// Step 5: Bundle and upload
|
|
102
|
+
const uploadSpinner = createSpinner();
|
|
103
|
+
uploadSpinner.start(options.dryRun ? 'Bundling components...' : 'Uploading to Loracle...');
|
|
104
|
+
try {
|
|
105
|
+
// Build bundle directly from scraped data
|
|
106
|
+
const bundle = { version, components: scrapedComponents };
|
|
107
|
+
const uploadedCount = bundle.components.length;
|
|
108
|
+
// Count variations
|
|
109
|
+
let variationCount = 0;
|
|
110
|
+
for (const component of bundle.components) {
|
|
111
|
+
variationCount += component.examples?.length || 0;
|
|
112
|
+
}
|
|
113
|
+
// Dry-run: skip upload, show summary
|
|
114
|
+
if (options.dryRun) {
|
|
115
|
+
uploadSpinner.stop('Dry run complete');
|
|
116
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
117
|
+
success('Dry run passed', [
|
|
118
|
+
`${uploadedCount} components · ${variationCount} variations`,
|
|
119
|
+
'Ready to publish \u2014 remove --dry-run to upload.'
|
|
120
|
+
]);
|
|
121
|
+
outro(startTime);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Initialize build
|
|
125
|
+
const { buildId, uploadUrl, projectId } = await initBuild(API_URL, apiKey, version, uploadedCount);
|
|
126
|
+
// Complete bundle with build metadata
|
|
127
|
+
const fullBundle = {
|
|
128
|
+
buildId,
|
|
129
|
+
projectId,
|
|
130
|
+
version,
|
|
131
|
+
components: bundle.components,
|
|
132
|
+
};
|
|
133
|
+
// Upload to S3
|
|
134
|
+
await uploadToS3(uploadUrl, fullBundle);
|
|
135
|
+
uploadSpinner.stop('Upload complete');
|
|
136
|
+
// Cleanup temp directory
|
|
137
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
138
|
+
// Show success
|
|
139
|
+
const trackingLink = link('Track progress', `https://getloracle.com/projects/${projectId}`);
|
|
140
|
+
success('Build submitted', [
|
|
141
|
+
`${uploadedCount} components · ${variationCount} variations`,
|
|
142
|
+
trackingLink
|
|
143
|
+
]);
|
|
144
|
+
outro(startTime);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
uploadSpinner.error(options.dryRun ? 'Dry run failed' : 'Upload failed');
|
|
148
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
149
|
+
if (errorMessage.includes('Insufficient credits')) {
|
|
150
|
+
error(ErrorMessages.insufficientCredits.title, ErrorMessages.insufficientCredits.details);
|
|
151
|
+
}
|
|
152
|
+
else if (errorMessage.includes('401') || errorMessage.includes('Unauthorized')) {
|
|
153
|
+
error(ErrorMessages.invalidApiKey.title, ErrorMessages.invalidApiKey.details);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
error(options.dryRun ? 'Dry run failed' : 'Upload failed', [errorMessage]);
|
|
157
|
+
}
|
|
158
|
+
cancel('Failed');
|
|
159
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
}
|
package/dist/scraper.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import pLimit from 'p-limit';
|
|
5
|
+
export class StorybookScraper {
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.browser = null;
|
|
8
|
+
this.context = null;
|
|
9
|
+
this.processedComponents = new Set();
|
|
10
|
+
this.cachedIndexData = null;
|
|
11
|
+
this.options = options;
|
|
12
|
+
this.concurrency = options.concurrency ?? 8;
|
|
13
|
+
}
|
|
14
|
+
emit(event) {
|
|
15
|
+
if (this.options.onProgress) {
|
|
16
|
+
// Skip debug/warn events unless verbose
|
|
17
|
+
if ((event.type === 'debug' || event.type === 'warn') && !this.options.verbose)
|
|
18
|
+
return;
|
|
19
|
+
this.options.onProgress(event);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async scrape() {
|
|
23
|
+
const results = [];
|
|
24
|
+
try {
|
|
25
|
+
await this.initialize();
|
|
26
|
+
this.emit({ type: 'debug', message: 'Discovering component documentation pages from index.json...' });
|
|
27
|
+
const storyInfos = await this.discoverStoryPages();
|
|
28
|
+
const isStoryMode = storyInfos.length > 0 && storyInfos[0].viewMode === 'story';
|
|
29
|
+
const uniqueComponents = new Set(storyInfos.map(s => s.kind)).size;
|
|
30
|
+
this.emit({ type: 'discovery', total: isStoryMode ? uniqueComponents : storyInfos.length });
|
|
31
|
+
if (isStoryMode) {
|
|
32
|
+
this.emit({ type: 'debug', message: `Found ${storyInfos.length} stories across ${uniqueComponents} components (story-only mode)` });
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
this.emit({ type: 'debug', message: `Found ${storyInfos.length} potential pages to process` });
|
|
36
|
+
}
|
|
37
|
+
this.emit({ type: 'debug', message: `Running with ${this.concurrency} parallel workers` });
|
|
38
|
+
let processedCount = 0;
|
|
39
|
+
let successCount = 0;
|
|
40
|
+
const totalCount = storyInfos.length;
|
|
41
|
+
// Create a limiter for parallel processing
|
|
42
|
+
const limit = pLimit(this.concurrency);
|
|
43
|
+
if (isStoryMode) {
|
|
44
|
+
// Story-only mode: scrape each story individually, then group by component
|
|
45
|
+
const storyResults = new Map();
|
|
46
|
+
const tasks = storyInfos.map((storyInfo) => limit(async () => {
|
|
47
|
+
const page = await this.context.newPage();
|
|
48
|
+
page.setDefaultTimeout(15000);
|
|
49
|
+
try {
|
|
50
|
+
const example = await this.scrapeStoryPage(storyInfo, page);
|
|
51
|
+
if (example) {
|
|
52
|
+
// Group into component
|
|
53
|
+
const existing = storyResults.get(storyInfo.kind);
|
|
54
|
+
if (existing && existing.examples) {
|
|
55
|
+
existing.examples.push(example);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
storyResults.set(storyInfo.kind, {
|
|
59
|
+
name: storyInfo.kind.split('/').pop()?.trim() || storyInfo.kind,
|
|
60
|
+
componentName: storyInfo.kind.split('/').pop()?.trim() || storyInfo.kind,
|
|
61
|
+
examples: [example],
|
|
62
|
+
metadata: { storyId: storyInfo.storyId, framework: 'react', kind: storyInfo.kind },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
successCount++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
this.emit({ type: 'debug', message: `Failed to process ${storyInfo.storyId}: ${error}` });
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await page.close();
|
|
73
|
+
}
|
|
74
|
+
processedCount++;
|
|
75
|
+
this.emit({ type: 'component_done', current: processedCount, total: totalCount, success: true });
|
|
76
|
+
}));
|
|
77
|
+
await Promise.all(tasks);
|
|
78
|
+
// Save grouped components
|
|
79
|
+
for (const componentData of storyResults.values()) {
|
|
80
|
+
await this.saveComponent(componentData);
|
|
81
|
+
results.push(componentData);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Docs mode: one page per component (original behavior)
|
|
86
|
+
const tasks = storyInfos.map((storyInfo) => limit(async () => {
|
|
87
|
+
this.emit({ type: 'component_start', name: storyInfo.kind });
|
|
88
|
+
const page = await this.context.newPage();
|
|
89
|
+
page.setDefaultTimeout(15000);
|
|
90
|
+
let success = false;
|
|
91
|
+
try {
|
|
92
|
+
const componentData = await this.scrapeComponentWithPage(storyInfo, page);
|
|
93
|
+
if (componentData) {
|
|
94
|
+
await this.saveComponent(componentData);
|
|
95
|
+
results.push(componentData);
|
|
96
|
+
successCount++;
|
|
97
|
+
success = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
this.emit({ type: 'debug', message: `Failed to process ${storyInfo.kind}: ${error}` });
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await page.close();
|
|
105
|
+
}
|
|
106
|
+
processedCount++;
|
|
107
|
+
this.emit({ type: 'component_done', current: processedCount, total: totalCount, success });
|
|
108
|
+
}));
|
|
109
|
+
await Promise.all(tasks);
|
|
110
|
+
}
|
|
111
|
+
const savedComponents = isStoryMode ? new Set(storyInfos.map(s => s.kind)).size : successCount;
|
|
112
|
+
this.emit({ type: 'complete', successCount: savedComponents, totalCount: successCount });
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await this.cleanup();
|
|
116
|
+
}
|
|
117
|
+
return results;
|
|
118
|
+
}
|
|
119
|
+
async initialize() {
|
|
120
|
+
this.emit({ type: 'browser_launch' });
|
|
121
|
+
this.browser = await chromium.launch({
|
|
122
|
+
headless: this.options.headless,
|
|
123
|
+
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
|
124
|
+
});
|
|
125
|
+
this.context = await this.browser.newContext();
|
|
126
|
+
// Ensure output directories exist (once, not per-component)
|
|
127
|
+
const componentsDir = path.join(this.options.outputDir, 'components');
|
|
128
|
+
const screenshotsDir = path.join(this.options.outputDir, 'screenshots');
|
|
129
|
+
await fs.mkdir(componentsDir, { recursive: true });
|
|
130
|
+
await fs.mkdir(screenshotsDir, { recursive: true });
|
|
131
|
+
}
|
|
132
|
+
async fetchIndexJson() {
|
|
133
|
+
// Return cached data if available
|
|
134
|
+
if (this.cachedIndexData)
|
|
135
|
+
return this.cachedIndexData;
|
|
136
|
+
try {
|
|
137
|
+
const baseUrl = this.options.url.endsWith('/') ? this.options.url : this.options.url + '/';
|
|
138
|
+
const indexUrl = baseUrl + 'index.json';
|
|
139
|
+
this.emit({ type: 'debug', message: `Fetching index from: ${indexUrl}` });
|
|
140
|
+
const response = await fetch(indexUrl);
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
this.emit({ type: 'warn', message: `Failed to fetch index.json (${response.status}). Falling back to DOM navigation.` });
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const data = await response.json();
|
|
146
|
+
if (!data.entries || typeof data.entries !== 'object') {
|
|
147
|
+
this.emit({ type: 'warn', message: 'Invalid index.json structure. Falling back to DOM navigation.' });
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
this.cachedIndexData = data;
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
this.emit({ type: 'warn', message: `Error fetching index.json: ${error.message}. Falling back to DOM navigation.` });
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async discoverStoryPagesFromIndex() {
|
|
159
|
+
const indexData = await this.fetchIndexJson();
|
|
160
|
+
if (!indexData) {
|
|
161
|
+
// Fallback to DOM navigation
|
|
162
|
+
return this.discoverStoryPagesFromDOM();
|
|
163
|
+
}
|
|
164
|
+
const storyInfos = [];
|
|
165
|
+
const baseUrl = this.options.url.endsWith('/') ? this.options.url : this.options.url + '/';
|
|
166
|
+
// Filter for docs pages with actual stories
|
|
167
|
+
for (const [id, entry] of Object.entries(indexData.entries)) {
|
|
168
|
+
if (entry.type === 'docs') {
|
|
169
|
+
// Skip pure documentation pages without associated stories
|
|
170
|
+
const hasNoStories = !entry.storiesImports || entry.storiesImports.length === 0;
|
|
171
|
+
const isUnattachedMdx = entry.tags?.includes('unattached-mdx');
|
|
172
|
+
if (hasNoStories && isUnattachedMdx) {
|
|
173
|
+
this.emit({ type: 'debug', message: `Skipping documentation page: ${entry.title}` });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// Navigate directly to iframe.html, bypassing the Storybook manager UI
|
|
177
|
+
const url = `${baseUrl}iframe.html?viewMode=docs&id=${id}`;
|
|
178
|
+
storyInfos.push({
|
|
179
|
+
url,
|
|
180
|
+
componentId: id,
|
|
181
|
+
kind: entry.title,
|
|
182
|
+
viewMode: 'docs',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.emit({ type: 'debug', message: `Found ${storyInfos.length} docs pages from index.json` });
|
|
187
|
+
// Fallback: if no docs entries found, emit one StoryInfo per individual story
|
|
188
|
+
if (storyInfos.length === 0) {
|
|
189
|
+
this.emit({ type: 'debug', message: 'No docs entries found, falling back to story-only mode' });
|
|
190
|
+
for (const [id, entry] of Object.entries(indexData.entries)) {
|
|
191
|
+
if (entry.type === 'story') {
|
|
192
|
+
storyInfos.push({
|
|
193
|
+
url: `${baseUrl}iframe.html?viewMode=story&id=${id}`,
|
|
194
|
+
componentId: entry.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
195
|
+
kind: entry.title,
|
|
196
|
+
viewMode: 'story',
|
|
197
|
+
storyId: id,
|
|
198
|
+
storyName: entry.name,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.emit({ type: 'debug', message: `Found ${storyInfos.length} stories across ${new Set(storyInfos.map(s => s.kind)).size} components` });
|
|
203
|
+
}
|
|
204
|
+
// Filter by kinds if specified
|
|
205
|
+
if (this.options.kinds && this.options.kinds.length > 0) {
|
|
206
|
+
return storyInfos.filter(story => this.options.kinds.some(kind => story.kind.includes(kind)));
|
|
207
|
+
}
|
|
208
|
+
return storyInfos;
|
|
209
|
+
}
|
|
210
|
+
async discoverStoryPages() {
|
|
211
|
+
// Try index.json first
|
|
212
|
+
return this.discoverStoryPagesFromIndex();
|
|
213
|
+
}
|
|
214
|
+
async discoverStoryPagesFromDOM() {
|
|
215
|
+
const page = await this.context.newPage();
|
|
216
|
+
try {
|
|
217
|
+
await page.goto(this.options.url);
|
|
218
|
+
// Wait for Storybook to load
|
|
219
|
+
await page.waitForSelector('nav', { timeout: 10000 });
|
|
220
|
+
this.emit({ type: 'debug', message: 'Expanding sidebar sections...' });
|
|
221
|
+
await this.expandAllSections(page);
|
|
222
|
+
this.emit({ type: 'debug', message: 'Collecting component links...' });
|
|
223
|
+
const storyInfos = await this.collectComponentLinks(page);
|
|
224
|
+
// Filter by kinds if specified
|
|
225
|
+
if (this.options.kinds && this.options.kinds.length > 0) {
|
|
226
|
+
return storyInfos.filter(story => this.options.kinds.some(kind => story.kind.includes(kind)));
|
|
227
|
+
}
|
|
228
|
+
return storyInfos;
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await page.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async expandAllSections(page) {
|
|
235
|
+
// Iteratively expand sections until no more can be expanded
|
|
236
|
+
let previousCount = 0;
|
|
237
|
+
let currentCount = 0;
|
|
238
|
+
let attempts = 0;
|
|
239
|
+
const maxAttempts = 10; // Prevent infinite loops
|
|
240
|
+
do {
|
|
241
|
+
previousCount = currentCount;
|
|
242
|
+
// Find all currently collapsed buttons
|
|
243
|
+
const collapsedButtons = await page.locator('nav button[aria-expanded="false"]').all();
|
|
244
|
+
currentCount = collapsedButtons.length;
|
|
245
|
+
this.emit({ type: 'debug', message: `Found ${currentCount} collapsed sections to expand` });
|
|
246
|
+
// Try to expand each collapsed button
|
|
247
|
+
for (let i = 0; i < collapsedButtons.length; i++) {
|
|
248
|
+
try {
|
|
249
|
+
// Re-query the button to avoid stale element issues
|
|
250
|
+
const button = page.locator('nav button[aria-expanded="false"]').nth(i);
|
|
251
|
+
// Check if button is still available and visible
|
|
252
|
+
if (await button.count() > 0 && await button.isVisible()) {
|
|
253
|
+
await button.click({ timeout: 1000 });
|
|
254
|
+
await page.waitForTimeout(200); // Allow time for expansion
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
// Silently continue - button might have disappeared or become unclickable
|
|
259
|
+
this.emit({ type: 'debug', message: `Could not expand button ${i}: ${error.message}` });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
attempts++;
|
|
263
|
+
// Small delay before checking again
|
|
264
|
+
await page.waitForTimeout(300);
|
|
265
|
+
} while (attempts < maxAttempts && currentCount > 0 && currentCount !== previousCount);
|
|
266
|
+
this.emit({ type: 'debug', message: `Expansion completed after ${attempts} attempts` });
|
|
267
|
+
}
|
|
268
|
+
async collectComponentLinks(page) {
|
|
269
|
+
// Find all links in the navigation that point to docs pages
|
|
270
|
+
const links = await page.locator('nav a[href*="/docs/"]').all();
|
|
271
|
+
const storyInfos = [];
|
|
272
|
+
const baseUrl = this.options.url.endsWith('/') ? this.options.url : this.options.url + '/';
|
|
273
|
+
for (const link of links) {
|
|
274
|
+
try {
|
|
275
|
+
const href = await link.getAttribute('href');
|
|
276
|
+
const text = await link.textContent();
|
|
277
|
+
if (href && text && href.includes('--docs')) {
|
|
278
|
+
// Extract kind from the URL pattern
|
|
279
|
+
const kindMatch = href.match(/\/docs\/(.+)--docs/);
|
|
280
|
+
if (kindMatch) {
|
|
281
|
+
const componentId = kindMatch[1];
|
|
282
|
+
const kind = text.trim();
|
|
283
|
+
// Use direct iframe URL
|
|
284
|
+
storyInfos.push({
|
|
285
|
+
url: `${baseUrl}iframe.html?viewMode=docs&id=${componentId}--docs`,
|
|
286
|
+
componentId,
|
|
287
|
+
kind,
|
|
288
|
+
viewMode: 'docs',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
// Ignore errors for individual links
|
|
295
|
+
this.emit({ type: 'debug', message: `Could not process link: ${error}` });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
this.emit({ type: 'debug', message: `Found ${storyInfos.length} component documentation links` });
|
|
299
|
+
return storyInfos;
|
|
300
|
+
}
|
|
301
|
+
async scrapeComponentWithPage(storyInfo, page) {
|
|
302
|
+
// Skip if already processed (thread-safe check)
|
|
303
|
+
if (this.processedComponents.has(storyInfo.componentId)) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
// Mark as processing to avoid duplicates in parallel execution
|
|
307
|
+
this.processedComponents.add(storyInfo.componentId);
|
|
308
|
+
// Navigate directly to iframe.html — no manager UI, no nested iframe
|
|
309
|
+
await page.goto(storyInfo.url, { waitUntil: 'load' });
|
|
310
|
+
try {
|
|
311
|
+
// Wait for Storybook preview to be ready with at least one story render
|
|
312
|
+
await page.waitForFunction(() => {
|
|
313
|
+
const preview = window.__STORYBOOK_PREVIEW__;
|
|
314
|
+
if (!preview?.currentSelection?.viewMode)
|
|
315
|
+
return false;
|
|
316
|
+
if (!preview.storyRenders)
|
|
317
|
+
return false;
|
|
318
|
+
const renders = Object.values(preview.storyRenders);
|
|
319
|
+
return renders.some((r) => r?.story);
|
|
320
|
+
}, { timeout: 15000, polling: 200 });
|
|
321
|
+
// Check if this is a docs page
|
|
322
|
+
const viewMode = await page.evaluate(() => {
|
|
323
|
+
return window.__STORYBOOK_PREVIEW__?.currentSelection.viewMode;
|
|
324
|
+
});
|
|
325
|
+
if (viewMode !== 'docs' && viewMode !== 'story') {
|
|
326
|
+
this.emit({ type: 'debug', message: `Skipping ${storyInfo.kind} - unexpected viewMode: ${viewMode}` });
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
this.emit({ type: 'debug', message: `Processing ${viewMode} page: ${storyInfo.kind}` });
|
|
330
|
+
// Extract component data directly from page (no iframe indirection)
|
|
331
|
+
const componentData = await this.extractComponentData(page, storyInfo);
|
|
332
|
+
return componentData;
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
336
|
+
this.emit({ type: 'debug', message: `Skipping ${storyInfo.kind}: ${errorMsg}` });
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Story-only mode: navigate to a single story, extract data + screenshot from #storybook-root.
|
|
342
|
+
* No DOM lookups for story-- elements (they don't exist in story mode).
|
|
343
|
+
*/
|
|
344
|
+
async scrapeStoryPage(storyInfo, page) {
|
|
345
|
+
await page.goto(storyInfo.url, { waitUntil: 'load' });
|
|
346
|
+
try {
|
|
347
|
+
await page.waitForFunction(() => {
|
|
348
|
+
const preview = window.__STORYBOOK_PREVIEW__;
|
|
349
|
+
if (!preview?.currentSelection?.viewMode)
|
|
350
|
+
return false;
|
|
351
|
+
if (!preview.storyRenders)
|
|
352
|
+
return false;
|
|
353
|
+
const renders = Object.values(preview.storyRenders);
|
|
354
|
+
return renders.some((r) => r?.story);
|
|
355
|
+
}, { timeout: 15000, polling: 200 });
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
this.emit({ type: 'debug', message: `Preview timeout for ${storyInfo.storyId}` });
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
// Extract story metadata from the preview API
|
|
362
|
+
const storyData = await page.evaluate(() => {
|
|
363
|
+
const preview = window.__STORYBOOK_PREVIEW__;
|
|
364
|
+
const storyRenders = preview?.storyRenders ? Object.values(preview.storyRenders) : [];
|
|
365
|
+
for (const render of storyRenders) {
|
|
366
|
+
const story = render?.story;
|
|
367
|
+
if (story) {
|
|
368
|
+
return {
|
|
369
|
+
name: story.name || 'Unknown',
|
|
370
|
+
code: story.parameters?.docs?.source?.code ||
|
|
371
|
+
story.parameters?.docs?.source?.originalSource ||
|
|
372
|
+
story.parameters?.storySource?.source || '',
|
|
373
|
+
description: story.parameters?.docs?.description?.story || '',
|
|
374
|
+
metadata: {
|
|
375
|
+
storyId: story.id,
|
|
376
|
+
framework: story.parameters?.framework || 'react',
|
|
377
|
+
kind: story.kind || story.title,
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
});
|
|
384
|
+
if (!storyData)
|
|
385
|
+
return null;
|
|
386
|
+
// Screenshot #storybook-root directly
|
|
387
|
+
let screenshot = '';
|
|
388
|
+
try {
|
|
389
|
+
const root = page.locator('#storybook-root').first();
|
|
390
|
+
const screenshotBuffer = await root.screenshot({ type: 'png', timeout: 5000 });
|
|
391
|
+
screenshot = screenshotBuffer.toString('base64');
|
|
392
|
+
// Save screenshot to file (non-blocking)
|
|
393
|
+
const screenshotPath = path.join(this.options.outputDir, 'screenshots', `${storyInfo.componentId}_${storyData.name.replace(/[^a-zA-Z0-9]/g, '_')}.png`);
|
|
394
|
+
fs.writeFile(screenshotPath, screenshotBuffer).catch(() => { });
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
this.emit({ type: 'warn', message: `Screenshot failed for ${storyInfo.storyId}` });
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
name: storyData.name,
|
|
401
|
+
code: storyData.code,
|
|
402
|
+
description: storyData.description,
|
|
403
|
+
screenshot,
|
|
404
|
+
metadata: {
|
|
405
|
+
...storyData.metadata,
|
|
406
|
+
storyId: storyInfo.storyId,
|
|
407
|
+
source: 'index.json',
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
async extractComponentData(page, storyInfo) {
|
|
412
|
+
// Execute extraction logic directly on the page (no iframe needed)
|
|
413
|
+
const extractedData = await page.evaluate(() => {
|
|
414
|
+
const preview = window.__STORYBOOK_PREVIEW__;
|
|
415
|
+
if (!preview) {
|
|
416
|
+
throw new Error('Storybook preview not available');
|
|
417
|
+
}
|
|
418
|
+
// Try to find current story from storyRenders or currentSelection
|
|
419
|
+
let currentStory;
|
|
420
|
+
// Get all story renders
|
|
421
|
+
const storyRenders = preview.storyRenders ? Object.values(preview.storyRenders) : [];
|
|
422
|
+
if (storyRenders.length > 0) {
|
|
423
|
+
// Use first story render that has a story object
|
|
424
|
+
for (const render of storyRenders) {
|
|
425
|
+
if (render?.story) {
|
|
426
|
+
currentStory = render.story;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// If still no story, create minimal fallback from URL
|
|
432
|
+
if (!currentStory) {
|
|
433
|
+
const url = window.location.href;
|
|
434
|
+
const urlMatch = url.match(/id=([^&]+)/);
|
|
435
|
+
const componentId = urlMatch ? urlMatch[1] : 'unknown';
|
|
436
|
+
const titleMatch = componentId.match(/^(.+?)--/);
|
|
437
|
+
const title = titleMatch ? titleMatch[1].replace(/-/g, ' ') : componentId;
|
|
438
|
+
currentStory = {
|
|
439
|
+
id: componentId,
|
|
440
|
+
title: title,
|
|
441
|
+
name: 'Default',
|
|
442
|
+
parameters: {},
|
|
443
|
+
argTypes: {}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
// Extract component-level data
|
|
447
|
+
const componentName = currentStory.component?.displayName ||
|
|
448
|
+
currentStory.component?.name ||
|
|
449
|
+
currentStory.title?.split('/').pop() ||
|
|
450
|
+
'Unknown Component';
|
|
451
|
+
const description = currentStory.component?.__docgenInfo?.description ||
|
|
452
|
+
currentStory.parameters?.docs?.description?.component ||
|
|
453
|
+
'';
|
|
454
|
+
// Extract props from argTypes
|
|
455
|
+
const props = [];
|
|
456
|
+
if (currentStory.argTypes) {
|
|
457
|
+
for (const [name, argType] of Object.entries(currentStory.argTypes)) {
|
|
458
|
+
// Transform table to match PropData structure (summary must be defined if present)
|
|
459
|
+
const table = argType.table ? {
|
|
460
|
+
type: argType.table.type?.summary ? { summary: argType.table.type.summary } : undefined,
|
|
461
|
+
defaultValue: argType.table.defaultValue?.summary ? { summary: argType.table.defaultValue.summary } : undefined,
|
|
462
|
+
category: argType.table.category
|
|
463
|
+
} : undefined;
|
|
464
|
+
props.push({
|
|
465
|
+
name,
|
|
466
|
+
description: argType.description,
|
|
467
|
+
type: argType.table?.type?.summary ?? argType.type?.name,
|
|
468
|
+
required: argType.type?.required,
|
|
469
|
+
options: argType.options,
|
|
470
|
+
defaultValue: argType.table?.defaultValue?.summary ? {
|
|
471
|
+
value: argType.table.defaultValue.summary,
|
|
472
|
+
computed: false
|
|
473
|
+
} : undefined,
|
|
474
|
+
control: argType.control,
|
|
475
|
+
table
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Extract stories from storyRenders
|
|
480
|
+
const stories = [];
|
|
481
|
+
const allRenders = preview.storyRenders ? Object.values(preview.storyRenders) : [];
|
|
482
|
+
for (let i = 0; i < allRenders.length; i++) {
|
|
483
|
+
const render = allRenders[i];
|
|
484
|
+
if (render && render.story) {
|
|
485
|
+
const story = render.story;
|
|
486
|
+
// Get source code if available.
|
|
487
|
+
// Priority: `docs.source.code` (user overrides / transforms / dynamic render sources)
|
|
488
|
+
// falls back to `docs.source.originalSource` (CSF enrichment default)
|
|
489
|
+
// falls back to legacy `storySource.source` (addon-storysource).
|
|
490
|
+
let code = '';
|
|
491
|
+
if (story.parameters?.docs?.source?.code) {
|
|
492
|
+
code = story.parameters.docs.source.code;
|
|
493
|
+
}
|
|
494
|
+
else if (story.parameters?.docs?.source?.originalSource) {
|
|
495
|
+
code = story.parameters.docs.source.originalSource;
|
|
496
|
+
}
|
|
497
|
+
else if (story.parameters?.storySource?.source) {
|
|
498
|
+
code = story.parameters.storySource.source;
|
|
499
|
+
}
|
|
500
|
+
const storyData = {
|
|
501
|
+
name: story.name || `Story ${i + 1}`,
|
|
502
|
+
code: code,
|
|
503
|
+
description: story.parameters?.docs?.description?.story || '',
|
|
504
|
+
storyIndex: i, // We'll use this to match with screenshots
|
|
505
|
+
metadata: {
|
|
506
|
+
storyId: story.id,
|
|
507
|
+
framework: story.parameters?.framework || 'react',
|
|
508
|
+
kind: story.kind || story.title
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
stories.push(storyData);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
componentName,
|
|
516
|
+
description,
|
|
517
|
+
props,
|
|
518
|
+
stories,
|
|
519
|
+
metadata: {
|
|
520
|
+
storyId: currentStory.id,
|
|
521
|
+
framework: currentStory.parameters?.framework || 'react',
|
|
522
|
+
kind: currentStory.kind || currentStory.title
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
// INDEX-GUIDED APPROACH: Use cached index.json as source of truth
|
|
527
|
+
const indexData = await this.fetchIndexJson();
|
|
528
|
+
const validStoryIds = [];
|
|
529
|
+
if (indexData) {
|
|
530
|
+
for (const [id, entry] of Object.entries(indexData.entries)) {
|
|
531
|
+
if (entry.type === 'story' && entry.title === storyInfo.kind) {
|
|
532
|
+
validStoryIds.push(id);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Fallback to DOM-first if index.json unavailable or no stories found
|
|
537
|
+
if (validStoryIds.length === 0) {
|
|
538
|
+
this.emit({ type: 'warn', message: 'No stories found in index.json, falling back to DOM-first approach' });
|
|
539
|
+
validStoryIds.push(...extractedData.stories.map(s => s.metadata?.storyId).filter(Boolean));
|
|
540
|
+
}
|
|
541
|
+
this.emit({ type: 'debug', message: `Screenshot strategy: Index-guided (${validStoryIds.length} stories from index.json)` });
|
|
542
|
+
// Capture screenshots by iterating over story IDs from index.json (source of truth)
|
|
543
|
+
const examples = [];
|
|
544
|
+
// Wait for first story element to ensure page is fully rendered before starting screenshots
|
|
545
|
+
if (validStoryIds.length > 0) {
|
|
546
|
+
const firstStoryId = validStoryIds[0];
|
|
547
|
+
await page.waitForSelector(`[id="story--${firstStoryId}"]`, { timeout: 8000 }).catch(() => {
|
|
548
|
+
this.emit({ type: 'warn', message: `First story element not found: ${firstStoryId}` });
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
for (let i = 0; i < validStoryIds.length; i++) {
|
|
552
|
+
const storyId = validStoryIds[i];
|
|
553
|
+
let screenshot = '';
|
|
554
|
+
let storyMetadata = null;
|
|
555
|
+
try {
|
|
556
|
+
// Direct page access — no iframe indirection needed
|
|
557
|
+
const storyElement = page.locator(`[id="story--${storyId}"]`).first();
|
|
558
|
+
try {
|
|
559
|
+
await storyElement.waitFor({ state: 'visible', timeout: 3000 });
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// Element didn't become visible in time, continue anyway
|
|
563
|
+
}
|
|
564
|
+
// Check if story element exists in DOM
|
|
565
|
+
const storyElementCount = await storyElement.count().catch(() => 0);
|
|
566
|
+
if (storyElementCount === 0) {
|
|
567
|
+
this.emit({ type: 'warn', message: `Story from index.json not found in DOM: ${storyId}` });
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
// Get the .docs-story container (what we want to screenshot)
|
|
571
|
+
const docsStoryContainer = storyElement.locator('xpath=ancestor::*[contains(@class, "docs-story")]').first();
|
|
572
|
+
// Check if container exists
|
|
573
|
+
const containerCount = await docsStoryContainer.count().catch(() => 0);
|
|
574
|
+
const domElement = containerCount > 0 ? docsStoryContainer : storyElement;
|
|
575
|
+
if (containerCount === 0) {
|
|
576
|
+
this.emit({ type: 'warn', message: `No .docs-story container found for ${storyId}, using story element directly` });
|
|
577
|
+
}
|
|
578
|
+
// Get story metadata from API (exact match guaranteed since we're using index.json IDs)
|
|
579
|
+
storyMetadata = await page.evaluate((targetStoryId) => {
|
|
580
|
+
const preview = window.__STORYBOOK_PREVIEW__;
|
|
581
|
+
const storyRenders = preview?.storyRenders ? Object.values(preview.storyRenders) : [];
|
|
582
|
+
// Find the story render matching this ID
|
|
583
|
+
for (const render of storyRenders) {
|
|
584
|
+
if (render?.story?.id === targetStoryId) {
|
|
585
|
+
const story = render.story;
|
|
586
|
+
return {
|
|
587
|
+
name: story.name || 'Unknown',
|
|
588
|
+
code: story.parameters?.docs?.source?.code ||
|
|
589
|
+
story.parameters?.docs?.source?.originalSource ||
|
|
590
|
+
story.parameters?.storySource?.source || '',
|
|
591
|
+
description: story.parameters?.docs?.description?.story || '',
|
|
592
|
+
metadata: {
|
|
593
|
+
storyId: story.id,
|
|
594
|
+
framework: story.parameters?.framework || 'react',
|
|
595
|
+
kind: story.kind || story.title
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Fallback: return minimal metadata
|
|
601
|
+
return {
|
|
602
|
+
name: targetStoryId.split('--').pop() || 'Unknown',
|
|
603
|
+
code: '',
|
|
604
|
+
description: '',
|
|
605
|
+
metadata: {
|
|
606
|
+
storyId: targetStoryId,
|
|
607
|
+
framework: 'react',
|
|
608
|
+
kind: ''
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
}, storyId);
|
|
612
|
+
// Take screenshot
|
|
613
|
+
const isVisible = await domElement.isVisible().catch(() => false);
|
|
614
|
+
if (!isVisible) {
|
|
615
|
+
await domElement.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => { });
|
|
616
|
+
}
|
|
617
|
+
const screenshotBuffer = await domElement.screenshot({
|
|
618
|
+
type: 'png',
|
|
619
|
+
omitBackground: false,
|
|
620
|
+
timeout: 5000
|
|
621
|
+
});
|
|
622
|
+
screenshot = screenshotBuffer.toString('base64');
|
|
623
|
+
// Save screenshot to file (non-blocking)
|
|
624
|
+
const screenshotPath = path.join(this.options.outputDir, 'screenshots', `${storyInfo.componentId}_${storyMetadata.name.replace(/[^a-zA-Z0-9]/g, '_')}.png`);
|
|
625
|
+
fs.writeFile(screenshotPath, screenshotBuffer).catch(() => { });
|
|
626
|
+
this.emit({ type: 'debug', message: `Screenshot captured for ${storyMetadata.name} (${storyId})` });
|
|
627
|
+
examples.push({
|
|
628
|
+
name: storyMetadata.name,
|
|
629
|
+
code: storyMetadata.code,
|
|
630
|
+
description: storyMetadata.description,
|
|
631
|
+
screenshot,
|
|
632
|
+
metadata: {
|
|
633
|
+
...storyMetadata.metadata,
|
|
634
|
+
storyId: storyId,
|
|
635
|
+
source: 'index.json'
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
catch (screenshotError) {
|
|
640
|
+
const errorMsg = screenshotError.message;
|
|
641
|
+
if (errorMsg.includes('detached') || errorMsg.includes('Target closed')) {
|
|
642
|
+
this.emit({ type: 'debug', message: `Screenshot skipped for ${storyId}: ${errorMsg}` });
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
this.emit({ type: 'warn', message: `Screenshot failed for ${storyId}: ${errorMsg}` });
|
|
646
|
+
}
|
|
647
|
+
// Still push the example with metadata even if screenshot fails
|
|
648
|
+
if (storyMetadata) {
|
|
649
|
+
examples.push({
|
|
650
|
+
name: storyMetadata.name,
|
|
651
|
+
code: storyMetadata.code,
|
|
652
|
+
description: storyMetadata.description,
|
|
653
|
+
screenshot: '',
|
|
654
|
+
metadata: storyMetadata.metadata
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Validate we captured the expected number of stories
|
|
660
|
+
if (examples.length === validStoryIds.length) {
|
|
661
|
+
this.emit({ type: 'debug', message: `Captured all ${examples.length} stories from index.json` });
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
this.emit({ type: 'warn', message: `Count mismatch: Expected ${validStoryIds.length} stories, captured ${examples.length}` });
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
name: extractedData.componentName,
|
|
668
|
+
componentName: extractedData.componentName,
|
|
669
|
+
description: extractedData.description,
|
|
670
|
+
props: extractedData.props,
|
|
671
|
+
examples,
|
|
672
|
+
metadata: extractedData.metadata
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
async saveComponent(componentData) {
|
|
676
|
+
// Directories are already created in initialize()
|
|
677
|
+
const componentsDir = path.join(this.options.outputDir, 'components');
|
|
678
|
+
const filename = `${componentData.componentName || componentData.name}.json`;
|
|
679
|
+
const filepath = path.join(componentsDir, filename);
|
|
680
|
+
await fs.writeFile(filepath, JSON.stringify(componentData, null, 2));
|
|
681
|
+
this.emit({ type: 'debug', message: `Saved component data to ${filepath}` });
|
|
682
|
+
}
|
|
683
|
+
async cleanup() {
|
|
684
|
+
if (this.browser) {
|
|
685
|
+
await this.browser.close();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Helper function to process prop types
|
|
2
|
+
export function processPropType(prop) {
|
|
3
|
+
if (prop.type?.name === "enum") {
|
|
4
|
+
return {
|
|
5
|
+
name: "enum",
|
|
6
|
+
value: prop.type.value.map((v) =>
|
|
7
|
+
// Handle both literal enum values and identifier references
|
|
8
|
+
v.value || v.name || String(v)),
|
|
9
|
+
required: !!prop.required,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
name: prop.type?.name || "any",
|
|
14
|
+
value: prop.type?.value,
|
|
15
|
+
required: !!prop.required,
|
|
16
|
+
defaultValue: prop.defaultValue?.value,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// Helper function to process component props into API documentation
|
|
20
|
+
export function processComponentAPI(props) {
|
|
21
|
+
return {
|
|
22
|
+
props: props.map((prop) => ({
|
|
23
|
+
name: prop.name,
|
|
24
|
+
description: prop.description,
|
|
25
|
+
type: processPropType(prop),
|
|
26
|
+
required: !!prop.required,
|
|
27
|
+
defaultValue: prop.defaultValue?.value,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as clack from '@clack/prompts';
|
|
2
|
+
import terminalLink from 'terminal-link';
|
|
3
|
+
export function intro(command, detail) {
|
|
4
|
+
const title = detail ? `loracle ${command} · ${detail}` : `loracle ${command}`;
|
|
5
|
+
clack.intro(title);
|
|
6
|
+
}
|
|
7
|
+
export function outro(startTime) {
|
|
8
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
9
|
+
clack.outro(`Done in ${elapsed}s`);
|
|
10
|
+
}
|
|
11
|
+
export function cancel(message) {
|
|
12
|
+
clack.cancel(message);
|
|
13
|
+
}
|
|
14
|
+
export function step(message) {
|
|
15
|
+
clack.log.step(message);
|
|
16
|
+
}
|
|
17
|
+
export function error(title, details) {
|
|
18
|
+
const lines = details ? `${title}\n${details.join('\n')}` : title;
|
|
19
|
+
clack.log.error(lines);
|
|
20
|
+
}
|
|
21
|
+
export function success(title, details) {
|
|
22
|
+
const lines = details ? `${title}\n${details.join('\n')}` : title;
|
|
23
|
+
clack.log.success(lines);
|
|
24
|
+
}
|
|
25
|
+
export function warn(message) {
|
|
26
|
+
clack.log.warn(message);
|
|
27
|
+
}
|
|
28
|
+
export function createSpinner() {
|
|
29
|
+
return clack.spinner({ indicator: 'dots' });
|
|
30
|
+
}
|
|
31
|
+
export function link(text, url) {
|
|
32
|
+
return terminalLink(text, url, { fallback: (t, u) => `${t} (${u})` });
|
|
33
|
+
}
|
|
34
|
+
export const ErrorMessages = {
|
|
35
|
+
missingApiKey: {
|
|
36
|
+
title: 'Missing API key',
|
|
37
|
+
details: [
|
|
38
|
+
'Set LORACLE_API_KEY environment variable.',
|
|
39
|
+
`Get your key at ${link('getloracle.com/settings/api-keys', 'https://getloracle.com/settings/api-keys')}`
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
invalidApiKey: {
|
|
43
|
+
title: 'Invalid API key',
|
|
44
|
+
details: [
|
|
45
|
+
`Check your API key at ${link('getloracle.com/settings/api-keys', 'https://getloracle.com/settings/api-keys')}`
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
invalidVersion: (version) => ({
|
|
49
|
+
title: `Invalid version "${version}"`,
|
|
50
|
+
details: [
|
|
51
|
+
'Use semantic versioning (e.g., 1.0.0, 2.1.0-beta.1)'
|
|
52
|
+
]
|
|
53
|
+
}),
|
|
54
|
+
connectionFailed: (url) => ({
|
|
55
|
+
title: 'Could not connect to Storybook',
|
|
56
|
+
details: [
|
|
57
|
+
'Make sure:',
|
|
58
|
+
` - Storybook is running at ${url}`,
|
|
59
|
+
' - The URL is accessible from this machine'
|
|
60
|
+
]
|
|
61
|
+
}),
|
|
62
|
+
insufficientCredits: {
|
|
63
|
+
title: 'Insufficient credits',
|
|
64
|
+
details: [
|
|
65
|
+
`Add credits at ${link('getloracle.com/billing', 'https://getloracle.com/billing')}`
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
};
|
package/dist/uploader.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Bundle all component JSON files into a single payload
|
|
5
|
+
*/
|
|
6
|
+
export async function bundleComponents(componentsDir, version) {
|
|
7
|
+
const files = await fs.readdir(componentsDir);
|
|
8
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
9
|
+
const components = [];
|
|
10
|
+
for (const file of jsonFiles) {
|
|
11
|
+
const filePath = path.join(componentsDir, file);
|
|
12
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
13
|
+
const component = JSON.parse(content);
|
|
14
|
+
components.push(component);
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
version,
|
|
18
|
+
components,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Initialize a build via the API
|
|
23
|
+
*/
|
|
24
|
+
export async function initBuild(apiUrl, apiKey, version, componentCount) {
|
|
25
|
+
const response = await fetch(`${apiUrl}/trpc/builds.initBuild`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
'X-Loracle-Client': 'cli/0.1.0',
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({ version, componentCount }),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const text = await response.text();
|
|
36
|
+
// Check for 402-style insufficient credits error
|
|
37
|
+
try {
|
|
38
|
+
const errorData = JSON.parse(text);
|
|
39
|
+
if (errorData.error?.data?.cause?.code === 'INSUFFICIENT_CREDITS') {
|
|
40
|
+
const cause = errorData.error.data.cause;
|
|
41
|
+
throw new Error(`Insufficient credits: need ${cause.required}, have ${cause.available}. ` +
|
|
42
|
+
`Buy more at ${cause.billingUrl}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e instanceof Error && e.message.includes('Insufficient credits'))
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`API request failed (${response.status}): ${text}`);
|
|
50
|
+
}
|
|
51
|
+
const data = await response.json();
|
|
52
|
+
if (data.error) {
|
|
53
|
+
throw new Error(`API error: ${data.error.message}`);
|
|
54
|
+
}
|
|
55
|
+
const result = data.result.data;
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Upload bundle to S3 using presigned URL
|
|
60
|
+
*/
|
|
61
|
+
export async function uploadToS3(uploadUrl, bundle) {
|
|
62
|
+
const response = await fetch(uploadUrl, {
|
|
63
|
+
method: 'PUT',
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify(bundle),
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const text = await response.text();
|
|
71
|
+
throw new Error(`S3 upload failed (${response.status}): ${text}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Main upload flow
|
|
76
|
+
*/
|
|
77
|
+
export async function uploadComponents(options) {
|
|
78
|
+
const { componentsDir, version, apiUrl, apiKey } = options;
|
|
79
|
+
// 1. Bundle components first to get count
|
|
80
|
+
const partialBundle = await bundleComponents(componentsDir, version);
|
|
81
|
+
const componentCount = partialBundle.components.length;
|
|
82
|
+
// 2. Initialize build with component count (triggers credit check)
|
|
83
|
+
const { buildId, uploadUrl, projectId } = await initBuild(apiUrl, apiKey, version, componentCount);
|
|
84
|
+
// 3. Complete bundle with buildId and projectId
|
|
85
|
+
const bundle = {
|
|
86
|
+
buildId,
|
|
87
|
+
projectId,
|
|
88
|
+
...partialBundle,
|
|
89
|
+
};
|
|
90
|
+
// 4. Upload to S3
|
|
91
|
+
await uploadToS3(uploadUrl, bundle);
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@loracle-js/cli",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Loracle CLI - Publish your design system to Loracle",
|
|
5
|
+
"author": "Loracle <team@getloracle.com>",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://getloracle.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/getloracle/loracle.git",
|
|
11
|
+
"directory": "packages/storybook-scraper-v2"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/getloracle/loracle/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"loracle": "./dist/bin/loracle.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"start": "tsx src/bin/loracle.ts",
|
|
29
|
+
"build": "tsc"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"storybook",
|
|
33
|
+
"loracle",
|
|
34
|
+
"design-system",
|
|
35
|
+
"llm",
|
|
36
|
+
"cli"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@clack/prompts": "^1.1.0",
|
|
40
|
+
"commander": "^12.0.0",
|
|
41
|
+
"dotenv": "^16.6.1",
|
|
42
|
+
"p-limit": "^7.1.1",
|
|
43
|
+
"playwright": "^1.47.0",
|
|
44
|
+
"terminal-link": "^5.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.13.10",
|
|
48
|
+
"tsx": "^4.19.3",
|
|
49
|
+
"typescript": "^5.8.2"
|
|
50
|
+
}
|
|
51
|
+
}
|