carterkit 0.1.0__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.
- carterkit/__init__.py +69 -0
- carterkit/buffer.py +230 -0
- carterkit/catalog.py +285 -0
- carterkit/client.py +138 -0
- carterkit/codegen.py +188 -0
- carterkit/controldocs/accordion.md +99 -0
- carterkit/controldocs/actions.md +51 -0
- carterkit/controldocs/animations.md +41 -0
- carterkit/controldocs/appearance.md +67 -0
- carterkit/controldocs/button.md +143 -0
- carterkit/controldocs/cardList.md +25 -0
- carterkit/controldocs/carousel.md +155 -0
- carterkit/controldocs/chat.md +114 -0
- carterkit/controldocs/color-picker.md +84 -0
- carterkit/controldocs/control-def.md +101 -0
- carterkit/controldocs/date-picker.md +148 -0
- carterkit/controldocs/divider.md +59 -0
- carterkit/controldocs/flip-card.md +99 -0
- carterkit/controldocs/gauge.md +198 -0
- carterkit/controldocs/graph.md +318 -0
- carterkit/controldocs/group-def.md +103 -0
- carterkit/controldocs/haptics.md +32 -0
- carterkit/controldocs/image.md +110 -0
- carterkit/controldocs/index.md +141 -0
- carterkit/controldocs/joystick.md +111 -0
- carterkit/controldocs/label.md +138 -0
- carterkit/controldocs/layout-config.md +129 -0
- carterkit/controldocs/list.md +92 -0
- carterkit/controldocs/log-console.md +121 -0
- carterkit/controldocs/long-press.md +46 -0
- carterkit/controldocs/map.md +165 -0
- carterkit/controldocs/picker.md +130 -0
- carterkit/controldocs/privacy.md +52 -0
- carterkit/controldocs/progress-ring.md +139 -0
- carterkit/controldocs/pulse.md +82 -0
- carterkit/controldocs/qr-code.md +101 -0
- carterkit/controldocs/segmented.md +145 -0
- carterkit/controldocs/slider.md +235 -0
- carterkit/controldocs/spacer.md +36 -0
- carterkit/controldocs/sparkline.md +120 -0
- carterkit/controldocs/status-light.md +114 -0
- carterkit/controldocs/stepper.md +131 -0
- carterkit/controldocs/sync.md +50 -0
- carterkit/controldocs/terms.md +40 -0
- carterkit/controldocs/text-input.md +126 -0
- carterkit/controldocs/theming.md +104 -0
- carterkit/controldocs/toggle.md +185 -0
- carterkit/controldocs/visibility.md +43 -0
- carterkit/controldocs/web-view.md +97 -0
- carterkit/e2ee.py +47 -0
- carterkit/grid.py +114 -0
- carterkit/infer.py +137 -0
- carterkit/theming.py +157 -0
- carterkit/tune.py +129 -0
- carterkit/validate.py +132 -0
- carterkit-0.1.0.dist-info/METADATA +98 -0
- carterkit-0.1.0.dist-info/RECORD +60 -0
- carterkit-0.1.0.dist-info/WHEEL +5 -0
- carterkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- carterkit-0.1.0.dist-info/top_level.txt +1 -0
carterkit/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""carterkit — build and drive CAR-TER layouts from Python.
|
|
2
|
+
|
|
3
|
+
The control vocabulary *is* the bundled documentation: every control's schema,
|
|
4
|
+
fields, and examples are parsed at runtime from the ControlDocs markdown shipped
|
|
5
|
+
inside this package (``carterkit/controldocs/``) — the same docs the CAR-TER app
|
|
6
|
+
renders. So the docs never drift from the definitions; they are one and the same.
|
|
7
|
+
|
|
8
|
+
Quick map:
|
|
9
|
+
- ``controls()`` / ``doc()`` / ``examples()`` — the docs-as-catalog surface
|
|
10
|
+
- ``LayoutBuffer`` — incrementally build a layout (auto-placement, dedupe)
|
|
11
|
+
- ``validate_layout()`` — schema + grid lint against the bundled catalog
|
|
12
|
+
- ``infer`` / ``codegen`` / ``theming`` / ``tune`` — generate layouts, servers, themes
|
|
13
|
+
- ``CarterClient`` / ``notify_http`` — connect over MeshSocket, push, send alerts
|
|
14
|
+
"""
|
|
15
|
+
from importlib.resources import files
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import catalog, grid, codegen, infer, theming, tune
|
|
19
|
+
from .buffer import LayoutBuffer, BufferError
|
|
20
|
+
from .validate import validate_layout as _validate_layout, format_findings
|
|
21
|
+
from .client import CarterClient, notify_http, CarterNotifyError
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
#: The layout/wire protocol version carterkit emits and understands. The JSON
|
|
26
|
+
#: contract — not this Python API — is the real compatibility boundary across the
|
|
27
|
+
#: app, the relay, and this library; unknown fields are tolerated on read.
|
|
28
|
+
PROTOCOL_VERSION = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def controldocs_dir() -> Path:
|
|
32
|
+
"""Filesystem path to the bundled ControlDocs markdown (the source of truth)."""
|
|
33
|
+
return Path(files(__package__) / "controldocs")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def controls(types=None, include_theme: bool = False) -> dict:
|
|
37
|
+
"""Machine-readable schema for every placeable control, keyed by layout ``type``."""
|
|
38
|
+
return catalog.build_catalog(controldocs_dir(), types=types, include_theme=include_theme)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def doc(control: str):
|
|
42
|
+
"""Full parsed doc (fields, themeFields, body, examples) for a control type or node_id."""
|
|
43
|
+
return catalog.resolve_doc(controldocs_dir(), control)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def doc_markdown(control: str):
|
|
47
|
+
"""The control's human/AI documentation prose (the rendered markdown body)."""
|
|
48
|
+
d = doc(control)
|
|
49
|
+
return d["body"] if d else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def examples(control: str):
|
|
53
|
+
"""Documented example snippets for a control: ``[{"name", "json"}, ...]``."""
|
|
54
|
+
return catalog.get_examples(controldocs_dir(), control)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_layout(layout: dict, catalog_: dict = None) -> list:
|
|
58
|
+
"""Lint a layout (schema + grid). Defaults to the bundled control catalog."""
|
|
59
|
+
return _validate_layout(layout, catalog_ if catalog_ is not None else controls(include_theme=True))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"__version__", "PROTOCOL_VERSION",
|
|
64
|
+
"CarterClient", "notify_http", "CarterNotifyError",
|
|
65
|
+
"LayoutBuffer", "BufferError",
|
|
66
|
+
"controls", "doc", "doc_markdown", "examples", "validate_layout",
|
|
67
|
+
"format_findings", "controldocs_dir",
|
|
68
|
+
"catalog", "grid", "codegen", "infer", "theming", "tune",
|
|
69
|
+
]
|
carterkit/buffer.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Working-layout buffer + incremental patch ops.
|
|
2
|
+
|
|
3
|
+
The headline authoring model: instead of re-emitting an 800-line layout on every
|
|
4
|
+
change, the LLM issues surgical operations against a server-held draft. The buffer
|
|
5
|
+
keeps the full document; each op mutates it and the result is pushed to the device as
|
|
6
|
+
a full layout (which it already knows how to render). Pure (no I/O, no socket) so it
|
|
7
|
+
is fully unit-testable; the async push/save live in server.py.
|
|
8
|
+
|
|
9
|
+
Top-level children (controls + groups) of a tab are addressable by id. Editing inside
|
|
10
|
+
a group is not yet supported (ids inside groups still count for uniqueness).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from . import grid as gridmod
|
|
19
|
+
|
|
20
|
+
DEFAULT_COLUMNS = 4
|
|
21
|
+
DEFAULT_ROWS = 8
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BufferError(Exception):
|
|
25
|
+
"""Raised on an invalid buffer operation (rendered to the user as a tool error)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LayoutBuffer:
|
|
29
|
+
def __init__(self, layout: dict):
|
|
30
|
+
self.layout = layout
|
|
31
|
+
|
|
32
|
+
# ─── construction ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def blank(cls, name: str = "Untitled", columns: int = DEFAULT_COLUMNS,
|
|
36
|
+
rows: int = DEFAULT_ROWS, accent: str = "#667eea",
|
|
37
|
+
tab_title: str = "Tab 1", tab_icon: str = "house.fill") -> "LayoutBuffer":
|
|
38
|
+
return cls({
|
|
39
|
+
"name": name,
|
|
40
|
+
"version": 1,
|
|
41
|
+
"accentColor": accent,
|
|
42
|
+
"tabs": [{
|
|
43
|
+
"title": tab_title,
|
|
44
|
+
"icon": tab_icon,
|
|
45
|
+
"grid": {"columns": columns, "rows": rows},
|
|
46
|
+
"children": [],
|
|
47
|
+
}],
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_layout(cls, layout: dict) -> "LayoutBuffer":
|
|
52
|
+
if not isinstance(layout, dict) or "tabs" not in layout:
|
|
53
|
+
raise BufferError("source layout must be an object with a 'tabs' array")
|
|
54
|
+
return cls(copy.deepcopy(layout))
|
|
55
|
+
|
|
56
|
+
# ─── access ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def tabs(self) -> list:
|
|
60
|
+
return self.layout.setdefault("tabs", [])
|
|
61
|
+
|
|
62
|
+
def _tab(self, i: int) -> dict:
|
|
63
|
+
tabs = self.tabs
|
|
64
|
+
if i < 0 or i >= len(tabs):
|
|
65
|
+
raise BufferError(f"tab index {i} out of range (have {len(tabs)} tab(s))")
|
|
66
|
+
return tabs[i]
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _grid_dims(tab: dict) -> tuple[int, int]:
|
|
70
|
+
g = tab.get("grid") or {}
|
|
71
|
+
return int(g.get("columns", DEFAULT_COLUMNS)), int(g.get("rows", DEFAULT_ROWS))
|
|
72
|
+
|
|
73
|
+
def all_ids(self) -> set[str]:
|
|
74
|
+
ids: set[str] = set()
|
|
75
|
+
|
|
76
|
+
def walk(children):
|
|
77
|
+
for ch in children or []:
|
|
78
|
+
if isinstance(ch, dict):
|
|
79
|
+
if "id" in ch:
|
|
80
|
+
ids.add(ch["id"])
|
|
81
|
+
if ch.get("type") == "group":
|
|
82
|
+
walk(ch.get("children"))
|
|
83
|
+
|
|
84
|
+
for tab in self.tabs:
|
|
85
|
+
walk(tab.get("children"))
|
|
86
|
+
return ids
|
|
87
|
+
|
|
88
|
+
def unique_id(self, base: str) -> str:
|
|
89
|
+
base = base or "control"
|
|
90
|
+
ids = self.all_ids()
|
|
91
|
+
if base not in ids:
|
|
92
|
+
return base
|
|
93
|
+
i = 2
|
|
94
|
+
while f"{base}-{i}" in ids:
|
|
95
|
+
i += 1
|
|
96
|
+
return f"{base}-{i}"
|
|
97
|
+
|
|
98
|
+
def find(self, control_id: str) -> Optional[tuple[int, int, dict]]:
|
|
99
|
+
"""Locate a top-level child by id -> (tab_index, child_index, child)."""
|
|
100
|
+
for ti, tab in enumerate(self.tabs):
|
|
101
|
+
for ci, ch in enumerate(tab.get("children", [])):
|
|
102
|
+
if isinstance(ch, dict) and ch.get("id") == control_id:
|
|
103
|
+
return ti, ci, ch
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# ─── mutations ───────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def add_control(self, control: dict, tab_index: int = 0,
|
|
109
|
+
position: Optional[list[int]] = None,
|
|
110
|
+
default_span: Optional[list[int]] = None) -> dict:
|
|
111
|
+
if not isinstance(control, dict) or "type" not in control:
|
|
112
|
+
raise BufferError("control must be an object with a 'type'")
|
|
113
|
+
control = copy.deepcopy(control)
|
|
114
|
+
control["id"] = self.unique_id(control.get("id") or control["type"])
|
|
115
|
+
|
|
116
|
+
tab = self._tab(tab_index)
|
|
117
|
+
children = tab.setdefault("children", [])
|
|
118
|
+
cols, rows = self._grid_dims(tab)
|
|
119
|
+
|
|
120
|
+
if control.get("span") is None and default_span and default_span != [1, 1]:
|
|
121
|
+
control["span"] = default_span
|
|
122
|
+
span = control.get("span") or [1, 1]
|
|
123
|
+
|
|
124
|
+
if position is None:
|
|
125
|
+
slot = gridmod.find_slot(children, cols, rows, span)
|
|
126
|
+
if slot is None:
|
|
127
|
+
raise BufferError(
|
|
128
|
+
f"no free {span} slot in tab {tab_index} ({rows}x{cols} grid). "
|
|
129
|
+
f"Grow the grid (add_tab/edit grid) or pass an explicit position.")
|
|
130
|
+
position = slot
|
|
131
|
+
control["position"] = position
|
|
132
|
+
children.append(control)
|
|
133
|
+
return control
|
|
134
|
+
|
|
135
|
+
def update_control(self, control_id: str, patch: dict) -> dict:
|
|
136
|
+
found = self.find(control_id)
|
|
137
|
+
if not found:
|
|
138
|
+
raise BufferError(f"no control '{control_id}' in the buffer")
|
|
139
|
+
ch = found[2]
|
|
140
|
+
for k, v in patch.items():
|
|
141
|
+
if v is None:
|
|
142
|
+
ch.pop(k, None)
|
|
143
|
+
else:
|
|
144
|
+
ch[k] = v
|
|
145
|
+
return ch
|
|
146
|
+
|
|
147
|
+
def remove_control(self, control_id: str) -> dict:
|
|
148
|
+
found = self.find(control_id)
|
|
149
|
+
if not found:
|
|
150
|
+
raise BufferError(f"no control '{control_id}' in the buffer")
|
|
151
|
+
ti, ci, _ = found
|
|
152
|
+
return self.tabs[ti]["children"].pop(ci)
|
|
153
|
+
|
|
154
|
+
def move_control(self, control_id: str, position: Optional[list[int]] = None,
|
|
155
|
+
span: Optional[list[int]] = None,
|
|
156
|
+
tab_index: Optional[int] = None) -> dict:
|
|
157
|
+
found = self.find(control_id)
|
|
158
|
+
if not found:
|
|
159
|
+
raise BufferError(f"no control '{control_id}' in the buffer")
|
|
160
|
+
ti, ci, ch = found
|
|
161
|
+
if tab_index is not None and tab_index != ti:
|
|
162
|
+
ch = self.tabs[ti]["children"].pop(ci)
|
|
163
|
+
self._tab(tab_index).setdefault("children", []).append(ch)
|
|
164
|
+
if position is not None:
|
|
165
|
+
ch["position"] = position
|
|
166
|
+
if span is not None:
|
|
167
|
+
ch["span"] = span
|
|
168
|
+
return ch
|
|
169
|
+
|
|
170
|
+
def add_tab(self, title: str, icon: str = "square.grid.2x2",
|
|
171
|
+
columns: int = DEFAULT_COLUMNS, rows: int = DEFAULT_ROWS) -> int:
|
|
172
|
+
self.tabs.append({
|
|
173
|
+
"title": title, "icon": icon,
|
|
174
|
+
"grid": {"columns": columns, "rows": rows}, "children": [],
|
|
175
|
+
})
|
|
176
|
+
return len(self.tabs) - 1
|
|
177
|
+
|
|
178
|
+
def add_group(self, group: dict, tab_index: int = 0,
|
|
179
|
+
position: Optional[list[int]] = None) -> dict:
|
|
180
|
+
group = copy.deepcopy(group)
|
|
181
|
+
group["type"] = "group"
|
|
182
|
+
group["id"] = self.unique_id(group.get("id") or "group")
|
|
183
|
+
tab = self._tab(tab_index)
|
|
184
|
+
children = tab.setdefault("children", [])
|
|
185
|
+
cols, rows = self._grid_dims(tab)
|
|
186
|
+
span = group.get("span") or [1, 1]
|
|
187
|
+
if position is None:
|
|
188
|
+
slot = gridmod.find_slot(children, cols, rows, span)
|
|
189
|
+
if slot is None:
|
|
190
|
+
raise BufferError(f"no free {span} slot in tab {tab_index} for the group")
|
|
191
|
+
position = slot
|
|
192
|
+
group["position"] = position
|
|
193
|
+
children.append(group)
|
|
194
|
+
return group
|
|
195
|
+
|
|
196
|
+
# ─── views ───────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def issues(self) -> list[dict]:
|
|
199
|
+
"""Placement issues across all tabs, each tagged with its tab index."""
|
|
200
|
+
out: list[dict] = []
|
|
201
|
+
for ti, tab in enumerate(self.tabs):
|
|
202
|
+
cols, rows = self._grid_dims(tab)
|
|
203
|
+
for issue in gridmod.validate_placement(tab.get("children", []), cols, rows):
|
|
204
|
+
out.append({"tab": ti, **issue})
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
def summary(self, show_grids: bool = True) -> str:
|
|
208
|
+
name = self.layout.get("name", "Untitled")
|
|
209
|
+
accent = self.layout.get("accentColor")
|
|
210
|
+
head = f"**{name}**" + (f" · accent {accent}" if accent else "")
|
|
211
|
+
lines = [head]
|
|
212
|
+
for ti, tab in enumerate(self.tabs):
|
|
213
|
+
cols, rows = self._grid_dims(tab)
|
|
214
|
+
children = tab.get("children", [])
|
|
215
|
+
lines.append(f"\nTab {ti}: {tab.get('title','?')} ({tab.get('icon','')}) "
|
|
216
|
+
f"— {rows}x{cols}, {len(children)} item(s)")
|
|
217
|
+
for ch in children:
|
|
218
|
+
pos = ch.get("position")
|
|
219
|
+
span = ch.get("span")
|
|
220
|
+
extra = f" span {span}" if span and span != [1, 1] else ""
|
|
221
|
+
lines.append(f" - {ch.get('id','?')} ({ch.get('type','?')}) @ {pos}{extra}")
|
|
222
|
+
if show_grids and children:
|
|
223
|
+
lines.append(gridmod.render_grid(children, cols, rows))
|
|
224
|
+
problems = self.issues()
|
|
225
|
+
if problems:
|
|
226
|
+
lines.append("\n⚠ placement issues:")
|
|
227
|
+
for p in problems:
|
|
228
|
+
ids = p.get("ids") or [p.get("id")]
|
|
229
|
+
lines.append(f" - [tab {p['tab']}] {p['kind']}: {', '.join(ids)} — {p['detail']}")
|
|
230
|
+
return "\n".join(lines)
|
carterkit/catalog.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Control catalog + example extraction — pure parsing of the ControlDocs markdown.
|
|
2
|
+
|
|
3
|
+
Mirrors the app's `ControlDocLoader` frontmatter parser (Swift) so the MCP and the
|
|
4
|
+
device agree on the field schema for every control. Deliberately does NOT use a real
|
|
5
|
+
YAML parser: the docs carry unquoted `#hex` defaults (e.g. `default: #667eea`) which
|
|
6
|
+
YAML would treat as comments, and some values omit quotes inconsistently. The
|
|
7
|
+
hand-rolled parser tolerates both.
|
|
8
|
+
|
|
9
|
+
Public surface (all pure; `docs_dir` is injected for testability):
|
|
10
|
+
parse_doc(content, node_id) -> dict | None one doc's frontmatter + body + examples
|
|
11
|
+
parse_all(docs_dir) -> {node_id: doc}
|
|
12
|
+
build_catalog(docs_dir, types) -> {type: compact} placeable-control schema
|
|
13
|
+
get_examples(docs_dir, control) -> [{"name", "json"}]
|
|
14
|
+
find_example(docs_dir, control, name) -> {"name", "json"} | None
|
|
15
|
+
resolve_doc(docs_dir, control) -> doc | None accepts node_id OR control type
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ─── Frontmatter parsing (mirrors ControlDocLoader.swift) ────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_str_array(value: str) -> list[str]:
|
|
29
|
+
inner = value.strip().strip("[]")
|
|
30
|
+
out = []
|
|
31
|
+
for part in inner.split(","):
|
|
32
|
+
s = part.strip().strip("\"'")
|
|
33
|
+
if s:
|
|
34
|
+
out.append(s)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_int_array(value: str) -> Optional[list[int]]:
|
|
39
|
+
inner = value.strip().strip("[]")
|
|
40
|
+
parts = []
|
|
41
|
+
for part in inner.split(","):
|
|
42
|
+
part = part.strip()
|
|
43
|
+
if not part:
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
parts.append(int(part))
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
return parts or None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _make_field(raw: dict[str, str]) -> dict:
|
|
53
|
+
field: dict = {"name": raw.get("name", ""), "type": raw.get("type", "string")}
|
|
54
|
+
if "values" in raw:
|
|
55
|
+
field["values"] = _parse_str_array(raw["values"])
|
|
56
|
+
if "default" in raw:
|
|
57
|
+
field["default"] = raw["default"]
|
|
58
|
+
if raw.get("description"):
|
|
59
|
+
field["description"] = raw["description"]
|
|
60
|
+
return field
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_doc(content: str, node_id: str) -> Optional[dict]:
|
|
64
|
+
"""Parse one control doc into {node_id,type,label,icon,category,defaultSpan,
|
|
65
|
+
fields,themeFields,body,examples}. Returns None if it lacks a type+label
|
|
66
|
+
(matches the Swift loader, which skips non-control prose-only docs)."""
|
|
67
|
+
lines = content.split("\n")
|
|
68
|
+
if not lines or lines[0].strip() != "---":
|
|
69
|
+
return None
|
|
70
|
+
end = None
|
|
71
|
+
for i in range(1, len(lines)):
|
|
72
|
+
if lines[i].strip() == "---":
|
|
73
|
+
end = i
|
|
74
|
+
break
|
|
75
|
+
if end is None:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
front = lines[1:end]
|
|
79
|
+
body = "\n".join(lines[end + 1:]).strip()
|
|
80
|
+
|
|
81
|
+
meta: dict = {
|
|
82
|
+
"node_id": node_id,
|
|
83
|
+
"type": "",
|
|
84
|
+
"label": "",
|
|
85
|
+
"icon": "",
|
|
86
|
+
"category": "",
|
|
87
|
+
"defaultSpan": None,
|
|
88
|
+
"fields": [],
|
|
89
|
+
"themeFields": [],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
in_fields = False
|
|
93
|
+
current_list: Optional[list] = None
|
|
94
|
+
current: dict[str, str] = {}
|
|
95
|
+
|
|
96
|
+
def flush():
|
|
97
|
+
nonlocal current
|
|
98
|
+
if in_fields and current and current_list is not None:
|
|
99
|
+
current_list.append(_make_field(current))
|
|
100
|
+
current = {}
|
|
101
|
+
|
|
102
|
+
for line in front:
|
|
103
|
+
if not line.strip():
|
|
104
|
+
continue
|
|
105
|
+
top_level = not line.startswith((" ", "\t")) and ":" in line
|
|
106
|
+
if top_level:
|
|
107
|
+
flush()
|
|
108
|
+
in_fields = False
|
|
109
|
+
current_list = None
|
|
110
|
+
key, _, value = line.partition(":")
|
|
111
|
+
key, value = key.strip(), value.strip()
|
|
112
|
+
if key == "type":
|
|
113
|
+
meta["type"] = value
|
|
114
|
+
elif key == "label":
|
|
115
|
+
meta["label"] = value
|
|
116
|
+
elif key == "icon":
|
|
117
|
+
meta["icon"] = value
|
|
118
|
+
elif key == "category":
|
|
119
|
+
meta["category"] = value
|
|
120
|
+
elif key == "defaultSpan":
|
|
121
|
+
meta["defaultSpan"] = _parse_int_array(value)
|
|
122
|
+
elif key == "fields":
|
|
123
|
+
in_fields = True
|
|
124
|
+
current_list = meta["fields"]
|
|
125
|
+
elif key == "themeFields":
|
|
126
|
+
in_fields = True
|
|
127
|
+
current_list = meta["themeFields"]
|
|
128
|
+
elif in_fields:
|
|
129
|
+
trimmed = line.strip()
|
|
130
|
+
if trimmed.startswith("- name:"):
|
|
131
|
+
flush()
|
|
132
|
+
current = {"name": trimmed[len("- name:"):].strip().strip("\"'")}
|
|
133
|
+
elif ":" in trimmed:
|
|
134
|
+
k, _, v = trimmed.partition(":")
|
|
135
|
+
current[k.strip()] = v.strip().strip('"')
|
|
136
|
+
flush()
|
|
137
|
+
|
|
138
|
+
if not meta["type"] or not meta["label"]:
|
|
139
|
+
return None
|
|
140
|
+
meta["body"] = body
|
|
141
|
+
meta["examples"] = extract_examples(body)
|
|
142
|
+
return meta
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ─── Example extraction (the `## Examples` section) ──────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def extract_examples(body: str) -> list[dict]:
|
|
149
|
+
"""Pull named JSON snippets from a doc's `## Examples` section: each `### Title`
|
|
150
|
+
followed by a ```json fenced block becomes {"name", "json"}. Only the Examples
|
|
151
|
+
section is scanned, so unrelated json blocks (e.g. a "## Segments" sample) are
|
|
152
|
+
ignored."""
|
|
153
|
+
lines = body.split("\n")
|
|
154
|
+
start = None
|
|
155
|
+
for i, l in enumerate(lines):
|
|
156
|
+
if l.strip().lower() == "## examples":
|
|
157
|
+
start = i + 1
|
|
158
|
+
break
|
|
159
|
+
if start is None:
|
|
160
|
+
return []
|
|
161
|
+
end = len(lines)
|
|
162
|
+
for i in range(start, len(lines)):
|
|
163
|
+
if lines[i].startswith("## "):
|
|
164
|
+
end = i
|
|
165
|
+
break
|
|
166
|
+
section = lines[start:end]
|
|
167
|
+
|
|
168
|
+
examples: list[dict] = []
|
|
169
|
+
title: Optional[str] = None
|
|
170
|
+
seen: dict[str, int] = {}
|
|
171
|
+
i = 0
|
|
172
|
+
while i < len(section):
|
|
173
|
+
l = section[i]
|
|
174
|
+
if l.startswith("### "):
|
|
175
|
+
title = l[4:].strip().strip("`")
|
|
176
|
+
i += 1
|
|
177
|
+
continue
|
|
178
|
+
if l.strip().startswith("```json"):
|
|
179
|
+
j = i + 1
|
|
180
|
+
buf = []
|
|
181
|
+
while j < len(section) and not section[j].strip().startswith("```"):
|
|
182
|
+
buf.append(section[j])
|
|
183
|
+
j += 1
|
|
184
|
+
name = title or f"Example {len(examples) + 1}"
|
|
185
|
+
if name in seen:
|
|
186
|
+
seen[name] += 1
|
|
187
|
+
name = f"{name} ({seen[name]})"
|
|
188
|
+
else:
|
|
189
|
+
seen[name] = 1
|
|
190
|
+
examples.append({"name": name, "json": "\n".join(buf).strip()})
|
|
191
|
+
i = j + 1
|
|
192
|
+
continue
|
|
193
|
+
i += 1
|
|
194
|
+
return examples
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ─── Catalog assembly ────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
# Categories whose docs describe controls that can be placed in a layout grid.
|
|
200
|
+
PLACEABLE_CATEGORIES = {"controls", "display"}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_all(docs_dir) -> dict[str, dict]:
|
|
204
|
+
"""Parse every `*.md` doc in the directory. Keyed by node_id (filename stem)."""
|
|
205
|
+
out: dict[str, dict] = {}
|
|
206
|
+
for f in sorted(Path(docs_dir).glob("*.md")):
|
|
207
|
+
doc = parse_doc(f.read_text(), f.stem)
|
|
208
|
+
if doc:
|
|
209
|
+
out[doc["node_id"]] = doc
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _compact(doc: dict, include_theme: bool = False) -> dict:
|
|
214
|
+
out: dict = {
|
|
215
|
+
"type": doc["type"],
|
|
216
|
+
"node_id": doc["node_id"],
|
|
217
|
+
"label": doc["label"],
|
|
218
|
+
"category": doc["category"],
|
|
219
|
+
}
|
|
220
|
+
if doc.get("defaultSpan"):
|
|
221
|
+
out["defaultSpan"] = doc["defaultSpan"]
|
|
222
|
+
if doc.get("fields"):
|
|
223
|
+
out["fields"] = doc["fields"]
|
|
224
|
+
if include_theme and doc.get("themeFields"):
|
|
225
|
+
out["themeFields"] = doc["themeFields"]
|
|
226
|
+
if doc.get("examples"):
|
|
227
|
+
out["examples"] = [e["name"] for e in doc["examples"]]
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def build_catalog(docs_dir, types: Optional[list[str]] = None,
|
|
232
|
+
include_theme: bool = False) -> dict[str, dict]:
|
|
233
|
+
"""Compact, machine-readable schema for placeable controls, keyed by control
|
|
234
|
+
`type` (the value used in a layout). `types` filters to specific control types;
|
|
235
|
+
`include_theme` adds the per-control theme override fields."""
|
|
236
|
+
docs = parse_all(docs_dir)
|
|
237
|
+
catalog: dict[str, dict] = {}
|
|
238
|
+
wanted = set(types) if types else None
|
|
239
|
+
for doc in docs.values():
|
|
240
|
+
t = doc["type"]
|
|
241
|
+
if wanted is not None:
|
|
242
|
+
if t not in wanted and doc["node_id"] not in wanted:
|
|
243
|
+
continue
|
|
244
|
+
elif doc["category"] not in PLACEABLE_CATEGORIES:
|
|
245
|
+
continue
|
|
246
|
+
catalog[t] = _compact(doc, include_theme=include_theme)
|
|
247
|
+
return catalog
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def resolve_doc(docs_dir, control: str) -> Optional[dict]:
|
|
251
|
+
"""Find a doc by node_id (e.g. 'color-picker') or control type (e.g. 'colorPicker')."""
|
|
252
|
+
docs = parse_all(docs_dir)
|
|
253
|
+
if control in docs:
|
|
254
|
+
return docs[control]
|
|
255
|
+
for doc in docs.values():
|
|
256
|
+
if doc["type"] == control:
|
|
257
|
+
return doc
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_examples(docs_dir, control: str) -> list[dict]:
|
|
262
|
+
doc = resolve_doc(docs_dir, control)
|
|
263
|
+
return doc["examples"] if doc else []
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def find_example(docs_dir, control: str, name: str) -> Optional[dict]:
|
|
267
|
+
"""Match an example by exact or case-insensitive-prefix name."""
|
|
268
|
+
examples = get_examples(docs_dir, control)
|
|
269
|
+
for ex in examples:
|
|
270
|
+
if ex["name"] == name:
|
|
271
|
+
return ex
|
|
272
|
+
low = name.lower()
|
|
273
|
+
for ex in examples:
|
|
274
|
+
if ex["name"].lower().startswith(low):
|
|
275
|
+
return ex
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def example_as_obj(example: dict) -> Optional[dict]:
|
|
280
|
+
"""Parse an example's JSON snippet into a dict (None if it doesn't parse)."""
|
|
281
|
+
try:
|
|
282
|
+
obj = json.loads(example["json"])
|
|
283
|
+
return obj if isinstance(obj, dict) else None
|
|
284
|
+
except (json.JSONDecodeError, TypeError):
|
|
285
|
+
return None
|