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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- 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()
|
tools/asset_extractor.py
ADDED
|
@@ -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())
|