first commit

This commit is contained in:
2026-09-02 11:44:52 +08:00
commit 0c8fa2653e
309 changed files with 57278 additions and 0 deletions
@@ -0,0 +1,514 @@
#!/usr/bin/env python3
"""Generate stable Yida custom-page source from a small JSON spec.
The generator is intentionally pure Python and emits conservative Yida runtime
source: named exports plus React.createElement calls. This avoids the most
common failure modes in hand-written JSX: unsupported hooks, event binding
mistakes, computed object keys, and fragile JSX transforms.
Usage:
python yida_page_generate.py product-homepage --spec page.json --output page.jsx --compile
python yida_page_generate.py todo-mvc --output todo.jsx --title "团队待办"
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from yida_jsx_pipeline import lint_check # noqa: E402
from yida_page_compiler import compile_jsx_to_schema # noqa: E402
IR_VERSION = "1.0"
KNOWN_TEMPLATES = ("product-homepage", "todo-mvc")
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2)
def _safe_filename_stem(value: str, fallback: str) -> str:
text = re.sub(r"[^A-Za-z0-9_.-]+", "-", (value or "").strip()).strip(".-")
return text or fallback
def _read_spec(path: str | None) -> dict[str, Any]:
if not path:
return {}
spec_path = Path(path).expanduser().resolve()
if not spec_path.exists():
raise ValueError(f"spec 文件不存在: {spec_path}")
try:
data = json.loads(spec_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"spec 不是合法 JSON: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("spec 顶层必须是 JSON object")
return data
def _merge_cli_vars(spec: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
merged = dict(spec)
for key in ("title", "subtitle", "brand_name", "tagline"):
value = getattr(args, key, None)
if value:
merged[key] = value
if args.item:
merged["items"] = [{"title": item, "text": ""} for item in args.item]
return merged
def _list_of_dicts(value: Any, fallback: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not isinstance(value, list):
return fallback
result: list[dict[str, Any]] = []
for item in value:
if isinstance(item, dict):
result.append(dict(item))
elif item is not None:
result.append({"title": str(item), "text": ""})
return result or fallback
def _normalize_product_homepage(raw: dict[str, Any]) -> dict[str, Any]:
features = _list_of_dicts(raw.get("features") or raw.get("items"), [
{"title": "统一入口", "text": "把常用流程、表单和报表聚合到一个页面。"},
{"title": "实时概览", "text": "用清晰的指标帮助团队快速判断当前状态。"},
{"title": "低风险交付", "text": "使用稳定模板生成,减少运行时白屏概率。"},
])
metrics = _list_of_dicts(raw.get("metrics"), [
{"value": "3", "label": "核心模块"},
{"value": "100%", "label": "纯 Python 生成"},
{"value": "0", "label": "外部依赖"},
])
actions = _list_of_dicts(raw.get("actions"), [
{"title": "查看能力", "target": "features"},
{"title": "查看指标", "target": "metrics"},
])
return {
"irVersion": IR_VERSION,
"template": "product-homepage",
"title": str(raw.get("title") or raw.get("brandName") or raw.get("brand_name") or "宜搭自定义页面"),
"subtitle": str(raw.get("subtitle") or raw.get("tagline") or "稳定生成、可检查、可发布的自定义页面骨架"),
"featuresTitle": str(raw.get("featuresTitle") or "核心能力"),
"metricsTitle": str(raw.get("metricsTitle") or "关键指标"),
"features": features[:8],
"metrics": metrics[:6],
"actions": actions[:4],
}
def _normalize_todo_mvc(raw: dict[str, Any]) -> dict[str, Any]:
todos = _list_of_dicts(raw.get("todos") or raw.get("items"), [
{"content": "确认页面需求", "done": True},
{"content": "生成稳定源码", "done": False},
{"content": "发布前 dry-run 校验", "done": False},
])
normalized_todos = []
for item in todos[:20]:
normalized_todos.append({
"content": str(item.get("content") or item.get("title") or "未命名任务"),
"done": bool(item.get("done")),
})
return {
"irVersion": IR_VERSION,
"template": "todo-mvc",
"title": str(raw.get("title") or "团队待办"),
"subtitle": str(raw.get("subtitle") or "验证状态、事件、列表渲染和本地交互的稳定模板"),
"placeholder": str(raw.get("placeholder") or "输入任务后点击添加"),
"todos": normalized_todos,
}
def normalize_spec(template: str, raw: dict[str, Any]) -> dict[str, Any]:
if template == "product-homepage":
return _normalize_product_homepage(raw)
if template == "todo-mvc":
return _normalize_todo_mvc(raw)
raise ValueError(f"未知模板: {template}")
def _common_runtime() -> str:
return """function h(type, props) {
var children = [];
for (var i = 2; i < arguments.length; i++) {
var child = arguments[i];
if (child === null || typeof child === 'undefined' || child === false) {
continue;
}
if (Array.isArray(child)) {
for (var j = 0; j < child.length; j++) {
if (child[j] !== null && typeof child[j] !== 'undefined' && child[j] !== false) {
children.push(child[j]);
}
}
} else {
children.push(child);
}
}
return React.createElement.apply(React, [type, props || null].concat(children));
}
function mergeStyle(base, extra) {
var out = {};
var key;
for (key in (base || {})) {
if (Object.prototype.hasOwnProperty.call(base, key)) {
out[key] = base[key];
}
}
for (key in (extra || {})) {
if (Object.prototype.hasOwnProperty.call(extra, key)) {
out[key] = extra[key];
}
}
return out;
}
var _customState = {};
export function getCustomState(key) {
if (key) {
return _customState[key];
}
var out = {};
var stateKey;
for (stateKey in _customState) {
if (Object.prototype.hasOwnProperty.call(_customState, stateKey)) {
out[stateKey] = _customState[stateKey];
}
}
return out;
}
export function setCustomState(newState) {
var data = newState || {};
Object.keys(data).forEach(function(key) {
_customState[key] = data[key];
});
this.forceUpdate();
}
export function forceUpdate() {
this.setState({ timestamp: new Date().getTime() });
}
export function didMount() {}
export function didUnmount() {}
"""
def render_product_homepage(ir: dict[str, Any]) -> str:
return f"""/* Generated by dws yida_page_generate.py. Edit the spec/manifest first when possible. */
var PAGE_SPEC = {_json(ir)};
{_common_runtime()}
export function scrollToSection(id) {{
if (!id || typeof document === 'undefined') {{
return;
}}
var node = document.getElementById(id);
if (node && node.scrollIntoView) {{
node.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
}}
export function renderJsx() {{
var self = this;
var features = [];
var metrics = [];
var actions = [];
var i;
var featureCardStyle = {{
padding: 18,
borderRadius: 8,
backgroundColor: '#ffffff',
border: '1px solid #e5e7eb',
boxShadow: '0 1px 4px rgba(15, 23, 42, 0.06)'
}};
var actionStyle = {{
height: 36,
padding: '0 14px',
borderRadius: 6,
border: '1px solid #2563eb',
backgroundColor: '#2563eb',
color: '#ffffff',
cursor: 'pointer'
}};
for (i = 0; i < PAGE_SPEC.features.length; i++) {{
features.push(h('div', {{ key: 'feature-' + i, style: featureCardStyle }},
h('div', {{ style: {{ fontSize: 16, fontWeight: 700, color: '#111827', marginBottom: 8 }} }}, PAGE_SPEC.features[i].title),
h('div', {{ style: {{ fontSize: 13, lineHeight: 1.7, color: '#4b5563' }} }}, PAGE_SPEC.features[i].text)
));
}}
for (i = 0; i < PAGE_SPEC.metrics.length; i++) {{
metrics.push(h('div', {{ key: 'metric-' + i, style: {{ minWidth: 120 }} }},
h('div', {{ style: {{ fontSize: 26, fontWeight: 800, color: '#111827' }} }}, PAGE_SPEC.metrics[i].value),
h('div', {{ style: {{ fontSize: 13, color: '#6b7280', marginTop: 4 }} }}, PAGE_SPEC.metrics[i].label)
));
}}
for (i = 0; i < PAGE_SPEC.actions.length; i++) {{
actions.push(h('button', {{
key: 'action-' + i,
type: 'button',
style: i === 0 ? actionStyle : mergeStyle(actionStyle, {{ backgroundColor: '#ffffff', color: '#2563eb' }}),
onClick: function(target) {{
return function(e) {{
if (e && e.preventDefault) {{
e.preventDefault();
}}
self.scrollToSection(target);
}};
}}(PAGE_SPEC.actions[i].target)
}}, PAGE_SPEC.actions[i].title));
}}
return h('div', {{ style: {{ minHeight: '100vh', padding: 24, backgroundColor: '#f3f4f6', color: '#111827', boxSizing: 'border-box' }} }},
h('div', {{ style: {{ display: 'none' }} }}, this.state && this.state.timestamp),
h('section', {{ style: {{ maxWidth: 1120, margin: '0 auto', padding: '34px 0 22px' }} }},
h('div', {{ style: {{ fontSize: 32, lineHeight: 1.25, fontWeight: 800, marginBottom: 12 }} }}, PAGE_SPEC.title),
h('div', {{ style: {{ maxWidth: 720, fontSize: 15, lineHeight: 1.8, color: '#4b5563', marginBottom: 20 }} }}, PAGE_SPEC.subtitle),
h('div', {{ style: {{ display: 'flex', flexWrap: 'wrap', gap: 10 }} }}, actions)
),
h('section', {{ id: 'metrics', style: {{ maxWidth: 1120, margin: '0 auto 18px', padding: 20, borderRadius: 8, backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }} }},
h('div', {{ style: {{ fontSize: 15, fontWeight: 700, marginBottom: 16 }} }}, PAGE_SPEC.metricsTitle),
h('div', {{ style: {{ display: 'flex', flexWrap: 'wrap', gap: 28 }} }}, metrics)
),
h('section', {{ id: 'features', style: {{ maxWidth: 1120, margin: '0 auto', padding: '10px 0 28px' }} }},
h('div', {{ style: {{ fontSize: 20, fontWeight: 800, margin: '10px 0 14px' }} }}, PAGE_SPEC.featuresTitle),
h('div', {{ style: {{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 14 }} }}, features)
)
);
}}
"""
def render_todo_mvc(ir: dict[str, Any]) -> str:
return f"""/* Generated by dws yida_page_generate.py. Edit the spec/manifest first when possible. */
var PAGE_SPEC = {_json(ir)};
{_common_runtime()}
_customState = {{
todos: PAGE_SPEC.todos || [],
draft: ''
}};
export function updateDraft(e) {{
_customState.draft = e && e.target ? e.target.value : '';
}}
export function addTodo(e) {{
if (e && e.preventDefault) {{
e.preventDefault();
}}
var text = String(_customState.draft || '').replace(/^\\s+|\\s+$/g, '');
if (!text) {{
if (this.utils && this.utils.toast) {{
this.utils.toast({{ title: '请输入待办内容', type: 'warning' }});
}}
return;
}}
var next = [];
for (var i = 0; i < _customState.todos.length; i++) {{
next.push(_customState.todos[i]);
}}
next.push({{ content: text, done: false }});
this.setCustomState({{ todos: next, draft: '' }});
}}
export function toggleTodo(index) {{
var next = [];
for (var i = 0; i < _customState.todos.length; i++) {{
var item = _customState.todos[i] || {{}};
next.push({{ content: item.content, done: i === index ? !item.done : !!item.done }});
}}
this.setCustomState({{ todos: next }});
}}
export function clearDone() {{
var next = [];
for (var i = 0; i < _customState.todos.length; i++) {{
if (!_customState.todos[i].done) {{
next.push(_customState.todos[i]);
}}
}}
this.setCustomState({{ todos: next }});
}}
export function renderJsx() {{
var self = this;
var todos = _customState.todos || [];
var rows = [];
var doneCount = 0;
var i;
for (i = 0; i < todos.length; i++) {{
if (todos[i].done) {{
doneCount += 1;
}}
rows.push(h('div', {{ key: 'todo-' + i, style: {{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 0', borderBottom: '1px solid #eef2f7' }} }},
h('button', {{
type: 'button',
style: {{ width: 26, height: 26, borderRadius: 6, border: '1px solid #cbd5e1', backgroundColor: todos[i].done ? '#16a34a' : '#ffffff', color: '#ffffff', cursor: 'pointer' }},
onClick: function(index) {{
return function(e) {{
if (e && e.preventDefault) {{
e.preventDefault();
}}
self.toggleTodo(index);
}};
}}(i)
}}, todos[i].done ? '' : ''),
h('span', {{ style: {{ flex: 1, color: todos[i].done ? '#94a3b8' : '#111827', textDecoration: todos[i].done ? 'line-through' : 'none' }} }}, todos[i].content)
));
}}
return h('div', {{ style: {{ minHeight: '100vh', padding: 24, backgroundColor: '#f8fafc', color: '#111827', boxSizing: 'border-box' }} }},
h('div', {{ style: {{ display: 'none' }} }}, this.state && this.state.timestamp),
h('section', {{ style: {{ maxWidth: 760, margin: '0 auto', padding: 22, borderRadius: 8, backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }} }},
h('div', {{ style: {{ fontSize: 26, fontWeight: 800, marginBottom: 8 }} }}, PAGE_SPEC.title),
h('div', {{ style: {{ fontSize: 14, color: '#64748b', marginBottom: 18 }} }}, PAGE_SPEC.subtitle),
h('div', {{ style: {{ display: 'flex', gap: 10, marginBottom: 12 }} }},
h('input', {{
key: 'draft-' + todos.length + '-' + String(_customState.draft || '').length,
defaultValue: _customState.draft || '',
placeholder: PAGE_SPEC.placeholder,
style: {{ flex: 1, height: 38, padding: '0 12px', borderRadius: 6, border: '1px solid #cbd5e1', outline: 'none' }},
onChange: function(e) {{ self.updateDraft(e); }},
onKeyDown: function(e) {{
if (e && e.key === 'Enter') {{
self.addTodo(e);
}}
}}
}}),
h('button', {{ type: 'button', style: {{ height: 38, padding: '0 14px', borderRadius: 6, border: '1px solid #2563eb', backgroundColor: '#2563eb', color: '#ffffff', cursor: 'pointer' }}, onClick: function(e) {{ self.addTodo(e); }} }}, '添加')
),
h('div', {{ style: {{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: '#64748b', margin: '8px 0 4px' }} }},
h('span', null, '' + todos.length + ''),
h('span', null, '已完成 ' + doneCount + '')
),
h('div', null, rows.length ? rows : h('div', {{ style: {{ padding: 26, textAlign: 'center', color: '#94a3b8' }} }}, '暂无待办')),
h('div', {{ style: {{ marginTop: 14, textAlign: 'right' }} }},
h('button', {{ type: 'button', style: {{ height: 34, padding: '0 12px', borderRadius: 6, border: '1px solid #cbd5e1', backgroundColor: '#ffffff', color: '#334155', cursor: 'pointer' }}, onClick: function(e) {{ if (e && e.preventDefault) {{ e.preventDefault(); }} self.clearDone(); }} }}, '清除已完成')
)
)
);
}}
"""
def render_source(ir: dict[str, Any]) -> str:
template = ir.get("template")
if template == "product-homepage":
return render_product_homepage(ir)
if template == "todo-mvc":
return render_todo_mvc(ir)
raise ValueError(f"未知模板: {template}")
def manifest_path_for(output: Path) -> Path:
suffix = "".join(output.suffixes)
stem = output.name[:-len(suffix)] if suffix else output.stem
return output.with_name(f"{stem}.dws-yida-page.json")
def write_generated(output: Path, source: str, ir: dict[str, Any]) -> Path:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(source, encoding="utf-8")
manifest_path = manifest_path_for(output)
manifest_path.write_text(_json(ir) + "\n", encoding="utf-8")
return manifest_path
def generate(args: argparse.Namespace) -> dict[str, Any]:
raw = _merge_cli_vars(_read_spec(args.spec), args)
template = args.template or raw.get("template") or "product-homepage"
if template not in KNOWN_TEMPLATES:
raise ValueError(f"未知模板: {template}; 可选: {', '.join(KNOWN_TEMPLATES)}")
ir = normalize_spec(template, raw)
output = Path(args.output or raw.get("output") or f"{_safe_filename_stem(ir.get('title', ''), template)}.jsx")
output = output.expanduser().resolve()
source = render_source(ir)
lint = lint_check(source, filename=str(output))
if not lint.get("ok"):
return {"ok": False, "stage": "lint", "errors": lint.get("errors", []), "warnings": lint.get("warnings", [])}
compile_result: dict[str, Any] | None = None
if args.compile:
compile_result = compile_jsx_to_schema(source, form_uuid=args.form or "FORM-GENERATED-PREVIEW")
if not compile_result.get("ok"):
return {
"ok": False,
"stage": "compile",
"errors": compile_result.get("errors", []),
"warnings": compile_result.get("lint", {}).get("warnings", []),
}
manifest = write_generated(output, source, ir)
result = {
"ok": True,
"template": template,
"output": str(output),
"manifest": str(manifest),
"lint": lint.get("info", {}),
}
if compile_result is not None:
result["compiledSize"] = len(compile_result.get("compiled_code", ""))
result["schemaSize"] = len(compile_result.get("schema", ""))
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="纯 Python 宜搭自定义页面稳定生成器")
parser.add_argument("template", nargs="?", choices=KNOWN_TEMPLATES, help="页面模板")
parser.add_argument("--spec", help="页面 JSON spec")
parser.add_argument("--output", help="输出源码路径,建议 pages/src/<name>.jsx")
parser.add_argument("--compile", action="store_true", help="生成前先通过纯 Python 编译校验")
parser.add_argument("--form", help="编译校验用 formUuid,默认 FORM-GENERATED-PREVIEW")
parser.add_argument("--title", help="页面标题")
parser.add_argument("--subtitle", help="页面副标题")
parser.add_argument("--brand-name", dest="brand_name", help="品牌名,等价于 title")
parser.add_argument("--tagline", help="标语,等价于 subtitle")
parser.add_argument("--item", action="append", help="快速添加 feature/todo 项,可重复")
parser.add_argument("--json", action="store_true", help="只输出 JSON 结果")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
result = generate(args)
except ValueError as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
if result.get("ok"):
print(f"[OK] 已生成: {result['output']}")
print(f"[OK] Manifest: {result['manifest']}")
if "compiledSize" in result:
print(f"[OK] 编译校验通过: compiledSize={result['compiledSize']} schemaSize={result['schemaSize']}")
else:
print(json.dumps(result, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
return 0 if result.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())