differix 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. tools/web_search.py +371 -0
tools/agents_chat.py ADDED
@@ -0,0 +1,152 @@
1
+ # tools/agents_chat.py
2
+ import sys
3
+ import json
4
+ import os
5
+ import config
6
+ from tools.llm_client import safe_chat_completion
7
+ from tools.lesson_manager import (
8
+ get_promoted_lessons_prompt,
9
+ get_observations_prompt,
10
+ get_candidates_prompt,
11
+ get_deprecated_prompt
12
+ )
13
+ from tools.skill_manager import get_combined_agent_skill
14
+
15
+ try:
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.prompt import Prompt
19
+ console = Console()
20
+ HAS_RICH = True
21
+ except ImportError:
22
+ console = None
23
+ HAS_RICH = False
24
+
25
+ AGENTS = {
26
+ "1": ("Researcher", "researcher"),
27
+ "2": ("Editor", "editor"),
28
+ "3": ("Reviewer", "reviewer"),
29
+ }
30
+
31
+ # Framework internal files that must never leak into workspace trees
32
+ FRAMEWORK_EXCLUSIONS = {
33
+ ".env", "config.py", "state.json", "agents_chat.py", "agents_chat_2.py",
34
+ "deduplicate.py", ".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"
35
+ }
36
+
37
+ def get_project_context(project_dir="."):
38
+ """Scans target project directory while keeping internal framework files hidden."""
39
+ files_list = []
40
+ base_path = os.path.abspath(project_dir)
41
+ for root, dirs, files in os.walk(base_path):
42
+ dirs[:] = [d for d in dirs if d not in FRAMEWORK_EXCLUSIONS]
43
+ for f in files:
44
+ if f in FRAMEWORK_EXCLUSIONS or f.endswith(".pyc"):
45
+ continue
46
+ rel_path = os.path.relpath(os.path.join(root, f), base_path)
47
+ files_list.append(rel_path)
48
+ return "\n".join(files_list[:100]) or "Empty project directory."
49
+
50
+ def get_iteration_logs_prompt(state=None):
51
+ """Retrieves standard iteration execution logs if available."""
52
+ if state and "iteration_logs" in state:
53
+ logs = state.get("iteration_logs", [])
54
+ if isinstance(logs, list):
55
+ return "\n".join([f"- {log}" for log in logs[-10:]])
56
+ return str(logs)
57
+ return "No iteration logs recorded."
58
+
59
+ def handle_msg_command(project_dir=".", state=None):
60
+ state = state or {}
61
+ if HAS_RICH:
62
+ console.print(Panel("[bold cyan]VegeX Interactive Agent Terminal[/bold cyan]", border_style="cyan"))
63
+ console.print("Select an agent to communicate with:")
64
+ console.print(" [1] Researcher (Super-User: Full Lesson & Memory Access)\n [2] Editor (Consumer: Read-Only Promoted Lessons)\n [3] Reviewer (Consumer: Read-Only Promoted Lessons)")
65
+ choice = Prompt.ask("Select agent", choices=["1", "2", "3"], default="1")
66
+ else:
67
+ print("\n--- VegeX Interactive Agent Terminal ---")
68
+ print("Select an agent:\n [1] Researcher (Super User)\n [2] Editor\n [3] Reviewer")
69
+ choice = input("Select agent (1-3) [default 1]: ").strip() or "1"
70
+ if choice not in AGENTS:
71
+ print("[!] Invalid agent selected.")
72
+ return
73
+
74
+ agent_name, role_key = AGENTS[choice]
75
+ # Динамическое получение активной модели из config
76
+ model_name = config.get_active_model(role_key)
77
+
78
+ # Dynamically load the combined role-specific common rule (.md) and domain skill
79
+ skill_prompt = get_combined_agent_skill(role_key, project_dir)
80
+ project_files = get_project_context(project_dir)
81
+ iteration_logs = get_iteration_logs_prompt(state)
82
+
83
+ if choice == "1":
84
+ # Researcher super-user access
85
+ lessons_context = (
86
+ f"--- PROMOTED LESSONS ---\n{get_promoted_lessons_prompt()}\n\n"
87
+ f"--- CANDIDATES ---\n{get_candidates_prompt()}\n\n"
88
+ f"--- RAW OBSERVATIONS ---\n{get_observations_prompt()}\n\n"
89
+ f"--- DEPRECATED ---\n{get_deprecated_prompt()}"
90
+ )
91
+ role_instruction = (
92
+ "You are the LESSON SUPER-USER & ARCHITECT. You have access to all memory lifecycle stages. "
93
+ "You may observe, propose candidate rules, refactor lessons, and analyze root causes."
94
+ )
95
+ else:
96
+ # Editor / Reviewer consumer access
97
+ lessons_context = f"--- PROMOTED LESSONS ONLY ---\n{get_promoted_lessons_prompt()}"
98
+ role_instruction = (
99
+ "You are a LESSON CONSUMER. You have read-only access to promoted lessons. "
100
+ "You CANNOT inspect raw observations or manage system memory directly."
101
+ )
102
+
103
+ system_prompt = (
104
+ f"{skill_prompt}\n\n"
105
+ f"=== WORKSPACE LESSONS & MEMORY ===\n{lessons_context}\n\n"
106
+ f"=== ITERATION EXECUTION LOGS ===\n{iteration_logs}\n\n"
107
+ f"=== SANDBOXED PROJECT TREE ({project_dir}) ===\n{project_files}\n\n"
108
+ f"INSTRUCTION: {role_instruction}\n"
109
+ "SECURITY RULE: Never attempt to scan or inspect files outside the target project folder."
110
+ )
111
+ messages = [{"role": "system", "content": system_prompt}]
112
+
113
+ if HAS_RICH:
114
+ console.print(f"\n[bold green]Connected to {agent_name} ({model_name}). Type 'exit' or 'back' to return.[/bold green]\n")
115
+ else:
116
+ print(f"\n[+] Connected to {agent_name} ({model_name}). Type 'exit' or 'back' to return.\n")
117
+
118
+ while True:
119
+ try:
120
+ user_input = Prompt.ask(f"[bold yellow]You -> {agent_name}[/bold yellow]").strip() if HAS_RICH else input(f"You -> {agent_name}: ").strip()
121
+
122
+ if not user_input:
123
+ continue
124
+ if user_input.lower() in ["exit", "back", "quit"]:
125
+ print("[+] Exiting chat session...\n")
126
+ break
127
+
128
+ messages.append({"role": "user", "content": user_input})
129
+
130
+ response, provider = safe_chat_completion(
131
+ role_or_model=model_name,
132
+ messages=messages,
133
+ temperature=0.3
134
+ )
135
+
136
+ reply = response.choices[0].message.content.strip()
137
+ messages.append({"role": "assistant", "content": reply})
138
+
139
+ if HAS_RICH:
140
+ console.print(Panel(reply, title=f"{agent_name} ({provider})", border_style="bold blue"))
141
+ else:
142
+ print(f"\n{agent_name} ({provider}):\n{reply}\n")
143
+
144
+ except (KeyboardInterrupt, EOFError):
145
+ print("\n[+] Session terminated.")
146
+ break
147
+
148
+ def start_chat():
149
+ handle_msg_command()
150
+
151
+ if __name__ == "__main__":
152
+ start_chat()
@@ -0,0 +1,545 @@
1
+ """
2
+ tools/asset_extractor.py
3
+ from plugin: webdev
4
+ ------------------------
5
+ Extract DifferiX components (.md) from local HTML "part" files.
6
+
7
+ Designed for the parts/*.html convention used by web projects: each part
8
+ is a self-contained HTML file that includes the component's markup, its
9
+ own <style> block, and (optionally) a link to a shared stylesheet plus
10
+ a <script> block.
11
+
12
+ What it does:
13
+ 1. Reads the HTML file.
14
+ 2. Extracts body content (ignoring <head> metadata).
15
+ 3. Extracts <style> blocks — from <head> AND from <body>.
16
+ 4. Extracts <script> blocks.
17
+ 5. Reads any <link rel="stylesheet"> targets if they exist locally.
18
+ 6. Filters the linked CSS to only rules whose selectors reference
19
+ classes present in the body.
20
+ 7. Emits a component .md with frontmatter, HTML, CSS, and JS.
21
+
22
+ What it does NOT do (v1):
23
+ - Rename classes
24
+ - Resolve inline styles
25
+ - Split multi-component files
26
+ - Infer `requires` dependencies from class references
27
+
28
+ Public API:
29
+ extract_component(input_path, output_dir, name=None) -> ComponentResult
30
+ extract_all(input_dir, output_dir, glob="*.html") -> List[ComponentResult]
31
+
32
+ CLI:
33
+ python tools/asset_extractor.py parts/ -o components/
34
+ python tools/asset_extractor.py parts/cta-btn.html -o components/
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import logging
41
+ import os
42
+ import re
43
+ import sys
44
+ import time
45
+ from dataclasses import dataclass, field, asdict
46
+ from html.parser import HTMLParser
47
+ from pathlib import Path
48
+ from typing import Dict, List, Optional, Set, Tuple
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Result type
55
+ # ---------------------------------------------------------------------------
56
+ @dataclass
57
+ class ComponentResult:
58
+ success: bool
59
+ component_id: str = ""
60
+ output_path: str = ""
61
+ markdown: str = ""
62
+ classes: List[str] = field(default_factory=list)
63
+ css_rules_kept: int = 0
64
+ css_rules_total: int = 0
65
+ js_blocks: int = 0
66
+ source: str = ""
67
+ error: str = ""
68
+
69
+ def as_dict(self) -> Dict:
70
+ return asdict(self)
71
+
72
+
73
+ class ExtractionError(Exception):
74
+ pass
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # HTML parser
79
+ # ---------------------------------------------------------------------------
80
+ class _PartParser(HTMLParser):
81
+ """
82
+ Extract the pieces we care about from a parts/*.html file:
83
+ - <style> blocks (head and body)
84
+ - <script> blocks
85
+ - <link rel="stylesheet" href="..."> targets
86
+ - <body> inner content
87
+ """
88
+
89
+ def __init__(self) -> None:
90
+ super().__init__(convert_charrefs=False)
91
+ self.styles: List[str] = []
92
+ self.scripts: List[str] = []
93
+ self.stylesheet_hrefs: List[str] = []
94
+ self.body_content: str = ""
95
+
96
+ self._in_style = False
97
+ self._style_buffer: List[str] = []
98
+
99
+ self._in_script = False
100
+ self._script_buffer: List[str] = []
101
+
102
+ self._in_body = False
103
+ self._body_depth = 0
104
+ self._body_start: Optional[int] = None
105
+ self._raw_html: str = ""
106
+
107
+ def feed(self, data: str) -> None:
108
+ self._raw_html = data
109
+ super().feed(data)
110
+
111
+ # ---- start tags ----
112
+ def handle_starttag(self, tag: str, attrs) -> None:
113
+ tag_lower = tag.lower()
114
+ attr_dict = {k.lower(): (v or "") for k, v in attrs}
115
+
116
+ if tag_lower == "style":
117
+ self._in_style = True
118
+ self._style_buffer = []
119
+ elif tag_lower == "script":
120
+ # ignore external scripts — we only keep inline JS
121
+ if "src" not in attr_dict:
122
+ self._in_script = True
123
+ self._script_buffer = []
124
+ elif tag_lower == "link":
125
+ rel = attr_dict.get("rel", "").lower()
126
+ href = attr_dict.get("href", "")
127
+ if "stylesheet" in rel and href:
128
+ self.stylesheet_hrefs.append(href)
129
+ elif tag_lower == "body":
130
+ self._in_body = True
131
+ self._body_depth = 1
132
+ self._body_start = self.getpos()
133
+
134
+ def handle_endtag(self, tag: str) -> None:
135
+ tag_lower = tag.lower()
136
+ if tag_lower == "style" and self._in_style:
137
+ self.styles.append("".join(self._style_buffer))
138
+ self._in_style = False
139
+ elif tag_lower == "script" and self._in_script:
140
+ self.scripts.append("".join(self._script_buffer))
141
+ self._in_script = False
142
+ elif tag_lower == "body":
143
+ self._in_body = False
144
+
145
+ def handle_data(self, data: str) -> None:
146
+ if self._in_style:
147
+ self._style_buffer.append(data)
148
+ elif self._in_script:
149
+ self._script_buffer.append(data)
150
+
151
+
152
+ def _extract_body_content(html: str) -> str:
153
+ """
154
+ Extract the inner content of <body>...</body>.
155
+
156
+ Uses a brace-aware walk rather than a regex so it survives nested
157
+ <body> strings in scripts. Returns "" if no <body> tag is found.
158
+ """
159
+ m = re.search(r"<body\b[^>]*>", html, re.IGNORECASE)
160
+ if not m:
161
+ return ""
162
+ start = m.end()
163
+ m2 = re.search(r"</body\s*>", html[start:], re.IGNORECASE)
164
+ if not m2:
165
+ return html[start:]
166
+ end = start + m2.start()
167
+ return html[start:end].strip()
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Class name extraction
172
+ # ---------------------------------------------------------------------------
173
+ _CLASS_ATTR_RE = re.compile(r'class\s*=\s*"([^"]*)"', re.IGNORECASE)
174
+ _CLASS_ATTR_RE_SINGLE = re.compile(r"class\s*=\s*'([^']*)'", re.IGNORECASE)
175
+ _CSS_CLASS_IN_SELECTOR = re.compile(r"\.([a-zA-Z_][\w-]*)")
176
+
177
+
178
+ def _classes_in_html(html: str) -> Set[str]:
179
+ out: Set[str] = set()
180
+ for pat in (_CLASS_ATTR_RE, _CLASS_ATTR_RE_SINGLE):
181
+ for m in pat.finditer(html):
182
+ for tok in m.group(1).split():
183
+ tok = tok.strip()
184
+ if tok:
185
+ out.add(tok)
186
+ return out
187
+
188
+
189
+ _JS_CLASS_PATTERNS = (
190
+ re.compile(r"""querySelector(?:All)?\(\s*['"]([^'"]+)['"]"""),
191
+ re.compile(r"""classList\.(?:add|remove|toggle|contains)\(\s*['"]([^'"]+)['"]"""),
192
+ re.compile(r"""className\s*=\s*['"]([^'"]+)['"]"""),
193
+ re.compile(r"""class\s*=\s*\\?['"]([a-zA-Z][\w\s-]*)\\?['"]""", re.IGNORECASE),
194
+ )
195
+
196
+
197
+ def _classes_in_js(js: str) -> Set[str]:
198
+ """Extract class names referenced by JS through common patterns."""
199
+ out: Set[str] = set()
200
+ for pattern in _JS_CLASS_PATTERNS:
201
+ for m in pattern.finditer(js):
202
+ token = m.group(1)
203
+ # querySelector returns a selector string; pull out `.class` tokens
204
+ for cls in _CSS_CLASS_IN_SELECTOR.findall(token):
205
+ out.add(cls)
206
+ # className and class="..." return a raw list of class names
207
+ for cls in re.split(r"\s+", token):
208
+ cls = cls.strip()
209
+ if cls and not cls.startswith("."):
210
+ # only if it looks like a class (no dots, no brackets)
211
+ if re.fullmatch(r"[a-zA-Z][\w-]*", cls):
212
+ out.add(cls)
213
+ return out
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # CSS parsing (brace-aware, handles @media correctly)
218
+ # ---------------------------------------------------------------------------
219
+ def _strip_css_comments(css: str) -> str:
220
+ return re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
221
+
222
+
223
+ def _walk_css_rules(css: str) -> List[Tuple[str, str]]:
224
+ """
225
+ Return a flat list of (selector, body) pairs. @media blocks are
226
+ recursed into and their inner rules are prefixed with the media query.
227
+ """
228
+ out: List[Tuple[str, str]] = []
229
+ i = 0
230
+ n = len(css)
231
+ while i < n:
232
+ while i < n and css[i].isspace():
233
+ i += 1
234
+ if i >= n:
235
+ break
236
+ start = i
237
+ while i < n and css[i] != "{":
238
+ i += 1
239
+ if i >= n:
240
+ break
241
+ selector = css[start:i].strip()
242
+ # find matching close brace
243
+ depth = 1
244
+ j = i + 1
245
+ while j < n and depth > 0:
246
+ if css[j] == "{":
247
+ depth += 1
248
+ elif css[j] == "}":
249
+ depth -= 1
250
+ j += 1
251
+ body = css[i + 1 : j - 1]
252
+ # @media / @supports: recurse
253
+ if selector.startswith("@"):
254
+ inner = _walk_css_rules(body)
255
+ for inner_sel, inner_body in inner:
256
+ out.append((f"{selector} {{ {inner_sel} }}", inner_body))
257
+ else:
258
+ out.append((selector, body.strip()))
259
+ i = j
260
+ return out
261
+
262
+
263
+ def _filter_css_by_classes(
264
+ css: str, class_set: Set[str]
265
+ ) -> Tuple[str, int, int]:
266
+ """
267
+ Return (filtered_css, kept_count, total_count).
268
+
269
+ A rule is kept if any class referenced in its selector is in class_set.
270
+ Rules with identical selectors are merged into a single rule with
271
+ concatenated bodies.
272
+ """
273
+ rules = _walk_css_rules(_strip_css_comments(css))
274
+ total = len(rules)
275
+
276
+ # Preserve order; merge on exact selector match
277
+ ordered_selectors: List[str] = []
278
+ bodies_by_selector: Dict[str, List[str]] = {}
279
+
280
+ for selector, body in rules:
281
+ refs = set(_CSS_CLASS_IN_SELECTOR.findall(selector))
282
+ if not (refs & class_set):
283
+ continue
284
+ if selector not in bodies_by_selector:
285
+ ordered_selectors.append(selector)
286
+ bodies_by_selector[selector] = []
287
+ bodies_by_selector[selector].append(body)
288
+
289
+ kept = sum(len(v) for v in bodies_by_selector.values())
290
+
291
+ # Reconstruct with merged bodies and normalized indentation
292
+ output_lines: List[str] = []
293
+ for selector in ordered_selectors:
294
+ merged_body = "\n".join(bodies_by_selector[selector])
295
+ normalized = _normalize_css_body(merged_body)
296
+ output_lines.append(f"{selector} {{\n{normalized}\n}}")
297
+
298
+ return "\n\n".join(output_lines), kept, total
299
+
300
+
301
+ def _normalize_css_body(body: str) -> str:
302
+ """
303
+ Rewrite a CSS body block with consistent 2-space indentation.
304
+ Original bodies often have mixed tabs/spaces and leftover indentation
305
+ from their source file.
306
+ """
307
+ lines = [line.strip() for line in body.splitlines() if line.strip()]
308
+ return "\n".join(f" {line}" for line in lines)
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Category inference
313
+ # ---------------------------------------------------------------------------
314
+ _CATEGORY_HINTS: List[Tuple[str, List[str]]] = [
315
+ # More specific categories first. Each hint checks against the
316
+ # component's source filename and its <body> content.
317
+ ("footer", ["site-footer", "footer-container"]),
318
+ ("nav", ["nav-search", "nav-links", "nav-hamburger", "navbar", "<nav"]),
319
+ ("language", ["custom-lang-selector", "lang-dropdown", "googtrans"]),
320
+ ("form", ["form-input", "form-label", "form-row"]),
321
+ ("hero", ["hero-title", "hero-split", "hero-section", "hero-headline"]),
322
+ ("card", ["feature-card", "card-icon", "card-title"]),
323
+ ("data", ["result-row", "result-label", "result-output"]),
324
+ ("button", ["cta-btn", "btn-primary", "btn-secondary"]),
325
+ ]
326
+
327
+
328
+ def _infer_category(source_name: str, html: str) -> str:
329
+ hay = (source_name + " " + html).lower()
330
+ for cat, keywords in _CATEGORY_HINTS:
331
+ if any(k in hay for k in keywords):
332
+ return cat
333
+ return "component"
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Public API
338
+ # ---------------------------------------------------------------------------
339
+ def _slug(name: str) -> str:
340
+ s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
341
+ return s or "component"
342
+
343
+
344
+ def extract_component(
345
+ input_path: str,
346
+ output_dir: str = "components",
347
+ name: Optional[str] = None,
348
+ ) -> ComponentResult:
349
+ """
350
+ Extract a single parts/*.html file into a .md component.
351
+ Never raises — check .success and .error.
352
+ """
353
+ result = ComponentResult(success=False, source=input_path)
354
+
355
+ path = Path(input_path)
356
+ if not path.exists():
357
+ result.error = f"Input file does not exist: {input_path}"
358
+ return result
359
+
360
+ try:
361
+ html = path.read_text(encoding="utf-8", errors="replace")
362
+ except OSError as exc:
363
+ result.error = f"Failed to read {input_path}: {exc}"
364
+ return result
365
+
366
+ parser = _PartParser()
367
+ try:
368
+ parser.feed(html)
369
+ except Exception as exc:
370
+ logger.debug("HTML parse partial: %s", exc)
371
+
372
+ body_content = _extract_body_content(html)
373
+ if not body_content.strip():
374
+ result.error = f"No <body> content found in {input_path}"
375
+ return result
376
+
377
+ classes = sorted(_classes_in_html(body_content))
378
+ result.classes = classes
379
+
380
+ # ---- Collect CSS sources ----
381
+ css_parts: List[str] = []
382
+
383
+ # inline <style> blocks (head + body)
384
+ css_parts.extend(parser.styles)
385
+
386
+ # linked local stylesheets
387
+ input_dir = path.parent
388
+ for href in parser.stylesheet_hrefs:
389
+ if href.startswith(("http://", "https://", "//")):
390
+ continue
391
+ candidate = (input_dir / href).resolve()
392
+ if candidate.exists() and candidate.is_file():
393
+ try:
394
+ css_parts.append(candidate.read_text(encoding="utf-8"))
395
+ except OSError:
396
+ pass
397
+
398
+ raw_css = "\n\n".join(css_parts)
399
+
400
+ # ---- JS blocks ----
401
+ inline_js = "\n\n".join(s.strip() for s in parser.scripts if s.strip())
402
+ result.js_blocks = len([s for s in parser.scripts if s.strip()])
403
+
404
+ # JS-referenced classes must be included so that rules for classes
405
+ # injected at runtime (e.g. .sri rows into .search-dropdown) survive
406
+ # the CSS filter.
407
+ js_classes = _classes_in_js(inline_js)
408
+ all_classes_for_css = set(classes) | js_classes
409
+
410
+ # (re-filter CSS with the expanded class set)
411
+ filtered_css, kept, total = _filter_css_by_classes(raw_css, all_classes_for_css)
412
+ result.css_rules_kept = kept
413
+ result.css_rules_total = total
414
+
415
+ # ---- Frontmatter + markdown ----
416
+ component_id = name or _slug(path.stem)
417
+ category = _infer_category(path.stem, body_content)
418
+ tags = _infer_tags(component_id, category, classes)
419
+
420
+ md_lines: List[str] = [
421
+ "---",
422
+ f"id: {component_id}",
423
+ f"category: {category}",
424
+ f"tags: [{', '.join(tags)}]",
425
+ f"source: {path.name}",
426
+ f"classes: [{', '.join(classes)}]",
427
+ "---",
428
+ "",
429
+ f"# {component_id}",
430
+ "",
431
+ "## HTML",
432
+ "",
433
+ "```html",
434
+ body_content.strip(),
435
+ "```",
436
+ "",
437
+ "## CSS",
438
+ "",
439
+ "```css",
440
+ filtered_css.strip() if filtered_css.strip() else "/* no matching CSS rules */",
441
+ "```",
442
+ ]
443
+
444
+ if inline_js:
445
+ md_lines.extend(["", "## JavaScript", "", "```javascript", inline_js.strip(), "```"])
446
+
447
+ md_lines.append("")
448
+ markdown = "\n".join(md_lines)
449
+
450
+ # ---- Write output ----
451
+ try:
452
+ out_dir = Path(output_dir)
453
+ out_dir.mkdir(parents=True, exist_ok=True)
454
+ out_path = out_dir / f"{component_id}.md"
455
+ out_path.write_text(markdown, encoding="utf-8")
456
+ result.output_path = str(out_path)
457
+ except OSError as exc:
458
+ result.error = f"Failed to write output: {exc}"
459
+ return result
460
+
461
+ result.markdown = markdown
462
+ result.component_id = component_id
463
+ result.success = True
464
+ return result
465
+
466
+
467
+ def _infer_tags(component_id: str, category: str, classes: List[str]) -> List[str]:
468
+ """Infer 3-6 tags from id, category, and top classes."""
469
+ tags = {category}
470
+ for chunk in component_id.split("-"):
471
+ if len(chunk) > 2:
472
+ tags.add(chunk)
473
+ for cls in classes[:4]:
474
+ for chunk in cls.split("-"):
475
+ if len(chunk) > 2 and chunk not in ("the", "and"):
476
+ tags.add(chunk)
477
+ return sorted(tags)[:8]
478
+
479
+
480
+ def extract_all(
481
+ input_dir: str,
482
+ output_dir: str = "components",
483
+ glob: str = "*.html",
484
+ ) -> List[ComponentResult]:
485
+ """Extract every *.html file in input_dir."""
486
+ results: List[ComponentResult] = []
487
+ src = Path(input_dir)
488
+ if not src.is_dir():
489
+ logger.error("Not a directory: %s", input_dir)
490
+ return results
491
+
492
+ for path in sorted(src.glob(glob)):
493
+ logger.info("Extracting %s", path.name)
494
+ results.append(extract_component(str(path), output_dir=output_dir))
495
+
496
+ return results
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # CLI
501
+ # ---------------------------------------------------------------------------
502
+ def _cli() -> int:
503
+ parser = argparse.ArgumentParser(
504
+ description="Extract DifferiX .md components from local HTML part files.",
505
+ )
506
+ parser.add_argument(
507
+ "input",
508
+ help="A single .html file or a directory containing *.html files",
509
+ )
510
+ parser.add_argument(
511
+ "-o", "--output-dir", default="components",
512
+ help="Directory to write .md files (default: components)",
513
+ )
514
+ parser.add_argument("--verbose", "-v", action="store_true")
515
+ args = parser.parse_args()
516
+
517
+ logging.basicConfig(
518
+ level=logging.DEBUG if args.verbose else logging.INFO,
519
+ format="%(levelname)s %(message)s",
520
+ )
521
+
522
+ inp = Path(args.input)
523
+ if inp.is_dir():
524
+ results = extract_all(str(inp), output_dir=args.output_dir)
525
+ else:
526
+ results = [extract_component(str(inp), output_dir=args.output_dir)]
527
+
528
+ ok = 0
529
+ for r in results:
530
+ if r.success:
531
+ ok += 1
532
+ print(
533
+ f"✓ {r.component_id:24s} → {r.output_path} "
534
+ f"({r.css_rules_kept}/{r.css_rules_total} css rules, "
535
+ f"{r.js_blocks} js blocks)"
536
+ )
537
+ else:
538
+ print(f"✗ {r.source}: {r.error}", file=sys.stderr)
539
+
540
+ print(f"\n{ok}/{len(results)} extracted.")
541
+ return 0 if ok == len(results) else 1
542
+
543
+
544
+ if __name__ == "__main__":
545
+ sys.exit(_cli())