loki-mode 6.79.0 → 6.80.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/magic/__init__.py +7 -0
- package/magic/core/__init__.py +0 -0
- package/magic/core/debate.py +781 -0
- package/magic/core/design_tokens.py +469 -0
- package/magic/core/freshness.py +86 -0
- package/magic/core/generator.py +755 -0
- package/magic/core/memory_bridge.py +220 -0
- package/magic/core/prd_scanner.py +265 -0
- package/magic/core/registry.py +340 -0
- package/magic/core/spec.py +337 -0
- package/magic/debate/personas/a11y.md +95 -0
- package/magic/debate/personas/conservative.md +83 -0
- package/magic/debate/personas/creative.md +73 -0
- package/magic/debate/personas/performance.md +93 -0
- package/magic/registry/schema.json +38 -0
- package/magic/testing/__init__.py +0 -0
- package/magic/testing/snapshot.py +224 -0
- package/magic/testing/test_generator.py +453 -0
- package/magic/tokens/README.md +83 -0
- package/magic/tokens/defaults.json +59 -0
- package/mcp/__init__.py +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Component registry for Magic Modules.
|
|
2
|
+
|
|
3
|
+
Tracks every generated component with: name, version (semver), spec hash,
|
|
4
|
+
file paths, tags, creation/update timestamps, debate results, deprecation.
|
|
5
|
+
|
|
6
|
+
Storage: JSON at .loki/magic/registry.json (atomic writes via tmp + rename).
|
|
7
|
+
Standard library only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, asdict, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
18
|
+
NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _utcnow() -> str:
|
|
22
|
+
"""Return current UTC timestamp in ISO 8601 format."""
|
|
23
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ComponentEntry:
|
|
28
|
+
name: str
|
|
29
|
+
version: str = "1.0.0"
|
|
30
|
+
spec_path: str = ""
|
|
31
|
+
react_path: str = ""
|
|
32
|
+
webcomponent_path: str = ""
|
|
33
|
+
test_path: str = ""
|
|
34
|
+
spec_hash: str = ""
|
|
35
|
+
created_at: str = ""
|
|
36
|
+
updated_at: str = ""
|
|
37
|
+
tags: list = field(default_factory=list)
|
|
38
|
+
description: str = ""
|
|
39
|
+
targets: list = field(default_factory=lambda: ["react"])
|
|
40
|
+
debate_passed: bool = False
|
|
41
|
+
debate_result: dict = field(default_factory=dict)
|
|
42
|
+
deprecated: bool = False
|
|
43
|
+
replaces: str = ""
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, data: dict) -> "ComponentEntry":
|
|
47
|
+
"""Construct from a dict, ignoring unknown keys."""
|
|
48
|
+
known = {f for f in cls.__dataclass_fields__}
|
|
49
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
50
|
+
return cls(**filtered)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ComponentRegistry:
|
|
54
|
+
def __init__(self, project_dir: str = "."):
|
|
55
|
+
self.project_dir = Path(project_dir)
|
|
56
|
+
self.registry_path = self.project_dir / ".loki" / "magic" / "registry.json"
|
|
57
|
+
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Persistence
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
def load(self) -> dict:
|
|
63
|
+
"""Load registry from disk, return empty structure if missing."""
|
|
64
|
+
if not self.registry_path.exists():
|
|
65
|
+
return {"version": "1", "updated_at": "", "components": []}
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(self.registry_path.read_text())
|
|
68
|
+
except (json.JSONDecodeError, OSError):
|
|
69
|
+
# Back up corrupted registry and start fresh
|
|
70
|
+
try:
|
|
71
|
+
backup = self.registry_path.with_suffix(".json.corrupt")
|
|
72
|
+
self.registry_path.rename(backup)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
return {"version": "1", "updated_at": "", "components": []}
|
|
76
|
+
|
|
77
|
+
# Ensure required fields exist
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
return {"version": "1", "updated_at": "", "components": []}
|
|
80
|
+
data.setdefault("version", "1")
|
|
81
|
+
data.setdefault("updated_at", "")
|
|
82
|
+
data.setdefault("components", [])
|
|
83
|
+
if not isinstance(data["components"], list):
|
|
84
|
+
data["components"] = []
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
def save(self, data: dict) -> None:
|
|
88
|
+
"""Atomic write via temp + rename."""
|
|
89
|
+
data["updated_at"] = _utcnow()
|
|
90
|
+
tmp = self.registry_path.with_suffix(".json.tmp")
|
|
91
|
+
tmp.write_text(json.dumps(data, indent=2, sort_keys=False))
|
|
92
|
+
tmp.replace(self.registry_path)
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# CRUD
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
def register(self, entry: ComponentEntry) -> ComponentEntry:
|
|
98
|
+
"""Add or update a component entry.
|
|
99
|
+
|
|
100
|
+
If name exists, bump version (patch) when version matches existing.
|
|
101
|
+
Validates name format and semver.
|
|
102
|
+
"""
|
|
103
|
+
if not NAME_RE.match(entry.name):
|
|
104
|
+
raise ValueError(f"Invalid component name: {entry.name}")
|
|
105
|
+
if not SEMVER_RE.match(entry.version):
|
|
106
|
+
raise ValueError(f"Invalid semver: {entry.version}")
|
|
107
|
+
|
|
108
|
+
now = _utcnow()
|
|
109
|
+
data = self.load()
|
|
110
|
+
existing_idx = next(
|
|
111
|
+
(i for i, c in enumerate(data["components"]) if c.get("name") == entry.name),
|
|
112
|
+
None,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if existing_idx is not None:
|
|
116
|
+
existing = data["components"][existing_idx]
|
|
117
|
+
entry.created_at = existing.get("created_at") or now
|
|
118
|
+
entry.updated_at = now
|
|
119
|
+
# Auto-bump patch if same version as existing
|
|
120
|
+
if entry.version == existing.get("version"):
|
|
121
|
+
entry.version = self._bump_patch(existing.get("version", "1.0.0"))
|
|
122
|
+
data["components"][existing_idx] = asdict(entry)
|
|
123
|
+
else:
|
|
124
|
+
entry.created_at = entry.created_at or now
|
|
125
|
+
entry.updated_at = now
|
|
126
|
+
data["components"].append(asdict(entry))
|
|
127
|
+
|
|
128
|
+
self.save(data)
|
|
129
|
+
return entry
|
|
130
|
+
|
|
131
|
+
def get(self, name: str) -> Optional[ComponentEntry]:
|
|
132
|
+
"""Fetch component by name. Returns None if not found."""
|
|
133
|
+
data = self.load()
|
|
134
|
+
for c in data["components"]:
|
|
135
|
+
if c.get("name") == name:
|
|
136
|
+
return ComponentEntry.from_dict(c)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def remove(self, name: str) -> bool:
|
|
140
|
+
"""Remove component entry (does not delete files). Returns True if removed."""
|
|
141
|
+
data = self.load()
|
|
142
|
+
original_count = len(data["components"])
|
|
143
|
+
data["components"] = [c for c in data["components"] if c.get("name") != name]
|
|
144
|
+
if len(data["components"]) == original_count:
|
|
145
|
+
return False
|
|
146
|
+
self.save(data)
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
def search(
|
|
150
|
+
self,
|
|
151
|
+
query: str = "",
|
|
152
|
+
tags: list = None,
|
|
153
|
+
target: str = None,
|
|
154
|
+
) -> list:
|
|
155
|
+
"""Search by name/description substring, tags, or target framework.
|
|
156
|
+
|
|
157
|
+
- query: case-insensitive substring against name and description.
|
|
158
|
+
- tags: all tags must be present on the component.
|
|
159
|
+
- target: match when target appears in component's targets list (or
|
|
160
|
+
component targets includes "both").
|
|
161
|
+
"""
|
|
162
|
+
data = self.load()
|
|
163
|
+
results = []
|
|
164
|
+
q = (query or "").lower().strip()
|
|
165
|
+
tag_set = set(tags or [])
|
|
166
|
+
|
|
167
|
+
for c in data["components"]:
|
|
168
|
+
name = c.get("name", "")
|
|
169
|
+
description = c.get("description", "")
|
|
170
|
+
ctags = set(c.get("tags") or [])
|
|
171
|
+
ctargets = c.get("targets") or []
|
|
172
|
+
|
|
173
|
+
if q and q not in name.lower() and q not in description.lower():
|
|
174
|
+
continue
|
|
175
|
+
if tag_set and not tag_set.issubset(ctags):
|
|
176
|
+
continue
|
|
177
|
+
if target:
|
|
178
|
+
if target not in ctargets and "both" not in ctargets:
|
|
179
|
+
continue
|
|
180
|
+
results.append(ComponentEntry.from_dict(c))
|
|
181
|
+
return results
|
|
182
|
+
|
|
183
|
+
def list_all(self, include_deprecated: bool = False) -> list:
|
|
184
|
+
"""List all components. Excludes deprecated by default."""
|
|
185
|
+
data = self.load()
|
|
186
|
+
results = []
|
|
187
|
+
for c in data["components"]:
|
|
188
|
+
if not include_deprecated and c.get("deprecated"):
|
|
189
|
+
continue
|
|
190
|
+
results.append(ComponentEntry.from_dict(c))
|
|
191
|
+
return results
|
|
192
|
+
|
|
193
|
+
def deprecate(self, name: str, replaces: str = "") -> bool:
|
|
194
|
+
"""Mark component as deprecated. `replaces` points to newer component."""
|
|
195
|
+
data = self.load()
|
|
196
|
+
for c in data["components"]:
|
|
197
|
+
if c.get("name") == name:
|
|
198
|
+
c["deprecated"] = True
|
|
199
|
+
if replaces:
|
|
200
|
+
c["replaces"] = replaces
|
|
201
|
+
c["updated_at"] = _utcnow()
|
|
202
|
+
self.save(data)
|
|
203
|
+
return True
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Stats & maintenance
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
def stats(self) -> dict:
|
|
210
|
+
"""Return registry stats: count, per-target counts, avg debate score, etc."""
|
|
211
|
+
data = self.load()
|
|
212
|
+
components = data["components"]
|
|
213
|
+
total = len(components)
|
|
214
|
+
deprecated = sum(1 for c in components if c.get("deprecated"))
|
|
215
|
+
active = total - deprecated
|
|
216
|
+
|
|
217
|
+
target_counts = {"react": 0, "webcomponent": 0, "both": 0}
|
|
218
|
+
for c in components:
|
|
219
|
+
for t in c.get("targets") or []:
|
|
220
|
+
if t in target_counts:
|
|
221
|
+
target_counts[t] += 1
|
|
222
|
+
|
|
223
|
+
debate_passed = sum(1 for c in components if c.get("debate_passed"))
|
|
224
|
+
scores = []
|
|
225
|
+
for c in components:
|
|
226
|
+
res = c.get("debate_result") or {}
|
|
227
|
+
score = res.get("score")
|
|
228
|
+
if isinstance(score, (int, float)):
|
|
229
|
+
scores.append(float(score))
|
|
230
|
+
avg_debate_score = sum(scores) / len(scores) if scores else 0.0
|
|
231
|
+
|
|
232
|
+
tag_counts: dict = {}
|
|
233
|
+
for c in components:
|
|
234
|
+
for t in c.get("tags") or []:
|
|
235
|
+
tag_counts[t] = tag_counts.get(t, 0) + 1
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
"total": total,
|
|
239
|
+
"active": active,
|
|
240
|
+
"deprecated": deprecated,
|
|
241
|
+
"targets": target_counts,
|
|
242
|
+
"debate_passed": debate_passed,
|
|
243
|
+
"avg_debate_score": avg_debate_score,
|
|
244
|
+
"tags": tag_counts,
|
|
245
|
+
"updated_at": data.get("updated_at", ""),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def prune(self, days_unused: int = 90) -> int:
|
|
249
|
+
"""Remove deprecated entries whose updated_at is older than N days.
|
|
250
|
+
|
|
251
|
+
Returns the number of entries removed.
|
|
252
|
+
"""
|
|
253
|
+
if days_unused < 0:
|
|
254
|
+
return 0
|
|
255
|
+
data = self.load()
|
|
256
|
+
cutoff = time.time() - (days_unused * 86400)
|
|
257
|
+
kept = []
|
|
258
|
+
removed = 0
|
|
259
|
+
for c in data["components"]:
|
|
260
|
+
if c.get("deprecated"):
|
|
261
|
+
ts = self._parse_ts(c.get("updated_at", ""))
|
|
262
|
+
if ts is not None and ts < cutoff:
|
|
263
|
+
removed += 1
|
|
264
|
+
continue
|
|
265
|
+
kept.append(c)
|
|
266
|
+
if removed:
|
|
267
|
+
data["components"] = kept
|
|
268
|
+
self.save(data)
|
|
269
|
+
return removed
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
# Helpers
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _bump_patch(version: str) -> str:
|
|
276
|
+
"""1.2.3 -> 1.2.4"""
|
|
277
|
+
m = SEMVER_RE.match(version or "")
|
|
278
|
+
if not m:
|
|
279
|
+
return "1.0.0"
|
|
280
|
+
major, minor, patch = m.groups()
|
|
281
|
+
return f"{major}.{minor}.{int(patch) + 1}"
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _parse_ts(value: str) -> Optional[float]:
|
|
285
|
+
"""Parse an ISO 8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ) to epoch seconds."""
|
|
286
|
+
if not value:
|
|
287
|
+
return None
|
|
288
|
+
try:
|
|
289
|
+
t = time.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
|
290
|
+
return time.mktime(t) - time.timezone
|
|
291
|
+
except (ValueError, TypeError):
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Module-level convenience API (called by autonomy/loki cmd_magic)
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def register_component(
|
|
300
|
+
registry_path: str = ".loki/magic/registry.json",
|
|
301
|
+
name: str = "",
|
|
302
|
+
spec_path: str = "",
|
|
303
|
+
target: str = "react",
|
|
304
|
+
react_path: str = "",
|
|
305
|
+
webcomponent_path: str = "",
|
|
306
|
+
test_path: str = "",
|
|
307
|
+
spec_hash: str = "",
|
|
308
|
+
description: str = "",
|
|
309
|
+
tags=None,
|
|
310
|
+
placement=None,
|
|
311
|
+
**extra,
|
|
312
|
+
) -> dict:
|
|
313
|
+
"""Register or update a component entry. Returns the stored entry as dict."""
|
|
314
|
+
tags = list(tags) if tags else []
|
|
315
|
+
if placement:
|
|
316
|
+
tags.append(f"placement:{placement}")
|
|
317
|
+
from pathlib import Path as _P
|
|
318
|
+
project_dir = str(_P(registry_path).parent.parent.parent)
|
|
319
|
+
reg = ComponentRegistry(project_dir)
|
|
320
|
+
entry = ComponentEntry(
|
|
321
|
+
name=name,
|
|
322
|
+
spec_path=spec_path,
|
|
323
|
+
react_path=react_path,
|
|
324
|
+
webcomponent_path=webcomponent_path,
|
|
325
|
+
test_path=test_path,
|
|
326
|
+
spec_hash=spec_hash,
|
|
327
|
+
description=description,
|
|
328
|
+
tags=tags,
|
|
329
|
+
targets=[target] if target != "both" else ["react", "webcomponent"],
|
|
330
|
+
)
|
|
331
|
+
stored = reg.register(entry)
|
|
332
|
+
from dataclasses import asdict as _asdict
|
|
333
|
+
return _asdict(stored)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def prune_registry(registry_path: str = ".loki/magic/registry.json", days_unused: int = 90) -> int:
|
|
337
|
+
"""Remove deprecated entries older than N days. Returns count removed."""
|
|
338
|
+
from pathlib import Path as _P
|
|
339
|
+
project_dir = str(_P(registry_path).parent.parent.parent)
|
|
340
|
+
return ComponentRegistry(project_dir).prune(days_unused)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Spec management for Magic Modules.
|
|
2
|
+
|
|
3
|
+
Specs are markdown files describing desired component behavior. They are
|
|
4
|
+
the source of truth -- implementations regenerate when specs change.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SPEC_TEMPLATE = """# {name}
|
|
14
|
+
|
|
15
|
+
## Description
|
|
16
|
+
{description}
|
|
17
|
+
|
|
18
|
+
## Props
|
|
19
|
+
- `prop1` (string, required): Description
|
|
20
|
+
- `prop2` (boolean, optional, default=false): Description
|
|
21
|
+
|
|
22
|
+
## Behavior
|
|
23
|
+
Describe how the component behaves, state transitions, user interactions.
|
|
24
|
+
|
|
25
|
+
## Visual / Styling
|
|
26
|
+
Describe the visual design: colors, spacing, typography, responsive behavior.
|
|
27
|
+
|
|
28
|
+
## Accessibility
|
|
29
|
+
- Keyboard navigation: [describe]
|
|
30
|
+
- Screen reader: [describe]
|
|
31
|
+
- Focus management: [describe]
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
```tsx
|
|
35
|
+
<{name} prop1="value" />
|
|
36
|
+
```
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ComponentSpec:
|
|
42
|
+
"""Structured representation of a component spec."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
markdown: str
|
|
47
|
+
props: dict = field(default_factory=dict)
|
|
48
|
+
behavior: str = ""
|
|
49
|
+
visual: str = ""
|
|
50
|
+
a11y: list = field(default_factory=list)
|
|
51
|
+
examples: list = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SpecManager:
|
|
55
|
+
"""Manage component specs stored as markdown under .loki/magic/specs/."""
|
|
56
|
+
|
|
57
|
+
# Matches a bullet prop line like:
|
|
58
|
+
# - `name` (type, required): description
|
|
59
|
+
# - `name` (type, optional, default=false): description
|
|
60
|
+
_PROP_LINE = re.compile(
|
|
61
|
+
r"^\s*[-*]\s*`(?P<name>[^`]+)`\s*"
|
|
62
|
+
r"(?:\((?P<meta>[^)]*)\))?"
|
|
63
|
+
r"\s*:\s*(?P<desc>.*)$"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Code fences used to extract examples.
|
|
67
|
+
_CODE_FENCE = re.compile(
|
|
68
|
+
r"```(?P<lang>[a-zA-Z0-9_+-]*)\n(?P<code>.*?)```",
|
|
69
|
+
re.DOTALL,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def __init__(self, project_dir: str = "."):
|
|
73
|
+
self.specs_dir = Path(project_dir) / ".loki" / "magic" / "specs"
|
|
74
|
+
self.specs_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Public API
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def create_spec(self, name: str, description: str) -> ComponentSpec:
|
|
81
|
+
"""Create a new spec file from a description.
|
|
82
|
+
|
|
83
|
+
This creates a scaffolded spec using SPEC_TEMPLATE. A caller can
|
|
84
|
+
later enrich the markdown via AI assistance and re-save.
|
|
85
|
+
"""
|
|
86
|
+
safe_name = self._sanitize_name(name)
|
|
87
|
+
markdown = SPEC_TEMPLATE.format(name=safe_name, description=description)
|
|
88
|
+
spec = self.parse_markdown(markdown)
|
|
89
|
+
# parse_markdown may extract a slightly different name; honor the
|
|
90
|
+
# requested one for filesystem purposes.
|
|
91
|
+
spec.name = safe_name
|
|
92
|
+
self.save_spec(spec)
|
|
93
|
+
return spec
|
|
94
|
+
|
|
95
|
+
def load_spec(self, name: str) -> Optional[ComponentSpec]:
|
|
96
|
+
"""Load existing spec by name."""
|
|
97
|
+
path = self._spec_path(name)
|
|
98
|
+
if not path.exists():
|
|
99
|
+
return None
|
|
100
|
+
markdown = path.read_text(encoding="utf-8")
|
|
101
|
+
spec = self.parse_markdown(markdown)
|
|
102
|
+
# Filesystem name is authoritative.
|
|
103
|
+
spec.name = self._sanitize_name(name)
|
|
104
|
+
return spec
|
|
105
|
+
|
|
106
|
+
def save_spec(self, spec: ComponentSpec) -> Path:
|
|
107
|
+
"""Write spec to disk as .loki/magic/specs/<name>.md."""
|
|
108
|
+
path = self._spec_path(spec.name)
|
|
109
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
path.write_text(spec.markdown, encoding="utf-8")
|
|
111
|
+
return path
|
|
112
|
+
|
|
113
|
+
def parse_markdown(self, md_text: str) -> ComponentSpec:
|
|
114
|
+
"""Parse markdown into a structured ComponentSpec.
|
|
115
|
+
|
|
116
|
+
Recognized sections (case-insensitive header match):
|
|
117
|
+
# <Component Name>
|
|
118
|
+
## Description
|
|
119
|
+
## Props
|
|
120
|
+
## Behavior
|
|
121
|
+
## Visual / Styling (also: Visual, Styling)
|
|
122
|
+
## Accessibility (also: A11y)
|
|
123
|
+
## Examples
|
|
124
|
+
"""
|
|
125
|
+
sections = self._split_sections(md_text)
|
|
126
|
+
|
|
127
|
+
name = sections.get("__title__", "").strip() or "Component"
|
|
128
|
+
description = sections.get("description", "").strip()
|
|
129
|
+
behavior = sections.get("behavior", "").strip()
|
|
130
|
+
visual = (
|
|
131
|
+
sections.get("visual / styling")
|
|
132
|
+
or sections.get("visual")
|
|
133
|
+
or sections.get("styling")
|
|
134
|
+
or ""
|
|
135
|
+
).strip()
|
|
136
|
+
|
|
137
|
+
props_text = sections.get("props", "")
|
|
138
|
+
props = self._parse_props(props_text)
|
|
139
|
+
|
|
140
|
+
a11y_text = (
|
|
141
|
+
sections.get("accessibility")
|
|
142
|
+
or sections.get("a11y")
|
|
143
|
+
or ""
|
|
144
|
+
)
|
|
145
|
+
a11y = self._parse_bullets(a11y_text)
|
|
146
|
+
|
|
147
|
+
examples_text = sections.get("examples", "")
|
|
148
|
+
examples = self._parse_examples(examples_text)
|
|
149
|
+
|
|
150
|
+
return ComponentSpec(
|
|
151
|
+
name=name,
|
|
152
|
+
description=description,
|
|
153
|
+
markdown=md_text,
|
|
154
|
+
props=props,
|
|
155
|
+
behavior=behavior,
|
|
156
|
+
visual=visual,
|
|
157
|
+
a11y=a11y,
|
|
158
|
+
examples=examples,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def list_specs(self) -> list:
|
|
162
|
+
"""List all spec names (without the .md extension)."""
|
|
163
|
+
if not self.specs_dir.exists():
|
|
164
|
+
return []
|
|
165
|
+
return sorted(p.stem for p in self.specs_dir.glob("*.md"))
|
|
166
|
+
|
|
167
|
+
def delete_spec(self, name: str) -> bool:
|
|
168
|
+
"""Delete spec file. Returns True if removed, False if not found."""
|
|
169
|
+
path = self._spec_path(name)
|
|
170
|
+
if not path.exists():
|
|
171
|
+
return False
|
|
172
|
+
path.unlink()
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Internal helpers
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _spec_path(self, name: str) -> Path:
|
|
180
|
+
return self.specs_dir / f"{self._sanitize_name(name)}.md"
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _sanitize_name(name: str) -> str:
|
|
184
|
+
"""Strip whitespace and any path separators from a spec name."""
|
|
185
|
+
cleaned = name.strip()
|
|
186
|
+
# Disallow path traversal characters.
|
|
187
|
+
cleaned = cleaned.replace("/", "_").replace("\\", "_")
|
|
188
|
+
return cleaned
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _split_sections(md_text: str) -> dict:
|
|
192
|
+
"""Split markdown into sections keyed by lowercased heading text.
|
|
193
|
+
|
|
194
|
+
The first H1 is stored under the special key '__title__'.
|
|
195
|
+
"""
|
|
196
|
+
sections: dict = {}
|
|
197
|
+
current_key: Optional[str] = None
|
|
198
|
+
buffer: list = []
|
|
199
|
+
title_captured = False
|
|
200
|
+
|
|
201
|
+
def flush():
|
|
202
|
+
if current_key is not None:
|
|
203
|
+
sections[current_key] = "\n".join(buffer).strip("\n")
|
|
204
|
+
|
|
205
|
+
for line in md_text.splitlines():
|
|
206
|
+
h1 = re.match(r"^#\s+(.*?)\s*$", line)
|
|
207
|
+
h2 = re.match(r"^##\s+(.*?)\s*$", line)
|
|
208
|
+
if h1 and not title_captured:
|
|
209
|
+
sections["__title__"] = h1.group(1).strip()
|
|
210
|
+
title_captured = True
|
|
211
|
+
# Reset buffer so title body does not leak into next section.
|
|
212
|
+
if current_key is not None:
|
|
213
|
+
flush()
|
|
214
|
+
buffer = []
|
|
215
|
+
current_key = None
|
|
216
|
+
continue
|
|
217
|
+
if h2:
|
|
218
|
+
# Close previous section.
|
|
219
|
+
flush()
|
|
220
|
+
current_key = h2.group(1).strip().lower()
|
|
221
|
+
buffer = []
|
|
222
|
+
continue
|
|
223
|
+
buffer.append(line)
|
|
224
|
+
|
|
225
|
+
flush()
|
|
226
|
+
return sections
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def _parse_props(cls, props_text: str) -> dict:
|
|
230
|
+
"""Parse a bullet list of prop definitions into a dict.
|
|
231
|
+
|
|
232
|
+
Each entry looks like:
|
|
233
|
+
- `name` (type, required): description
|
|
234
|
+
- `name` (type, optional, default=false): description
|
|
235
|
+
"""
|
|
236
|
+
props: dict = {}
|
|
237
|
+
for raw in props_text.splitlines():
|
|
238
|
+
line = raw.rstrip()
|
|
239
|
+
if not line.strip():
|
|
240
|
+
continue
|
|
241
|
+
match = cls._PROP_LINE.match(line)
|
|
242
|
+
if not match:
|
|
243
|
+
continue
|
|
244
|
+
name = match.group("name").strip()
|
|
245
|
+
meta_raw = (match.group("meta") or "").strip()
|
|
246
|
+
desc = match.group("desc").strip()
|
|
247
|
+
|
|
248
|
+
prop_type = ""
|
|
249
|
+
required = False
|
|
250
|
+
default = None
|
|
251
|
+
extras: list = []
|
|
252
|
+
|
|
253
|
+
if meta_raw:
|
|
254
|
+
parts = [p.strip() for p in meta_raw.split(",") if p.strip()]
|
|
255
|
+
for idx, part in enumerate(parts):
|
|
256
|
+
lowered = part.lower()
|
|
257
|
+
if idx == 0 and "=" not in part and lowered not in (
|
|
258
|
+
"required",
|
|
259
|
+
"optional",
|
|
260
|
+
):
|
|
261
|
+
prop_type = part
|
|
262
|
+
continue
|
|
263
|
+
if lowered == "required":
|
|
264
|
+
required = True
|
|
265
|
+
elif lowered == "optional":
|
|
266
|
+
required = False
|
|
267
|
+
elif "=" in part:
|
|
268
|
+
key, _, value = part.partition("=")
|
|
269
|
+
key = key.strip().lower()
|
|
270
|
+
value = value.strip()
|
|
271
|
+
if key == "default":
|
|
272
|
+
default = value
|
|
273
|
+
else:
|
|
274
|
+
extras.append(part)
|
|
275
|
+
else:
|
|
276
|
+
extras.append(part)
|
|
277
|
+
|
|
278
|
+
props[name] = {
|
|
279
|
+
"type": prop_type,
|
|
280
|
+
"required": required,
|
|
281
|
+
"default": default,
|
|
282
|
+
"description": desc,
|
|
283
|
+
"extras": extras,
|
|
284
|
+
}
|
|
285
|
+
return props
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _parse_bullets(text: str) -> list:
|
|
289
|
+
"""Extract bullet list items as plain strings."""
|
|
290
|
+
items: list = []
|
|
291
|
+
for raw in text.splitlines():
|
|
292
|
+
line = raw.strip()
|
|
293
|
+
if not line:
|
|
294
|
+
continue
|
|
295
|
+
if line.startswith(("-", "*")):
|
|
296
|
+
items.append(line[1:].strip())
|
|
297
|
+
return items
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
def _parse_examples(cls, text: str) -> list:
|
|
301
|
+
"""Extract fenced code blocks as example dicts."""
|
|
302
|
+
examples: list = []
|
|
303
|
+
for match in cls._CODE_FENCE.finditer(text):
|
|
304
|
+
lang = (match.group("lang") or "").strip()
|
|
305
|
+
code = match.group("code")
|
|
306
|
+
examples.append({"language": lang, "code": code})
|
|
307
|
+
return examples
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Module-level convenience API (called by autonomy/loki cmd_magic)
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def generate_spec(name: str, out_path: str, from_spec=None, from_screenshot=None, description: str = "") -> str:
|
|
315
|
+
"""Generate or copy a component spec to out_path.
|
|
316
|
+
|
|
317
|
+
If from_spec is given, copy its contents. If from_screenshot, delegate to
|
|
318
|
+
the generator's vision path. Otherwise create a template from SPEC_TEMPLATE.
|
|
319
|
+
Returns the path written.
|
|
320
|
+
"""
|
|
321
|
+
from pathlib import Path as _P
|
|
322
|
+
out = _P(out_path)
|
|
323
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
if from_spec:
|
|
325
|
+
out.write_text(_P(from_spec).read_text())
|
|
326
|
+
return str(out)
|
|
327
|
+
if from_screenshot:
|
|
328
|
+
try:
|
|
329
|
+
from magic.core.generator import ComponentGenerator
|
|
330
|
+
md = ComponentGenerator().generate_from_screenshot(from_screenshot, name)
|
|
331
|
+
out.write_text(md)
|
|
332
|
+
return str(out)
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
desc = description or f"Loki Magic component {name}."
|
|
336
|
+
out.write_text(SPEC_TEMPLATE.format(name=name, description=desc))
|
|
337
|
+
return str(out)
|