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.
Files changed (47) hide show
  1. package/.env.example +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/SKILL.md +239 -0
  5. package/bin/cli.js +45 -0
  6. package/bin/commands/help.js +29 -0
  7. package/bin/commands/init.js +126 -0
  8. package/bin/commands/verify.js +99 -0
  9. package/bin/utils/copy.js +65 -0
  10. package/bin/utils/validate.js +122 -0
  11. package/docs/basic-clone.md +63 -0
  12. package/docs/cli-reference.md +94 -0
  13. package/docs/design-clone-architecture.md +247 -0
  14. package/docs/pixel-perfect.md +86 -0
  15. package/docs/troubleshooting.md +97 -0
  16. package/package.json +57 -0
  17. package/requirements.txt +5 -0
  18. package/src/ai/analyze-structure.py +305 -0
  19. package/src/ai/extract-design-tokens.py +439 -0
  20. package/src/ai/prompts/__init__.py +2 -0
  21. package/src/ai/prompts/design_tokens.py +183 -0
  22. package/src/ai/prompts/structure_analysis.py +273 -0
  23. package/src/core/cookie-handler.js +76 -0
  24. package/src/core/css-extractor.js +107 -0
  25. package/src/core/dimension-extractor.js +366 -0
  26. package/src/core/dimension-output.js +208 -0
  27. package/src/core/extract-assets.js +468 -0
  28. package/src/core/filter-css.js +499 -0
  29. package/src/core/html-extractor.js +102 -0
  30. package/src/core/lazy-loader.js +188 -0
  31. package/src/core/page-readiness.js +161 -0
  32. package/src/core/screenshot.js +380 -0
  33. package/src/post-process/enhance-assets.js +157 -0
  34. package/src/post-process/fetch-images.js +398 -0
  35. package/src/post-process/inject-icons.js +311 -0
  36. package/src/utils/__init__.py +16 -0
  37. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  39. package/src/utils/browser.js +103 -0
  40. package/src/utils/env.js +153 -0
  41. package/src/utils/env.py +134 -0
  42. package/src/utils/helpers.js +71 -0
  43. package/src/utils/puppeteer.js +281 -0
  44. package/src/verification/verify-layout.js +424 -0
  45. package/src/verification/verify-menu.js +422 -0
  46. package/templates/base.css +705 -0
  47. 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 };