design-clone 2.1.0 → 3.0.0

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 (177) hide show
  1. package/README.md +13 -34
  2. package/SKILL.md +69 -45
  3. package/bin/cli.js +22 -4
  4. package/bin/commands/clone-site.js +31 -171
  5. package/bin/commands/help.js +19 -6
  6. package/bin/commands/init.js +9 -86
  7. package/bin/commands/uninstall.js +105 -0
  8. package/bin/commands/update.js +70 -0
  9. package/bin/commands/verify.js +7 -14
  10. package/bin/utils/paths.js +28 -0
  11. package/bin/utils/validate.js +2 -22
  12. package/bin/utils/version.js +23 -0
  13. package/docs/code-standards.md +789 -0
  14. package/docs/codebase-summary.md +533 -286
  15. package/docs/index.md +74 -0
  16. package/docs/project-overview-pdr.md +797 -0
  17. package/docs/system-architecture.md +718 -0
  18. package/package.json +14 -17
  19. package/src/ai/prompts/design-tokens/basic.md +80 -0
  20. package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
  21. package/src/ai/prompts/design-tokens/section.md +48 -0
  22. package/src/ai/prompts/design-tokens/with-css.md +87 -0
  23. package/src/ai/prompts/structure-analysis/basic.md +55 -0
  24. package/src/ai/prompts/structure-analysis/with-context.md +59 -0
  25. package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
  26. package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
  27. package/src/ai/prompts/ux-audit/aggregation.md +42 -0
  28. package/src/ai/prompts/ux-audit/desktop.md +92 -0
  29. package/src/ai/prompts/ux-audit/mobile.md +93 -0
  30. package/src/ai/prompts/ux-audit/tablet.md +92 -0
  31. package/src/core/animation/animation-extractor-ast.js +183 -0
  32. package/src/core/animation/animation-extractor-output.js +152 -0
  33. package/src/core/animation/animation-extractor.js +178 -0
  34. package/src/core/animation/state-capture-detection.js +200 -0
  35. package/src/core/animation/state-capture.js +193 -0
  36. package/src/core/capture/browser-context-pool.js +96 -0
  37. package/src/core/capture/multi-page-screenshot-page.js +110 -0
  38. package/src/core/capture/multi-page-screenshot.js +208 -0
  39. package/src/core/capture/screenshot-extraction.js +186 -0
  40. package/src/core/capture/screenshot-helpers.js +175 -0
  41. package/src/core/capture/screenshot-orchestrator.js +174 -0
  42. package/src/core/capture/screenshot-viewport.js +93 -0
  43. package/src/core/capture/screenshot.js +192 -0
  44. package/src/core/content/content-counter-dom.js +191 -0
  45. package/src/core/content/content-counter.js +76 -0
  46. package/src/core/css/breakpoint-detector.js +66 -0
  47. package/src/core/css/chromium-defaults.json +23 -0
  48. package/src/core/css/computed-style-extractor.js +102 -0
  49. package/src/core/css/css-chunker.js +103 -0
  50. package/src/core/css/filter-css-dead-code.js +120 -0
  51. package/src/core/css/filter-css-html-analyzer.js +110 -0
  52. package/src/core/css/filter-css-selector-matcher.js +172 -0
  53. package/src/core/css/filter-css.js +206 -0
  54. package/src/core/css/merge-css-atrule-processor.js +158 -0
  55. package/src/core/css/merge-css-file-io.js +68 -0
  56. package/src/core/css/merge-css.js +148 -0
  57. package/src/core/detection/framework-detector-routing.js +68 -0
  58. package/src/core/detection/framework-detector-signals.js +65 -0
  59. package/src/core/detection/framework-detector.js +198 -0
  60. package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
  61. package/src/core/dimension/dimension-extractor.js +317 -0
  62. package/src/core/dimension/dimension-output-ai-summary.js +111 -0
  63. package/src/core/dimension/dimension-output.js +173 -0
  64. package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
  65. package/src/core/dimension/dom-tree-analyzer.js +191 -0
  66. package/src/core/discovery/app-state-snapshot-capture.js +195 -0
  67. package/src/core/discovery/app-state-snapshot-utils.js +178 -0
  68. package/src/core/discovery/app-state-snapshot.js +131 -0
  69. package/src/core/discovery/discover-pages-routes.js +84 -0
  70. package/src/core/discovery/discover-pages-utils.js +177 -0
  71. package/src/core/discovery/discover-pages.js +191 -0
  72. package/src/core/html/html-extractor-inline-styler.js +70 -0
  73. package/src/core/html/html-extractor.js +147 -0
  74. package/src/core/html/semantic-enhancer-mappings.js +200 -0
  75. package/src/core/html/semantic-enhancer-page.js +148 -0
  76. package/src/core/html/semantic-enhancer.js +135 -0
  77. package/src/core/links/rewrite-links-css-rewriter.js +53 -0
  78. package/src/core/links/rewrite-links.js +173 -0
  79. package/src/core/media/asset-validator.js +118 -0
  80. package/src/core/media/extract-assets-downloader.js +187 -0
  81. package/src/core/media/extract-assets-page-scraper.js +115 -0
  82. package/src/core/media/extract-assets.js +159 -0
  83. package/src/core/media/video-capture-convert.js +200 -0
  84. package/src/core/media/video-capture.js +201 -0
  85. package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
  86. package/src/core/section/section-cropper-helpers.js +43 -0
  87. package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
  88. package/src/core/section/section-detector-strategies.js +139 -0
  89. package/src/core/section/section-detector-utils.js +100 -0
  90. package/src/core/section/section-detector.js +88 -0
  91. package/src/core/tests/test-section-cropper.js +2 -2
  92. package/src/core/tests/test-section-detector.js +2 -2
  93. package/src/post-process/enhance-assets.js +29 -4
  94. package/src/post-process/fetch-images-unsplash-client.js +123 -0
  95. package/src/post-process/fetch-images.js +60 -263
  96. package/src/post-process/inject-gosnap.js +88 -0
  97. package/src/post-process/inject-icons-svg-replacer.js +76 -0
  98. package/src/post-process/inject-icons.js +47 -200
  99. package/src/route-discoverers/base-discoverer-utils.js +137 -0
  100. package/src/route-discoverers/base-discoverer.js +29 -118
  101. package/src/route-discoverers/index.js +1 -1
  102. package/src/shared/config.js +38 -0
  103. package/src/shared/error-codes.js +31 -0
  104. package/src/shared/viewports.js +46 -0
  105. package/src/utils/browser.js +0 -7
  106. package/src/utils/helpers.js +4 -0
  107. package/src/utils/log.js +12 -0
  108. package/src/utils/playwright-loader.js +76 -0
  109. package/src/utils/playwright.js +3 -69
  110. package/src/utils/progress.js +32 -0
  111. package/src/verification/generate-audit-report-css-fixes.js +52 -0
  112. package/src/verification/generate-audit-report-sections.js +158 -0
  113. package/src/verification/generate-audit-report.js +5 -281
  114. package/src/verification/quality-scorer.js +92 -0
  115. package/src/verification/verify-footer-checks.js +103 -0
  116. package/src/verification/verify-footer-helpers.js +178 -0
  117. package/src/verification/verify-footer.js +23 -381
  118. package/src/verification/verify-header-checks.js +104 -0
  119. package/src/verification/verify-header-helpers.js +156 -0
  120. package/src/verification/verify-header.js +23 -365
  121. package/src/verification/verify-layout-report.js +101 -0
  122. package/src/verification/verify-layout.js +13 -259
  123. package/src/verification/verify-menu-checks.js +104 -0
  124. package/src/verification/verify-menu-helpers.js +112 -0
  125. package/src/verification/verify-menu.js +17 -285
  126. package/src/verification/verify-slider-checks.js +115 -0
  127. package/src/verification/verify-slider-constants.js +65 -0
  128. package/src/verification/verify-slider-helpers.js +164 -0
  129. package/src/verification/verify-slider.js +23 -414
  130. package/.env.example +0 -14
  131. package/docs/basic-clone.md +0 -63
  132. package/docs/cli-reference.md +0 -316
  133. package/docs/design-clone-architecture.md +0 -492
  134. package/docs/pixel-perfect.md +0 -117
  135. package/docs/project-roadmap.md +0 -382
  136. package/docs/troubleshooting.md +0 -170
  137. package/requirements.txt +0 -5
  138. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  139. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  140. package/src/ai/analyze-structure.py +0 -375
  141. package/src/ai/extract-design-tokens.py +0 -782
  142. package/src/ai/prompts/__init__.py +0 -2
  143. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  144. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  145. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  146. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  147. package/src/ai/prompts/design_tokens.py +0 -316
  148. package/src/ai/prompts/structure_analysis.py +0 -592
  149. package/src/ai/prompts/ux_audit.py +0 -198
  150. package/src/ai/ux-audit.js +0 -596
  151. package/src/core/animation-extractor.js +0 -526
  152. package/src/core/app-state-snapshot.js +0 -511
  153. package/src/core/content-counter.js +0 -342
  154. package/src/core/design-tokens.js +0 -103
  155. package/src/core/dimension-extractor.js +0 -438
  156. package/src/core/dimension-output.js +0 -305
  157. package/src/core/discover-pages.js +0 -542
  158. package/src/core/dom-tree-analyzer.js +0 -298
  159. package/src/core/extract-assets.js +0 -468
  160. package/src/core/filter-css.js +0 -499
  161. package/src/core/framework-detector.js +0 -538
  162. package/src/core/html-extractor.js +0 -212
  163. package/src/core/merge-css.js +0 -407
  164. package/src/core/multi-page-screenshot.js +0 -380
  165. package/src/core/rewrite-links.js +0 -226
  166. package/src/core/screenshot.js +0 -701
  167. package/src/core/section-detector.js +0 -386
  168. package/src/core/semantic-enhancer.js +0 -492
  169. package/src/core/state-capture.js +0 -598
  170. package/src/core/video-capture.js +0 -546
  171. package/src/utils/__init__.py +0 -16
  172. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  173. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  174. package/src/utils/env.py +0 -134
  175. /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
  176. /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
  177. /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Unsplash API Client
3
+ *
4
+ * Handles searching Unsplash for photos and triggering download attribution.
5
+ * Uses a module-level cache to avoid duplicate API calls within a session.
6
+ */
7
+
8
+ const UNSPLASH_API = 'https://api.unsplash.com';
9
+
10
+ // Cache for search results to avoid duplicate API calls
11
+ const searchCache = new Map();
12
+
13
+ /**
14
+ * Generate search keywords from image context.
15
+ * Prioritises alt text; falls back to surrounding text words.
16
+ * Translates common Japanese keywords for better Unsplash results.
17
+ * @param {{ alt: string, context: string }} imageContext
18
+ * @returns {string} Keyword string
19
+ */
20
+ export function generateKeywords(imageContext) {
21
+ const { alt, context } = imageContext;
22
+ let keywords = alt;
23
+
24
+ if (!keywords || keywords.length < 3) {
25
+ const words = context
26
+ .toLowerCase()
27
+ .replace(/[^\w\s]/g, '')
28
+ .split(/\s+/)
29
+ .filter(w => w.length > 3)
30
+ .filter(w => !['http', 'https', 'www', 'html', 'class', 'style'].includes(w));
31
+ keywords = words.slice(0, 3).join(' ');
32
+ }
33
+
34
+ const translations = {
35
+ '会社': 'company office',
36
+ '仕事': 'work business',
37
+ '人': 'people team',
38
+ 'サービス': 'service',
39
+ 'ビジネス': 'business',
40
+ '技術': 'technology',
41
+ 'オフィス': 'office',
42
+ 'チーム': 'team',
43
+ 'ミーティング': 'meeting',
44
+ '開発': 'development',
45
+ 'デザイン': 'design',
46
+ 'マーケティング': 'marketing',
47
+ '事例': 'case study business',
48
+ '導入': 'implementation',
49
+ 'CTA': 'business success'
50
+ };
51
+
52
+ for (const [jp, en] of Object.entries(translations)) {
53
+ if (keywords.includes(jp)) keywords = keywords.replace(jp, en);
54
+ }
55
+
56
+ if (!keywords || keywords.length < 3) keywords = 'business professional';
57
+
58
+ return keywords.trim();
59
+ }
60
+
61
+ /**
62
+ * Search Unsplash for an image matching the keywords.
63
+ * Returns null if no API key or no results found.
64
+ * @param {string} keywords
65
+ * @param {'landscape'|'portrait'|'squarish'} orientation
66
+ * @returns {Promise<{ id, url, thumb, photographer, photographerUrl, downloadLocation }|null>}
67
+ */
68
+ export async function searchUnsplash(keywords, orientation = 'landscape') {
69
+ const apiKey = process.env.UNSPLASH_ACCESS_KEY;
70
+ if (!apiKey) return null;
71
+
72
+ const cacheKey = `${keywords}-${orientation}`;
73
+ if (searchCache.has(cacheKey)) return searchCache.get(cacheKey);
74
+
75
+ try {
76
+ const params = new URLSearchParams({ query: keywords, orientation, per_page: '1' });
77
+ const response = await fetch(`${UNSPLASH_API}/search/photos?${params}`, {
78
+ headers: {
79
+ 'Authorization': `Client-ID ${apiKey}`,
80
+ 'Accept-Version': 'v1'
81
+ }
82
+ });
83
+
84
+ if (!response.ok) {
85
+ if (response.status === 403) console.warn(' ⚠ Unsplash rate limit reached');
86
+ return null;
87
+ }
88
+
89
+ const data = await response.json();
90
+ if (!data.results || data.results.length === 0) return null;
91
+
92
+ const photo = data.results[0];
93
+ const result = {
94
+ id: photo.id,
95
+ url: photo.urls.regular,
96
+ thumb: photo.urls.thumb,
97
+ photographer: photo.user.name,
98
+ photographerUrl: photo.user.links.html,
99
+ downloadLocation: photo.links.download_location
100
+ };
101
+
102
+ searchCache.set(cacheKey, result);
103
+ return result;
104
+ } catch (error) {
105
+ console.warn(` ⚠ Unsplash search failed: ${error.message}`);
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Trigger download event for attribution (required by Unsplash API terms).
112
+ * Silently ignores failures — not critical.
113
+ * @param {string} downloadLocation
114
+ */
115
+ export async function triggerDownload(downloadLocation) {
116
+ const apiKey = process.env.UNSPLASH_ACCESS_KEY;
117
+ if (!apiKey || !downloadLocation) return;
118
+ try {
119
+ await fetch(downloadLocation, {
120
+ headers: { 'Authorization': `Client-ID ${apiKey}` }
121
+ });
122
+ } catch { /* silently fail */ }
123
+ }
@@ -20,8 +20,9 @@
20
20
 
21
21
  import fs from 'fs/promises';
22
22
  import path from 'path';
23
+ import { parseArgs as parseRawArgs } from '../utils/helpers.js';
24
+ import { generateKeywords, searchUnsplash, triggerDownload } from './fetch-images-unsplash-client.js';
23
25
 
24
- const UNSPLASH_API = 'https://api.unsplash.com';
25
26
  const PLACEHOLDER_PATTERNS = [
26
27
  /https?:\/\/placehold\.co\/[^"'\s)]+/gi,
27
28
  /https?:\/\/placeholder\.com\/[^"'\s)]+/gi,
@@ -29,272 +30,107 @@ const PLACEHOLDER_PATTERNS = [
29
30
  /https?:\/\/picsum\.photos\/[^"'\s)]+/gi
30
31
  ];
31
32
 
32
- // Cache for search results to avoid duplicate API calls
33
- const searchCache = new Map();
34
-
35
33
  /**
36
- * Parse command line arguments
34
+ * Parse command line arguments.
35
+ * @returns {{ html: string|null, output: string|null, verbose: boolean }}
37
36
  */
38
37
  function parseArgs() {
39
- const args = process.argv.slice(2);
40
- const options = {
41
- html: null,
42
- output: null,
43
- verbose: false
38
+ const raw = parseRawArgs(process.argv.slice(2));
39
+ return {
40
+ html: raw.html || null,
41
+ output: raw.output || null,
42
+ verbose: raw.verbose === true || raw.verbose === 'true'
44
43
  };
45
-
46
- for (let i = 0; i < args.length; i++) {
47
- switch (args[i]) {
48
- case '--html':
49
- options.html = args[++i];
50
- break;
51
- case '--output':
52
- options.output = args[++i];
53
- break;
54
- case '--verbose':
55
- options.verbose = true;
56
- break;
57
- }
58
- }
59
-
60
- return options;
61
44
  }
62
45
 
63
46
  /**
64
- * Extract image contexts from HTML
65
- * Returns array of { placeholder, alt, context, orientation }
47
+ * Extract image contexts from HTML.
48
+ * @param {string} html
49
+ * @returns {Array<{ placeholder, alt, context, orientation, originalTag }>}
66
50
  */
67
- function extractImageContexts(html) {
51
+ export function extractImageContexts(html) {
68
52
  const contexts = [];
69
53
  const imgRegex = /<img[^>]*>/gi;
70
- const matches = html.matchAll(imgRegex);
71
54
 
72
- for (const match of matches) {
55
+ for (const match of html.matchAll(imgRegex)) {
73
56
  const imgTag = match[0];
74
57
 
75
- // Check if src is a placeholder
76
58
  let placeholderUrl = null;
77
59
  for (const pattern of PLACEHOLDER_PATTERNS) {
78
60
  const srcMatch = imgTag.match(pattern);
79
- if (srcMatch) {
80
- placeholderUrl = srcMatch[0];
81
- break;
82
- }
61
+ if (srcMatch) { placeholderUrl = srcMatch[0]; break; }
83
62
  }
84
-
85
63
  if (!placeholderUrl) continue;
86
64
 
87
- // Extract alt text
88
65
  const altMatch = imgTag.match(/alt=["']([^"']*)["']/i);
89
- const alt = altMatch ? altMatch[1] : '';
66
+ const alt = altMatch ? altMatch[1] : '';
90
67
 
91
- // Determine orientation from placeholder URL dimensions
92
68
  let orientation = 'landscape';
93
- const dimMatch = placeholderUrl.match(/(\d+)x(\d+)/);
69
+ const dimMatch = placeholderUrl.match(/(\d+)x(\d+)/);
94
70
  if (dimMatch) {
95
- const width = parseInt(dimMatch[1]);
96
- const height = parseInt(dimMatch[2]);
97
- if (height > width) orientation = 'portrait';
98
- else if (height === width) orientation = 'squarish';
71
+ const w = parseInt(dimMatch[1]);
72
+ const h = parseInt(dimMatch[2]);
73
+ if (h > w) orientation = 'portrait';
74
+ else if (h === w) orientation = 'squarish';
99
75
  }
100
76
 
101
- // Extract surrounding context (100 chars before/after)
102
- const position = match.index;
77
+ const position = match.index;
103
78
  const contextStart = Math.max(0, position - 100);
104
- const contextEnd = Math.min(html.length, position + imgTag.length + 100);
79
+ const contextEnd = Math.min(html.length, position + imgTag.length + 100);
105
80
  const surroundingText = html.slice(contextStart, contextEnd)
106
81
  .replace(/<[^>]+>/g, ' ')
107
82
  .replace(/\s+/g, ' ')
108
83
  .trim();
109
84
 
110
- contexts.push({
111
- placeholder: placeholderUrl,
112
- alt: alt,
113
- context: surroundingText,
114
- orientation: orientation,
115
- originalTag: imgTag
116
- });
85
+ contexts.push({ placeholder: placeholderUrl, alt, context: surroundingText, orientation, originalTag: imgTag });
117
86
  }
118
87
 
119
88
  return contexts;
120
89
  }
121
90
 
122
- /**
123
- * Generate search keywords from image context
124
- */
125
- function generateKeywords(imageContext) {
126
- const { alt, context } = imageContext;
127
-
128
- // Priority: alt text > surrounding context
129
- let keywords = alt;
130
-
131
- if (!keywords || keywords.length < 3) {
132
- // Extract meaningful words from context
133
- const words = context
134
- .toLowerCase()
135
- .replace(/[^\w\s]/g, '')
136
- .split(/\s+/)
137
- .filter(w => w.length > 3)
138
- .filter(w => !['http', 'https', 'www', 'html', 'class', 'style'].includes(w));
139
-
140
- keywords = words.slice(0, 3).join(' ');
141
- }
142
-
143
- // Translate common Japanese keywords for better Unsplash results
144
- const translations = {
145
- '会社': 'company office',
146
- '仕事': 'work business',
147
- '人': 'people team',
148
- 'サービス': 'service',
149
- 'ビジネス': 'business',
150
- '技術': 'technology',
151
- 'オフィス': 'office',
152
- 'チーム': 'team',
153
- 'ミーティング': 'meeting',
154
- '開発': 'development',
155
- 'デザイン': 'design',
156
- 'マーケティング': 'marketing',
157
- '事例': 'case study business',
158
- '導入': 'implementation',
159
- 'CTA': 'business success'
160
- };
161
-
162
- for (const [jp, en] of Object.entries(translations)) {
163
- if (keywords.includes(jp)) {
164
- keywords = keywords.replace(jp, en);
165
- }
166
- }
167
-
168
- // Default fallback
169
- if (!keywords || keywords.length < 3) {
170
- keywords = 'business professional';
171
- }
172
-
173
- return keywords.trim();
174
- }
175
-
176
- /**
177
- * Search Unsplash for images
178
- */
179
- async function searchUnsplash(keywords, orientation = 'landscape') {
180
- const apiKey = process.env.UNSPLASH_ACCESS_KEY;
181
-
182
- if (!apiKey) {
183
- return null;
184
- }
185
-
186
- // Check cache
187
- const cacheKey = `${keywords}-${orientation}`;
188
- if (searchCache.has(cacheKey)) {
189
- return searchCache.get(cacheKey);
190
- }
191
-
192
- try {
193
- const params = new URLSearchParams({
194
- query: keywords,
195
- orientation: orientation,
196
- per_page: '1'
197
- });
198
-
199
- const response = await fetch(`${UNSPLASH_API}/search/photos?${params}`, {
200
- headers: {
201
- 'Authorization': `Client-ID ${apiKey}`,
202
- 'Accept-Version': 'v1'
203
- }
204
- });
205
-
206
- if (!response.ok) {
207
- if (response.status === 403) {
208
- console.warn(' ⚠ Unsplash rate limit reached');
209
- }
210
- return null;
211
- }
212
-
213
- const data = await response.json();
214
-
215
- if (data.results && data.results.length > 0) {
216
- const photo = data.results[0];
217
- const result = {
218
- id: photo.id,
219
- url: photo.urls.regular, // 1080px width
220
- thumb: photo.urls.thumb,
221
- photographer: photo.user.name,
222
- photographerUrl: photo.user.links.html,
223
- downloadLocation: photo.links.download_location
224
- };
225
-
226
- searchCache.set(cacheKey, result);
227
- return result;
228
- }
229
-
230
- return null;
231
- } catch (error) {
232
- console.warn(` ⚠ Unsplash search failed: ${error.message}`);
233
- return null;
234
- }
235
- }
236
-
237
- /**
238
- * Trigger download event for attribution (required by Unsplash API)
239
- */
240
- async function triggerDownload(downloadLocation) {
241
- const apiKey = process.env.UNSPLASH_ACCESS_KEY;
242
- if (!apiKey || !downloadLocation) return;
243
-
244
- try {
245
- await fetch(downloadLocation, {
246
- headers: {
247
- 'Authorization': `Client-ID ${apiKey}`
248
- }
249
- });
250
- } catch {
251
- // Silently fail - not critical
252
- }
91
+ /** Build Unsplash attribution HTML comment block. */
92
+ function addAttributionComment(html, attributions) {
93
+ if (attributions.length === 0) return html;
94
+ const lines = attributions.map(a => ` - "${a.keywords}": Photo by ${a.photographer} (${a.photographerUrl})`).join('\n');
95
+ const comment = `\n<!--\n Image Credits (Unsplash)\n ========================\n${lines}\n\n Licensed under the Unsplash License: https://unsplash.com/license\n-->\n`;
96
+ return html.replace(/<head>/i, `<head>${comment}`);
253
97
  }
254
98
 
255
99
  /**
256
- * Replace placeholder URLs in HTML with Unsplash images
100
+ * Replace placeholder image URLs in HTML with Unsplash images.
101
+ * @param {string} html
102
+ * @param {boolean} verbose
103
+ * @returns {Promise<{ html: string, attributions: Array, replacedCount: number }>}
257
104
  */
258
105
  async function replaceImages(html, verbose = false) {
259
- const contexts = extractImageContexts(html);
260
- const attributions = [];
261
- let updatedHtml = html;
262
- let replacedCount = 0;
106
+ const contexts = extractImageContexts(html);
107
+ const attributions = [];
108
+ let updatedHtml = html;
109
+ let replacedCount = 0;
263
110
 
264
- if (verbose) {
265
- console.log(` Found ${contexts.length} placeholder images`);
266
- }
111
+ if (verbose) console.log(` Found ${contexts.length} placeholder images`);
267
112
 
268
113
  for (const ctx of contexts) {
269
114
  const keywords = generateKeywords(ctx);
270
-
271
- if (verbose) {
272
- console.log(` → Searching: "${keywords}" (${ctx.orientation})`);
273
- }
115
+ if (verbose) console.log(` → Searching: "${keywords}" (${ctx.orientation})`);
274
116
 
275
117
  const photo = await searchUnsplash(keywords, ctx.orientation);
276
118
 
277
119
  if (photo) {
278
- // Replace placeholder URL with Unsplash URL
279
120
  updatedHtml = updatedHtml.replace(ctx.placeholder, photo.url);
280
121
  replacedCount++;
281
122
 
282
- // Track attribution
283
123
  attributions.push({
284
- keywords: keywords,
285
- photoId: photo.id,
286
- url: photo.url,
287
- photographer: photo.photographer,
124
+ keywords,
125
+ photoId: photo.id,
126
+ url: photo.url,
127
+ photographer: photo.photographer,
288
128
  photographerUrl: photo.photographerUrl,
289
- license: 'Unsplash License'
129
+ license: 'Unsplash License'
290
130
  });
291
131
 
292
- // Trigger download for attribution tracking
293
132
  await triggerDownload(photo.downloadLocation);
294
-
295
- if (verbose) {
296
- console.log(` ✓ Found: ${photo.photographer}`);
297
- }
133
+ if (verbose) console.log(` ✓ Found: ${photo.photographer}`);
298
134
  } else if (verbose) {
299
135
  console.log(` ✗ No results`);
300
136
  }
@@ -307,59 +143,32 @@ async function replaceImages(html, verbose = false) {
307
143
  }
308
144
 
309
145
  /**
310
- * Add attribution comment to HTML
146
+ * Main function reads HTML, replaces images, writes results.
147
+ * @param {string} htmlPath
148
+ * @param {string} outputDir
149
+ * @param {boolean} verbose
150
+ * @returns {Promise<{ success: boolean, replacedCount?: number, attributions?: Array, skipped?: boolean }>}
311
151
  */
312
- function addAttributionComment(html, attributions) {
313
- if (attributions.length === 0) return html;
314
-
315
- const comment = `
316
- <!--
317
- Image Credits (Unsplash)
318
- ========================
319
- ${attributions.map(a => ` - "${a.keywords}": Photo by ${a.photographer} (${a.photographerUrl})`).join('\n')}
320
-
321
- Licensed under the Unsplash License: https://unsplash.com/license
322
- -->
323
- `;
324
-
325
- // Insert after <head> tag
326
- return html.replace(/<head>/i, `<head>${comment}`);
327
- }
328
-
329
- /**
330
- * Main function
331
- */
332
- async function fetchImages(htmlPath, outputDir, verbose = false) {
152
+ export async function fetchImages(htmlPath, outputDir, verbose = false) {
333
153
  const apiKey = process.env.UNSPLASH_ACCESS_KEY;
334
154
 
335
155
  if (!apiKey) {
336
156
  console.log(' → Skipping Unsplash (no UNSPLASH_ACCESS_KEY set)');
337
- return {
338
- success: true,
339
- skipped: true,
340
- message: 'No API key configured'
341
- };
157
+ return { success: true, skipped: true, message: 'No API key configured' };
342
158
  }
343
159
 
344
- // Read HTML
345
160
  const html = await fs.readFile(htmlPath, 'utf-8');
346
-
347
- // Replace images
348
161
  const { html: updatedHtml, attributions, replacedCount } = await replaceImages(html, verbose);
349
162
 
350
163
  if (replacedCount > 0) {
351
- // Add attribution comment
352
164
  const finalHtml = addAttributionComment(updatedHtml, attributions);
353
-
354
- // Write updated HTML
355
165
  await fs.writeFile(htmlPath, finalHtml, 'utf-8');
356
166
 
357
- // Write attribution JSON
358
167
  const attrPath = path.join(outputDir, 'attribution.json');
359
168
  await fs.writeFile(attrPath, JSON.stringify({
360
169
  generated: new Date().toISOString(),
361
- source: 'Unsplash',
362
- images: attributions
170
+ source: 'Unsplash',
171
+ images: attributions
363
172
  }, null, 2), 'utf-8');
364
173
 
365
174
  console.log(` ✓ Replaced ${replacedCount} images from Unsplash`);
@@ -367,32 +176,20 @@ async function fetchImages(htmlPath, outputDir, verbose = false) {
367
176
  console.log(' → No placeholder images found to replace');
368
177
  }
369
178
 
370
- return {
371
- success: true,
372
- replacedCount,
373
- attributions
374
- };
179
+ return { success: true, replacedCount, attributions };
375
180
  }
376
181
 
377
182
  // CLI execution
378
183
  if (process.argv[1] === new URL(import.meta.url).pathname) {
379
- const args = parseArgs();
184
+ const args = parseArgs();
185
+ const outputDir = args.output || path.dirname(args.html);
380
186
 
381
187
  if (!args.html) {
382
188
  console.error('Usage: node fetch-images.js --html <path> --output <dir>');
383
189
  process.exit(1);
384
190
  }
385
191
 
386
- const outputDir = args.output || path.dirname(args.html);
387
-
388
192
  fetchImages(args.html, outputDir, args.verbose)
389
- .then(result => {
390
- console.log(JSON.stringify(result, null, 2));
391
- })
392
- .catch(error => {
393
- console.error('Error:', error.message);
394
- process.exit(1);
395
- });
193
+ .then(result => console.log(JSON.stringify(result, null, 2)))
194
+ .catch(error => { console.error('Error:', error.message); process.exit(1); });
396
195
  }
397
-
398
- export { fetchImages, extractImageContexts, generateKeywords };
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GoSnap Widget Injector for Design Clone
4
+ *
5
+ * Injects gosnap-widget Web Component into generated HTML files.
6
+ * Scans pages/ directory and adds <go-snap> element before </body>.
7
+ * Idempotent -- skips files that already contain the widget.
8
+ *
9
+ * Usage:
10
+ * node inject-gosnap.js --dir ./pages [--verbose]
11
+ */
12
+
13
+ import fs from 'fs/promises';
14
+ import path from 'path';
15
+
16
+ const GOSNAP_TAG = 'go-snap';
17
+ const GOSNAP_SNIPPET = `<script src="https://unpkg.com/gosnap-widget@1.0.1/dist/embed.global.js"></script>
18
+ <go-snap position="bottom-right" theme="dark" persist></go-snap>`;
19
+
20
+ /**
21
+ * Inject gosnap-widget into all HTML files in a directory
22
+ * @param {string} pagesDir - Path to directory containing HTML files
23
+ * @param {boolean} [verbose=false] - Show detailed progress
24
+ * @returns {Promise<{success: boolean, injectedCount: number, skippedCount: number}>}
25
+ */
26
+ async function injectGosnap(pagesDir, verbose = false) {
27
+ // Guard: check directory exists
28
+ try {
29
+ await fs.access(pagesDir);
30
+ } catch {
31
+ throw new Error(`Directory not found: ${pagesDir}`);
32
+ }
33
+
34
+ const entries = await fs.readdir(pagesDir);
35
+ const htmlFiles = entries.filter(f => f.endsWith('.html'));
36
+
37
+ let injectedCount = 0;
38
+ let skippedCount = 0;
39
+
40
+ for (const file of htmlFiles) {
41
+ const filePath = path.join(pagesDir, file);
42
+ const content = await fs.readFile(filePath, 'utf-8');
43
+
44
+ if (content.includes(GOSNAP_TAG)) {
45
+ skippedCount++;
46
+ if (verbose) console.log(` -> Skipped (already present): ${file}`);
47
+ continue;
48
+ }
49
+
50
+ if (!content.includes('</body>')) {
51
+ skippedCount++;
52
+ if (verbose) console.log(` -> Skipped (no </body> tag): ${file}`);
53
+ continue;
54
+ }
55
+
56
+ const updated = content.replace('</body>', `\n${GOSNAP_SNIPPET}\n</body>`);
57
+ await fs.writeFile(filePath, updated, 'utf-8');
58
+ injectedCount++;
59
+ if (verbose) console.log(` -> Injected: ${file}`);
60
+ }
61
+
62
+ console.log(` Injected gosnap-widget into ${injectedCount} file(s), skipped ${skippedCount}`);
63
+
64
+ return { success: true, injectedCount, skippedCount };
65
+ }
66
+
67
+ // CLI execution
68
+ if (process.argv[1] === new URL(import.meta.url).pathname) {
69
+ const args = process.argv.slice(2);
70
+ let dir = null;
71
+ let verbose = false;
72
+
73
+ for (let i = 0; i < args.length; i++) {
74
+ if (args[i] === '--dir' && args[i + 1]) dir = args[++i];
75
+ else if (args[i] === '--verbose') verbose = true;
76
+ }
77
+
78
+ if (!dir) {
79
+ console.error('Usage: node inject-gosnap.js --dir <path> [--verbose]');
80
+ process.exit(1);
81
+ }
82
+
83
+ injectGosnap(dir, verbose)
84
+ .then(result => console.log(JSON.stringify(result, null, 2)))
85
+ .catch(error => { console.error('Error:', error.message); process.exit(1); });
86
+ }
87
+
88
+ export { injectGosnap };