oh-my-design-cli 1.6.1 → 1.6.3
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.ko.md +14 -0
- package/README.md +16 -0
- package/data/reference-fingerprints.json +979 -402
- package/dist/bin/oh-my-design.js +5 -3
- package/dist/bin/oh-my-design.js.map +1 -1
- package/dist/{install-skills-UKEVE3KT.js → install-skills-52LCRBZZ.js} +125 -40
- package/dist/install-skills-52LCRBZZ.js.map +1 -0
- package/package.json +2 -1
- package/skills/claude-design/SKILL.md +385 -0
- package/skills/claude-design/references/claude-design-flow.md +425 -0
- package/skills/claude-design/references/codebase-analysis.md +373 -0
- package/skills/claude-design/scripts/analyze_codebase.py +1369 -0
- package/skills/claude-design/scripts/clickable_link.sh +48 -0
- package/skills/claude-design/scripts/collect_source.py +178 -0
- package/skills/claude-design/scripts/drive_claude_design.cjs +378 -0
- package/skills/claude-design/scripts/gather_references.py +437 -0
- package/web/references/bunjang/DESIGN.md +1 -1
- package/web/references/classting/DESIGN.md +251 -0
- package/web/references/coinone/DESIGN.md +218 -0
- package/web/references/devsisters/DESIGN.md +253 -0
- package/web/references/drnow/DESIGN.md +331 -0
- package/web/references/flo/DESIGN.md +306 -0
- package/web/references/fugle/DESIGN.md +250 -0
- package/web/references/gogolook/DESIGN.md +5 -0
- package/web/references/grip/DESIGN.md +250 -0
- package/web/references/hogangnono/DESIGN.md +308 -0
- package/web/references/hyundaicard/DESIGN.md +5 -0
- package/web/references/jkopay/DESIGN.md +249 -0
- package/web/references/jobkorea/DESIGN.md +310 -0
- package/web/references/krafton/DESIGN.md +230 -0
- package/web/references/laftel/DESIGN.md +253 -0
- package/web/references/lezhin/DESIGN.md +301 -0
- package/web/references/momoshop/DESIGN.md +279 -0
- package/web/references/mustit/DESIGN.md +282 -0
- package/web/references/payco/DESIGN.md +227 -0
- package/web/references/piccollage/DESIGN.md +277 -0
- package/web/references/riiid/DESIGN.md +228 -0
- package/web/references/trenbe/DESIGN.md +252 -0
- package/web/references/voicetube/DESIGN.md +227 -0
- package/dist/install-skills-UKEVE3KT.js.map +0 -1
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Analyze a code project and synthesize a Claude Design "design context brief".
|
|
3
|
+
|
|
4
|
+
This is the codebase->Claude Design context bridge for the `claude-design` skill (v2).
|
|
5
|
+
It turns a project directory into a paste-ready markdown brief (stack, design tokens,
|
|
6
|
+
routes, components, real UI copy, curated visual assets, git repo) so Claude Design can
|
|
7
|
+
faithfully reference the actual code without ever running it.
|
|
8
|
+
|
|
9
|
+
DESIGN PRINCIPLES (hard requirements):
|
|
10
|
+
- stdlib only (argparse, os, sys, json, re, subprocess, datetime).
|
|
11
|
+
- NEVER eval/import/exec project code. All parsing is regex/string based.
|
|
12
|
+
- NEVER crash. Every file read, stat, regex, and subprocess call is defensively
|
|
13
|
+
guarded; on any error the relevant section is simply skipped or left empty.
|
|
14
|
+
- Bounded walks that prune node_modules/.git/dist/build/.next/out/.venv/coverage etc.
|
|
15
|
+
- Works on a Next.js+Tailwind repo AND on a non-JS (python/rust/go/unknown) repo.
|
|
16
|
+
|
|
17
|
+
CLI (per the skill FILE CONTRACT):
|
|
18
|
+
python3 analyze_codebase.py [--root DIR] [--level lean|comprehensive]
|
|
19
|
+
[--json] [--out FILE] [--max-components N] [--max-assets N]
|
|
20
|
+
[--max-copy N] [--include-docs]
|
|
21
|
+
|
|
22
|
+
Defaults: root=cwd, level=comprehensive, max-components=40, max-assets=10,
|
|
23
|
+
max-copy=60.
|
|
24
|
+
|
|
25
|
+
OUTPUT:
|
|
26
|
+
- Default: a clean markdown brief to stdout (or to --out FILE).
|
|
27
|
+
* comprehensive: Stack, Design system, Routes, Components, UI Copy, Assets, Repo.
|
|
28
|
+
* lean: Stack, Design system, Assets, Repo only (skips routes/components/copy).
|
|
29
|
+
- With --json: a machine object
|
|
30
|
+
{project, root, level, stack{}, tokens{palette[],fonts{},radius[]},
|
|
31
|
+
routes[], landing_route, components[], copy[], assets[ABS paths], repo{}}
|
|
32
|
+
PLUS a "brief_markdown" string (the same brief rendered as markdown).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Constants / defaults (mirrored in the SKILL.md file contract)
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
DEFAULT_MAX_COMPONENTS = 40
|
|
48
|
+
DEFAULT_MAX_ASSETS = 10
|
|
49
|
+
DEFAULT_MAX_COPY = 60
|
|
50
|
+
DEFAULT_MAX_TOKENS = 40 # cap on palette + design tokens reported
|
|
51
|
+
|
|
52
|
+
# Directories pruned from every bounded walk.
|
|
53
|
+
EXCLUDED_DIRS = {
|
|
54
|
+
"node_modules", ".git", "dist", "build", ".next", ".nuxt", "out",
|
|
55
|
+
"venv", ".venv", "__pycache__", ".cache", "coverage", "target",
|
|
56
|
+
".turbo", ".svelte-kit", ".vercel", ".output", ".parcel-cache",
|
|
57
|
+
"Pods", ".gradle", ".idea", ".vscode", ".open-next", ".wrangler",
|
|
58
|
+
"playwright-report", "test-results", ".pytest_cache", ".mypy_cache",
|
|
59
|
+
"vendor", "tmp", "temp",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Visual asset extensions (lowercase, with dot).
|
|
63
|
+
VISUAL_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".svg", ".avif", ".gif"}
|
|
64
|
+
|
|
65
|
+
# Document extensions excluded unless --include-docs is given.
|
|
66
|
+
DOC_EXTS = {
|
|
67
|
+
".pdf", ".hwp", ".hwpx", ".doc", ".docx", ".ppt", ".pptx",
|
|
68
|
+
".xls", ".xlsx",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Source-code extensions worth scanning for copy.
|
|
72
|
+
CODE_EXTS = {".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte", ".astro", ".mdx"}
|
|
73
|
+
|
|
74
|
+
# Brand-likelihood filename hints (regex, case-insensitive). Higher = more brand-like.
|
|
75
|
+
BRAND_HINTS = [
|
|
76
|
+
(re.compile(r"logo", re.I), 100),
|
|
77
|
+
(re.compile(r"wordmark", re.I), 95),
|
|
78
|
+
(re.compile(r"symbol", re.I), 90),
|
|
79
|
+
(re.compile(r"brand", re.I), 88),
|
|
80
|
+
(re.compile(r"app[-_]?icon", re.I), 86),
|
|
81
|
+
(re.compile(r"favicon", re.I), 84),
|
|
82
|
+
(re.compile(r"\bicon\b|(?<![a-z])icon", re.I), 70),
|
|
83
|
+
(re.compile(r"\bog\b|og[-_]?image|opengraph", re.I), 78),
|
|
84
|
+
(re.compile(r"hero", re.I), 66),
|
|
85
|
+
(re.compile(r"cover", re.I), 60),
|
|
86
|
+
(re.compile(r"banner", re.I), 50),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
# Directories that strongly suggest landing/marketing relevance (flagged in output).
|
|
90
|
+
LANDING_DIR_HINTS = re.compile(
|
|
91
|
+
r"(?:^|[/\\_-])(home|landing|marketing|hero|sections?|layout)(?:[/\\_-]|$)",
|
|
92
|
+
re.I,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Walk safety caps so we never run away on a pathological tree.
|
|
96
|
+
MAX_WALK_FILES = 60000
|
|
97
|
+
MAX_WALK_DIRS = 20000
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Low-level safe helpers
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _read_text(path, limit_bytes=2_000_000):
|
|
105
|
+
"""Read a text file defensively. Returns '' on any error. Bounded size."""
|
|
106
|
+
try:
|
|
107
|
+
if not os.path.isfile(path):
|
|
108
|
+
return ""
|
|
109
|
+
if os.path.getsize(path) > limit_bytes:
|
|
110
|
+
limit = limit_bytes
|
|
111
|
+
else:
|
|
112
|
+
limit = None
|
|
113
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
114
|
+
return fh.read(limit) if limit is not None else fh.read()
|
|
115
|
+
except (OSError, ValueError, UnicodeError):
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _ext_of(path):
|
|
120
|
+
try:
|
|
121
|
+
return os.path.splitext(path)[1].lower()
|
|
122
|
+
except Exception:
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _basename(path):
|
|
127
|
+
try:
|
|
128
|
+
return os.path.basename(path.rstrip("/\\"))
|
|
129
|
+
except Exception:
|
|
130
|
+
return str(path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _mtime_iso(epoch):
|
|
134
|
+
try:
|
|
135
|
+
return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat()
|
|
136
|
+
except (OverflowError, OSError, ValueError, TypeError):
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _abspath(path):
|
|
141
|
+
try:
|
|
142
|
+
return os.path.abspath(path)
|
|
143
|
+
except (OSError, ValueError):
|
|
144
|
+
return str(path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _safe_stat(path):
|
|
148
|
+
try:
|
|
149
|
+
return os.stat(path)
|
|
150
|
+
except (OSError, ValueError):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _dedup(seq):
|
|
155
|
+
"""Order-preserving dedup of a sequence of hashable items."""
|
|
156
|
+
seen = set()
|
|
157
|
+
out = []
|
|
158
|
+
for item in seq:
|
|
159
|
+
if item in seen:
|
|
160
|
+
continue
|
|
161
|
+
seen.add(item)
|
|
162
|
+
out.append(item)
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def walk_files(root, want_ext=None, prune=EXCLUDED_DIRS, max_depth=None):
|
|
167
|
+
"""Bounded, prune-in-place walk yielding absolute file paths.
|
|
168
|
+
|
|
169
|
+
- want_ext: optional set of lowercase extensions to keep (None = all).
|
|
170
|
+
- prune: directory basenames to skip entirely.
|
|
171
|
+
- max_depth: optional max depth relative to root (None = unlimited within caps).
|
|
172
|
+
Never raises; swallows per-entry errors. Honors global file/dir caps.
|
|
173
|
+
"""
|
|
174
|
+
results = []
|
|
175
|
+
abs_root = _abspath(root)
|
|
176
|
+
if not os.path.isdir(abs_root):
|
|
177
|
+
return results
|
|
178
|
+
root_depth = abs_root.rstrip(os.sep).count(os.sep)
|
|
179
|
+
dir_count = 0
|
|
180
|
+
file_count = 0
|
|
181
|
+
try:
|
|
182
|
+
walker = os.walk(abs_root, topdown=True, onerror=lambda e: None)
|
|
183
|
+
for current, dirnames, filenames in walker:
|
|
184
|
+
dir_count += 1
|
|
185
|
+
if dir_count > MAX_WALK_DIRS:
|
|
186
|
+
break
|
|
187
|
+
# Depth control.
|
|
188
|
+
try:
|
|
189
|
+
depth = current.rstrip(os.sep).count(os.sep) - root_depth
|
|
190
|
+
except Exception:
|
|
191
|
+
depth = 0
|
|
192
|
+
if max_depth is not None and depth >= max_depth:
|
|
193
|
+
dirnames[:] = []
|
|
194
|
+
else:
|
|
195
|
+
dirnames[:] = [
|
|
196
|
+
d for d in dirnames
|
|
197
|
+
if d not in prune and not d.startswith(".git")
|
|
198
|
+
]
|
|
199
|
+
for fname in filenames:
|
|
200
|
+
if want_ext is not None and _ext_of(fname) not in want_ext:
|
|
201
|
+
continue
|
|
202
|
+
file_count += 1
|
|
203
|
+
if file_count > MAX_WALK_FILES:
|
|
204
|
+
return results
|
|
205
|
+
results.append(os.path.join(current, fname))
|
|
206
|
+
except (OSError, ValueError):
|
|
207
|
+
pass
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _first_existing(root, candidates):
|
|
212
|
+
"""Return the first candidate (relative to root) that exists, else None."""
|
|
213
|
+
for rel in candidates:
|
|
214
|
+
path = os.path.join(root, rel)
|
|
215
|
+
if os.path.exists(path):
|
|
216
|
+
return path
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# 1. STACK detection
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def _parse_package_json(root):
|
|
225
|
+
"""Return (deps_dict, raw_text) from package.json, or ({}, '')."""
|
|
226
|
+
path = os.path.join(root, "package.json")
|
|
227
|
+
raw = _read_text(path)
|
|
228
|
+
if not raw:
|
|
229
|
+
return {}, ""
|
|
230
|
+
try:
|
|
231
|
+
data = json.loads(raw)
|
|
232
|
+
except (ValueError, TypeError):
|
|
233
|
+
return {}, raw
|
|
234
|
+
if not isinstance(data, dict):
|
|
235
|
+
return {}, raw
|
|
236
|
+
deps = {}
|
|
237
|
+
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
238
|
+
section = data.get(key)
|
|
239
|
+
if isinstance(section, dict):
|
|
240
|
+
for name, ver in section.items():
|
|
241
|
+
if name not in deps and isinstance(name, str):
|
|
242
|
+
deps[name] = ver if isinstance(ver, str) else ""
|
|
243
|
+
return deps, raw
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _dep_version(deps, name):
|
|
247
|
+
ver = deps.get(name, "")
|
|
248
|
+
if not isinstance(ver, str):
|
|
249
|
+
return ""
|
|
250
|
+
m = re.search(r"(\d+(?:\.\d+){0,2})", ver)
|
|
251
|
+
return m.group(1) if m else ver.strip()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _named(label, ver):
|
|
255
|
+
"""Join a framework label with an optional version: 'Next.js 16' / 'Remix'."""
|
|
256
|
+
return label + (" " + ver if ver else "")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def detect_stack(root):
|
|
260
|
+
"""Best-effort framework/UI-stack detection. Never raises."""
|
|
261
|
+
stack = {
|
|
262
|
+
"frameworks": [],
|
|
263
|
+
"ui_libraries": [],
|
|
264
|
+
"styling": [],
|
|
265
|
+
"language": [],
|
|
266
|
+
"router": "",
|
|
267
|
+
"package_manager": "",
|
|
268
|
+
"notes": [],
|
|
269
|
+
"name": "",
|
|
270
|
+
}
|
|
271
|
+
deps, raw = _parse_package_json(root)
|
|
272
|
+
|
|
273
|
+
# --- package.json based (web stacks) ---
|
|
274
|
+
if deps or raw:
|
|
275
|
+
pkg = {}
|
|
276
|
+
parse_ok = False
|
|
277
|
+
if raw:
|
|
278
|
+
try:
|
|
279
|
+
pkg = json.loads(raw)
|
|
280
|
+
parse_ok = isinstance(pkg, dict)
|
|
281
|
+
except (ValueError, TypeError):
|
|
282
|
+
parse_ok = False
|
|
283
|
+
if not isinstance(pkg, dict):
|
|
284
|
+
pkg = {}
|
|
285
|
+
# package.json present but unparseable (or not an object) -> note it so the
|
|
286
|
+
# brief reflects reality instead of silently claiming a bare JS stack.
|
|
287
|
+
if raw and not parse_ok:
|
|
288
|
+
stack["notes"].append("package.json present but could not be parsed")
|
|
289
|
+
if isinstance(pkg.get("name"), str):
|
|
290
|
+
stack["name"] = pkg["name"]
|
|
291
|
+
|
|
292
|
+
has = lambda n: n in deps # noqa: E731
|
|
293
|
+
|
|
294
|
+
# Next.js
|
|
295
|
+
if has("next"):
|
|
296
|
+
stack["frameworks"].append(_named("Next.js", _dep_version(deps, "next")))
|
|
297
|
+
# app vs pages router by directory presence.
|
|
298
|
+
app_dir = _first_existing(root, ["src/app", "app"])
|
|
299
|
+
pages_dir = _first_existing(root, ["src/pages", "pages"])
|
|
300
|
+
if app_dir and not pages_dir:
|
|
301
|
+
stack["router"] = "app"
|
|
302
|
+
elif pages_dir and not app_dir:
|
|
303
|
+
stack["router"] = "pages"
|
|
304
|
+
elif app_dir and pages_dir:
|
|
305
|
+
stack["router"] = "app+pages"
|
|
306
|
+
else:
|
|
307
|
+
stack["router"] = "unknown"
|
|
308
|
+
# React (only standalone-note if no meta-framework already claimed it)
|
|
309
|
+
if has("react"):
|
|
310
|
+
stack["frameworks"].append(_named("React", _dep_version(deps, "react")))
|
|
311
|
+
if has("vue"):
|
|
312
|
+
stack["frameworks"].append(_named("Vue", _dep_version(deps, "vue")))
|
|
313
|
+
if has("svelte") or has("@sveltejs/kit"):
|
|
314
|
+
stack["frameworks"].append(_named("Svelte", _dep_version(deps, "svelte")))
|
|
315
|
+
if has("astro"):
|
|
316
|
+
stack["frameworks"].append(_named("Astro", _dep_version(deps, "astro")))
|
|
317
|
+
if has("vite"):
|
|
318
|
+
stack["frameworks"].append(_named("Vite", _dep_version(deps, "vite")))
|
|
319
|
+
if has("@remix-run/react") or has("@remix-run/node"):
|
|
320
|
+
stack["frameworks"].append("Remix")
|
|
321
|
+
if has("nuxt"):
|
|
322
|
+
stack["frameworks"].append(_named("Nuxt", _dep_version(deps, "nuxt")))
|
|
323
|
+
if has("gatsby"):
|
|
324
|
+
stack["frameworks"].append("Gatsby")
|
|
325
|
+
|
|
326
|
+
# Styling
|
|
327
|
+
if has("tailwindcss"):
|
|
328
|
+
stack["styling"].append(_named("Tailwind CSS", _dep_version(deps, "tailwindcss")))
|
|
329
|
+
if has("styled-components"):
|
|
330
|
+
stack["styling"].append("styled-components")
|
|
331
|
+
if has("@emotion/react") or has("@emotion/styled"):
|
|
332
|
+
stack["styling"].append("Emotion")
|
|
333
|
+
if has("sass") or has("node-sass"):
|
|
334
|
+
stack["styling"].append("Sass")
|
|
335
|
+
|
|
336
|
+
# UI component libraries
|
|
337
|
+
if has("@mui/material") or has("@material-ui/core"):
|
|
338
|
+
stack["ui_libraries"].append("MUI")
|
|
339
|
+
if has("@chakra-ui/react"):
|
|
340
|
+
stack["ui_libraries"].append("Chakra UI")
|
|
341
|
+
if has("antd"):
|
|
342
|
+
stack["ui_libraries"].append("Ant Design")
|
|
343
|
+
if has("@mantine/core"):
|
|
344
|
+
stack["ui_libraries"].append("Mantine")
|
|
345
|
+
if has("@radix-ui/react-dialog") or any(d.startswith("@radix-ui/") for d in deps):
|
|
346
|
+
stack["ui_libraries"].append("Radix UI")
|
|
347
|
+
if has("shadcn-ui") or has("shadcn") or os.path.exists(os.path.join(root, "components.json")):
|
|
348
|
+
stack["ui_libraries"].append("shadcn/ui")
|
|
349
|
+
if has("@headlessui/react"):
|
|
350
|
+
stack["ui_libraries"].append("Headless UI")
|
|
351
|
+
if has("framer-motion") or has("motion"):
|
|
352
|
+
stack["ui_libraries"].append("Framer Motion")
|
|
353
|
+
|
|
354
|
+
# Language
|
|
355
|
+
if has("typescript") or _first_existing(root, ["tsconfig.json"]):
|
|
356
|
+
stack["language"].append("TypeScript")
|
|
357
|
+
else:
|
|
358
|
+
stack["language"].append("JavaScript")
|
|
359
|
+
|
|
360
|
+
# Package manager
|
|
361
|
+
if _first_existing(root, ["pnpm-lock.yaml"]):
|
|
362
|
+
stack["package_manager"] = "pnpm"
|
|
363
|
+
elif _first_existing(root, ["yarn.lock"]):
|
|
364
|
+
stack["package_manager"] = "yarn"
|
|
365
|
+
elif _first_existing(root, ["bun.lockb"]):
|
|
366
|
+
stack["package_manager"] = "bun"
|
|
367
|
+
elif _first_existing(root, ["package-lock.json"]):
|
|
368
|
+
stack["package_manager"] = "npm"
|
|
369
|
+
|
|
370
|
+
# --- non-JS / unknown-stack fallbacks ---
|
|
371
|
+
if not deps and not raw:
|
|
372
|
+
if _first_existing(root, ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"]):
|
|
373
|
+
stack["frameworks"].append("Python")
|
|
374
|
+
stack["language"].append("Python")
|
|
375
|
+
stack["notes"].append("non-web or unknown UI stack")
|
|
376
|
+
if _first_existing(root, ["Cargo.toml"]):
|
|
377
|
+
stack["frameworks"].append("Rust (Cargo)")
|
|
378
|
+
stack["language"].append("Rust")
|
|
379
|
+
stack["notes"].append("non-web or unknown UI stack")
|
|
380
|
+
if _first_existing(root, ["go.mod"]):
|
|
381
|
+
stack["frameworks"].append("Go")
|
|
382
|
+
stack["language"].append("Go")
|
|
383
|
+
stack["notes"].append("non-web or unknown UI stack")
|
|
384
|
+
if _first_existing(root, ["Gemfile"]):
|
|
385
|
+
stack["frameworks"].append("Ruby")
|
|
386
|
+
stack["language"].append("Ruby")
|
|
387
|
+
stack["notes"].append("non-web or unknown UI stack")
|
|
388
|
+
if _first_existing(root, ["pom.xml", "build.gradle", "build.gradle.kts"]):
|
|
389
|
+
stack["frameworks"].append("JVM (Maven/Gradle)")
|
|
390
|
+
stack["notes"].append("non-web or unknown UI stack")
|
|
391
|
+
if not stack["frameworks"]:
|
|
392
|
+
stack["notes"].append("no package.json found; non-web or unknown UI stack")
|
|
393
|
+
|
|
394
|
+
# Dedup lists.
|
|
395
|
+
for key in ("frameworks", "ui_libraries", "styling", "language", "notes"):
|
|
396
|
+
stack[key] = _dedup(stack[key])
|
|
397
|
+
return stack
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# 2. DESIGN TOKENS
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
_COLOR_VALUE_RE = re.compile(
|
|
405
|
+
r"(#[0-9a-fA-F]{3,8}\b"
|
|
406
|
+
r"|rgba?\([^)]*\)"
|
|
407
|
+
r"|hsla?\([^)]*\)"
|
|
408
|
+
r"|oklch\([^)]*\)"
|
|
409
|
+
r"|oklab\([^)]*\)"
|
|
410
|
+
r"|lab\([^)]*\)"
|
|
411
|
+
r"|lch\([^)]*\))"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _looks_like_color(value):
|
|
416
|
+
if not value:
|
|
417
|
+
return False
|
|
418
|
+
return bool(_COLOR_VALUE_RE.search(value))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _extract_tailwind_config_tokens(root, palette, fonts, radii):
|
|
422
|
+
"""Regex-scan tailwind.config.{js,ts,cjs,mjs} for theme.extend literals."""
|
|
423
|
+
cfg = _first_existing(root, [
|
|
424
|
+
"tailwind.config.js", "tailwind.config.ts",
|
|
425
|
+
"tailwind.config.cjs", "tailwind.config.mjs",
|
|
426
|
+
])
|
|
427
|
+
if not cfg:
|
|
428
|
+
return
|
|
429
|
+
text = _read_text(cfg)
|
|
430
|
+
if not text:
|
|
431
|
+
return
|
|
432
|
+
# colors: capture "name": "#hex" / 'name': 'rgb(...)' pairs anywhere in file.
|
|
433
|
+
for m in re.finditer(
|
|
434
|
+
r"""['"]?([A-Za-z0-9_-]+)['"]?\s*:\s*['"]([^'"]+)['"]""", text
|
|
435
|
+
):
|
|
436
|
+
name, value = m.group(1), m.group(2)
|
|
437
|
+
if _looks_like_color(value) and len(palette) < DEFAULT_MAX_TOKENS:
|
|
438
|
+
palette.append((name, value.strip()))
|
|
439
|
+
# fontFamily: extract "sans"/"display"/"mono" families.
|
|
440
|
+
ff = re.search(r"fontFamily\s*:\s*{(.*?)}", text, re.S)
|
|
441
|
+
if ff:
|
|
442
|
+
block = ff.group(1)
|
|
443
|
+
for m in re.finditer(
|
|
444
|
+
r"""['"]?(sans|serif|mono|display|heading|body)['"]?\s*:\s*\[([^\]]*)\]""",
|
|
445
|
+
block, re.I,
|
|
446
|
+
):
|
|
447
|
+
role = m.group(1).lower()
|
|
448
|
+
fam = re.findall(r"""['"]([^'"]+)['"]""", m.group(2))
|
|
449
|
+
if fam:
|
|
450
|
+
fonts.setdefault(role, fam[0])
|
|
451
|
+
# borderRadius literals: capture entries inside a borderRadius: { ... } block,
|
|
452
|
+
# plus any token whose name itself mentions radius/rounded.
|
|
453
|
+
br = re.search(r"borderRadius\s*:\s*{(.*?)}", text, re.S)
|
|
454
|
+
if br:
|
|
455
|
+
for m in re.finditer(
|
|
456
|
+
r"""['"]?([A-Za-z0-9_-]+)['"]?\s*:\s*['"]([^'"]+)['"]""", br.group(1)
|
|
457
|
+
):
|
|
458
|
+
radii.append(("radius-" + m.group(1), m.group(2)))
|
|
459
|
+
for m in re.finditer(
|
|
460
|
+
r"""['"]?([A-Za-z0-9_-]+)['"]?\s*:\s*['"]([0-9.]+(?:px|rem|em)?)['"]""",
|
|
461
|
+
text,
|
|
462
|
+
):
|
|
463
|
+
name, value = m.group(1), m.group(2)
|
|
464
|
+
if "radius" in name.lower() or "rounded" in name.lower():
|
|
465
|
+
radii.append((name, value))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _stylesheet_candidates(root):
|
|
469
|
+
"""Find main stylesheet(s) to scan for CSS custom properties / @theme."""
|
|
470
|
+
cands = []
|
|
471
|
+
explicit = [
|
|
472
|
+
"src/app/globals.css", "app/globals.css", "src/index.css",
|
|
473
|
+
"src/styles/globals.css", "styles/globals.css", "src/App.css",
|
|
474
|
+
"src/global.css", "app/global.css", "src/main.css", "styles/index.css",
|
|
475
|
+
]
|
|
476
|
+
for rel in explicit:
|
|
477
|
+
p = os.path.join(root, rel)
|
|
478
|
+
if os.path.isfile(p):
|
|
479
|
+
cands.append(p)
|
|
480
|
+
# Plus any *.css under styles/ or src/styles/ (bounded), capped.
|
|
481
|
+
for base in ("styles", "src/styles", "src/css"):
|
|
482
|
+
d = os.path.join(root, base)
|
|
483
|
+
if os.path.isdir(d):
|
|
484
|
+
for css in walk_files(d, want_ext={".css"}, max_depth=3):
|
|
485
|
+
cands.append(css)
|
|
486
|
+
return _dedup(cands)[:8]
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _extract_css_tokens(root, palette, fonts, radii):
|
|
490
|
+
"""Scan main stylesheet(s) for @theme/:root custom properties, @font-face, fonts."""
|
|
491
|
+
for css_path in _stylesheet_candidates(root):
|
|
492
|
+
text = _read_text(css_path)
|
|
493
|
+
if not text:
|
|
494
|
+
continue
|
|
495
|
+
# CSS custom properties: --name: value;
|
|
496
|
+
for m in re.finditer(r"--([A-Za-z0-9_-]+)\s*:\s*([^;]+);", text):
|
|
497
|
+
name = "--" + m.group(1)
|
|
498
|
+
value = m.group(2).strip()
|
|
499
|
+
low = m.group(1).lower()
|
|
500
|
+
# Skip var(...) indirection-only values for the palette (no literal color),
|
|
501
|
+
# but still capture them as token aliases when they are color tokens.
|
|
502
|
+
if low.startswith("color") or _looks_like_color(value):
|
|
503
|
+
if len(palette) < DEFAULT_MAX_TOKENS:
|
|
504
|
+
palette.append((name, value))
|
|
505
|
+
if low.startswith("font") and "family" not in low and value:
|
|
506
|
+
# --font-sans / --font-display / --font-mono
|
|
507
|
+
role = low.replace("font-", "").replace("font", "") or "sans"
|
|
508
|
+
fam = _first_font_family(value)
|
|
509
|
+
if fam:
|
|
510
|
+
fonts.setdefault(role, fam)
|
|
511
|
+
if "radius" in low or "rounded" in low:
|
|
512
|
+
radii.append((name, value))
|
|
513
|
+
# @font-face family names.
|
|
514
|
+
for m in re.finditer(
|
|
515
|
+
r"@font-face\s*{[^}]*?font-family\s*:\s*['\"]?([^;'\"}]+)['\"]?", text, re.S
|
|
516
|
+
):
|
|
517
|
+
fam = m.group(1).strip()
|
|
518
|
+
if fam:
|
|
519
|
+
fonts.setdefault("face:" + fam, fam)
|
|
520
|
+
# Bare font-family declarations (outside @font-face).
|
|
521
|
+
for m in re.finditer(r"font-family\s*:\s*([^;{}]+);", text):
|
|
522
|
+
fam = _first_font_family(m.group(1))
|
|
523
|
+
if fam and "body" not in fonts:
|
|
524
|
+
fonts.setdefault("body", fam)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _first_font_family(value):
|
|
528
|
+
"""Pull the first concrete font-family token from a font-family value string."""
|
|
529
|
+
if not value:
|
|
530
|
+
return ""
|
|
531
|
+
value = value.strip()
|
|
532
|
+
# Skip pure var() references with no literal family.
|
|
533
|
+
parts = [p.strip() for p in value.split(",") if p.strip()]
|
|
534
|
+
for part in parts:
|
|
535
|
+
cleaned = part.strip("'\"").strip()
|
|
536
|
+
if not cleaned:
|
|
537
|
+
continue
|
|
538
|
+
if cleaned.lower().startswith("var("):
|
|
539
|
+
continue
|
|
540
|
+
# Skip generic-only first tokens like "ui-monospace" if a named one follows;
|
|
541
|
+
# but it's fine to return it as a representative family.
|
|
542
|
+
return cleaned
|
|
543
|
+
return ""
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def extract_tokens(root):
|
|
547
|
+
"""Aggregate design tokens from Tailwind config and CSS. Never raises."""
|
|
548
|
+
palette = [] # list of (name, value)
|
|
549
|
+
fonts = {} # role -> family
|
|
550
|
+
radii = [] # list of (name, value)
|
|
551
|
+
try:
|
|
552
|
+
_extract_tailwind_config_tokens(root, palette, fonts, radii)
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
try:
|
|
556
|
+
_extract_css_tokens(root, palette, fonts, radii)
|
|
557
|
+
except Exception:
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
# Dedup palette by name (keep first), then rank LITERAL color values
|
|
561
|
+
# (hex/rgb/hsl/oklch) ahead of `var(--...)` alias-only tokens so the most
|
|
562
|
+
# useful concrete colors survive the cap. Stable within each group.
|
|
563
|
+
seen = set()
|
|
564
|
+
deduped = []
|
|
565
|
+
for name, value in palette:
|
|
566
|
+
key = name.lower()
|
|
567
|
+
if key in seen:
|
|
568
|
+
continue
|
|
569
|
+
seen.add(key)
|
|
570
|
+
deduped.append((name, value))
|
|
571
|
+
literals = [(n, v) for (n, v) in deduped if _looks_like_color(v)]
|
|
572
|
+
aliases = [(n, v) for (n, v) in deduped if not _looks_like_color(v)]
|
|
573
|
+
pal_out = []
|
|
574
|
+
for name, value in literals + aliases:
|
|
575
|
+
pal_out.append({"name": name, "value": value})
|
|
576
|
+
if len(pal_out) >= DEFAULT_MAX_TOKENS:
|
|
577
|
+
break
|
|
578
|
+
|
|
579
|
+
# Dedup radii by name.
|
|
580
|
+
rseen = set()
|
|
581
|
+
rad_out = []
|
|
582
|
+
for name, value in radii:
|
|
583
|
+
key = name.lower()
|
|
584
|
+
if key in rseen:
|
|
585
|
+
continue
|
|
586
|
+
rseen.add(key)
|
|
587
|
+
rad_out.append({"name": name, "value": value})
|
|
588
|
+
if len(rad_out) >= 12:
|
|
589
|
+
break
|
|
590
|
+
|
|
591
|
+
# Normalize fonts: prefer sans/display/mono primary roles, keep face: entries too.
|
|
592
|
+
# Avoid listing the same family twice across roles (e.g. a bare font-family
|
|
593
|
+
# declaration that duplicates the display face).
|
|
594
|
+
fonts_out = {}
|
|
595
|
+
used_families = set()
|
|
596
|
+
for pref in ("sans", "display", "mono", "serif", "heading", "body"):
|
|
597
|
+
fam = fonts.get(pref)
|
|
598
|
+
if fam and fam not in used_families:
|
|
599
|
+
fonts_out[pref] = fam
|
|
600
|
+
used_families.add(fam)
|
|
601
|
+
for role, fam in fonts.items():
|
|
602
|
+
if role.startswith("face:") and fam and fam not in used_families:
|
|
603
|
+
fonts_out[role] = fam
|
|
604
|
+
used_families.add(fam)
|
|
605
|
+
return {"palette": pal_out, "fonts": fonts_out, "radius": rad_out}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# ---------------------------------------------------------------------------
|
|
609
|
+
# 3. ROUTES
|
|
610
|
+
# ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
def extract_routes(root):
|
|
613
|
+
"""Detect routes. Returns (routes[], landing_route). Never raises."""
|
|
614
|
+
routes = []
|
|
615
|
+
landing = ""
|
|
616
|
+
|
|
617
|
+
# Next.js app router: **/app/**/page.{tsx,jsx,ts,js}
|
|
618
|
+
app_base = _first_existing(root, ["src/app", "app"])
|
|
619
|
+
if app_base and os.path.isdir(app_base):
|
|
620
|
+
page_files = []
|
|
621
|
+
for ext in (".tsx", ".jsx", ".ts", ".js"):
|
|
622
|
+
page_files.extend(walk_files(app_base, want_ext={ext}, max_depth=8))
|
|
623
|
+
for pf in page_files:
|
|
624
|
+
if _basename(pf).rsplit(".", 1)[0] != "page":
|
|
625
|
+
continue
|
|
626
|
+
rel = os.path.relpath(os.path.dirname(pf), app_base)
|
|
627
|
+
url = _app_dir_to_url(rel)
|
|
628
|
+
routes.append(url)
|
|
629
|
+
routes = _dedup(routes)
|
|
630
|
+
routes.sort(key=lambda r: (r != "/", r))
|
|
631
|
+
if "/" in routes:
|
|
632
|
+
landing = "/"
|
|
633
|
+
|
|
634
|
+
# Next.js / Nuxt pages router: **/pages/**
|
|
635
|
+
if not routes:
|
|
636
|
+
pages_base = _first_existing(root, ["src/pages", "pages"])
|
|
637
|
+
if pages_base and os.path.isdir(pages_base):
|
|
638
|
+
page_files = []
|
|
639
|
+
for ext in (".tsx", ".jsx", ".ts", ".js", ".vue"):
|
|
640
|
+
page_files.extend(walk_files(pages_base, want_ext={ext}, max_depth=8))
|
|
641
|
+
for pf in page_files:
|
|
642
|
+
name = _basename(pf)
|
|
643
|
+
if name.startswith("_"): # _app, _document
|
|
644
|
+
continue
|
|
645
|
+
if name.startswith("api") or "/api/" in pf.replace("\\", "/"):
|
|
646
|
+
continue
|
|
647
|
+
rel = os.path.relpath(pf, pages_base)
|
|
648
|
+
url = _pages_file_to_url(rel)
|
|
649
|
+
routes.append(url)
|
|
650
|
+
routes = _dedup(routes)
|
|
651
|
+
routes.sort(key=lambda r: (r != "/", r))
|
|
652
|
+
if "/" in routes:
|
|
653
|
+
landing = "/"
|
|
654
|
+
|
|
655
|
+
# SvelteKit: src/routes/**/+page.svelte
|
|
656
|
+
if not routes:
|
|
657
|
+
sk = os.path.join(root, "src", "routes")
|
|
658
|
+
if os.path.isdir(sk):
|
|
659
|
+
for pf in walk_files(sk, want_ext={".svelte"}, max_depth=8):
|
|
660
|
+
if _basename(pf) not in ("+page.svelte",):
|
|
661
|
+
continue
|
|
662
|
+
rel = os.path.relpath(os.path.dirname(pf), sk)
|
|
663
|
+
routes.append(_app_dir_to_url(rel))
|
|
664
|
+
routes = _dedup(routes)
|
|
665
|
+
routes.sort(key=lambda r: (r != "/", r))
|
|
666
|
+
if "/" in routes:
|
|
667
|
+
landing = "/"
|
|
668
|
+
|
|
669
|
+
# Generic: note an index/main entry if nothing matched.
|
|
670
|
+
if not routes:
|
|
671
|
+
entry = _first_existing(root, [
|
|
672
|
+
"src/index.html", "index.html", "public/index.html",
|
|
673
|
+
"src/main.tsx", "src/main.ts", "src/main.jsx", "src/main.js",
|
|
674
|
+
"src/App.tsx", "src/App.jsx", "src/index.tsx", "src/index.jsx",
|
|
675
|
+
])
|
|
676
|
+
if entry:
|
|
677
|
+
routes.append("(entry: %s)" % os.path.relpath(entry, root))
|
|
678
|
+
|
|
679
|
+
return routes, landing
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _app_dir_to_url(rel):
|
|
683
|
+
"""Convert a Next app-router page directory (relative to app/) to a URL path."""
|
|
684
|
+
rel = rel.replace("\\", "/")
|
|
685
|
+
if rel in (".", ""):
|
|
686
|
+
return "/"
|
|
687
|
+
segments = []
|
|
688
|
+
for seg in rel.split("/"):
|
|
689
|
+
if not seg:
|
|
690
|
+
continue
|
|
691
|
+
# Skip route groups like (marketing) and parallel/intercept slots.
|
|
692
|
+
if seg.startswith("(") and seg.endswith(")"):
|
|
693
|
+
continue
|
|
694
|
+
if seg.startswith("@"):
|
|
695
|
+
continue
|
|
696
|
+
segments.append(seg)
|
|
697
|
+
return "/" + "/".join(segments) if segments else "/"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _pages_file_to_url(rel):
|
|
701
|
+
"""Convert a Next pages-router file (relative to pages/) to a URL path."""
|
|
702
|
+
rel = rel.replace("\\", "/")
|
|
703
|
+
rel = re.sub(r"\.(tsx|jsx|ts|js|vue)$", "", rel)
|
|
704
|
+
if rel in ("index", ""):
|
|
705
|
+
return "/"
|
|
706
|
+
if rel.endswith("/index"):
|
|
707
|
+
rel = rel[: -len("/index")]
|
|
708
|
+
return "/" + rel
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
# 4. COMPONENTS
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
def extract_components(root, max_components):
|
|
716
|
+
"""Inventory component basenames; flag landing-relevant ones. Never raises."""
|
|
717
|
+
comp_dirs = []
|
|
718
|
+
for rel in ("components", "src/components", "app/components", "src/app/components"):
|
|
719
|
+
d = os.path.join(root, rel)
|
|
720
|
+
if os.path.isdir(d):
|
|
721
|
+
comp_dirs.append(d)
|
|
722
|
+
components = []
|
|
723
|
+
seen = set()
|
|
724
|
+
for base in comp_dirs:
|
|
725
|
+
files = walk_files(
|
|
726
|
+
base, want_ext={".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte"},
|
|
727
|
+
max_depth=6,
|
|
728
|
+
)
|
|
729
|
+
for f in files:
|
|
730
|
+
name = _basename(f)
|
|
731
|
+
stem = name.rsplit(".", 1)[0]
|
|
732
|
+
# Skip obvious non-component files.
|
|
733
|
+
if stem in ("index", "types", "constants", "utils"):
|
|
734
|
+
continue
|
|
735
|
+
if name.endswith((".test.tsx", ".test.ts", ".spec.tsx", ".spec.ts",
|
|
736
|
+
".stories.tsx", ".stories.ts", ".d.ts")):
|
|
737
|
+
continue
|
|
738
|
+
rel_path = os.path.relpath(f, root).replace("\\", "/")
|
|
739
|
+
key = rel_path.lower()
|
|
740
|
+
if key in seen:
|
|
741
|
+
continue
|
|
742
|
+
seen.add(key)
|
|
743
|
+
landing_flag = bool(LANDING_DIR_HINTS.search(rel_path))
|
|
744
|
+
components.append({
|
|
745
|
+
"name": stem,
|
|
746
|
+
"path": rel_path,
|
|
747
|
+
"landing": landing_flag,
|
|
748
|
+
})
|
|
749
|
+
# Landing-relevant first, then alpha by path.
|
|
750
|
+
components.sort(key=lambda c: (not c["landing"], c["path"].lower()))
|
|
751
|
+
truncated = len(components) > max_components
|
|
752
|
+
return components[:max_components], truncated
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# ---------------------------------------------------------------------------
|
|
756
|
+
# 5. COPY (human-readable UI strings)
|
|
757
|
+
# ---------------------------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
# Tokens that indicate a string is NOT human copy (className, import, url, path...).
|
|
760
|
+
_NON_COPY_RE = re.compile(
|
|
761
|
+
r"^\s*(?:"
|
|
762
|
+
r"https?://" # url
|
|
763
|
+
r"|/[A-Za-z0-9_./-]*$" # absolute path
|
|
764
|
+
r"|\./|\.\./" # relative path
|
|
765
|
+
r"|#[0-9a-fA-F]{3,8}$" # hex color
|
|
766
|
+
r"|@[A-Za-z0-9/_-]+$" # scoped package / at-rule
|
|
767
|
+
r"|[a-z0-9-]+(?:\s+[a-z0-9:-]+)+$" # tailwind class list (all-lowercase words)
|
|
768
|
+
r")",
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
# Korean OR Latin letters present (so we keep real copy, drop pure-symbol strings).
|
|
772
|
+
_HAS_LETTER_RE = re.compile(r"[A-Za-z가-힣-]")
|
|
773
|
+
# Korean syllable / jamo range for "looks Korean" boost.
|
|
774
|
+
_KOREAN_RE = re.compile(r"[가-힣]")
|
|
775
|
+
|
|
776
|
+
# String-literal extractor: "...", '...', `...` (no interpolation), and JSX text.
|
|
777
|
+
_STRING_LIT_RE = re.compile(r"""(["'`])((?:\\.|(?!\1)[^\\])*)\1""")
|
|
778
|
+
_JSX_TEXT_RE = re.compile(r">([^<>{}]{2,})<")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# Tokens that mark a string as a Tailwind/CSS utility class list rather than copy.
|
|
782
|
+
_TW_PREFIX_RE = re.compile(
|
|
783
|
+
r"\b(?:flex|grid|inline|block|hidden|absolute|relative|fixed|sticky|"
|
|
784
|
+
r"items-|justify-|gap-|px-|py-|pt-|pb-|pl-|pr-|mx-|my-|mt-|mb-|ml-|mr-|"
|
|
785
|
+
r"w-|h-|min-|max-|text-|bg-|border|rounded|shadow|opacity-|z-|top-|left-|"
|
|
786
|
+
r"right-|bottom-|font-|leading-|tracking-|space-|divide-|ring-|"
|
|
787
|
+
r"hover:|focus:|active:|group-|md:|lg:|sm:|xl:|dark:|backdrop-|transition|"
|
|
788
|
+
r"duration-|ease-|translate|scale-|rotate-|overflow-)"
|
|
789
|
+
)
|
|
790
|
+
# CSS-value / at-rule fragments that are clearly not UI copy.
|
|
791
|
+
_CSS_FRAGMENT_RE = re.compile(
|
|
792
|
+
r"(@keyframes|@media|@import|linear-gradient|radial-gradient|"
|
|
793
|
+
r"cubic-bezier|background-position|var\(--|calc\(|rgba?\(|hsla?\(|oklch\()",
|
|
794
|
+
re.I,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _is_classname_like(s):
|
|
799
|
+
"""True if the string looks like a Tailwind/CSS class list (not human copy)."""
|
|
800
|
+
if "{" in s or "}" in s or ";" in s:
|
|
801
|
+
return True
|
|
802
|
+
if _CSS_FRAGMENT_RE.search(s):
|
|
803
|
+
return True
|
|
804
|
+
words = s.split()
|
|
805
|
+
if len(words) >= 2:
|
|
806
|
+
hits = sum(1 for w in words if _TW_PREFIX_RE.search(w) or "-" in w and ":" not in w[:1])
|
|
807
|
+
# Mostly utility-looking tokens with no Korean -> classname.
|
|
808
|
+
if hits >= max(2, len(words) // 2) and _TW_PREFIX_RE.search(s):
|
|
809
|
+
return True
|
|
810
|
+
elif len(words) == 1 and _TW_PREFIX_RE.match(s) and "-" in s:
|
|
811
|
+
return True
|
|
812
|
+
return False
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _looks_like_copy(s):
|
|
816
|
+
"""Heuristic: is this string human-facing UI copy (KO/EN), not code noise?"""
|
|
817
|
+
if not s:
|
|
818
|
+
return False
|
|
819
|
+
s = s.strip()
|
|
820
|
+
if len(s) < 2 or len(s) > 200:
|
|
821
|
+
return False
|
|
822
|
+
if not _HAS_LETTER_RE.search(s):
|
|
823
|
+
return False
|
|
824
|
+
# Drop urls/paths/hex/at-rules/etc.
|
|
825
|
+
if _NON_COPY_RE.match(s):
|
|
826
|
+
return False
|
|
827
|
+
# Drop strings that contain template-literal interpolation markers.
|
|
828
|
+
if "${" in s:
|
|
829
|
+
return False
|
|
830
|
+
# Drop leftover comment markers / JSX-comment fragments.
|
|
831
|
+
if s.startswith("//") or s.startswith("/*") or s.endswith("*/") or "//" in s[:3]:
|
|
832
|
+
return False
|
|
833
|
+
# Drop import/require-ish module specifiers (no spaces, has a slash or dot-path).
|
|
834
|
+
if " " not in s and ("/" in s or s.startswith(".") or s.startswith("@")):
|
|
835
|
+
return False
|
|
836
|
+
# Drop Tailwind/CSS utility class lists and CSS-value fragments
|
|
837
|
+
# (these are noise even when long). Korean copy never looks like this.
|
|
838
|
+
if not _KOREAN_RE.search(s) and _is_classname_like(s):
|
|
839
|
+
return False
|
|
840
|
+
# Korean strings that are purely a CSS fragment (rare) still get dropped.
|
|
841
|
+
if _CSS_FRAGMENT_RE.search(s) and not re.search(r"[가-힣]{2,}", s):
|
|
842
|
+
return False
|
|
843
|
+
# Drop single short ALLCAPS_CONST or camelCase identifiers with no spaces (likely keys).
|
|
844
|
+
if " " not in s and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", s) and len(s) < 4:
|
|
845
|
+
return False
|
|
846
|
+
# Drop things that are obviously CSS values.
|
|
847
|
+
if re.match(r"^[0-9.]+(px|rem|em|vh|vw|%|s|ms)$", s):
|
|
848
|
+
return False
|
|
849
|
+
return True
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _strip_comments(text):
|
|
853
|
+
"""Best-effort removal of // line and /* block */ comments to avoid copy noise.
|
|
854
|
+
|
|
855
|
+
Regex-only and intentionally conservative: it can over/under-strip inside string
|
|
856
|
+
literals, but copy extraction is heuristic anyway and this kills the common
|
|
857
|
+
'// Korean comment //' false positives. Never raises.
|
|
858
|
+
"""
|
|
859
|
+
try:
|
|
860
|
+
text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S) # block comments
|
|
861
|
+
text = re.sub(r"(?m)//[^\n]*$", " ", text) # line comments
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
return text
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _unescape(raw):
|
|
868
|
+
"""Decode \\n \\t \\" etc. WITHOUT corrupting valid UTF-8 (e.g. Korean).
|
|
869
|
+
|
|
870
|
+
Only attempts unicode_escape when a backslash is actually present; otherwise the
|
|
871
|
+
string is returned verbatim so multi-byte characters are never mangled.
|
|
872
|
+
"""
|
|
873
|
+
if "\\" not in raw:
|
|
874
|
+
return raw
|
|
875
|
+
try:
|
|
876
|
+
return raw.encode("latin-1", "backslashreplace").decode("unicode_escape")
|
|
877
|
+
except (UnicodeDecodeError, UnicodeEncodeError, ValueError):
|
|
878
|
+
return raw
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _extract_strings_from_file(path):
|
|
882
|
+
"""Return a list of candidate copy strings from a single source file."""
|
|
883
|
+
text = _read_text(path, limit_bytes=400_000)
|
|
884
|
+
if not text:
|
|
885
|
+
return []
|
|
886
|
+
text = _strip_comments(text)
|
|
887
|
+
out = []
|
|
888
|
+
# Quoted/backtick string literals.
|
|
889
|
+
for m in _STRING_LIT_RE.finditer(text):
|
|
890
|
+
out.append(_unescape(m.group(2)))
|
|
891
|
+
# JSX text nodes (between > and <).
|
|
892
|
+
for m in _JSX_TEXT_RE.finditer(text):
|
|
893
|
+
out.append(m.group(1))
|
|
894
|
+
return out
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def extract_copy(root, landing_route, components, max_copy):
|
|
898
|
+
"""Extract real UI copy from the landing page file + flagged landing components."""
|
|
899
|
+
target_files = []
|
|
900
|
+
|
|
901
|
+
# Landing page file (app router or pages router).
|
|
902
|
+
app_base = _first_existing(root, ["src/app", "app"])
|
|
903
|
+
if app_base:
|
|
904
|
+
for ext in (".tsx", ".jsx", ".ts", ".js"):
|
|
905
|
+
cand = os.path.join(app_base, "page" + ext)
|
|
906
|
+
if os.path.isfile(cand):
|
|
907
|
+
target_files.append(cand)
|
|
908
|
+
break
|
|
909
|
+
if not target_files:
|
|
910
|
+
pages_base = _first_existing(root, ["src/pages", "pages"])
|
|
911
|
+
if pages_base:
|
|
912
|
+
for ext in (".tsx", ".jsx", ".ts", ".js", ".vue"):
|
|
913
|
+
cand = os.path.join(pages_base, "index" + ext)
|
|
914
|
+
if os.path.isfile(cand):
|
|
915
|
+
target_files.append(cand)
|
|
916
|
+
break
|
|
917
|
+
# Generic entry fallback.
|
|
918
|
+
if not target_files:
|
|
919
|
+
entry = _first_existing(root, [
|
|
920
|
+
"src/App.tsx", "src/App.jsx", "src/index.html", "index.html",
|
|
921
|
+
])
|
|
922
|
+
if entry and _ext_of(entry) in CODE_EXTS:
|
|
923
|
+
target_files.append(entry)
|
|
924
|
+
|
|
925
|
+
# Follow the landing page's local imports → the actually-rendered components,
|
|
926
|
+
# regardless of directory naming (e.g. src/components/Hero.tsx, not .../home/).
|
|
927
|
+
_imp_re = re.compile(r"""(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]""")
|
|
928
|
+
for page_file in list(target_files):
|
|
929
|
+
try:
|
|
930
|
+
_txt = open(page_file, encoding="utf-8", errors="ignore").read()
|
|
931
|
+
except OSError:
|
|
932
|
+
continue
|
|
933
|
+
_pdir = os.path.dirname(page_file)
|
|
934
|
+
for spec in _imp_re.findall(_txt):
|
|
935
|
+
if spec.startswith("@/"):
|
|
936
|
+
bases = [os.path.join(root, "src", spec[2:]), os.path.join(root, spec[2:])]
|
|
937
|
+
elif spec.startswith("./") or spec.startswith("../"):
|
|
938
|
+
bases = [os.path.normpath(os.path.join(_pdir, spec))]
|
|
939
|
+
else:
|
|
940
|
+
continue
|
|
941
|
+
for base in bases:
|
|
942
|
+
done = False
|
|
943
|
+
for ext in (".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte", ".astro"):
|
|
944
|
+
if os.path.isfile(base + ext):
|
|
945
|
+
target_files.append(base + ext)
|
|
946
|
+
done = True
|
|
947
|
+
break
|
|
948
|
+
if done:
|
|
949
|
+
break
|
|
950
|
+
if os.path.isdir(base):
|
|
951
|
+
for fn in sorted(os.listdir(base)):
|
|
952
|
+
if _ext_of(fn) in CODE_EXTS:
|
|
953
|
+
target_files.append(os.path.join(base, fn))
|
|
954
|
+
break
|
|
955
|
+
for idx in ("index.tsx", "index.ts", "index.jsx", "index.js"):
|
|
956
|
+
if os.path.isfile(os.path.join(base, idx)):
|
|
957
|
+
target_files.append(os.path.join(base, idx))
|
|
958
|
+
break
|
|
959
|
+
|
|
960
|
+
# Flagged landing-relevant components.
|
|
961
|
+
for comp in components:
|
|
962
|
+
if comp.get("landing"):
|
|
963
|
+
p = os.path.join(root, comp["path"])
|
|
964
|
+
if os.path.isfile(p):
|
|
965
|
+
target_files.append(p)
|
|
966
|
+
target_files = _dedup(target_files)[:25]
|
|
967
|
+
|
|
968
|
+
copy = []
|
|
969
|
+
for f in target_files:
|
|
970
|
+
try:
|
|
971
|
+
for s in _extract_strings_from_file(f):
|
|
972
|
+
if _looks_like_copy(s):
|
|
973
|
+
# Collapse internal whitespace runs (JSX indentation noise).
|
|
974
|
+
copy.append(re.sub(r"\s+", " ", s).strip())
|
|
975
|
+
except Exception:
|
|
976
|
+
continue
|
|
977
|
+
|
|
978
|
+
# Dedup (case-sensitive, order-preserving), then cap. Prefer longer/Korean-rich.
|
|
979
|
+
copy = _dedup(copy)
|
|
980
|
+
# Light ranking: Korean-containing and longer strings first (more headline-like),
|
|
981
|
+
# but keep stable order otherwise.
|
|
982
|
+
copy.sort(key=lambda s: (0 if _KOREAN_RE.search(s) else 1, -min(len(s), 80)))
|
|
983
|
+
truncated = len(copy) > max_copy
|
|
984
|
+
return copy[:max_copy], truncated
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# ---------------------------------------------------------------------------
|
|
988
|
+
# 6. ASSETS
|
|
989
|
+
# ---------------------------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
def _brand_score(name):
|
|
992
|
+
score = 0
|
|
993
|
+
for rx, val in BRAND_HINTS:
|
|
994
|
+
if rx.search(name):
|
|
995
|
+
score = max(score, val)
|
|
996
|
+
return score
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def extract_assets(root, max_assets, include_docs):
|
|
1000
|
+
"""Scan asset dirs for visual files, rank brand-likelihood first. ABS paths."""
|
|
1001
|
+
exts = set(VISUAL_EXTS)
|
|
1002
|
+
if include_docs:
|
|
1003
|
+
exts |= DOC_EXTS
|
|
1004
|
+
|
|
1005
|
+
asset_dirs = []
|
|
1006
|
+
for rel in ("public", "static", "assets", "src/assets", "app/assets", "src/static"):
|
|
1007
|
+
d = os.path.join(root, rel)
|
|
1008
|
+
if os.path.isdir(d):
|
|
1009
|
+
asset_dirs.append(d)
|
|
1010
|
+
# If no conventional asset dir, do a shallow scan of root for visuals.
|
|
1011
|
+
if not asset_dirs:
|
|
1012
|
+
asset_dirs.append(root)
|
|
1013
|
+
|
|
1014
|
+
found = []
|
|
1015
|
+
seen = set()
|
|
1016
|
+
for base in asset_dirs:
|
|
1017
|
+
depth = 4 if base != root else 2
|
|
1018
|
+
for f in walk_files(base, want_ext=exts, max_depth=depth):
|
|
1019
|
+
if f in seen:
|
|
1020
|
+
continue
|
|
1021
|
+
seen.add(f)
|
|
1022
|
+
name = _basename(f)
|
|
1023
|
+
st = _safe_stat(f)
|
|
1024
|
+
if st is None:
|
|
1025
|
+
continue
|
|
1026
|
+
found.append({
|
|
1027
|
+
"path": _abspath(f),
|
|
1028
|
+
"name": name,
|
|
1029
|
+
"bytes": st.st_size,
|
|
1030
|
+
"mtime": st.st_mtime,
|
|
1031
|
+
"brand_score": _brand_score(name),
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
# Rank: brand-likelihood first, then newer mtime, then larger size.
|
|
1035
|
+
found.sort(key=lambda a: (-a["brand_score"], -a["mtime"], -a["bytes"]))
|
|
1036
|
+
|
|
1037
|
+
truncated = len(found) > max_assets
|
|
1038
|
+
out = []
|
|
1039
|
+
for a in found[:max_assets]:
|
|
1040
|
+
out.append({
|
|
1041
|
+
"path": a["path"],
|
|
1042
|
+
"name": a["name"],
|
|
1043
|
+
"bytes": a["bytes"],
|
|
1044
|
+
"mtime_iso": _mtime_iso(a["mtime"]),
|
|
1045
|
+
"brand": a["brand_score"] >= 70,
|
|
1046
|
+
})
|
|
1047
|
+
return out, truncated
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
# ---------------------------------------------------------------------------
|
|
1051
|
+
# 7. REPO (git)
|
|
1052
|
+
# ---------------------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
def _git(root, args):
|
|
1055
|
+
try:
|
|
1056
|
+
out = subprocess.run(
|
|
1057
|
+
["git", "-C", root] + args,
|
|
1058
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
1059
|
+
timeout=8, check=False,
|
|
1060
|
+
)
|
|
1061
|
+
if out.returncode != 0:
|
|
1062
|
+
return ""
|
|
1063
|
+
return out.stdout.decode("utf-8", "replace").strip()
|
|
1064
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
1065
|
+
return ""
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def extract_repo(root):
|
|
1069
|
+
"""Return git repo info or {} if not a git repo / no git. Never raises."""
|
|
1070
|
+
if not os.path.isdir(os.path.join(root, ".git")):
|
|
1071
|
+
# Could still be a worktree/submodule; a cheap probe confirms.
|
|
1072
|
+
inside = _git(root, ["rev-parse", "--is-inside-work-tree"])
|
|
1073
|
+
if inside != "true":
|
|
1074
|
+
return {}
|
|
1075
|
+
repo = {}
|
|
1076
|
+
url = _git(root, ["remote", "get-url", "origin"])
|
|
1077
|
+
if url:
|
|
1078
|
+
repo["remote"] = url
|
|
1079
|
+
branch = _git(root, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
1080
|
+
if branch:
|
|
1081
|
+
repo["branch"] = branch
|
|
1082
|
+
sha = _git(root, ["rev-parse", "--short", "HEAD"])
|
|
1083
|
+
if sha:
|
|
1084
|
+
repo["sha"] = sha
|
|
1085
|
+
return repo
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
# ---------------------------------------------------------------------------
|
|
1089
|
+
# Markdown brief rendering
|
|
1090
|
+
# ---------------------------------------------------------------------------
|
|
1091
|
+
|
|
1092
|
+
def _project_name(root, stack):
|
|
1093
|
+
if stack.get("name"):
|
|
1094
|
+
return stack["name"]
|
|
1095
|
+
base = _basename(_abspath(root))
|
|
1096
|
+
return base or "project"
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def render_brief(model, level):
|
|
1100
|
+
"""Render the markdown design context brief from the analysis model."""
|
|
1101
|
+
project = model["project"]
|
|
1102
|
+
lines = []
|
|
1103
|
+
lines.append("# %s — Claude Design 컨텍스트 브리프" % project)
|
|
1104
|
+
lines.append("")
|
|
1105
|
+
lines.append("> 코드베이스를 분석해 추출한 디자인 컨텍스트. Claude Design 이 실제 코드의 "
|
|
1106
|
+
"스택·토큰·카피·에셋을 충실히 참조하도록 전달한다.")
|
|
1107
|
+
lines.append("")
|
|
1108
|
+
|
|
1109
|
+
# --- Stack ---
|
|
1110
|
+
stack = model["stack"]
|
|
1111
|
+
lines.append("## Stack")
|
|
1112
|
+
fw = ", ".join(stack["frameworks"]) or "(감지 안 됨)"
|
|
1113
|
+
lines.append("- Framework: %s" % fw)
|
|
1114
|
+
if stack.get("router"):
|
|
1115
|
+
lines.append("- Router: %s" % stack["router"])
|
|
1116
|
+
if stack.get("language"):
|
|
1117
|
+
lines.append("- Language: %s" % ", ".join(stack["language"]))
|
|
1118
|
+
if stack.get("styling"):
|
|
1119
|
+
lines.append("- Styling: %s" % ", ".join(stack["styling"]))
|
|
1120
|
+
if stack.get("ui_libraries"):
|
|
1121
|
+
lines.append("- UI: %s" % ", ".join(stack["ui_libraries"]))
|
|
1122
|
+
if stack.get("package_manager"):
|
|
1123
|
+
lines.append("- Package manager: %s" % stack["package_manager"])
|
|
1124
|
+
for note in stack.get("notes", []):
|
|
1125
|
+
lines.append("- Note: %s" % note)
|
|
1126
|
+
lines.append("")
|
|
1127
|
+
|
|
1128
|
+
# --- Design system ---
|
|
1129
|
+
tokens = model["tokens"]
|
|
1130
|
+
lines.append("## Design system")
|
|
1131
|
+
palette = tokens["palette"]
|
|
1132
|
+
if palette:
|
|
1133
|
+
lines.append("**Palette / color tokens:**")
|
|
1134
|
+
for t in palette:
|
|
1135
|
+
lines.append("- `%s` = `%s`" % (t["name"], t["value"]))
|
|
1136
|
+
else:
|
|
1137
|
+
lines.append("- Palette: (감지된 컬러 토큰 없음)")
|
|
1138
|
+
fonts = tokens["fonts"]
|
|
1139
|
+
if fonts:
|
|
1140
|
+
lines.append("")
|
|
1141
|
+
lines.append("**Fonts:**")
|
|
1142
|
+
for role, fam in fonts.items():
|
|
1143
|
+
label = role.replace("face:", "@font-face ")
|
|
1144
|
+
lines.append("- %s: %s" % (label, fam))
|
|
1145
|
+
radii = tokens["radius"]
|
|
1146
|
+
if radii:
|
|
1147
|
+
lines.append("")
|
|
1148
|
+
lines.append("**Radius / spacing:**")
|
|
1149
|
+
for r in radii:
|
|
1150
|
+
lines.append("- `%s` = `%s`" % (r["name"], r["value"]))
|
|
1151
|
+
lines.append("")
|
|
1152
|
+
|
|
1153
|
+
if level == "comprehensive":
|
|
1154
|
+
# --- Routes ---
|
|
1155
|
+
lines.append("## Routes")
|
|
1156
|
+
routes = model["routes"]
|
|
1157
|
+
landing = model["landing_route"]
|
|
1158
|
+
if routes:
|
|
1159
|
+
for r in routes:
|
|
1160
|
+
mark = " ← landing/root" if (r == landing and landing) else ""
|
|
1161
|
+
lines.append("- `%s`%s" % (r, mark))
|
|
1162
|
+
else:
|
|
1163
|
+
lines.append("- (라우트 감지 안 됨)")
|
|
1164
|
+
lines.append("")
|
|
1165
|
+
|
|
1166
|
+
# --- Components ---
|
|
1167
|
+
lines.append("## Components")
|
|
1168
|
+
comps = model["components"]
|
|
1169
|
+
if comps:
|
|
1170
|
+
landing_comps = [c for c in comps if c.get("landing")]
|
|
1171
|
+
other_comps = [c for c in comps if not c.get("landing")]
|
|
1172
|
+
if landing_comps:
|
|
1173
|
+
lines.append("**Landing/marketing-relevant:**")
|
|
1174
|
+
for c in landing_comps:
|
|
1175
|
+
lines.append("- `%s` — %s" % (c["name"], c["path"]))
|
|
1176
|
+
lines.append("")
|
|
1177
|
+
if other_comps:
|
|
1178
|
+
lines.append("**Other components:**")
|
|
1179
|
+
for c in other_comps:
|
|
1180
|
+
lines.append("- `%s` — %s" % (c["name"], c["path"]))
|
|
1181
|
+
else:
|
|
1182
|
+
lines.append("- (컴포넌트 감지 안 됨)")
|
|
1183
|
+
lines.append("")
|
|
1184
|
+
|
|
1185
|
+
# --- UI Copy ---
|
|
1186
|
+
lines.append("## UI Copy")
|
|
1187
|
+
copy = model["copy"]
|
|
1188
|
+
if copy:
|
|
1189
|
+
lines.append("> 랜딩 페이지/랜딩 컴포넌트에서 추출한 실제 문구 (헤드라인·CTA·섹션 카피).")
|
|
1190
|
+
for c in copy:
|
|
1191
|
+
lines.append("- %s" % c.replace("\n", " ").strip())
|
|
1192
|
+
else:
|
|
1193
|
+
lines.append("- (추출된 UI 카피 없음)")
|
|
1194
|
+
lines.append("")
|
|
1195
|
+
|
|
1196
|
+
# --- Assets ---
|
|
1197
|
+
lines.append("## Assets")
|
|
1198
|
+
lines.append("> 업로드/드래그용 절대 경로. 브랜드 후보를 우선 정렬.")
|
|
1199
|
+
assets = model["assets"]
|
|
1200
|
+
if assets:
|
|
1201
|
+
for a in assets:
|
|
1202
|
+
tag = " [brand]" if a.get("brand") else ""
|
|
1203
|
+
lines.append("- %s%s" % (a["path"], tag))
|
|
1204
|
+
else:
|
|
1205
|
+
lines.append("- (시각 에셋 없음)")
|
|
1206
|
+
lines.append("")
|
|
1207
|
+
|
|
1208
|
+
# --- Repo ---
|
|
1209
|
+
lines.append("## Repo")
|
|
1210
|
+
repo = model["repo"]
|
|
1211
|
+
if repo:
|
|
1212
|
+
if repo.get("remote"):
|
|
1213
|
+
lines.append("- Remote: %s" % repo["remote"])
|
|
1214
|
+
if repo.get("branch"):
|
|
1215
|
+
lines.append("- Branch: %s" % repo["branch"])
|
|
1216
|
+
if repo.get("sha"):
|
|
1217
|
+
lines.append("- Commit: %s" % repo["sha"])
|
|
1218
|
+
lines.append("- Root: %s" % model["root"])
|
|
1219
|
+
else:
|
|
1220
|
+
lines.append("- (git 저장소 아님 / 원격 없음)")
|
|
1221
|
+
lines.append("- Root: %s" % model["root"])
|
|
1222
|
+
lines.append("")
|
|
1223
|
+
|
|
1224
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
# ---------------------------------------------------------------------------
|
|
1228
|
+
# Orchestration
|
|
1229
|
+
# ---------------------------------------------------------------------------
|
|
1230
|
+
|
|
1231
|
+
def analyze(root, level, max_components, max_assets, max_copy, include_docs):
|
|
1232
|
+
"""Run all extractors and assemble the analysis model. Never raises."""
|
|
1233
|
+
abs_root = _abspath(root)
|
|
1234
|
+
|
|
1235
|
+
stack = detect_stack(abs_root)
|
|
1236
|
+
tokens = extract_tokens(abs_root)
|
|
1237
|
+
assets, _assets_trunc = extract_assets(abs_root, max_assets, include_docs)
|
|
1238
|
+
repo = extract_repo(abs_root)
|
|
1239
|
+
|
|
1240
|
+
routes, landing = [], ""
|
|
1241
|
+
components, copy = [], []
|
|
1242
|
+
if level == "comprehensive":
|
|
1243
|
+
routes, landing = extract_routes(abs_root)
|
|
1244
|
+
components, _comp_trunc = extract_components(abs_root, max_components)
|
|
1245
|
+
copy, _copy_trunc = extract_copy(abs_root, landing, components, max_copy)
|
|
1246
|
+
|
|
1247
|
+
project = _project_name(abs_root, stack)
|
|
1248
|
+
|
|
1249
|
+
model = {
|
|
1250
|
+
"project": project,
|
|
1251
|
+
"root": abs_root,
|
|
1252
|
+
"level": level,
|
|
1253
|
+
"stack": stack,
|
|
1254
|
+
"tokens": tokens,
|
|
1255
|
+
"routes": routes,
|
|
1256
|
+
"landing_route": landing,
|
|
1257
|
+
"components": components,
|
|
1258
|
+
"copy": copy,
|
|
1259
|
+
"assets": assets,
|
|
1260
|
+
"repo": repo,
|
|
1261
|
+
}
|
|
1262
|
+
model["brief_markdown"] = render_brief(model, level)
|
|
1263
|
+
return model
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
# ---------------------------------------------------------------------------
|
|
1267
|
+
# CLI
|
|
1268
|
+
# ---------------------------------------------------------------------------
|
|
1269
|
+
|
|
1270
|
+
def build_parser():
|
|
1271
|
+
parser = argparse.ArgumentParser(
|
|
1272
|
+
prog="analyze_codebase.py",
|
|
1273
|
+
description=(
|
|
1274
|
+
"Analyze a code project and synthesize a Claude Design 'design context "
|
|
1275
|
+
"brief' (stack, design tokens, routes, components, UI copy, assets, repo)."
|
|
1276
|
+
),
|
|
1277
|
+
)
|
|
1278
|
+
parser.add_argument("--root", default=os.getcwd(),
|
|
1279
|
+
help="Project directory to analyze (default: cwd).")
|
|
1280
|
+
parser.add_argument("--level", choices=["lean", "comprehensive"],
|
|
1281
|
+
default="comprehensive",
|
|
1282
|
+
help="Context depth (default: comprehensive). "
|
|
1283
|
+
"lean = stack+tokens+assets+repo only.")
|
|
1284
|
+
parser.add_argument("--json", action="store_true",
|
|
1285
|
+
help="Emit a machine JSON object (with brief_markdown) "
|
|
1286
|
+
"instead of plain markdown.")
|
|
1287
|
+
parser.add_argument("--out", default=None, metavar="FILE",
|
|
1288
|
+
help="Write output to FILE instead of stdout.")
|
|
1289
|
+
parser.add_argument("--max-components", dest="max_components", type=int,
|
|
1290
|
+
default=DEFAULT_MAX_COMPONENTS,
|
|
1291
|
+
help="Max components to list (default: %d)."
|
|
1292
|
+
% DEFAULT_MAX_COMPONENTS)
|
|
1293
|
+
parser.add_argument("--max-assets", dest="max_assets", type=int,
|
|
1294
|
+
default=DEFAULT_MAX_ASSETS,
|
|
1295
|
+
help="Max assets to list (default: %d)." % DEFAULT_MAX_ASSETS)
|
|
1296
|
+
parser.add_argument("--max-copy", dest="max_copy", type=int,
|
|
1297
|
+
default=DEFAULT_MAX_COPY,
|
|
1298
|
+
help="Max UI copy strings to list (default: %d)."
|
|
1299
|
+
% DEFAULT_MAX_COPY)
|
|
1300
|
+
parser.add_argument("--include-docs", dest="include_docs", action="store_true",
|
|
1301
|
+
help="Also include document assets (.pdf/.hwp/.doc*/...) "
|
|
1302
|
+
"(default: visual-only).")
|
|
1303
|
+
return parser
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _clamp(value, lo, default):
|
|
1307
|
+
try:
|
|
1308
|
+
v = int(value)
|
|
1309
|
+
except (TypeError, ValueError):
|
|
1310
|
+
return default
|
|
1311
|
+
return v if v >= lo else default
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def main(argv=None):
|
|
1315
|
+
parser = build_parser()
|
|
1316
|
+
args = parser.parse_args(argv)
|
|
1317
|
+
|
|
1318
|
+
root = args.root or os.getcwd()
|
|
1319
|
+
level = args.level if args.level in ("lean", "comprehensive") else "comprehensive"
|
|
1320
|
+
max_components = _clamp(args.max_components, 0, DEFAULT_MAX_COMPONENTS)
|
|
1321
|
+
max_assets = _clamp(args.max_assets, 0, DEFAULT_MAX_ASSETS)
|
|
1322
|
+
max_copy = _clamp(args.max_copy, 0, DEFAULT_MAX_COPY)
|
|
1323
|
+
|
|
1324
|
+
try:
|
|
1325
|
+
model = analyze(
|
|
1326
|
+
root=root, level=level,
|
|
1327
|
+
max_components=max_components, max_assets=max_assets,
|
|
1328
|
+
max_copy=max_copy, include_docs=bool(args.include_docs),
|
|
1329
|
+
)
|
|
1330
|
+
except Exception as exc: # absolute last-resort guard: never crash.
|
|
1331
|
+
fallback = {
|
|
1332
|
+
"project": _basename(_abspath(root)) or "project",
|
|
1333
|
+
"root": _abspath(root), "level": level,
|
|
1334
|
+
"stack": {"frameworks": [], "notes": ["analysis error: %s" % exc]},
|
|
1335
|
+
"tokens": {"palette": [], "fonts": {}, "radius": []},
|
|
1336
|
+
"routes": [], "landing_route": "", "components": [], "copy": [],
|
|
1337
|
+
"assets": [], "repo": {},
|
|
1338
|
+
}
|
|
1339
|
+
fallback["brief_markdown"] = (
|
|
1340
|
+
"# %s — Claude Design 컨텍스트 브리프\n\n"
|
|
1341
|
+
"분석 중 오류가 발생해 부분 결과만 제공합니다: %s\n"
|
|
1342
|
+
% (fallback["project"], exc)
|
|
1343
|
+
)
|
|
1344
|
+
model = fallback
|
|
1345
|
+
|
|
1346
|
+
if args.json:
|
|
1347
|
+
out_model = dict(model)
|
|
1348
|
+
# Per contract: assets[] entries carry ABSOLUTE paths (the `path` field),
|
|
1349
|
+
# plus a flat `asset_paths` list of those absolute paths for easy upload.
|
|
1350
|
+
out_model["asset_paths"] = [a["path"] for a in model.get("assets", [])]
|
|
1351
|
+
output = json.dumps(out_model, ensure_ascii=False, indent=2)
|
|
1352
|
+
else:
|
|
1353
|
+
output = model["brief_markdown"]
|
|
1354
|
+
|
|
1355
|
+
if args.out:
|
|
1356
|
+
try:
|
|
1357
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
1358
|
+
fh.write(output if output.endswith("\n") else output + "\n")
|
|
1359
|
+
except OSError as exc:
|
|
1360
|
+
sys.stderr.write("Could not write --out file %s: %s\n" % (args.out, exc))
|
|
1361
|
+
sys.stdout.write(output + ("\n" if not output.endswith("\n") else ""))
|
|
1362
|
+
return 1
|
|
1363
|
+
else:
|
|
1364
|
+
sys.stdout.write(output + ("\n" if not output.endswith("\n") else ""))
|
|
1365
|
+
return 0
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
if __name__ == "__main__":
|
|
1369
|
+
sys.exit(main())
|