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.
Files changed (74) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/COX_FILE_FORMAT_SPECIFICATION_v1.0.md +40 -200
  3. package/FINALIZATION.md +50 -0
  4. package/LocalAppManager/Dashboard/analytics_sidebar_panels/analytics_sidebar_panels.cox +56 -0
  5. package/LocalAppManager/Dashboard/analytics_sidebar_panels/analytics_sidebar_panels_screenshot.placeholder.txt +1 -0
  6. package/LocalAppManager/Dashboard/dashboard.cox +62 -0
  7. package/LocalAppManager/Dashboard/dashboard_screenshot.placeholder.txt +1 -0
  8. package/LocalAppManager/Dashboard/left_navigation_sidebar/left_navigation_sidebar.cox +56 -0
  9. package/LocalAppManager/Dashboard/left_navigation_sidebar/left_navigation_sidebar_screenshot.placeholder.txt +1 -0
  10. package/LocalAppManager/Dashboard/projects_grid_cards/projects_grid_cards.cox +56 -0
  11. package/LocalAppManager/Dashboard/projects_grid_cards/projects_grid_cards_screenshot.placeholder.txt +1 -0
  12. package/LocalAppManager/Dashboard/top_header_stats_bar/top_header_stats_bar.cox +56 -0
  13. package/LocalAppManager/Dashboard/top_header_stats_bar/top_header_stats_bar_screenshot.placeholder.txt +1 -0
  14. package/MANIFEST.in +20 -0
  15. package/README.md +67 -154
  16. package/bin/cox-protocol.js +23 -97
  17. package/cox_protocol/__init__.py +2 -0
  18. package/cox_protocol/__main__.py +3 -0
  19. package/cox_protocol/compiler.py +273 -0
  20. package/cox_protocol/cox_cli.py +146 -0
  21. package/cox_protocol/cox_lib.py +599 -0
  22. package/cox_protocol/package_target.py +262 -0
  23. package/cox_protocol/react_target.py +306 -0
  24. package/cox_protocol/scaffold.py +56 -0
  25. package/cox_protocol/schemas/cox.component.v1.schema.json +180 -0
  26. package/cox_protocol/schemas/cox.crop_metadata.v1.schema.json +83 -0
  27. package/cox_protocol/schemas/cox.manifest.v1.schema.json +172 -0
  28. package/cox_protocol/schemas/cox.page.v1.schema.json +242 -0
  29. package/cox_protocol/schemas/cox.protocol.v1.schema.json +38 -0
  30. package/cox_protocol/starter/README.md +7 -0
  31. package/cox_protocol/starter/manifest.cox +26 -0
  32. package/cox_protocol/starter/page/action_button/action_button.cox +34 -0
  33. package/cox_protocol/starter/page/action_button/reference.svg +1 -0
  34. package/cox_protocol/starter/page/page.cox +39 -0
  35. package/cox_protocol/starter/page/reference.svg +1 -0
  36. package/editor-support/vscode/CHANGELOG.md +6 -0
  37. package/editor-support/vscode/README.md +2 -1
  38. package/editor-support/vscode/package.json +3 -3
  39. package/editor-support/vscode/snippets/cox-common.json +1 -1
  40. package/editor-support/vscode/snippets/cox-component.json +2 -1
  41. package/editor-support/vscode/snippets/cox-crop-metadata.json +1 -1
  42. package/editor-support/vscode/snippets/cox-manifest.json +1 -1
  43. package/editor-support/vscode/snippets/cox-page.json +2 -1
  44. package/editor-support/vscode/snippets/cox-protocol.json +1 -1
  45. package/examples/quickstart/README.md +7 -0
  46. package/examples/quickstart/manifest.cox +26 -0
  47. package/examples/quickstart/page/action_button/action_button.cox +34 -0
  48. package/examples/quickstart/page/action_button/reference.svg +1 -0
  49. package/examples/quickstart/page/page.cox +39 -0
  50. package/examples/quickstart/page/reference.svg +1 -0
  51. package/guides/contract-demo.md +24 -0
  52. package/guides/npm-installation.md +16 -287
  53. package/manifest.cox +7 -4
  54. package/package.json +30 -14
  55. package/protocols/recursive_decomposition_protocol.cox +8 -8
  56. package/pyproject.toml +17 -6
  57. package/schemas/cox.component.v1.schema.json +17 -4
  58. package/schemas/cox.crop_metadata.v1.schema.json +1 -1
  59. package/schemas/cox.manifest.v1.schema.json +1 -1
  60. package/schemas/cox.page.v1.schema.json +17 -4
  61. package/schemas/cox.protocol.v1.schema.json +1 -1
  62. package/scripts/check_distribution.py +87 -0
  63. package/scripts/demo.py +62 -0
  64. package/scripts/package-vscode.mjs +12 -0
  65. package/scripts/release_check.py +86 -0
  66. package/tests/conftest.py +21 -0
  67. package/tests/test_cli_and_typescript.py +123 -0
  68. package/tests/test_compiler.py +279 -0
  69. package/tests/test_validation.py +215 -0
  70. package/tools/__init__.py +2 -2
  71. package/tools/cox_cli.py +2 -123
  72. package/tools/cox_lib.py +2 -558
  73. package/tools/package_target.py +2 -214
  74. 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