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.
- package/README.md +13 -34
- package/SKILL.md +69 -45
- package/bin/cli.js +22 -4
- package/bin/commands/clone-site.js +31 -171
- package/bin/commands/help.js +19 -6
- package/bin/commands/init.js +9 -86
- package/bin/commands/uninstall.js +105 -0
- package/bin/commands/update.js +70 -0
- package/bin/commands/verify.js +7 -14
- package/bin/utils/paths.js +28 -0
- package/bin/utils/validate.js +2 -22
- package/bin/utils/version.js +23 -0
- package/docs/code-standards.md +789 -0
- package/docs/codebase-summary.md +533 -286
- package/docs/index.md +74 -0
- package/docs/project-overview-pdr.md +797 -0
- package/docs/system-architecture.md +718 -0
- package/package.json +14 -17
- package/src/ai/prompts/design-tokens/basic.md +80 -0
- package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
- package/src/ai/prompts/design-tokens/section.md +48 -0
- package/src/ai/prompts/design-tokens/with-css.md +87 -0
- package/src/ai/prompts/structure-analysis/basic.md +55 -0
- package/src/ai/prompts/structure-analysis/with-context.md +59 -0
- package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
- package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
- package/src/ai/prompts/ux-audit/aggregation.md +42 -0
- package/src/ai/prompts/ux-audit/desktop.md +92 -0
- package/src/ai/prompts/ux-audit/mobile.md +93 -0
- package/src/ai/prompts/ux-audit/tablet.md +92 -0
- package/src/core/animation/animation-extractor-ast.js +183 -0
- package/src/core/animation/animation-extractor-output.js +152 -0
- package/src/core/animation/animation-extractor.js +178 -0
- package/src/core/animation/state-capture-detection.js +200 -0
- package/src/core/animation/state-capture.js +193 -0
- package/src/core/capture/browser-context-pool.js +96 -0
- package/src/core/capture/multi-page-screenshot-page.js +110 -0
- package/src/core/capture/multi-page-screenshot.js +208 -0
- package/src/core/capture/screenshot-extraction.js +186 -0
- package/src/core/capture/screenshot-helpers.js +175 -0
- package/src/core/capture/screenshot-orchestrator.js +174 -0
- package/src/core/capture/screenshot-viewport.js +93 -0
- package/src/core/capture/screenshot.js +192 -0
- package/src/core/content/content-counter-dom.js +191 -0
- package/src/core/content/content-counter.js +76 -0
- package/src/core/css/breakpoint-detector.js +66 -0
- package/src/core/css/chromium-defaults.json +23 -0
- package/src/core/css/computed-style-extractor.js +102 -0
- package/src/core/css/css-chunker.js +103 -0
- package/src/core/css/filter-css-dead-code.js +120 -0
- package/src/core/css/filter-css-html-analyzer.js +110 -0
- package/src/core/css/filter-css-selector-matcher.js +172 -0
- package/src/core/css/filter-css.js +206 -0
- package/src/core/css/merge-css-atrule-processor.js +158 -0
- package/src/core/css/merge-css-file-io.js +68 -0
- package/src/core/css/merge-css.js +148 -0
- package/src/core/detection/framework-detector-routing.js +68 -0
- package/src/core/detection/framework-detector-signals.js +65 -0
- package/src/core/detection/framework-detector.js +198 -0
- package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
- package/src/core/dimension/dimension-extractor.js +317 -0
- package/src/core/dimension/dimension-output-ai-summary.js +111 -0
- package/src/core/dimension/dimension-output.js +173 -0
- package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
- package/src/core/dimension/dom-tree-analyzer.js +191 -0
- package/src/core/discovery/app-state-snapshot-capture.js +195 -0
- package/src/core/discovery/app-state-snapshot-utils.js +178 -0
- package/src/core/discovery/app-state-snapshot.js +131 -0
- package/src/core/discovery/discover-pages-routes.js +84 -0
- package/src/core/discovery/discover-pages-utils.js +177 -0
- package/src/core/discovery/discover-pages.js +191 -0
- package/src/core/html/html-extractor-inline-styler.js +70 -0
- package/src/core/html/html-extractor.js +147 -0
- package/src/core/html/semantic-enhancer-mappings.js +200 -0
- package/src/core/html/semantic-enhancer-page.js +148 -0
- package/src/core/html/semantic-enhancer.js +135 -0
- package/src/core/links/rewrite-links-css-rewriter.js +53 -0
- package/src/core/links/rewrite-links.js +173 -0
- package/src/core/media/asset-validator.js +118 -0
- package/src/core/media/extract-assets-downloader.js +187 -0
- package/src/core/media/extract-assets-page-scraper.js +115 -0
- package/src/core/media/extract-assets.js +159 -0
- package/src/core/media/video-capture-convert.js +200 -0
- package/src/core/media/video-capture.js +201 -0
- package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
- package/src/core/section/section-cropper-helpers.js +43 -0
- package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
- package/src/core/section/section-detector-strategies.js +139 -0
- package/src/core/section/section-detector-utils.js +100 -0
- package/src/core/section/section-detector.js +88 -0
- package/src/core/tests/test-section-cropper.js +2 -2
- package/src/core/tests/test-section-detector.js +2 -2
- package/src/post-process/enhance-assets.js +29 -4
- package/src/post-process/fetch-images-unsplash-client.js +123 -0
- package/src/post-process/fetch-images.js +60 -263
- package/src/post-process/inject-gosnap.js +88 -0
- package/src/post-process/inject-icons-svg-replacer.js +76 -0
- package/src/post-process/inject-icons.js +47 -200
- package/src/route-discoverers/base-discoverer-utils.js +137 -0
- package/src/route-discoverers/base-discoverer.js +29 -118
- package/src/route-discoverers/index.js +1 -1
- package/src/shared/config.js +38 -0
- package/src/shared/error-codes.js +31 -0
- package/src/shared/viewports.js +46 -0
- package/src/utils/browser.js +0 -7
- package/src/utils/helpers.js +4 -0
- package/src/utils/log.js +12 -0
- package/src/utils/playwright-loader.js +76 -0
- package/src/utils/playwright.js +3 -69
- package/src/utils/progress.js +32 -0
- package/src/verification/generate-audit-report-css-fixes.js +52 -0
- package/src/verification/generate-audit-report-sections.js +158 -0
- package/src/verification/generate-audit-report.js +5 -281
- package/src/verification/quality-scorer.js +92 -0
- package/src/verification/verify-footer-checks.js +103 -0
- package/src/verification/verify-footer-helpers.js +178 -0
- package/src/verification/verify-footer.js +23 -381
- package/src/verification/verify-header-checks.js +104 -0
- package/src/verification/verify-header-helpers.js +156 -0
- package/src/verification/verify-header.js +23 -365
- package/src/verification/verify-layout-report.js +101 -0
- package/src/verification/verify-layout.js +13 -259
- package/src/verification/verify-menu-checks.js +104 -0
- package/src/verification/verify-menu-helpers.js +112 -0
- package/src/verification/verify-menu.js +17 -285
- package/src/verification/verify-slider-checks.js +115 -0
- package/src/verification/verify-slider-constants.js +65 -0
- package/src/verification/verify-slider-helpers.js +164 -0
- package/src/verification/verify-slider.js +23 -414
- package/.env.example +0 -14
- package/docs/basic-clone.md +0 -63
- package/docs/cli-reference.md +0 -316
- package/docs/design-clone-architecture.md +0 -492
- package/docs/pixel-perfect.md +0 -117
- package/docs/project-roadmap.md +0 -382
- package/docs/troubleshooting.md +0 -170
- package/requirements.txt +0 -5
- package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
- package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
- package/src/ai/analyze-structure.py +0 -375
- package/src/ai/extract-design-tokens.py +0 -782
- package/src/ai/prompts/__init__.py +0 -2
- package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +0 -316
- package/src/ai/prompts/structure_analysis.py +0 -592
- package/src/ai/prompts/ux_audit.py +0 -198
- package/src/ai/ux-audit.js +0 -596
- package/src/core/animation-extractor.js +0 -526
- package/src/core/app-state-snapshot.js +0 -511
- package/src/core/content-counter.js +0 -342
- package/src/core/design-tokens.js +0 -103
- package/src/core/dimension-extractor.js +0 -438
- package/src/core/dimension-output.js +0 -305
- package/src/core/discover-pages.js +0 -542
- package/src/core/dom-tree-analyzer.js +0 -298
- package/src/core/extract-assets.js +0 -468
- package/src/core/filter-css.js +0 -499
- package/src/core/framework-detector.js +0 -538
- package/src/core/html-extractor.js +0 -212
- package/src/core/merge-css.js +0 -407
- package/src/core/multi-page-screenshot.js +0 -380
- package/src/core/rewrite-links.js +0 -226
- package/src/core/screenshot.js +0 -701
- package/src/core/section-detector.js +0 -386
- package/src/core/semantic-enhancer.js +0 -492
- package/src/core/state-capture.js +0 -598
- package/src/core/video-capture.js +0 -546
- package/src/utils/__init__.py +0 -16
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/env.py +0 -134
- /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
- /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
- /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG Icon Replacer Utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles finding SVG placeholder elements in HTML and preserving
|
|
5
|
+
* original attributes (class, width, height, aria-*) when injecting
|
|
6
|
+
* replacement icons.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract surrounding context text from HTML at a given position.
|
|
11
|
+
* @param {string} html
|
|
12
|
+
* @param {number} position - Character index of the SVG element
|
|
13
|
+
* @param {number} range - Characters to capture before and after
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function extractContext(html, position, range = 200) {
|
|
17
|
+
const start = Math.max(0, position - range);
|
|
18
|
+
const end = Math.min(html.length, position + range);
|
|
19
|
+
return html.slice(start, end);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Find SVG elements that are candidates for icon replacement.
|
|
24
|
+
* Skips logos (text elements, >6 path nodes, logo/brand class).
|
|
25
|
+
* @param {string} html
|
|
26
|
+
* @param {Function} detectPurpose - detectIconPurpose(svgTag, context) → string
|
|
27
|
+
* @returns {Array<{ original, position, context, purpose }>}
|
|
28
|
+
*/
|
|
29
|
+
export function findSvgElements(html, detectPurpose) {
|
|
30
|
+
const elements = [];
|
|
31
|
+
const svgRegex = /<svg[^>]*viewBox=["'][^"']*["'][^>]*>[\s\S]*?<\/svg>/gi;
|
|
32
|
+
let match;
|
|
33
|
+
|
|
34
|
+
while ((match = svgRegex.exec(html)) !== null) {
|
|
35
|
+
const svgTag = match[0];
|
|
36
|
+
const context = extractContext(html, match.index);
|
|
37
|
+
|
|
38
|
+
const pathCount = (svgTag.match(/<(path|circle|rect|line|polyline|polygon)/gi) || []).length;
|
|
39
|
+
|
|
40
|
+
// Skip complex SVGs (likely logos or illustrations)
|
|
41
|
+
if (svgTag.includes('<text') || pathCount > 6) continue;
|
|
42
|
+
if (/class=["'][^"']*(logo|brand)[^"']*["']/i.test(svgTag)) continue;
|
|
43
|
+
|
|
44
|
+
elements.push({
|
|
45
|
+
original: svgTag,
|
|
46
|
+
position: match.index,
|
|
47
|
+
context,
|
|
48
|
+
purpose: detectPurpose(svgTag, context)
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return elements;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Preserve original SVG attributes (class, width, height, aria-*)
|
|
57
|
+
* when replacing with a new icon SVG string.
|
|
58
|
+
* @param {string} originalSvg - The original SVG markup
|
|
59
|
+
* @param {string} newSvg - The replacement icon SVG markup
|
|
60
|
+
* @returns {string} New SVG with original attributes preserved
|
|
61
|
+
*/
|
|
62
|
+
export function preserveAttributes(originalSvg, newSvg) {
|
|
63
|
+
const classMatch = originalSvg.match(/class=["']([^"']*)["']/i);
|
|
64
|
+
const widthMatch = originalSvg.match(/width=["']([^"']*)["']/i);
|
|
65
|
+
const heightMatch = originalSvg.match(/height=["']([^"']*)["']/i);
|
|
66
|
+
const ariaMatch = originalSvg.match(/aria-[^=]+=["'][^"']*["']/gi);
|
|
67
|
+
|
|
68
|
+
let result = newSvg;
|
|
69
|
+
|
|
70
|
+
if (classMatch) result = result.replace('<svg', `<svg class="${classMatch[1]}"`);
|
|
71
|
+
if (widthMatch) result = result.replace('<svg', `<svg width="${widthMatch[1]}"`);
|
|
72
|
+
if (heightMatch) result = result.replace('<svg', `<svg height="${heightMatch[1]}"`);
|
|
73
|
+
if (ariaMatch) result = result.replace('<svg', `<svg ${ariaMatch.join(' ')}`);
|
|
74
|
+
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
@@ -12,85 +12,52 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import fs from 'fs/promises';
|
|
15
|
-
import
|
|
16
|
-
import {
|
|
15
|
+
import { icons, iconMapping, getIcon } from './icons/japanese-icons.js';
|
|
16
|
+
import { parseArgs as parseRawArgs } from '../utils/helpers.js';
|
|
17
|
+
import { findSvgElements, preserveAttributes } from './inject-icons-svg-replacer.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* Parse command line arguments
|
|
20
|
+
* Parse command line arguments.
|
|
21
|
+
* @returns {{ html: string|null, verbose: boolean }}
|
|
20
22
|
*/
|
|
21
23
|
function parseArgs() {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
html: null,
|
|
25
|
-
verbose:
|
|
24
|
+
const raw = parseRawArgs(process.argv.slice(2));
|
|
25
|
+
return {
|
|
26
|
+
html: raw.html || null,
|
|
27
|
+
verbose: raw.verbose === true || raw.verbose === 'true'
|
|
26
28
|
};
|
|
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
29
|
}
|
|
50
30
|
|
|
51
31
|
/**
|
|
52
|
-
* Detect icon purpose from class names, aria-label, or surrounding text
|
|
32
|
+
* Detect icon purpose from class names, aria-label, or surrounding text.
|
|
33
|
+
* @param {string} svgTag - The SVG opening tag markup
|
|
34
|
+
* @param {string} context - Surrounding HTML context string
|
|
35
|
+
* @returns {string} Matched keyword or 'decorative'
|
|
53
36
|
*/
|
|
54
|
-
function detectIconPurpose(svgTag, context) {
|
|
55
|
-
// Check class names
|
|
37
|
+
export function detectIconPurpose(svgTag, context) {
|
|
56
38
|
const classMatch = svgTag.match(/class=["']([^"']*)["']/i);
|
|
57
39
|
if (classMatch) {
|
|
58
40
|
const classes = classMatch[1].toLowerCase();
|
|
59
|
-
|
|
60
|
-
// Check for icon type in class
|
|
61
41
|
for (const keyword of Object.keys(iconMapping)) {
|
|
62
|
-
if (classes.includes(keyword))
|
|
63
|
-
return keyword;
|
|
64
|
-
}
|
|
42
|
+
if (classes.includes(keyword)) return keyword;
|
|
65
43
|
}
|
|
66
|
-
|
|
67
|
-
// Check for category hints
|
|
68
44
|
if (classes.includes('icon')) {
|
|
69
|
-
// Try to extract purpose from class like "icon-mail" or "mail-icon"
|
|
70
45
|
const parts = classes.split(/[-_\s]+/);
|
|
71
46
|
for (const part of parts) {
|
|
72
|
-
if (iconMapping[part])
|
|
73
|
-
return part;
|
|
74
|
-
}
|
|
47
|
+
if (iconMapping[part]) return part;
|
|
75
48
|
}
|
|
76
49
|
}
|
|
77
50
|
}
|
|
78
51
|
|
|
79
|
-
// Check aria-label
|
|
80
52
|
const ariaMatch = svgTag.match(/aria-label=["']([^"']*)["']/i);
|
|
81
53
|
if (ariaMatch) {
|
|
82
54
|
const label = ariaMatch[1].toLowerCase();
|
|
83
55
|
for (const keyword of Object.keys(iconMapping)) {
|
|
84
|
-
if (label.includes(keyword))
|
|
85
|
-
return keyword;
|
|
86
|
-
}
|
|
56
|
+
if (label.includes(keyword)) return keyword;
|
|
87
57
|
}
|
|
88
58
|
}
|
|
89
59
|
|
|
90
|
-
// Check surrounding context for hints
|
|
91
60
|
const contextLower = context.toLowerCase();
|
|
92
|
-
|
|
93
|
-
// Priority keywords for Japanese business sites
|
|
94
61
|
const priorityKeywords = [
|
|
95
62
|
'mail', 'email', 'phone', 'tel', 'location', 'address',
|
|
96
63
|
'menu', 'search', 'home', 'arrow', 'chevron',
|
|
@@ -99,191 +66,78 @@ function detectIconPurpose(svgTag, context) {
|
|
|
99
66
|
'check', 'info', 'warning', 'success', 'star',
|
|
100
67
|
'sakura', 'wave', 'zen'
|
|
101
68
|
];
|
|
102
|
-
|
|
103
69
|
for (const keyword of priorityKeywords) {
|
|
104
|
-
if (contextLower.includes(keyword))
|
|
105
|
-
return keyword;
|
|
106
|
-
}
|
|
70
|
+
if (contextLower.includes(keyword)) return keyword;
|
|
107
71
|
}
|
|
108
72
|
|
|
109
|
-
// Default to decorative
|
|
110
73
|
return 'decorative';
|
|
111
74
|
}
|
|
112
75
|
|
|
113
76
|
/**
|
|
114
|
-
*
|
|
77
|
+
* Inject replacement icons into HTML file.
|
|
78
|
+
* @param {string} htmlPath
|
|
79
|
+
* @param {boolean} verbose
|
|
80
|
+
* @returns {Promise<{ success: boolean, replacedCount: number, replacements?: Array }>}
|
|
115
81
|
*/
|
|
116
|
-
function
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
// Pattern 1: Generic SVG with viewBox (likely placeholder)
|
|
120
|
-
const svgRegex = /<svg[^>]*viewBox=["'][^"']*["'][^>]*>[\s\S]*?<\/svg>/gi;
|
|
82
|
+
export async function injectIcons(htmlPath, verbose = false) {
|
|
83
|
+
const html = await fs.readFile(htmlPath, 'utf-8');
|
|
84
|
+
const elements = findSvgElements(html, detectIconPurpose);
|
|
121
85
|
|
|
122
|
-
|
|
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
|
-
}
|
|
86
|
+
if (verbose) console.log(` Found ${elements.length} SVG elements to enhance`);
|
|
199
87
|
|
|
200
88
|
if (elements.length === 0) {
|
|
201
89
|
console.log(' → No SVG icons to enhance');
|
|
202
|
-
return {
|
|
203
|
-
success: true,
|
|
204
|
-
replacedCount: 0
|
|
205
|
-
};
|
|
90
|
+
return { success: true, replacedCount: 0 };
|
|
206
91
|
}
|
|
207
92
|
|
|
208
|
-
let updatedHtml
|
|
93
|
+
let updatedHtml = html;
|
|
209
94
|
let replacedCount = 0;
|
|
210
95
|
const replacements = [];
|
|
211
96
|
|
|
212
|
-
// Process
|
|
213
|
-
const
|
|
97
|
+
// Process in reverse order to preserve character positions
|
|
98
|
+
const sorted = [...elements].sort((a, b) => b.position - a.position);
|
|
214
99
|
|
|
215
|
-
for (const element of
|
|
216
|
-
const iconName
|
|
217
|
-
const newIcon
|
|
100
|
+
for (const element of sorted) {
|
|
101
|
+
const iconName = iconMapping[element.purpose] || 'decorative-dot';
|
|
102
|
+
const newIcon = getIcon(iconName);
|
|
218
103
|
const preservedIcon = preserveAttributes(element.original, newIcon);
|
|
219
104
|
|
|
220
105
|
updatedHtml = updatedHtml.replace(element.original, preservedIcon);
|
|
221
106
|
replacedCount++;
|
|
107
|
+
replacements.push({ purpose: element.purpose, iconName });
|
|
222
108
|
|
|
223
|
-
|
|
224
|
-
purpose: element.purpose,
|
|
225
|
-
iconName: iconName
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
if (verbose) {
|
|
229
|
-
console.log(` → Replaced: ${element.purpose} → ${iconName}`);
|
|
230
|
-
}
|
|
109
|
+
if (verbose) console.log(` → Replaced: ${element.purpose} → ${iconName}`);
|
|
231
110
|
}
|
|
232
111
|
|
|
233
|
-
// Write updated HTML
|
|
234
112
|
await fs.writeFile(htmlPath, updatedHtml, 'utf-8');
|
|
235
|
-
|
|
236
113
|
console.log(` ✓ Enhanced ${replacedCount} icons with Japanese style`);
|
|
237
114
|
|
|
238
|
-
return {
|
|
239
|
-
success: true,
|
|
240
|
-
replacedCount,
|
|
241
|
-
replacements
|
|
242
|
-
};
|
|
115
|
+
return { success: true, replacedCount, replacements };
|
|
243
116
|
}
|
|
244
117
|
|
|
245
118
|
/**
|
|
246
|
-
*
|
|
119
|
+
* Ensure icon base styles are present in HTML (idempotent).
|
|
120
|
+
* @param {string} htmlPath
|
|
247
121
|
*/
|
|
248
|
-
async function ensureIconStyles(htmlPath) {
|
|
122
|
+
export async function ensureIconStyles(htmlPath) {
|
|
249
123
|
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
|
-
}
|
|
124
|
+
if (html.includes('.icon {') || html.includes('/* Icon styles */')) return;
|
|
255
125
|
|
|
256
126
|
const iconStyles = `
|
|
257
127
|
/* Japanese-style icon defaults */
|
|
258
|
-
.icon {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
}
|
|
128
|
+
.icon { width: 24px; height: 24px; flex-shrink: 0; }
|
|
129
|
+
.icon--sm { width: 16px; height: 16px; }
|
|
130
|
+
.icon--lg { width: 32px; height: 32px; }
|
|
131
|
+
.icon--decorative { opacity: 0.6; }
|
|
277
132
|
`;
|
|
278
133
|
|
|
279
|
-
// Find </style> or add before </head>
|
|
280
134
|
let updatedHtml;
|
|
281
135
|
if (html.includes('</style>')) {
|
|
282
136
|
updatedHtml = html.replace('</style>', `${iconStyles}\n</style>`);
|
|
283
137
|
} else if (html.includes('</head>')) {
|
|
284
138
|
updatedHtml = html.replace('</head>', `<style>${iconStyles}</style>\n</head>`);
|
|
285
139
|
} else {
|
|
286
|
-
return;
|
|
140
|
+
return;
|
|
287
141
|
}
|
|
288
142
|
|
|
289
143
|
await fs.writeFile(htmlPath, updatedHtml, 'utf-8');
|
|
@@ -299,13 +153,6 @@ if (process.argv[1] === new URL(import.meta.url).pathname) {
|
|
|
299
153
|
}
|
|
300
154
|
|
|
301
155
|
injectIcons(args.html, args.verbose)
|
|
302
|
-
.then(result =>
|
|
303
|
-
|
|
304
|
-
})
|
|
305
|
-
.catch(error => {
|
|
306
|
-
console.error('Error:', error.message);
|
|
307
|
-
process.exit(1);
|
|
308
|
-
});
|
|
156
|
+
.then(result => console.log(JSON.stringify(result, null, 2)))
|
|
157
|
+
.catch(error => { console.error('Error:', error.message); process.exit(1); });
|
|
309
158
|
}
|
|
310
|
-
|
|
311
|
-
export { injectIcons, findSvgElements, detectIconPurpose };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Discoverer Utilities
|
|
3
|
+
*
|
|
4
|
+
* Route normalization, deduplication, name extraction, and string helpers
|
|
5
|
+
* extracted from base-discoverer.js to keep each file under 200 lines.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Dynamic segment patterns for detecting parameterized routes
|
|
9
|
+
export const DYNAMIC_PATTERNS = [
|
|
10
|
+
/\[[\w-]+\]/, // Next.js [slug]
|
|
11
|
+
/\[\.\.\.([\w-]+)\]/, // Next.js catch-all [...slug]
|
|
12
|
+
/:[\w-]+/, // Vue/React :id
|
|
13
|
+
/\{[\w-]+\}/, // Angular {id}
|
|
14
|
+
/\*[\w-]*/ // Wildcard
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Source priority for deduplication (higher = preferred)
|
|
18
|
+
export const SOURCE_PRIORITY = {
|
|
19
|
+
'framework': 4,
|
|
20
|
+
'interception': 3,
|
|
21
|
+
'sitemap': 2,
|
|
22
|
+
'link-scrape': 1
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalize a route path:
|
|
27
|
+
* - Strips full URL to pathname
|
|
28
|
+
* - Ensures leading slash
|
|
29
|
+
* - Removes query params and hash
|
|
30
|
+
* - Removes trailing slash (except root)
|
|
31
|
+
* @param {string} rawPath
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeRoute(rawPath) {
|
|
35
|
+
if (!rawPath || typeof rawPath !== 'string') return '/';
|
|
36
|
+
|
|
37
|
+
let p = rawPath;
|
|
38
|
+
|
|
39
|
+
if (p.startsWith('http')) {
|
|
40
|
+
try {
|
|
41
|
+
p = new URL(p).pathname;
|
|
42
|
+
} catch {
|
|
43
|
+
return '/';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
48
|
+
|
|
49
|
+
p = p.split('?')[0].split('#')[0];
|
|
50
|
+
|
|
51
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
52
|
+
|
|
53
|
+
return p;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if a path contains dynamic segments
|
|
58
|
+
* @param {string} path
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isDynamicRoute(path) {
|
|
62
|
+
return DYNAMIC_PATTERNS.some(pattern => pattern.test(path));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Convert string to Title Case (handles kebab-case and snake_case)
|
|
67
|
+
* @param {string} str
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function titleCase(str) {
|
|
71
|
+
return str
|
|
72
|
+
.replace(/[-_]/g, ' ')
|
|
73
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract a human-readable page name from a route path
|
|
78
|
+
* @param {string} path - Route path
|
|
79
|
+
* @param {string} [componentName] - Optional component name hint
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function extractPageName(path, componentName) {
|
|
83
|
+
if (componentName && componentName !== 'default' && componentName !== 'index') {
|
|
84
|
+
return componentName
|
|
85
|
+
.replace(/([A-Z])/g, ' $1')
|
|
86
|
+
.replace(/^./, s => s.toUpperCase())
|
|
87
|
+
.trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const normalized = normalizeRoute(path);
|
|
91
|
+
if (normalized === '/') return 'Home';
|
|
92
|
+
|
|
93
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
94
|
+
if (segments.length === 0) return 'Home';
|
|
95
|
+
|
|
96
|
+
let lastSegment = segments[segments.length - 1];
|
|
97
|
+
|
|
98
|
+
if (isDynamicRoute(lastSegment)) {
|
|
99
|
+
lastSegment = lastSegment.replace(/[\[\]:{}*\.]/g, '');
|
|
100
|
+
return `${titleCase(lastSegment)} (Dynamic)`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return titleCase(lastSegment);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Deduplicate routes by path, preferring higher-priority sources.
|
|
108
|
+
* @param {Array} routes - Raw route objects
|
|
109
|
+
* @param {string} baseOrigin - Origin for building full URLs
|
|
110
|
+
* @returns {Array} Deduplicated routes with normalized paths and full URLs
|
|
111
|
+
*/
|
|
112
|
+
export function deduplicateRoutes(routes, baseOrigin) {
|
|
113
|
+
const seen = new Map();
|
|
114
|
+
|
|
115
|
+
for (const route of routes) {
|
|
116
|
+
const normalized = normalizeRoute(route.path);
|
|
117
|
+
const existing = seen.get(normalized);
|
|
118
|
+
|
|
119
|
+
const currentPriority = SOURCE_PRIORITY[route.source] || 0;
|
|
120
|
+
const existingPriority = existing ? (SOURCE_PRIORITY[existing.source] || 0) : -1;
|
|
121
|
+
|
|
122
|
+
const shouldReplace = !existing ||
|
|
123
|
+
currentPriority > existingPriority ||
|
|
124
|
+
(currentPriority === existingPriority && route.name && !existing.name);
|
|
125
|
+
|
|
126
|
+
if (shouldReplace) {
|
|
127
|
+
seen.set(normalized, {
|
|
128
|
+
...route,
|
|
129
|
+
path: normalized,
|
|
130
|
+
url: `${baseOrigin}${normalized}`,
|
|
131
|
+
dynamic: isDynamicRoute(normalized)
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Array.from(seen.values());
|
|
137
|
+
}
|