design-clone 1.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.
- package/.env.example +14 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/SKILL.md +239 -0
- package/bin/cli.js +45 -0
- package/bin/commands/help.js +29 -0
- package/bin/commands/init.js +126 -0
- package/bin/commands/verify.js +99 -0
- package/bin/utils/copy.js +65 -0
- package/bin/utils/validate.js +122 -0
- package/docs/basic-clone.md +63 -0
- package/docs/cli-reference.md +94 -0
- package/docs/design-clone-architecture.md +247 -0
- package/docs/pixel-perfect.md +86 -0
- package/docs/troubleshooting.md +97 -0
- package/package.json +57 -0
- package/requirements.txt +5 -0
- package/src/ai/analyze-structure.py +305 -0
- package/src/ai/extract-design-tokens.py +439 -0
- package/src/ai/prompts/__init__.py +2 -0
- package/src/ai/prompts/design_tokens.py +183 -0
- package/src/ai/prompts/structure_analysis.py +273 -0
- package/src/core/cookie-handler.js +76 -0
- package/src/core/css-extractor.js +107 -0
- package/src/core/dimension-extractor.js +366 -0
- package/src/core/dimension-output.js +208 -0
- package/src/core/extract-assets.js +468 -0
- package/src/core/filter-css.js +499 -0
- package/src/core/html-extractor.js +102 -0
- package/src/core/lazy-loader.js +188 -0
- package/src/core/page-readiness.js +161 -0
- package/src/core/screenshot.js +380 -0
- package/src/post-process/enhance-assets.js +157 -0
- package/src/post-process/fetch-images.js +398 -0
- package/src/post-process/inject-icons.js +311 -0
- package/src/utils/__init__.py +16 -0
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/browser.js +103 -0
- package/src/utils/env.js +153 -0
- package/src/utils/env.py +134 -0
- package/src/utils/helpers.js +71 -0
- package/src/utils/puppeteer.js +281 -0
- package/src/verification/verify-layout.js +424 -0
- package/src/verification/verify-menu.js +422 -0
- package/templates/base.css +705 -0
- package/templates/base.html +293 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Unsplash Image Fetcher for Design Clone
|
|
4
|
+
*
|
|
5
|
+
* Fetches relevant images from Unsplash to replace placeholder URLs in generated HTML.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node fetch-images.js --html ./index.html --output ./
|
|
9
|
+
*
|
|
10
|
+
* Environment:
|
|
11
|
+
* UNSPLASH_ACCESS_KEY - Your Unsplash API access key
|
|
12
|
+
*
|
|
13
|
+
* Features:
|
|
14
|
+
* - Extracts context from alt text and surrounding content
|
|
15
|
+
* - Searches Unsplash for relevant images
|
|
16
|
+
* - Replaces placehold.co URLs with real images
|
|
17
|
+
* - Creates attribution.json for credits
|
|
18
|
+
* - Graceful fallback when API unavailable
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'fs/promises';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
|
|
24
|
+
const UNSPLASH_API = 'https://api.unsplash.com';
|
|
25
|
+
const PLACEHOLDER_PATTERNS = [
|
|
26
|
+
/https?:\/\/placehold\.co\/[^"'\s)]+/gi,
|
|
27
|
+
/https?:\/\/placeholder\.com\/[^"'\s)]+/gi,
|
|
28
|
+
/https?:\/\/via\.placeholder\.com\/[^"'\s)]+/gi,
|
|
29
|
+
/https?:\/\/picsum\.photos\/[^"'\s)]+/gi
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Cache for search results to avoid duplicate API calls
|
|
33
|
+
const searchCache = new Map();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse command line arguments
|
|
37
|
+
*/
|
|
38
|
+
function parseArgs() {
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
const options = {
|
|
41
|
+
html: null,
|
|
42
|
+
output: null,
|
|
43
|
+
verbose: false
|
|
44
|
+
};
|
|
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
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extract image contexts from HTML
|
|
65
|
+
* Returns array of { placeholder, alt, context, orientation }
|
|
66
|
+
*/
|
|
67
|
+
function extractImageContexts(html) {
|
|
68
|
+
const contexts = [];
|
|
69
|
+
const imgRegex = /<img[^>]*>/gi;
|
|
70
|
+
const matches = html.matchAll(imgRegex);
|
|
71
|
+
|
|
72
|
+
for (const match of matches) {
|
|
73
|
+
const imgTag = match[0];
|
|
74
|
+
|
|
75
|
+
// Check if src is a placeholder
|
|
76
|
+
let placeholderUrl = null;
|
|
77
|
+
for (const pattern of PLACEHOLDER_PATTERNS) {
|
|
78
|
+
const srcMatch = imgTag.match(pattern);
|
|
79
|
+
if (srcMatch) {
|
|
80
|
+
placeholderUrl = srcMatch[0];
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!placeholderUrl) continue;
|
|
86
|
+
|
|
87
|
+
// Extract alt text
|
|
88
|
+
const altMatch = imgTag.match(/alt=["']([^"']*)["']/i);
|
|
89
|
+
const alt = altMatch ? altMatch[1] : '';
|
|
90
|
+
|
|
91
|
+
// Determine orientation from placeholder URL dimensions
|
|
92
|
+
let orientation = 'landscape';
|
|
93
|
+
const dimMatch = placeholderUrl.match(/(\d+)x(\d+)/);
|
|
94
|
+
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';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Extract surrounding context (100 chars before/after)
|
|
102
|
+
const position = match.index;
|
|
103
|
+
const contextStart = Math.max(0, position - 100);
|
|
104
|
+
const contextEnd = Math.min(html.length, position + imgTag.length + 100);
|
|
105
|
+
const surroundingText = html.slice(contextStart, contextEnd)
|
|
106
|
+
.replace(/<[^>]+>/g, ' ')
|
|
107
|
+
.replace(/\s+/g, ' ')
|
|
108
|
+
.trim();
|
|
109
|
+
|
|
110
|
+
contexts.push({
|
|
111
|
+
placeholder: placeholderUrl,
|
|
112
|
+
alt: alt,
|
|
113
|
+
context: surroundingText,
|
|
114
|
+
orientation: orientation,
|
|
115
|
+
originalTag: imgTag
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return contexts;
|
|
120
|
+
}
|
|
121
|
+
|
|
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
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Replace placeholder URLs in HTML with Unsplash images
|
|
257
|
+
*/
|
|
258
|
+
async function replaceImages(html, verbose = false) {
|
|
259
|
+
const contexts = extractImageContexts(html);
|
|
260
|
+
const attributions = [];
|
|
261
|
+
let updatedHtml = html;
|
|
262
|
+
let replacedCount = 0;
|
|
263
|
+
|
|
264
|
+
if (verbose) {
|
|
265
|
+
console.log(` Found ${contexts.length} placeholder images`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const ctx of contexts) {
|
|
269
|
+
const keywords = generateKeywords(ctx);
|
|
270
|
+
|
|
271
|
+
if (verbose) {
|
|
272
|
+
console.log(` → Searching: "${keywords}" (${ctx.orientation})`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const photo = await searchUnsplash(keywords, ctx.orientation);
|
|
276
|
+
|
|
277
|
+
if (photo) {
|
|
278
|
+
// Replace placeholder URL with Unsplash URL
|
|
279
|
+
updatedHtml = updatedHtml.replace(ctx.placeholder, photo.url);
|
|
280
|
+
replacedCount++;
|
|
281
|
+
|
|
282
|
+
// Track attribution
|
|
283
|
+
attributions.push({
|
|
284
|
+
keywords: keywords,
|
|
285
|
+
photoId: photo.id,
|
|
286
|
+
url: photo.url,
|
|
287
|
+
photographer: photo.photographer,
|
|
288
|
+
photographerUrl: photo.photographerUrl,
|
|
289
|
+
license: 'Unsplash License'
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Trigger download for attribution tracking
|
|
293
|
+
await triggerDownload(photo.downloadLocation);
|
|
294
|
+
|
|
295
|
+
if (verbose) {
|
|
296
|
+
console.log(` ✓ Found: ${photo.photographer}`);
|
|
297
|
+
}
|
|
298
|
+
} else if (verbose) {
|
|
299
|
+
console.log(` ✗ No results`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Small delay to avoid rate limiting
|
|
303
|
+
await new Promise(r => setTimeout(r, 100));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { html: updatedHtml, attributions, replacedCount };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Add attribution comment to HTML
|
|
311
|
+
*/
|
|
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) {
|
|
333
|
+
const apiKey = process.env.UNSPLASH_ACCESS_KEY;
|
|
334
|
+
|
|
335
|
+
if (!apiKey) {
|
|
336
|
+
console.log(' → Skipping Unsplash (no UNSPLASH_ACCESS_KEY set)');
|
|
337
|
+
return {
|
|
338
|
+
success: true,
|
|
339
|
+
skipped: true,
|
|
340
|
+
message: 'No API key configured'
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Read HTML
|
|
345
|
+
const html = await fs.readFile(htmlPath, 'utf-8');
|
|
346
|
+
|
|
347
|
+
// Replace images
|
|
348
|
+
const { html: updatedHtml, attributions, replacedCount } = await replaceImages(html, verbose);
|
|
349
|
+
|
|
350
|
+
if (replacedCount > 0) {
|
|
351
|
+
// Add attribution comment
|
|
352
|
+
const finalHtml = addAttributionComment(updatedHtml, attributions);
|
|
353
|
+
|
|
354
|
+
// Write updated HTML
|
|
355
|
+
await fs.writeFile(htmlPath, finalHtml, 'utf-8');
|
|
356
|
+
|
|
357
|
+
// Write attribution JSON
|
|
358
|
+
const attrPath = path.join(outputDir, 'attribution.json');
|
|
359
|
+
await fs.writeFile(attrPath, JSON.stringify({
|
|
360
|
+
generated: new Date().toISOString(),
|
|
361
|
+
source: 'Unsplash',
|
|
362
|
+
images: attributions
|
|
363
|
+
}, null, 2), 'utf-8');
|
|
364
|
+
|
|
365
|
+
console.log(` ✓ Replaced ${replacedCount} images from Unsplash`);
|
|
366
|
+
} else {
|
|
367
|
+
console.log(' → No placeholder images found to replace');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
success: true,
|
|
372
|
+
replacedCount,
|
|
373
|
+
attributions
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// CLI execution
|
|
378
|
+
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
|
379
|
+
const args = parseArgs();
|
|
380
|
+
|
|
381
|
+
if (!args.html) {
|
|
382
|
+
console.error('Usage: node fetch-images.js --html <path> --output <dir>');
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const outputDir = args.output || path.dirname(args.html);
|
|
387
|
+
|
|
388
|
+
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
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export { fetchImages, extractImageContexts, generateKeywords };
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Japanese-style SVG Icon Injector for Design Clone
|
|
4
|
+
*
|
|
5
|
+
* Injects Japanese-style SVG icons into generated HTML by:
|
|
6
|
+
* - Detecting generic SVG placeholders
|
|
7
|
+
* - Matching semantic keywords to icon library
|
|
8
|
+
* - Replacing with curated Japanese-aesthetic icons
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node inject-icons.js --html ./index.html
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs/promises';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { icons, iconMapping, getIcon, getIconByKeyword } from './icons/japanese-icons.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse command line arguments
|
|
20
|
+
*/
|
|
21
|
+
function parseArgs() {
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const options = {
|
|
24
|
+
html: null,
|
|
25
|
+
verbose: false
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
switch (args[i]) {
|
|
30
|
+
case '--html':
|
|
31
|
+
options.html = args[++i];
|
|
32
|
+
break;
|
|
33
|
+
case '--verbose':
|
|
34
|
+
options.verbose = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return options;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract context from element's surrounding HTML
|
|
44
|
+
*/
|
|
45
|
+
function extractContext(html, position, range = 200) {
|
|
46
|
+
const start = Math.max(0, position - range);
|
|
47
|
+
const end = Math.min(html.length, position + range);
|
|
48
|
+
return html.slice(start, end);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detect icon purpose from class names, aria-label, or surrounding text
|
|
53
|
+
*/
|
|
54
|
+
function detectIconPurpose(svgTag, context) {
|
|
55
|
+
// Check class names
|
|
56
|
+
const classMatch = svgTag.match(/class=["']([^"']*)["']/i);
|
|
57
|
+
if (classMatch) {
|
|
58
|
+
const classes = classMatch[1].toLowerCase();
|
|
59
|
+
|
|
60
|
+
// Check for icon type in class
|
|
61
|
+
for (const keyword of Object.keys(iconMapping)) {
|
|
62
|
+
if (classes.includes(keyword)) {
|
|
63
|
+
return keyword;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check for category hints
|
|
68
|
+
if (classes.includes('icon')) {
|
|
69
|
+
// Try to extract purpose from class like "icon-mail" or "mail-icon"
|
|
70
|
+
const parts = classes.split(/[-_\s]+/);
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
if (iconMapping[part]) {
|
|
73
|
+
return part;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check aria-label
|
|
80
|
+
const ariaMatch = svgTag.match(/aria-label=["']([^"']*)["']/i);
|
|
81
|
+
if (ariaMatch) {
|
|
82
|
+
const label = ariaMatch[1].toLowerCase();
|
|
83
|
+
for (const keyword of Object.keys(iconMapping)) {
|
|
84
|
+
if (label.includes(keyword)) {
|
|
85
|
+
return keyword;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check surrounding context for hints
|
|
91
|
+
const contextLower = context.toLowerCase();
|
|
92
|
+
|
|
93
|
+
// Priority keywords for Japanese business sites
|
|
94
|
+
const priorityKeywords = [
|
|
95
|
+
'mail', 'email', 'phone', 'tel', 'location', 'address',
|
|
96
|
+
'menu', 'search', 'home', 'arrow', 'chevron',
|
|
97
|
+
'user', 'users', 'team', 'company', 'building',
|
|
98
|
+
'twitter', 'facebook', 'instagram', 'linkedin', 'line',
|
|
99
|
+
'check', 'info', 'warning', 'success', 'star',
|
|
100
|
+
'sakura', 'wave', 'zen'
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const keyword of priorityKeywords) {
|
|
104
|
+
if (contextLower.includes(keyword)) {
|
|
105
|
+
return keyword;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Default to decorative
|
|
110
|
+
return 'decorative';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find SVG elements that need replacement
|
|
115
|
+
*/
|
|
116
|
+
function findSvgElements(html) {
|
|
117
|
+
const elements = [];
|
|
118
|
+
|
|
119
|
+
// Pattern 1: Generic SVG with viewBox (likely placeholder)
|
|
120
|
+
const svgRegex = /<svg[^>]*viewBox=["'][^"']*["'][^>]*>[\s\S]*?<\/svg>/gi;
|
|
121
|
+
|
|
122
|
+
let match;
|
|
123
|
+
while ((match = svgRegex.exec(html)) !== null) {
|
|
124
|
+
const svgTag = match[0];
|
|
125
|
+
const context = extractContext(html, match.index);
|
|
126
|
+
|
|
127
|
+
// Skip if it's a complex SVG (logo, illustration)
|
|
128
|
+
// Simple icons typically have fewer than 3 path/shape elements
|
|
129
|
+
const pathCount = (svgTag.match(/<(path|circle|rect|line|polyline|polygon)/gi) || []).length;
|
|
130
|
+
|
|
131
|
+
// Skip logo SVGs (typically contain text elements or complex paths)
|
|
132
|
+
if (svgTag.includes('<text') || pathCount > 6) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Skip if it has specific classes indicating it's a logo
|
|
137
|
+
if (/class=["'][^"']*(logo|brand)[^"']*["']/i.test(svgTag)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
elements.push({
|
|
142
|
+
original: svgTag,
|
|
143
|
+
position: match.index,
|
|
144
|
+
context: context,
|
|
145
|
+
purpose: detectIconPurpose(svgTag, context)
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return elements;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Preserve original attributes when replacing SVG
|
|
154
|
+
*/
|
|
155
|
+
function preserveAttributes(originalSvg, newSvg) {
|
|
156
|
+
// Extract class from original
|
|
157
|
+
const classMatch = originalSvg.match(/class=["']([^"']*)["']/i);
|
|
158
|
+
const widthMatch = originalSvg.match(/width=["']([^"']*)["']/i);
|
|
159
|
+
const heightMatch = originalSvg.match(/height=["']([^"']*)["']/i);
|
|
160
|
+
const ariaMatch = originalSvg.match(/aria-[^=]+=["'][^"']*["']/gi);
|
|
161
|
+
|
|
162
|
+
let result = newSvg;
|
|
163
|
+
|
|
164
|
+
// Add class if present
|
|
165
|
+
if (classMatch) {
|
|
166
|
+
result = result.replace('<svg', `<svg class="${classMatch[1]}"`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Preserve width/height if specified
|
|
170
|
+
if (widthMatch) {
|
|
171
|
+
result = result.replace('<svg', `<svg width="${widthMatch[1]}"`);
|
|
172
|
+
}
|
|
173
|
+
if (heightMatch) {
|
|
174
|
+
result = result.replace('<svg', `<svg height="${heightMatch[1]}"`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Preserve aria attributes
|
|
178
|
+
if (ariaMatch) {
|
|
179
|
+
const attrs = ariaMatch.join(' ');
|
|
180
|
+
result = result.replace('<svg', `<svg ${attrs}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Inject icons into HTML
|
|
188
|
+
*/
|
|
189
|
+
async function injectIcons(htmlPath, verbose = false) {
|
|
190
|
+
// Read HTML
|
|
191
|
+
const html = await fs.readFile(htmlPath, 'utf-8');
|
|
192
|
+
|
|
193
|
+
// Find SVG elements
|
|
194
|
+
const elements = findSvgElements(html);
|
|
195
|
+
|
|
196
|
+
if (verbose) {
|
|
197
|
+
console.log(` Found ${elements.length} SVG elements to enhance`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (elements.length === 0) {
|
|
201
|
+
console.log(' → No SVG icons to enhance');
|
|
202
|
+
return {
|
|
203
|
+
success: true,
|
|
204
|
+
replacedCount: 0
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let updatedHtml = html;
|
|
209
|
+
let replacedCount = 0;
|
|
210
|
+
const replacements = [];
|
|
211
|
+
|
|
212
|
+
// Process elements in reverse order to maintain positions
|
|
213
|
+
const sortedElements = [...elements].sort((a, b) => b.position - a.position);
|
|
214
|
+
|
|
215
|
+
for (const element of sortedElements) {
|
|
216
|
+
const iconName = iconMapping[element.purpose] || 'decorative-dot';
|
|
217
|
+
const newIcon = getIcon(iconName);
|
|
218
|
+
const preservedIcon = preserveAttributes(element.original, newIcon);
|
|
219
|
+
|
|
220
|
+
updatedHtml = updatedHtml.replace(element.original, preservedIcon);
|
|
221
|
+
replacedCount++;
|
|
222
|
+
|
|
223
|
+
replacements.push({
|
|
224
|
+
purpose: element.purpose,
|
|
225
|
+
iconName: iconName
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (verbose) {
|
|
229
|
+
console.log(` → Replaced: ${element.purpose} → ${iconName}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Write updated HTML
|
|
234
|
+
await fs.writeFile(htmlPath, updatedHtml, 'utf-8');
|
|
235
|
+
|
|
236
|
+
console.log(` ✓ Enhanced ${replacedCount} icons with Japanese style`);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
success: true,
|
|
240
|
+
replacedCount,
|
|
241
|
+
replacements
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Add icon styles to HTML if not present
|
|
247
|
+
*/
|
|
248
|
+
async function ensureIconStyles(htmlPath) {
|
|
249
|
+
const html = await fs.readFile(htmlPath, 'utf-8');
|
|
250
|
+
|
|
251
|
+
// Check if icon styles already exist
|
|
252
|
+
if (html.includes('.icon {') || html.includes('/* Icon styles */')) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const iconStyles = `
|
|
257
|
+
/* Japanese-style icon defaults */
|
|
258
|
+
.icon {
|
|
259
|
+
width: 24px;
|
|
260
|
+
height: 24px;
|
|
261
|
+
flex-shrink: 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.icon--sm {
|
|
265
|
+
width: 16px;
|
|
266
|
+
height: 16px;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.icon--lg {
|
|
270
|
+
width: 32px;
|
|
271
|
+
height: 32px;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.icon--decorative {
|
|
275
|
+
opacity: 0.6;
|
|
276
|
+
}
|
|
277
|
+
`;
|
|
278
|
+
|
|
279
|
+
// Find </style> or add before </head>
|
|
280
|
+
let updatedHtml;
|
|
281
|
+
if (html.includes('</style>')) {
|
|
282
|
+
updatedHtml = html.replace('</style>', `${iconStyles}\n</style>`);
|
|
283
|
+
} else if (html.includes('</head>')) {
|
|
284
|
+
updatedHtml = html.replace('</head>', `<style>${iconStyles}</style>\n</head>`);
|
|
285
|
+
} else {
|
|
286
|
+
return; // Can't safely add styles
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await fs.writeFile(htmlPath, updatedHtml, 'utf-8');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// CLI execution
|
|
293
|
+
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
|
294
|
+
const args = parseArgs();
|
|
295
|
+
|
|
296
|
+
if (!args.html) {
|
|
297
|
+
console.error('Usage: node inject-icons.js --html <path>');
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
injectIcons(args.html, args.verbose)
|
|
302
|
+
.then(result => {
|
|
303
|
+
console.log(JSON.stringify(result, null, 2));
|
|
304
|
+
})
|
|
305
|
+
.catch(error => {
|
|
306
|
+
console.error('Error:', error.message);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export { injectIcons, findSvgElements, detectIconPurpose };
|