cox-protocol-package 1.0.2 → 1.2.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/CHANGELOG.md +22 -0
- package/COX_FILE_FORMAT_SPECIFICATION_v1.0.md +40 -200
- package/FINALIZATION.md +50 -0
- package/LocalAppManager/Dashboard/analytics_sidebar_panels/analytics_sidebar_panels.cox +56 -0
- package/LocalAppManager/Dashboard/analytics_sidebar_panels/analytics_sidebar_panels_screenshot.placeholder.txt +1 -0
- package/LocalAppManager/Dashboard/dashboard.cox +62 -0
- package/LocalAppManager/Dashboard/dashboard_screenshot.placeholder.txt +1 -0
- package/LocalAppManager/Dashboard/left_navigation_sidebar/left_navigation_sidebar.cox +56 -0
- package/LocalAppManager/Dashboard/left_navigation_sidebar/left_navigation_sidebar_screenshot.placeholder.txt +1 -0
- package/LocalAppManager/Dashboard/projects_grid_cards/projects_grid_cards.cox +56 -0
- package/LocalAppManager/Dashboard/projects_grid_cards/projects_grid_cards_screenshot.placeholder.txt +1 -0
- package/LocalAppManager/Dashboard/top_header_stats_bar/top_header_stats_bar.cox +56 -0
- package/LocalAppManager/Dashboard/top_header_stats_bar/top_header_stats_bar_screenshot.placeholder.txt +1 -0
- package/MANIFEST.in +20 -0
- package/README.md +67 -154
- package/bin/cox-protocol.js +23 -97
- package/cox_protocol/__init__.py +2 -0
- package/cox_protocol/__main__.py +3 -0
- package/cox_protocol/compiler.py +273 -0
- package/cox_protocol/cox_cli.py +146 -0
- package/cox_protocol/cox_lib.py +599 -0
- package/cox_protocol/package_target.py +262 -0
- package/cox_protocol/react_target.py +306 -0
- package/cox_protocol/scaffold.py +56 -0
- package/cox_protocol/schemas/cox.component.v1.schema.json +180 -0
- package/cox_protocol/schemas/cox.crop_metadata.v1.schema.json +83 -0
- package/cox_protocol/schemas/cox.manifest.v1.schema.json +172 -0
- package/cox_protocol/schemas/cox.page.v1.schema.json +242 -0
- package/cox_protocol/schemas/cox.protocol.v1.schema.json +38 -0
- package/cox_protocol/starter/README.md +7 -0
- package/cox_protocol/starter/manifest.cox +26 -0
- package/cox_protocol/starter/page/action_button/action_button.cox +34 -0
- package/cox_protocol/starter/page/action_button/reference.svg +1 -0
- package/cox_protocol/starter/page/page.cox +39 -0
- package/cox_protocol/starter/page/reference.svg +1 -0
- package/editor-support/vscode/CHANGELOG.md +6 -0
- package/editor-support/vscode/README.md +2 -1
- package/editor-support/vscode/package.json +3 -3
- package/editor-support/vscode/snippets/cox-common.json +1 -1
- package/editor-support/vscode/snippets/cox-component.json +2 -1
- package/editor-support/vscode/snippets/cox-crop-metadata.json +1 -1
- package/editor-support/vscode/snippets/cox-manifest.json +1 -1
- package/editor-support/vscode/snippets/cox-page.json +2 -1
- package/editor-support/vscode/snippets/cox-protocol.json +1 -1
- package/examples/quickstart/README.md +7 -0
- package/examples/quickstart/manifest.cox +26 -0
- package/examples/quickstart/page/action_button/action_button.cox +34 -0
- package/examples/quickstart/page/action_button/reference.svg +1 -0
- package/examples/quickstart/page/page.cox +39 -0
- package/examples/quickstart/page/reference.svg +1 -0
- package/guides/contract-demo.md +24 -0
- package/guides/npm-installation.md +16 -287
- package/manifest.cox +7 -4
- package/package.json +30 -14
- package/protocols/recursive_decomposition_protocol.cox +8 -8
- package/pyproject.toml +17 -6
- package/schemas/cox.component.v1.schema.json +17 -4
- package/schemas/cox.crop_metadata.v1.schema.json +1 -1
- package/schemas/cox.manifest.v1.schema.json +1 -1
- package/schemas/cox.page.v1.schema.json +17 -4
- package/schemas/cox.protocol.v1.schema.json +1 -1
- package/scripts/check_distribution.py +87 -0
- package/scripts/demo.py +62 -0
- package/scripts/package-vscode.mjs +12 -0
- package/scripts/release_check.py +86 -0
- package/tests/conftest.py +21 -0
- package/tests/test_cli_and_typescript.py +123 -0
- package/tests/test_compiler.py +279 -0
- package/tests/test_validation.py +215 -0
- package/tools/__init__.py +2 -2
- package/tools/cox_cli.py +2 -123
- package/tools/cox_lib.py +2 -558
- package/tools/package_target.py +2 -214
- package/tools/react_target.py +2 -278
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Portable, deterministic archives with an independently checkable file inventory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import stat
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .cox_lib import (
|
|
13
|
+
CoxError,
|
|
14
|
+
TRANSIENT_DIRS,
|
|
15
|
+
fail,
|
|
16
|
+
portable_segment,
|
|
17
|
+
relative_path,
|
|
18
|
+
walk_files,
|
|
19
|
+
write_json,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
FIXED_ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
|
23
|
+
EXCLUDED_FILENAMES = {
|
|
24
|
+
".DS_Store",
|
|
25
|
+
"cox-guides.zip",
|
|
26
|
+
"cox_protocol_package_compiled.zip",
|
|
27
|
+
"llm_agent_zip_skills_bundle.zip",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sha256_bytes(content: bytes) -> str:
|
|
32
|
+
return hashlib.sha256(content).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def excluded(path: Path) -> bool:
|
|
36
|
+
return (
|
|
37
|
+
path.name in EXCLUDED_FILENAMES
|
|
38
|
+
or path.name == ".env"
|
|
39
|
+
or path.name.startswith(".env.")
|
|
40
|
+
or path.suffix in (".pyc", ".pyo", ".tgz")
|
|
41
|
+
or any(p.endswith(".egg-info") for p in path.parts)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def package_filename(summary) -> str:
|
|
46
|
+
name = (
|
|
47
|
+
summary.documents[summary.manifest_path].get("PackageName")
|
|
48
|
+
or summary.documents[summary.manifest_path]["Slug"] + ".zip"
|
|
49
|
+
)
|
|
50
|
+
if not portable_segment(name):
|
|
51
|
+
fail("PACKAGE_NAME", "PackageName must be a portable filename, not a path")
|
|
52
|
+
return name if name.endswith(".zip") else name + ".zip"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def zip_write_bytes(archive, name, content, mode=0o644):
|
|
56
|
+
info = zipfile.ZipInfo(name, FIXED_ZIP_TIMESTAMP)
|
|
57
|
+
info.create_system = 3
|
|
58
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
59
|
+
info.external_attr = (stat.S_IFREG | mode) << 16
|
|
60
|
+
archive.writestr(info, content)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def emit_package_archive(summary, build_root: Path, target_results) -> dict[str, Any]:
|
|
64
|
+
archive_root = summary.documents[summary.manifest_path]["Slug"]
|
|
65
|
+
if not portable_segment(archive_root):
|
|
66
|
+
fail("PACKAGE_NAME", "Manifest Slug is a reserved archive directory name")
|
|
67
|
+
entries = {}
|
|
68
|
+
for reference in [summary.manifest_path, *summary.referenced_paths]:
|
|
69
|
+
if any(
|
|
70
|
+
part in TRANSIENT_DIRS
|
|
71
|
+
for part in reference.relative_to(summary.project_root).parts
|
|
72
|
+
):
|
|
73
|
+
fail(
|
|
74
|
+
"PACKAGE_EXCLUDED",
|
|
75
|
+
"Manifest explicitly references a transient directory",
|
|
76
|
+
str(reference),
|
|
77
|
+
)
|
|
78
|
+
files = walk_files(reference) if reference.is_dir() else [reference]
|
|
79
|
+
for source in files:
|
|
80
|
+
if excluded(source):
|
|
81
|
+
if source == reference:
|
|
82
|
+
fail(
|
|
83
|
+
"PACKAGE_EXCLUDED",
|
|
84
|
+
"Manifest explicitly references a transient or secret-like artifact",
|
|
85
|
+
str(source),
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
if source.is_symlink() or not source.is_file():
|
|
89
|
+
fail(
|
|
90
|
+
"PACKAGE_FILE",
|
|
91
|
+
"Package sources must be regular files without symlinks",
|
|
92
|
+
str(source),
|
|
93
|
+
)
|
|
94
|
+
rel = relative_path(summary.project_root, source)
|
|
95
|
+
if any(not portable_segment(part) for part in rel.split("/")):
|
|
96
|
+
fail(
|
|
97
|
+
"PACKAGE_PATH",
|
|
98
|
+
"Package source contains a non-portable archive path",
|
|
99
|
+
rel,
|
|
100
|
+
)
|
|
101
|
+
if rel == "package.manifest.json" or rel.startswith("build/"):
|
|
102
|
+
fail("PACKAGE_RESERVED", "Source occupies a reserved archive path", rel)
|
|
103
|
+
entries[rel] = (
|
|
104
|
+
source.read_bytes(),
|
|
105
|
+
0o755 if source.stat().st_mode & 0o111 else 0o644,
|
|
106
|
+
"source",
|
|
107
|
+
)
|
|
108
|
+
# Only this staging directory is scanned, never the old destination directory.
|
|
109
|
+
for path in sorted(build_root.rglob("*")):
|
|
110
|
+
if path.is_file():
|
|
111
|
+
rel = "build/" + path.relative_to(build_root).as_posix()
|
|
112
|
+
entries[rel] = (path.read_bytes(), 0o644, "generated")
|
|
113
|
+
records = [
|
|
114
|
+
{
|
|
115
|
+
"path": archive_root + "/" + rel,
|
|
116
|
+
"source": rel,
|
|
117
|
+
"kind": kind,
|
|
118
|
+
"size": len(data),
|
|
119
|
+
"sha256": sha256_bytes(data),
|
|
120
|
+
"mode": mode,
|
|
121
|
+
}
|
|
122
|
+
for rel, (data, mode, kind) in sorted(entries.items())
|
|
123
|
+
]
|
|
124
|
+
archive_path = build_root / "package" / package_filename(summary)
|
|
125
|
+
manifest = {
|
|
126
|
+
"generatedWith": f"cox {__version__}",
|
|
127
|
+
"deterministic": True,
|
|
128
|
+
"archiveRoot": archive_root,
|
|
129
|
+
"projectName": summary.documents[summary.manifest_path]["ProjectName"],
|
|
130
|
+
"packageName": archive_path.name,
|
|
131
|
+
"fileCount": len(records),
|
|
132
|
+
"files": records,
|
|
133
|
+
}
|
|
134
|
+
all_entries = {
|
|
135
|
+
archive_root + "/" + rel: (data, mode)
|
|
136
|
+
for rel, (data, mode, _) in entries.items()
|
|
137
|
+
}
|
|
138
|
+
all_entries[archive_root + "/package.manifest.json"] = (
|
|
139
|
+
(json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8"),
|
|
140
|
+
0o644,
|
|
141
|
+
)
|
|
142
|
+
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
with zipfile.ZipFile(archive_path, "w") as archive:
|
|
144
|
+
for name, (content, mode) in sorted(all_entries.items()):
|
|
145
|
+
zip_write_bytes(archive, name, content, mode)
|
|
146
|
+
checksum = sha256_bytes(archive_path.read_bytes())
|
|
147
|
+
manifest_path = archive_path.with_suffix(".manifest.json")
|
|
148
|
+
write_json(
|
|
149
|
+
manifest_path,
|
|
150
|
+
{
|
|
151
|
+
**manifest,
|
|
152
|
+
"archive": {
|
|
153
|
+
"path": archive_path.name,
|
|
154
|
+
"size": archive_path.stat().st_size,
|
|
155
|
+
"sha256": checksum,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
return {
|
|
160
|
+
"output_root": archive_path.parent,
|
|
161
|
+
"archive": archive_path,
|
|
162
|
+
"manifest": manifest_path,
|
|
163
|
+
"file_count": len(all_entries),
|
|
164
|
+
"sha256": checksum,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def verify_archive(path: Path) -> dict[str, Any]:
|
|
169
|
+
try:
|
|
170
|
+
with zipfile.ZipFile(path) as archive:
|
|
171
|
+
names = archive.namelist()
|
|
172
|
+
if len(set(names)) != len(names):
|
|
173
|
+
fail("ARCHIVE_DUPLICATE", "Archive contains duplicate names")
|
|
174
|
+
for info in archive.infolist():
|
|
175
|
+
name = info.filename
|
|
176
|
+
if (
|
|
177
|
+
any(part in ("", ".", "..") for part in name.split("/"))
|
|
178
|
+
or any(not portable_segment(part) for part in name.split("/"))
|
|
179
|
+
or any(c in name for c in '\\<>:"|?*')
|
|
180
|
+
or any(ord(c) < 32 for c in name)
|
|
181
|
+
or len(PurePosixPath(name).parts) < 2
|
|
182
|
+
):
|
|
183
|
+
fail("ARCHIVE_PATH", "Archive contains an invalid path", name)
|
|
184
|
+
if stat.S_ISLNK(info.external_attr >> 16):
|
|
185
|
+
fail("ARCHIVE_SYMLINK", "Symlinks are not supported", name)
|
|
186
|
+
indexes = [
|
|
187
|
+
name
|
|
188
|
+
for name in names
|
|
189
|
+
if len(PurePosixPath(name).parts) == 2
|
|
190
|
+
and name.endswith("/package.manifest.json")
|
|
191
|
+
]
|
|
192
|
+
if len(indexes) != 1:
|
|
193
|
+
fail(
|
|
194
|
+
"ARCHIVE_MANIFEST",
|
|
195
|
+
"Expected one package.manifest.json at the archive root",
|
|
196
|
+
)
|
|
197
|
+
manifest = json.loads(archive.read(indexes[0]))
|
|
198
|
+
root = manifest["archiveRoot"]
|
|
199
|
+
if indexes[0] != root + "/package.manifest.json":
|
|
200
|
+
fail("ARCHIVE_ROOT", "Manifest root differs from archive root")
|
|
201
|
+
records = manifest["files"]
|
|
202
|
+
if (
|
|
203
|
+
not isinstance(root, str)
|
|
204
|
+
or not isinstance(records, list)
|
|
205
|
+
or any(not isinstance(record, dict) for record in records)
|
|
206
|
+
):
|
|
207
|
+
fail("ARCHIVE_MANIFEST", "Invalid root or file inventory")
|
|
208
|
+
for record in records:
|
|
209
|
+
if (
|
|
210
|
+
not isinstance(record.get("path"), str)
|
|
211
|
+
or type(record.get("size")) is not int
|
|
212
|
+
or not isinstance(record.get("sha256"), str)
|
|
213
|
+
or record.get("mode") not in (0o644, 0o755)
|
|
214
|
+
):
|
|
215
|
+
fail("ARCHIVE_MANIFEST", "Invalid file record")
|
|
216
|
+
if manifest["fileCount"] != len(records):
|
|
217
|
+
fail("ARCHIVE_COUNT", "Manifest file count differs from records")
|
|
218
|
+
declared = [record["path"] for record in records]
|
|
219
|
+
if len(set(declared)) != len(declared) or set(names) != (
|
|
220
|
+
set(declared) | set(indexes)
|
|
221
|
+
):
|
|
222
|
+
fail(
|
|
223
|
+
"ARCHIVE_CONTENTS", "Archive entries differ from the file inventory"
|
|
224
|
+
)
|
|
225
|
+
for record in records:
|
|
226
|
+
name = record["path"]
|
|
227
|
+
if not name.startswith(root + "/"):
|
|
228
|
+
fail(
|
|
229
|
+
"ARCHIVE_ROOT",
|
|
230
|
+
"File is outside the declared archive root",
|
|
231
|
+
name,
|
|
232
|
+
)
|
|
233
|
+
data = archive.read(name)
|
|
234
|
+
if (
|
|
235
|
+
len(data) != record["size"]
|
|
236
|
+
or sha256_bytes(data) != record["sha256"]
|
|
237
|
+
):
|
|
238
|
+
fail(
|
|
239
|
+
"ARCHIVE_HASH",
|
|
240
|
+
"File does not match its size/SHA-256 record",
|
|
241
|
+
name,
|
|
242
|
+
)
|
|
243
|
+
info = archive.getinfo(name)
|
|
244
|
+
if stat.S_IMODE(info.external_attr >> 16) != record["mode"]:
|
|
245
|
+
fail("ARCHIVE_MODE", "File permissions differ from inventory", name)
|
|
246
|
+
return {
|
|
247
|
+
"ok": True,
|
|
248
|
+
"archive": str(path),
|
|
249
|
+
"fileCount": len(records),
|
|
250
|
+
"sha256": sha256_bytes(path.read_bytes()),
|
|
251
|
+
}
|
|
252
|
+
except (
|
|
253
|
+
OSError,
|
|
254
|
+
zipfile.BadZipFile,
|
|
255
|
+
ValueError,
|
|
256
|
+
KeyError,
|
|
257
|
+
TypeError,
|
|
258
|
+
RuntimeError,
|
|
259
|
+
) as exc:
|
|
260
|
+
if isinstance(exc, CoxError):
|
|
261
|
+
raise
|
|
262
|
+
raise CoxError(f"Could not verify archive: {exc}") from exc
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .cox_lib import fail, portable_segment
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .cox_lib import ValidationSummary
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pascal_case(value: str) -> str:
|
|
16
|
+
pieces = value.replace("-", "_").split("_")
|
|
17
|
+
name = "".join(piece.capitalize() for piece in pieces if piece)
|
|
18
|
+
return "Cox" + name if name[:1].isdigit() else name
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def relative_import(from_dir: Path, target_dir: Path) -> str:
|
|
22
|
+
import_path = os.path.relpath(target_dir, from_dir).replace(os.sep, "/")
|
|
23
|
+
if import_path.startswith("."):
|
|
24
|
+
return import_path
|
|
25
|
+
return f"./{import_path}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def document_for_instruction(
|
|
29
|
+
summary: "ValidationSummary",
|
|
30
|
+
instruction_path: str,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
document_path = (summary.project_root / instruction_path).resolve()
|
|
33
|
+
try:
|
|
34
|
+
return summary.documents[document_path]
|
|
35
|
+
except KeyError as exc:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"Missing parsed COX document for {instruction_path}"
|
|
38
|
+
) from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_comment_block(lines: list[str]) -> str:
|
|
42
|
+
body = "\n".join(
|
|
43
|
+
" * " + line.replace("*/", "* /").replace("\r", " ").replace("\n", "\n * ")
|
|
44
|
+
for line in lines
|
|
45
|
+
)
|
|
46
|
+
return f"/**\n{body}\n */"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_list(items: list[str]) -> str:
|
|
50
|
+
if not items:
|
|
51
|
+
return "none"
|
|
52
|
+
if not isinstance(items, list):
|
|
53
|
+
return json.dumps(items, ensure_ascii=True)
|
|
54
|
+
return ", ".join(
|
|
55
|
+
item if isinstance(item, str) else json.dumps(item, ensure_ascii=True)
|
|
56
|
+
for item in items
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def evidence_comment_lines(document: dict[str, Any]) -> list[str]:
|
|
61
|
+
return [
|
|
62
|
+
f"{section}.{level}: {format_list(values)}"
|
|
63
|
+
for section in ("EvidenceSummary", "Interactions")
|
|
64
|
+
for level, values in document.get(section, {}).items()
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_text(path: Path, content: str) -> None:
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
path.write_text(content, encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def component_comment_lines(component: dict[str, Any]) -> list[str]:
|
|
74
|
+
visual_style = component.get("VisualStyle", {})
|
|
75
|
+
responsive = component.get("Responsive", {})
|
|
76
|
+
interactions = component.get("Interactions", {})
|
|
77
|
+
hints = component.get("ImplementationHints", [])
|
|
78
|
+
return [
|
|
79
|
+
f"COX component slug: {component.get('Slug', 'unknown')}",
|
|
80
|
+
f"Role: {component.get('Role', 'unspecified')}",
|
|
81
|
+
f"Token refs: {format_list(visual_style.get('Tokens', []))}",
|
|
82
|
+
f"Desktop: {responsive.get('Desktop', 'unspecified')}",
|
|
83
|
+
f"Tablet: {responsive.get('Tablet', 'unspecified')}",
|
|
84
|
+
f"Mobile: {responsive.get('Mobile', 'unspecified')}",
|
|
85
|
+
(f"Interaction notes: {format_list(interactions.get('Inferred', []))}"),
|
|
86
|
+
f"Implementation hints: {format_list(hints)}",
|
|
87
|
+
] + evidence_comment_lines(component)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def page_comment_lines(page: dict[str, Any]) -> list[str]:
|
|
91
|
+
layout = page.get("Layout", {})
|
|
92
|
+
regions = page.get("VisibleStructure", {}).get("Regions", [])
|
|
93
|
+
return [
|
|
94
|
+
f"COX page slug: {page.get('Slug', 'unknown')}",
|
|
95
|
+
f"Page type: {page.get('PageType', 'unspecified')}",
|
|
96
|
+
f"Visible regions: {format_list(regions)}",
|
|
97
|
+
f"Desktop layout: {layout.get('Desktop', 'unspecified')}",
|
|
98
|
+
f"Tablet layout: {layout.get('Tablet', 'unspecified')}",
|
|
99
|
+
f"Mobile layout: {layout.get('Mobile', 'unspecified')}",
|
|
100
|
+
] + evidence_comment_lines(page)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_component_stub(component: dict[str, Any]) -> str:
|
|
104
|
+
component_name = pascal_case(component["Slug"])
|
|
105
|
+
comment_block = format_comment_block(component_comment_lines(component))
|
|
106
|
+
role = json.dumps(component.get("Role", "Component"))
|
|
107
|
+
lines = [
|
|
108
|
+
'import type { ReactNode } from "react";',
|
|
109
|
+
"",
|
|
110
|
+
comment_block,
|
|
111
|
+
f"export interface {component_name}Props {{",
|
|
112
|
+
" children?: ReactNode;",
|
|
113
|
+
"}",
|
|
114
|
+
"",
|
|
115
|
+
f"export function {component_name}({{",
|
|
116
|
+
" children,",
|
|
117
|
+
f"}}: {component_name}Props) {{",
|
|
118
|
+
" return (",
|
|
119
|
+
(f' <section data-cox-slug="{component["Slug"]}" aria-label={{{role}}}>'),
|
|
120
|
+
(
|
|
121
|
+
" {/* TODO: Replace this structural stub with the real UI "
|
|
122
|
+
"implementation. */}"
|
|
123
|
+
),
|
|
124
|
+
" {children}",
|
|
125
|
+
" </section>",
|
|
126
|
+
" );",
|
|
127
|
+
"}",
|
|
128
|
+
]
|
|
129
|
+
return "\n".join(lines) + "\n"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def render_page_stub(
|
|
133
|
+
page: dict[str, Any],
|
|
134
|
+
component_entries: list[dict[str, Any]],
|
|
135
|
+
react_root: Path,
|
|
136
|
+
) -> str:
|
|
137
|
+
page_name = pascal_case(page["Slug"]) + "Page"
|
|
138
|
+
page_dir = react_root / "pages" / page["ProjectName"] / pascal_case(page["Slug"])
|
|
139
|
+
import_lines: list[str] = []
|
|
140
|
+
render_lines: list[str] = []
|
|
141
|
+
|
|
142
|
+
for component_entry in component_entries:
|
|
143
|
+
component_name = pascal_case(component_entry["Slug"])
|
|
144
|
+
component_dir = react_root / "components" / component_name
|
|
145
|
+
import_path = relative_import(page_dir, component_dir)
|
|
146
|
+
import_lines.append(f'import {{ {component_name} }} from "{import_path}";')
|
|
147
|
+
render_lines.append(f" <{component_name} />")
|
|
148
|
+
|
|
149
|
+
comment_block = format_comment_block(page_comment_lines(page))
|
|
150
|
+
imports = "\n".join(import_lines)
|
|
151
|
+
rendered_components = "\n".join(render_lines)
|
|
152
|
+
if not rendered_components:
|
|
153
|
+
rendered_components = (
|
|
154
|
+
" {/* TODO: Wire child components from the COX page spec. */}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
f"{imports}\n\n"
|
|
159
|
+
f"{comment_block}\n"
|
|
160
|
+
f"export interface {page_name}Props {{}}\n\n"
|
|
161
|
+
f"export function {page_name}(_: {page_name}Props) {{\n"
|
|
162
|
+
" return (\n"
|
|
163
|
+
f' <main data-cox-page="{page["Slug"]}">\n'
|
|
164
|
+
f"{rendered_components}\n"
|
|
165
|
+
" </main>\n"
|
|
166
|
+
" );\n"
|
|
167
|
+
"}\n"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def emit_react_stubs(
|
|
172
|
+
summary: "ValidationSummary",
|
|
173
|
+
build_root: Path,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
symbols = {}
|
|
176
|
+
for entry in summary.build_index["pages"]:
|
|
177
|
+
payload = document_for_instruction(summary, entry["instruction"])
|
|
178
|
+
project = payload["ProjectName"]
|
|
179
|
+
if not portable_segment(project):
|
|
180
|
+
fail(
|
|
181
|
+
"REACT_PATH",
|
|
182
|
+
"ProjectName must be a portable directory name for React output",
|
|
183
|
+
entry["instruction"],
|
|
184
|
+
)
|
|
185
|
+
for symbol, source in [
|
|
186
|
+
(pascal_case(entry["slug"]) + "Page", entry["instruction"])
|
|
187
|
+
] + [(pascal_case(c["slug"]), c["instruction"]) for c in entry["components"]]:
|
|
188
|
+
if not portable_segment(symbol):
|
|
189
|
+
fail(
|
|
190
|
+
"REACT_PATH",
|
|
191
|
+
f"Generated name {symbol} is a reserved filename",
|
|
192
|
+
source,
|
|
193
|
+
)
|
|
194
|
+
for declared in (symbol, symbol + "Props"):
|
|
195
|
+
prior = symbols.setdefault(declared.casefold(), source)
|
|
196
|
+
if prior != source:
|
|
197
|
+
fail(
|
|
198
|
+
"REACT_COLLISION",
|
|
199
|
+
f"Generated identifier {declared} collides with {prior}",
|
|
200
|
+
source,
|
|
201
|
+
)
|
|
202
|
+
react_root = build_root / "react"
|
|
203
|
+
component_records: dict[str, dict[str, Any]] = {}
|
|
204
|
+
page_records: list[dict[str, Any]] = []
|
|
205
|
+
|
|
206
|
+
for page_entry in summary.build_index["pages"]:
|
|
207
|
+
page_payload = document_for_instruction(
|
|
208
|
+
summary,
|
|
209
|
+
page_entry["instruction"],
|
|
210
|
+
)
|
|
211
|
+
page_dir = (
|
|
212
|
+
react_root
|
|
213
|
+
/ "pages"
|
|
214
|
+
/ page_payload["ProjectName"]
|
|
215
|
+
/ pascal_case(page_entry["slug"])
|
|
216
|
+
)
|
|
217
|
+
page_output = page_dir / "index.tsx"
|
|
218
|
+
|
|
219
|
+
page_component_entries: list[dict[str, Any]] = []
|
|
220
|
+
for component_entry in page_entry["components"]:
|
|
221
|
+
slug = component_entry["slug"]
|
|
222
|
+
component_payload = document_for_instruction(
|
|
223
|
+
summary,
|
|
224
|
+
component_entry["instruction"],
|
|
225
|
+
)
|
|
226
|
+
component_name = pascal_case(slug)
|
|
227
|
+
component_dir = react_root / "components" / component_name
|
|
228
|
+
component_output = component_dir / "index.tsx"
|
|
229
|
+
if slug not in component_records:
|
|
230
|
+
write_text(
|
|
231
|
+
component_output,
|
|
232
|
+
render_component_stub(component_payload),
|
|
233
|
+
)
|
|
234
|
+
component_records[slug] = {
|
|
235
|
+
"slug": slug,
|
|
236
|
+
"componentName": component_name,
|
|
237
|
+
"output": component_output.relative_to(react_root).as_posix(),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
page_component_entries.append(
|
|
241
|
+
{
|
|
242
|
+
"Slug": slug,
|
|
243
|
+
"ComponentName": component_name,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
write_text(
|
|
248
|
+
page_output,
|
|
249
|
+
render_page_stub(page_payload, page_component_entries, react_root),
|
|
250
|
+
)
|
|
251
|
+
page_records.append(
|
|
252
|
+
{
|
|
253
|
+
"projectName": page_payload["ProjectName"],
|
|
254
|
+
"slug": page_payload["Slug"],
|
|
255
|
+
"componentName": pascal_case(page_payload["Slug"]) + "Page",
|
|
256
|
+
"output": page_output.relative_to(react_root).as_posix(),
|
|
257
|
+
"children": page_component_entries,
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
barrel_lines: list[str] = []
|
|
262
|
+
for component in sorted(
|
|
263
|
+
component_records.values(),
|
|
264
|
+
key=lambda item: item["componentName"],
|
|
265
|
+
):
|
|
266
|
+
component_name = component["componentName"]
|
|
267
|
+
barrel_lines.append(
|
|
268
|
+
(f'export {{ {component_name} }} from "./components/{component_name}";')
|
|
269
|
+
)
|
|
270
|
+
barrel_lines.append(
|
|
271
|
+
(
|
|
272
|
+
f"export type {{ {component_name}Props }} "
|
|
273
|
+
f'from "./components/{component_name}";'
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
for page in sorted(page_records, key=lambda item: item["componentName"]):
|
|
278
|
+
page_component_name = page["componentName"]
|
|
279
|
+
page_path = page["output"].removesuffix("/index.tsx")
|
|
280
|
+
barrel_lines.append(f'export {{ {page_component_name} }} from "./{page_path}";')
|
|
281
|
+
barrel_lines.append(
|
|
282
|
+
(f'export type {{ {page_component_name}Props }} from "./{page_path}";')
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
write_text(react_root / "index.ts", "\n".join(barrel_lines) + "\n")
|
|
286
|
+
|
|
287
|
+
manifest_payload = {
|
|
288
|
+
"generatedWith": f"cox {__version__}",
|
|
289
|
+
"target": "react",
|
|
290
|
+
"components": sorted(
|
|
291
|
+
component_records.values(),
|
|
292
|
+
key=lambda item: item["componentName"],
|
|
293
|
+
),
|
|
294
|
+
"pages": page_records,
|
|
295
|
+
}
|
|
296
|
+
write_text(
|
|
297
|
+
react_root / "manifest.react.json",
|
|
298
|
+
json.dumps(manifest_payload, indent=2, ensure_ascii=True) + "\n",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
"output_root": react_root,
|
|
303
|
+
"component_count": len(component_records),
|
|
304
|
+
"page_count": len(page_records),
|
|
305
|
+
"manifest": react_root / "manifest.react.json",
|
|
306
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Create a self-contained COX project from the installed starter assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import yaml
|
|
10
|
+
from .cox_lib import CoxError, fail, portable_segment, validate_project
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def init_project(destination: Path, name: str | None = None) -> Path:
|
|
14
|
+
if destination.is_symlink():
|
|
15
|
+
fail("INIT_EXISTS", "Destination must not be a symlink", str(destination))
|
|
16
|
+
destination = destination.resolve()
|
|
17
|
+
if destination.exists() and (
|
|
18
|
+
not destination.is_dir() or any(destination.iterdir())
|
|
19
|
+
):
|
|
20
|
+
fail(
|
|
21
|
+
"INIT_EXISTS",
|
|
22
|
+
"Destination must be new or empty; existing files are preserved",
|
|
23
|
+
str(destination),
|
|
24
|
+
)
|
|
25
|
+
title = name or destination.name
|
|
26
|
+
if not portable_segment(title):
|
|
27
|
+
fail(
|
|
28
|
+
"PROJECT_NAME",
|
|
29
|
+
"Choose a non-empty portable project name, without path separators",
|
|
30
|
+
title,
|
|
31
|
+
)
|
|
32
|
+
slug = re.sub("[^a-z0-9]+", "_", title.lower()).strip("_") or "cox_project"
|
|
33
|
+
try:
|
|
34
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
with tempfile.TemporaryDirectory(
|
|
36
|
+
prefix=".cox-init-", dir=destination.parent
|
|
37
|
+
) as temp:
|
|
38
|
+
stage = Path(temp) / "project"
|
|
39
|
+
shutil.copytree(Path(__file__).parent / "starter", stage)
|
|
40
|
+
for rel in ["manifest.cox", "page/page.cox"]:
|
|
41
|
+
path = stage / rel
|
|
42
|
+
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
43
|
+
doc["ProjectName"] = title
|
|
44
|
+
if rel == "manifest.cox":
|
|
45
|
+
doc["Slug"] = slug
|
|
46
|
+
doc["PackageName"] = slug + ".zip"
|
|
47
|
+
path.write_text(
|
|
48
|
+
yaml.safe_dump(doc, sort_keys=False, allow_unicode=True),
|
|
49
|
+
encoding="utf-8",
|
|
50
|
+
)
|
|
51
|
+
validate_project(stage)
|
|
52
|
+
# rename refuses a concurrently populated destination directory.
|
|
53
|
+
os.replace(stage, destination)
|
|
54
|
+
return destination
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
raise CoxError(f"Could not create project: {exc}") from exc
|