Files
EP-Hub-Skill/.agents/skills/dingtalk-misc/scripts/yida_page_compiler.py
T
2026-09-02 11:44:52 +08:00

1442 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""yida_page_compiler.py — 纯 Python 宜搭自定义页面编译管线。
两块能力按 region 分段维护:
1) [region: page_compat] 现代 JSX (export default + useState/useEffect)
→ 宜搭 runtime 兼容格式 (export function renderJsx
+ _customState + didMount/didUnmount)
2) [region: compiler] page-compat → JSX→createElement → bind-this
→ ESM→CJS → minify 的端到端编译。
公开 API(被 yida_custom_page_update.py 直接 import):
- compile_jsx(jsx_source, modern=None, minify=True) -> dict
- compile_jsx_to_schema(jsx_source, form_uuid=None, ...) -> dict
- build_page_source(source_code) -> dict
- ensure_runtime_contract(source_code) -> dict
- minify_js(source) -> str
外部依赖:无(纯 Python 标准库)。JSX 转换从 yida_jsx_pipeline.transform_jsx
schema 外壳从 yida_page_schema.build_schema_content。
"""
import json
import re
import sys
from pathlib import Path
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
# JSX 转换与 schema 外壳从其它聚合文件 import(避免重复实现)
from yida_jsx_pipeline import transform_jsx
from yida_page_schema import build_schema_content
# ===========================================================================
# region: page_compat —— modern JSX → 宜搭 runtime 格式
# ===========================================================================
#
# 支持的转换:
# - export default function Page() → export function renderJsx()
# - var [x, setX] = useState(init) → this.getCustomState('x') / this.setCustomState({x: val})
# - setX(function(prev) { ... }) → setCustomState calls updater with previous value
# - useEffect(fn, []) → didMount 生命周期
# - useEffect cleanup return → didUnmount 生命周期
# - 移除 React/react-dom import
# - const/let → var
# - 补齐 runtime contract exports
# ---------------------------------------------------------------------------
_REMOVABLE_IMPORTS = frozenset(['react', 'react-dom', 'lodash', 'moment'])
_RUNTIME_EXPORTS = {
'getCustomState': (
'export function getCustomState(key) {\n'
' if (typeof _customState === "undefined") {\n'
' return key ? undefined : {};\n'
' }\n'
' if (key) {\n'
' return _customState[key];\n'
' }\n'
' return Object.assign({}, _customState);\n'
'}'
),
'setCustomState': (
'export function setCustomState(newState) {\n'
' if (typeof _customState === "undefined") {\n'
' return;\n'
' }\n'
' Object.keys(newState || {}).forEach(function(key) {\n'
' var value = newState[key];\n'
' if (typeof value === "function") {\n'
' value = value(_customState[key]);\n'
' }\n'
' _customState[key] = value;\n'
' });\n'
' this.forceUpdate();\n'
'}'
),
'forceUpdate': (
'export function forceUpdate() {\n'
' this.setState({ timestamp: new Date().getTime() });\n'
'}'
),
'didMount': 'export function didMount() {}',
'didUnmount': 'export function didUnmount() {}',
}
_TIMESTAMP_NODE = (
"React.createElement('div', {style: {display: 'none'}}, "
"this.state && this.state.timestamp)"
)
_CUSTOM_STATE_DECL_RE = re.compile(r'\bvar\s+_customState\b')
def _build_render_jsx_with_marker(render_body):
"""把 render_body 包进 IIFE,外层加 timestamp marker。
产出:
export function renderJsx() {
var __yidaTs = this.state && this.state.timestamp;
var __yidaContent = (function() {
...原 render_body...
}).call(this);
return React.createElement('div', null,
React.createElement('div', {style: {display: 'none'}}, __yidaTs),
__yidaContent
);
}
作用:__yidaTs 本体在 IIFE 之外被读取使 setState({timestamp}) 能被 React diff、
隐藏 div 把 __yidaTs 放到 JSX 树里使其参与重渲染;原 content 不动。
多一层匿名 <div> 包裹,不影响布局,仅 body > div > xxx 选择器会失效。
"""
if not render_body:
render_body = ' return null;'
indented = '\n'.join(
(' ' + line) if line else line
for line in render_body.split('\n')
)
return (
'export function renderJsx() {\n'
' var __yidaTs = this.state && this.state.timestamp;\n'
' var __yidaContent = (function() {\n'
+ indented + '\n'
' }).call(this);\n'
" return React.createElement('div', null,\n"
" React.createElement('div', {style: {display: 'none'}}, __yidaTs),\n"
' __yidaContent\n'
' );\n'
'}'
)
def build_page_source(source_code):
"""将现代 JSX 页面源码转换为宜搭 runtime 兼容格式。
Args:
source_code: 现代格式的 JSX 源码(export default function Page
Returns:
dict: {
'code': str, 转换后的代码
'lint': {'errors': [], 'warnings': []},
'fixes': [str], 应用的修复描述
'errors': [{'code': str, 'message': str}],
}
"""
if not _should_transform(source_code):
result = ensure_runtime_contract(source_code)
return {
'code': result['code'],
'lint': {'errors': [], 'warnings': []},
'fixes': result['fixes'],
'errors': [],
}
fixes = []
errors = []
# Step 0: 剥离 JSX 表达式注释 {/* ... */}
source_code = _strip_jsx_comments(source_code, fixes)
lines = source_code.split('\n')
# Step 1: 移除 React 等 import
lines = _remove_imports(lines, fixes, errors)
# Step 2: const/let → var
lines = _fix_variable_declarations(lines, fixes)
# Step 3: 提取 Page 函数体
page_info = _extract_page_function(lines, errors)
if page_info is None:
code = '\n'.join(lines)
result = ensure_runtime_contract(code)
return {
'code': result['code'],
'lint': {'errors': errors, 'warnings': []},
'fixes': fixes + result['fixes'],
'errors': errors,
}
# Step 4: 从函数体中提取 useState 和 useEffect
body_lines = page_info['body_lines']
state_info = _extract_use_state(body_lines, fixes, errors)
effect_info = _extract_use_effect(body_lines, fixes, errors)
# Step 5: 构建 renderJsx 函数
remaining_body = _remove_hooks_from_body(body_lines, state_info, effect_info)
# Step 5.5: 分离函数体为三类
separated = _separate_body_parts(remaining_body, state_info)
# Step 6: 构建输出
output_parts = []
output_parts.append('\n'.join(page_info['before_lines']))
# _customState 初始化
state_inits = []
for st in state_info['states']:
state_inits.append(f" {st['name']}: {st['init']},")
if state_inits:
output_parts.append('var _customState = {\n' + '\n'.join(state_inits) + '\n};')
else:
output_parts.append('var _customState = {};')
# 模块顶层变量
if separated['top_level_vars']:
top_vars = _replace_state_references(separated['top_level_vars'], state_info)
output_parts.append(top_vars)
# 纯工具函数
for fn_code in separated['utility_functions']:
output_parts.append(fn_code.strip())
# 导出函数声明(变为组件方法)
function_names = separated['function_names']
for fn_code in separated['functions']:
fn_transformed = _replace_state_references(fn_code, state_info)
fn_transformed = _add_bind_this_to_then_catch(fn_transformed)
fn_transformed = _replace_fn_calls_with_this(fn_transformed, function_names)
output_parts.append('export ' + fn_transformed.strip())
# renderJsx
render_body = _replace_state_references(separated['render_body'], state_info, receiver='self')
render_body = _replace_fn_calls_with_this(render_body, function_names)
if ('self.getCustomState' in render_body or 'self.setCustomState' in render_body) \
and not re.search(r'\bvar\s+self\s*=\s*this\b', render_body):
render_body = 'var self = this;\n' + render_body
render_fn = _build_render_jsx_with_marker(render_body)
output_parts.append(render_fn)
fixes.append('Converted export default function Page → export function renderJsx (with re-render marker)')
# didMount/didUnmount
mount_code = _replace_state_references(effect_info['mount_code'], state_info) if effect_info['mount_code'] else ''
unmount_code = _replace_state_references(effect_info['unmount_code'], state_info) if effect_info['unmount_code'] else ''
if mount_code:
mount_code = _replace_fn_calls_with_this(mount_code, function_names)
mount_code = _add_bind_this_to_then_catch(mount_code)
if unmount_code:
unmount_code = _replace_fn_calls_with_this(unmount_code, function_names)
unmount_code = _add_bind_this_to_then_catch(unmount_code)
if mount_code:
mount_fn = 'export function didMount() {\n' + mount_code + '\n}'
output_parts.append(mount_fn)
fixes.append('Extracted useEffect(fn, []) → didMount')
else:
output_parts.append('export function didMount() {}')
if unmount_code:
unmount_fn = 'export function didUnmount() {\n' + unmount_code + '\n}'
output_parts.append(unmount_fn)
fixes.append('Extracted useEffect cleanup → didUnmount')
else:
output_parts.append('export function didUnmount() {}')
if page_info['after_lines']:
output_parts.append('\n'.join(page_info['after_lines']))
code = '\n\n'.join(p for p in output_parts if p.strip())
result = ensure_runtime_contract(code)
return {
'code': result['code'],
'lint': {'errors': errors, 'warnings': []},
'fixes': fixes + result['fixes'],
'errors': errors,
}
def ensure_runtime_contract(source_code):
"""确保代码包含宜搭 runtime 必需的 export 函数。
Args:
source_code: 已转换的源码
Returns:
dict: {'code': str, 'fixes': [str]}
"""
fixes = []
append_parts = []
exported_names = set(re.findall(r'export\s+function\s+(\w+)', source_code))
if '_customState' in source_code and not _CUSTOM_STATE_DECL_RE.search(source_code):
append_parts.insert(0, 'var _customState = {};')
fixes.append('Inserted missing _customState store')
if 'getCustomState' not in exported_names or 'setCustomState' not in exported_names:
if '_customState' not in source_code:
append_parts.insert(0, 'var _customState = {};')
fixes.append('Inserted default _customState store')
for name, body in _RUNTIME_EXPORTS.items():
if name not in exported_names:
append_parts.append(body)
fixes.append(f'Inserted missing export function {name}')
if not append_parts:
return {'code': source_code, 'fixes': fixes}
return {
'code': source_code.rstrip() + '\n\n' + '\n\n'.join(append_parts) + '\n',
'fixes': fixes,
}
def _should_transform(source_code):
"""判断是否需要做 modern → yida 转换"""
if re.search(r'export\s+function\s+renderJsx\s*\(', source_code):
return False
return bool(re.search(r'export\s+default\b', source_code))
def _strip_jsx_comments(source_code, fixes):
"""剥离 JSX 表达式容器注释 {/* ... */}(避免 ,, 双逗号)。"""
if not source_code or '/*' not in source_code:
return source_code
original = source_code
source_code = re.sub(
r'(?m)^[ \t]*\{\s*/\*[\s\S]*?\*/\s*\}[ \t]*\r?\n',
'',
source_code,
)
source_code = re.sub(
r'(>)\s*\{\s*/\*[\s\S]*?\*/\s*\}\s*(<)',
r'\1\2',
source_code,
)
if source_code != original:
fixes.append('Stripped JSX expression comments (avoid ",," double-comma)')
return source_code
def _remove_imports(lines, fixes, errors):
"""移除已知的 removable imports,对未知的报错"""
result = []
for line in lines:
match = re.match(r"^\s*import\s+.*?from\s+['\"]([^'\"]+)['\"]", line)
if not match:
match = re.match(r"^\s*import\s+['\"]([^'\"]+)['\"]", line)
if match:
module_name = match.group(1).lower()
if module_name in _REMOVABLE_IMPORTS:
fixes.append(f'Removed {module_name} import')
continue
else:
errors.append({
'code': 'UNSUPPORTED_IMPORT',
'message': f'Unsupported import "{match.group(1)}". '
'Use this.utils.loadScript for external libraries.',
})
result.append(line)
return result
def _fix_variable_declarations(lines, fixes):
"""const/let → var"""
result = []
pattern = re.compile(r'^(\s*)(const|let)\b(.*)$')
changed = False
for line in lines:
m = pattern.match(line)
if m:
result.append(f'{m.group(1)}var{m.group(3)}')
changed = True
else:
result.append(line)
if changed:
fixes.append('Replaced const/let with var')
return result
def _extract_page_function(lines, errors):
"""提取 export default function Page() { ... } 的位置和内容"""
pattern = re.compile(r'^(\s*)export\s+default\s+function\s+\w*\s*\([^)]*\)\s*\{?\s*$')
start_idx = None
for i, line in enumerate(lines):
if pattern.match(line):
start_idx = i
break
if re.match(r'^\s*export\s+default\s+function\b', line):
start_idx = i
break
if start_idx is None:
errors.append({
'code': 'NO_DEFAULT_EXPORT',
'message': 'Cannot find export default function Page().',
})
return None
brace_start = None
text_from_start = '\n'.join(lines[start_idx:])
for i, ch in enumerate(text_from_start):
if ch == '{':
brace_start = i
break
if brace_start is None:
errors.append({'code': 'PARSE_ERROR', 'message': 'Cannot find opening brace of Page function.'})
return None
depth = 0
all_text = '\n'.join(lines)
char_pos = len('\n'.join(lines[:start_idx])) + (1 if start_idx > 0 else 0) + brace_start
end_char = None
pos = char_pos
while pos < len(all_text):
ch = all_text[pos]
if ch in ('"', "'", '`'):
pos = _skip_string_in_source(all_text, pos) + 1
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
end_char = pos
break
pos += 1
if end_char is None:
errors.append({'code': 'PARSE_ERROR', 'message': 'Cannot find closing brace of Page function.'})
return None
body_content = all_text[char_pos + 1:end_char]
end_line = all_text[:end_char + 1].count('\n')
before_lines = lines[:start_idx]
after_lines = lines[end_line + 1:] if end_line + 1 < len(lines) else []
body_lines = body_content.split('\n')
return {
'before_lines': before_lines,
'body_lines': body_lines,
'after_lines': after_lines,
}
def _skip_string_in_source(text, pos):
"""跳过字符串字面量"""
quote = text[pos]
pos += 1
while pos < len(text):
ch = text[pos]
if ch == '\\':
pos += 2
continue
if ch == quote:
return pos
pos += 1
return pos
def _extract_use_state(body_lines, fixes, errors):
"""提取所有 useState 声明(支持跨行初值)"""
states = []
body_text = '\n'.join(body_lines)
decl_pattern = re.compile(
r'(?m)^([ \t]*)var\s+\[(\w+)\s*,\s*(\w+)\]\s*=\s*useState\s*\('
)
for m in decl_pattern.finditer(body_text):
name = m.group(2)
setter = m.group(3)
paren_start = m.end() - 1
depth = 0
pos = paren_start
while pos < len(body_text):
ch = body_text[pos]
if ch in ('"', "'", '`'):
pos = _skip_string_in_source(body_text, pos) + 1
continue
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if depth == 0:
break
pos += 1
if depth != 0:
errors.append({
'code': 'UNSUPPORTED_USE_STATE',
'message': f'Unbalanced parentheses in useState({name}) declaration.',
})
continue
init_text = body_text[paren_start + 1:pos].strip()
decl_end = pos + 1
while decl_end < len(body_text) and body_text[decl_end] in ' \t':
decl_end += 1
if decl_end < len(body_text) and body_text[decl_end] == ';':
decl_end += 1
states.append({
'name': name,
'setter': setter,
'init': init_text,
'raw_text': body_text[m.start():decl_end],
})
for line in body_lines:
hook_match = re.search(r'\buse([A-Z]\w*)\b', line)
if hook_match:
hook_name = 'use' + hook_match.group(1)
if hook_name not in ('useState', 'useEffect'):
errors.append({
'code': 'UNSUPPORTED_HOOK',
'message': f'{hook_name} is not supported.',
})
return {'states': states}
def _extract_use_effect(body_lines, fixes, errors):
"""提取 useEffect(fn, []) 调用"""
mount_code = ''
unmount_code = ''
body_text = '\n'.join(body_lines)
effect_pattern = re.compile(r'useEffect\s*\(\s*function\s*\(\)\s*\{')
for match in effect_pattern.finditer(body_text):
start = match.end()
depth = 1
pos = start
while pos < len(body_text) and depth > 0:
ch = body_text[pos]
if ch in ('"', "'", '`'):
pos = _skip_string_in_source(body_text, pos) + 1
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
pos += 1
fn_body = body_text[start:pos - 1]
remaining_after_fn = body_text[pos:].lstrip()
if not remaining_after_fn.startswith(','):
errors.append({'code': 'UNSUPPORTED_EFFECT_DEPS', 'message': 'useEffect must have [], as second arg.'})
continue
deps_start = body_text.index(',', pos) + 1
deps_text = body_text[deps_start:].lstrip()
if not deps_text.startswith('[]') and not deps_text.startswith('[ ]'):
errors.append({'code': 'UNSUPPORTED_EFFECT_DEPS', 'message': 'Only useEffect(fn, []) is supported.'})
continue
mount_lines, unmount_lines = _split_effect_body(fn_body)
if mount_lines:
mount_code += mount_lines + '\n'
if unmount_lines:
unmount_code += unmount_lines + '\n'
return {
'mount_code': mount_code.strip(),
'unmount_code': unmount_code.strip(),
}
def _split_effect_body(fn_body):
"""将 effect 函数体分为 mount 代码和 unmountreturn 的 cleanup 函数)"""
lines = fn_body.strip().split('\n')
mount_lines = []
unmount_lines = []
return_idx = None
for i in range(len(lines) - 1, -1, -1):
if re.match(r'^\s*return\s+function\b', lines[i]):
return_idx = i
break
if re.match(r'^\s*return\s*\(\s*function\b', lines[i]):
return_idx = i
break
if return_idx is None:
return '\n'.join(lines), ''
mount_lines = lines[:return_idx]
return_text = '\n'.join(lines[return_idx:])
brace_match = return_text.find('{')
if brace_match == -1:
return '\n'.join(mount_lines), ''
depth = 0
end_pos = None
for i in range(brace_match, len(return_text)):
if return_text[i] == '{':
depth += 1
elif return_text[i] == '}':
depth -= 1
if depth == 0:
end_pos = i
break
if end_pos is not None:
cleanup_body = return_text[brace_match + 1:end_pos]
unmount_lines = cleanup_body.strip()
return '\n'.join(mount_lines), unmount_lines
def _remove_hooks_from_body(body_lines, state_info, effect_info):
"""从函数体中移除 useState 和 useEffect 声明行"""
body_text = '\n'.join(body_lines)
for st in state_info['states']:
raw = st.get('raw_text') or st.get('line') or ''
if raw:
body_text = body_text.replace(raw, '')
effect_pattern = re.compile(r'\s*useEffect\s*\(')
while True:
m = effect_pattern.search(body_text)
if not m:
break
start = m.start()
paren_start = body_text.index('(', m.start())
depth = 0
pos = paren_start
while pos < len(body_text):
ch = body_text[pos]
if ch in ('"', "'", '`'):
pos = _skip_string_in_source(body_text, pos) + 1
continue
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if depth == 0:
break
pos += 1
end = pos + 1
if end < len(body_text) and body_text[end] == ';':
end += 1
body_text = body_text[:start] + body_text[end:]
body_text = re.sub(r'\n{3,}', '\n\n', body_text)
return body_text.strip()
def _replace_state_references(body_text, state_info, receiver='this'):
"""将 state 变量引用替换为 this.getCustomState/setCustomState(跳过字符串)。"""
for st in state_info['states']:
name = st['name']
setter = st['setter']
body_text = _replace_setter_calls(body_text, setter, name, receiver=receiver)
body_text = _replace_state_reads(body_text, name, receiver=receiver)
return body_text
def _replace_state_reads(text, state_name, receiver='this'):
"""替换裸变量名引用为 this.getCustomState('name'),跳过字符串内容和对象属性 key。"""
pattern = re.compile(r'(?<![.\w])' + re.escape(state_name) + r'(?!\w)')
result = []
pos = 0
while pos < len(text):
ch = text[pos]
if ch in ('"', "'", '`'):
end = _find_string_end(text, pos)
result.append(text[pos:end + 1])
pos = end + 1
continue
m = pattern.match(text, pos)
if m:
after_end = m.end()
preceding = ''.join(result).rstrip()
if preceding and preceding[-1] == '>':
prev_prev = preceding[-2] if len(preceding) >= 2 else ''
if prev_prev not in ('=', '!', '<', '>'):
result.append(text[pos:m.end()])
pos = m.end()
continue
rest_after = text[after_end:].lstrip()
if rest_after and rest_after[0] == ':' and not rest_after.startswith('::'):
if preceding and preceding[-1] in ('{', ',', '\n'):
result.append(text[pos:m.end()])
pos = m.end()
continue
result.append(f"{receiver}.getCustomState('{state_name}')")
pos = m.end()
else:
result.append(ch)
pos += 1
return ''.join(result)
def _replace_setter_calls(text, setter_name, state_name, receiver='this'):
"""替换 setX(value) 为 this.setCustomState({'x': value}),跳过字符串内容。"""
setter_pattern = re.compile(r'(?<![.\w])' + re.escape(setter_name) + r'\s*\(')
result_parts = []
search_start = 0
while search_start < len(text):
ch = text[search_start] if search_start < len(text) else ''
if ch in ('"', "'", '`'):
end = _find_string_end(text, search_start)
result_parts.append(text[search_start:end + 1])
search_start = end + 1
continue
m = setter_pattern.search(text, search_start)
if not m:
result_parts.append(text[search_start:])
break
result_parts.append(text[search_start:m.start()])
paren_start = m.end() - 1
depth = 0
pos = paren_start
while pos < len(text):
c = text[pos]
if c in ('"', "'", '`'):
pos = _find_string_end(text, pos) + 1
continue
if c == '(':
depth += 1
elif c == ')':
depth -= 1
if depth == 0:
break
pos += 1
arg_content = text[paren_start + 1:pos]
replacement = f"{receiver}.setCustomState({{ '{state_name}': {arg_content} }})"
result_parts.append(replacement)
search_start = pos + 1
return ''.join(result_parts)
def _find_string_end(text, start):
"""找到从 start 位置开始的字符串字面量结束位置(含闭合引号)"""
quote = text[start]
pos = start + 1
if quote == '`':
while pos < len(text):
ch = text[pos]
if ch == '\\':
pos += 2
continue
if ch == '`':
return pos
if ch == '$' and pos + 1 < len(text) and text[pos + 1] == '{':
pos += 2
depth = 1
while pos < len(text) and depth > 0:
if text[pos] == '{':
depth += 1
elif text[pos] == '}':
depth -= 1
pos += 1
continue
pos += 1
return len(text) - 1
while pos < len(text):
ch = text[pos]
if ch == '\\':
pos += 2
continue
if ch == quote:
return pos
pos += 1
return len(text) - 1
def _consume_var_declaration(lines, start_index):
"""从 lines[start_index] 起读一条完整的 var 声明,允许初值跨多行。
跟踪 {} [] () 括号深度(跳过字符串、模板字符串、单/多行注释),
括号深度归零的那一行视为声明结束。返回 (end_index_inclusive, full_text)。
"""
depth = 0
in_string = False
string_quote = ''
n = len(lines)
end = start_index
i = start_index
while i < n:
line = lines[i]
j = 0
L = len(line)
while j < L:
ch = line[j]
if in_string:
if string_quote == '`':
if ch == '\\' and j + 1 < L:
j += 2
continue
if ch == '`':
in_string = False
j += 1
continue
if ch == '\\' and j + 1 < L:
j += 2
continue
if ch == string_quote:
in_string = False
j += 1
continue
if ch == '/' and j + 1 < L and line[j + 1] == '/':
break
if ch == '/' and j + 1 < L and line[j + 1] == '*':
end_block = line.find('*/', j + 2)
if end_block == -1:
j = L
break
j = end_block + 2
continue
if ch in ('"', "'", '`'):
in_string = True
string_quote = ch
j += 1
continue
if ch in '{[(':
depth += 1
elif ch in '}])':
depth -= 1
j += 1
if depth <= 0:
end = i
break
i += 1
else:
end = n - 1
return end, '\n'.join(lines[start_index:end + 1])
def _var_function_to_declaration(decl_text):
"""Convert `var name = function(args) { ... };` to `function name(args) { ... }`."""
header = re.match(r'^\s*var\s+(\w+)\s*=\s*function\s*(\([^)]*\))\s*\{', decl_text, re.S)
if not header:
return None, None
name = header.group(1)
args = header.group(2)
open_brace = decl_text.find('{', header.start())
close_brace = decl_text.rfind('}')
if open_brace < 0 or close_brace <= open_brace:
return None, None
body = decl_text[open_brace + 1:close_brace]
return name, f"function {name}{args} {{{body}\n}}"
def _extract_declared_var_names(decl_text):
"""Return declared identifiers from a `var ...` declaration.
The modern authoring splitter needs these names to keep chained render
derivations together, e.g. `filtered` followed by `amount =
filtered.reduce(...)`. This parser only handles normal identifiers because
useState destructuring is removed before this phase.
"""
m = re.match(r'^\s*var\s+([\s\S]*)$', decl_text.strip())
if not m:
return []
tail = m.group(1)
names = []
pos = 0
n = len(tail)
while pos < n:
while pos < n and tail[pos].isspace():
pos += 1
ident = re.match(r'[A-Za-z_$][\w$]*', tail[pos:])
if ident:
names.append(ident.group(0))
pos += len(ident.group(0))
depth = 0
while pos < n:
ch = tail[pos]
if ch in ('"', "'", '`'):
pos = _find_string_end(tail, pos) + 1
continue
if ch in '([{':
depth += 1
elif ch in ')]}':
depth -= 1
elif ch == ',' and depth == 0:
pos += 1
break
pos += 1
else:
break
return names
def _references_any_identifier(text, names):
"""Whether text references any identifier in names outside strings."""
if not names:
return False
clean_parts = []
pos = 0
while pos < len(text):
ch = text[pos]
if ch in ('"', "'", '`'):
end = _find_string_end(text, pos)
clean_parts.append(' ')
pos = end + 1
else:
clean_parts.append(ch)
pos += 1
clean = ''.join(clean_parts)
return any(re.search(r'\b' + re.escape(name) + r'\b', clean) for name in names)
def _separate_body_parts(body_text, state_info):
"""将函数体分离为:需导出的方法、模块级工具函数、顶层常量、render 部分。
分类规则:
- 函数内含 setState/getState 调用 → 导出为组件方法(用 this.xxx() 调用)
- 纯工具函数(不依赖 state)→ 模块级普通函数(直接调用)
- var 声明依赖 state → 放入 renderJsx
- var 声明不依赖 state → 模块顶层常量
- return 语句及之后 → render_body
"""
lines = body_text.split('\n')
exported_functions = []
exported_fn_names = []
utility_functions = []
utility_fn_names = []
top_level_vars = []
render_lines = []
render_var_names = set()
state_setters = set(st['setter'] for st in state_info['states'])
state_names = set(st['name'] for st in state_info['states'])
i = 0
found_return = False
while i < len(lines):
line = lines[i]
stripped = line.strip()
if found_return:
render_lines.append(line)
i += 1
continue
if re.match(r'^\s*return\s*[\(]', stripped) or stripped == 'return (' or stripped == 'return(':
found_return = True
render_lines.append(line)
i += 1
continue
fn_match = re.match(r'^(\s*)function\s+(\w+)\s*\(', line)
if fn_match:
fn_name = fn_match.group(2)
fn_start = i
brace_depth = 0
fn_end = i
for j in range(i, len(lines)):
for ch in lines[j]:
if ch == '{':
brace_depth += 1
elif ch == '}':
brace_depth -= 1
if brace_depth == 0 and j > i:
fn_end = j
break
if brace_depth == 0 and '{' in lines[j]:
fn_end = j
break
fn_code = '\n'.join(lines[fn_start:fn_end + 1])
uses_state = (
any(s + '(' in fn_code for s in state_setters)
or any(re.search(r'\b' + re.escape(sn) + r'\b', fn_code) for sn in state_names)
or bool(re.search(r'\bthis\.', fn_code))
)
if uses_state:
exported_functions.append(fn_code)
exported_fn_names.append(fn_name)
else:
utility_functions.append(fn_code)
utility_fn_names.append(fn_name)
i = fn_end + 1
continue
if re.match(r'^\s*var\s+\w+\s*=\s*function\s*\(', line):
decl_end, decl_text = _consume_var_declaration(lines, i)
fn_name, fn_code = _var_function_to_declaration(decl_text)
if fn_name and fn_code:
exported_functions.append(fn_code)
exported_fn_names.append(fn_name)
else:
render_lines.extend(lines[i:decl_end + 1])
i = decl_end + 1
continue
if stripped.startswith('var ') and not stripped.startswith('var ['):
decl_end, decl_text = _consume_var_declaration(lines, i)
decl_names = _extract_declared_var_names(decl_text)
uses_state_var = _references_any_identifier(decl_text, state_names)
uses_render_var = _references_any_identifier(decl_text, render_var_names)
if uses_state_var or uses_render_var:
render_lines.extend(lines[i:decl_end + 1])
render_var_names.update(decl_names)
else:
top_level_vars.extend(lines[i:decl_end + 1])
i = decl_end + 1
continue
if stripped:
render_lines.append(line)
i += 1
return {
'functions': exported_functions,
'function_names': exported_fn_names,
'utility_functions': utility_functions,
'utility_fn_names': utility_fn_names,
'top_level_vars': '\n'.join(top_level_vars),
'render_body': '\n'.join(render_lines).strip(),
}
def _replace_fn_calls_with_this(code, function_names):
"""将裸函数调用 xxx() 替换为 this.xxx()(仅对导出为组件方法的函数)。"""
for fn_name in function_names:
pattern = re.compile(r'(?<!function )(?<![.\w])' + re.escape(fn_name) + r'(?=\s*\()')
code = pattern.sub('this.' + fn_name, code)
return code
def _add_bind_this_to_then_catch(code):
"""在 .then(function(...) { ... }) 和 .catch(function(...) { ... }) 回调中,
如果内部使用了 this.,则在闭合 } 后添加 .bind(this)。
"""
result = []
pos = 0
text = code
while pos < len(text):
match = None
for method in ('then', 'catch'):
pattern = '.' + method + '(function'
if text[pos:pos + len(pattern)] == pattern:
match = method
break
if match is None:
result.append(text[pos])
pos += 1
continue
method_text = '.' + match + '('
result.append(method_text)
pos += len(method_text)
brace_pos = text.find('{', pos)
if brace_pos == -1:
result.append(text[pos:])
break
result.append(text[pos:brace_pos + 1])
pos = brace_pos + 1
depth = 1
body_start = pos
while pos < len(text) and depth > 0:
ch = text[pos]
if ch in ('"', "'", '`'):
pos = _find_string_end(text, pos) + 1
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
pos += 1
fn_body = text[body_start:pos - 1]
result.append(fn_body)
if 'this.' in fn_body:
result.append('}.bind(this)')
else:
result.append('}')
return ''.join(result)
# ===========================================================================
# region: compiler —— page-compat → JSX→createElement → bind-this → ESM→CJS → minify
# ===========================================================================
def compile_jsx(jsx_source, modern=None, minify=True):
"""编译 JSX 源码为宜搭 runtime 兼容的 JS。
Args:
jsx_source: JSX/JS 源码字符串
modern: True 强制走 page-compat 转换;None 自动判断
minify: 是否压缩(默认 True
Returns:
dict: {
'source_code': str, 原始输入
'intermediate_code': str, page-compat 改写后的中间产物
'compiled_code': str, JSX 转换 + minify 后的最终 JS
'lint': {'errors': [], 'warnings': []},
'ok': bool,
'errors': [{'message': str, ...}],
}
"""
errors = []
# Step 1: page-compat 转换
compat_result = build_page_source(jsx_source)
intermediate_code = compat_result['code']
lint = compat_result.get('lint', {'errors': [], 'warnings': []})
if compat_result.get('errors'):
for err in compat_result['errors']:
errors.append(err)
if lint.get('errors'):
return {
'source_code': jsx_source,
'intermediate_code': intermediate_code,
'compiled_code': '',
'lint': lint,
'ok': False,
'errors': errors + lint['errors'],
}
# Step 2: JSX → React.createElement
try:
transformed_code = transform_jsx(intermediate_code)
except Exception as exc:
errors.append({'code': 'JSX_TRANSFORM_ERROR', 'message': str(exc)})
return {
'source_code': jsx_source,
'intermediate_code': intermediate_code,
'compiled_code': '',
'lint': lint,
'ok': False,
'errors': errors,
}
# Step 2.5: 给含 this. 的匿名函数加 .bind(this)
transformed_code = _bind_this_to_anonymous_fns(transformed_code)
# Step 2.6: ESM exports → CommonJS(宜搭 runtime 只认 CommonJS
transformed_code = _convert_esm_to_commonjs(transformed_code)
# Step 3: minify
if minify:
compiled_code = minify_js(transformed_code)
else:
compiled_code = transformed_code
ok = len(errors) == 0
return {
'source_code': jsx_source,
'intermediate_code': intermediate_code,
'compiled_code': compiled_code,
'lint': lint,
'ok': ok,
'errors': errors,
}
def compile_jsx_to_schema(jsx_source, form_uuid=None, existing_data_source=None,
modern=None, minify=True):
"""编译 JSX 源码,可选生成完整自定义页面 schema。"""
result = compile_jsx(jsx_source, modern=modern, minify=minify)
if form_uuid and result.get('ok'):
result['schema'] = build_schema_content(
result['source_code'],
result['compiled_code'],
form_uuid,
existing_data_source=existing_data_source,
)
else:
result['schema'] = None
return result
def minify_js(source):
"""简单 JS minifier:移除注释、压缩连续空白、保留字符串内容。"""
result = []
pos = 0
length = len(source)
while pos < length:
ch = source[pos]
if ch in ('"', "'"):
end = _consume_string(source, pos)
result.append(source[pos:end + 1])
pos = end + 1
continue
if ch == '`':
end = _consume_template(source, pos)
result.append(source[pos:end + 1])
pos = end + 1
continue
if ch == '/' and pos + 1 < length and source[pos + 1] == '/':
while pos < length and source[pos] != '\n':
pos += 1
continue
if ch == '/' and pos + 1 < length and source[pos + 1] == '*':
pos += 2
while pos < length - 1:
if source[pos] == '*' and source[pos + 1] == '/':
pos += 2
break
pos += 1
else:
pos = length
continue
if ch in (' ', '\t', '\r'):
while pos < length and source[pos] in (' ', '\t', '\r'):
pos += 1
if result and result[-1] and result[-1][-1:].isalnum() or (result and result[-1][-1:] == '_'):
if pos < length and (source[pos].isalnum() or source[pos] == '_'):
result.append(' ')
continue
if ch == '\n':
while pos < length and source[pos] in ('\n', '\r', ' ', '\t'):
pos += 1
if result and result[-1] and result[-1][-1:] not in ('{', '}', ';', ',', '(', '[', '\n', ''):
result.append('\n')
continue
result.append(ch)
pos += 1
return ''.join(result)
def _consume_string(source, start):
"""找到字符串字面量的结束位置(含闭合引号)"""
quote = source[start]
pos = start + 1
while pos < len(source):
ch = source[pos]
if ch == '\\':
pos += 2
continue
if ch == quote:
return pos
pos += 1
return len(source) - 1
def _consume_template(source, start):
"""找到模板字面量的结束位置(含闭合反引号)"""
pos = start + 1
while pos < len(source):
ch = source[pos]
if ch == '\\':
pos += 2
continue
if ch == '`':
return pos
if ch == '$' and pos + 1 < len(source) and source[pos + 1] == '{':
pos += 2
depth = 1
while pos < len(source) and depth > 0:
if source[pos] == '{':
depth += 1
elif source[pos] == '}':
depth -= 1
elif source[pos] == '\\':
pos += 1
pos += 1
continue
pos += 1
return len(source) - 1
def _bind_this_to_anonymous_fns(code):
"""给含有 this. 引用的匿名函数表达式自动添加 .bind(this)。"""
result = []
pos = 0
length = len(code)
while pos < length:
fn_idx = code.find('function', pos)
if fn_idx == -1:
result.append(code[pos:])
break
if fn_idx > 0 and (code[fn_idx - 1].isalnum() or code[fn_idx - 1] == '_'):
result.append(code[pos:fn_idx + 8])
pos = fn_idx + 8
continue
after_fn = fn_idx + 8
if after_fn < length and (code[after_fn].isalnum() or code[after_fn] == '_'):
pass
result.append(code[pos:fn_idx])
line_start = code.rfind('\n', 0, fn_idx)
line_start = line_start + 1 if line_start != -1 else 0
prefix = code[line_start:fn_idx].strip()
is_declaration = (prefix == '' or prefix == 'export')
scan = fn_idx + 8
while scan < length and code[scan] in (' ', '\t'):
scan += 1
if scan < length and (code[scan].isalpha() or code[scan] == '_'):
while scan < length and (code[scan].isalnum() or code[scan] == '_'):
scan += 1
while scan < length and code[scan] in (' ', '\t'):
scan += 1
if scan >= length or code[scan] != '(':
result.append('function')
pos = fn_idx + 8
continue
depth = 1
scan += 1
while scan < length and depth > 0:
ch = code[scan]
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif ch in ('"', "'", '`'):
scan = _skip_str_for_bind(code, scan)
scan += 1
paren_end = scan
while scan < length and code[scan] in (' ', '\t', '\n', '\r'):
scan += 1
if scan >= length or code[scan] != '{':
result.append(code[fn_idx:paren_end])
pos = paren_end
continue
brace_start = scan
if is_declaration:
result.append(code[fn_idx:brace_start + 1])
pos = brace_start + 1
continue
depth = 1
scan += 1
while scan < length and depth > 0:
ch = code[scan]
if ch in ('"', "'", '`'):
scan = _skip_str_for_bind(code, scan)
scan += 1
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
scan += 1
brace_end = scan
fn_body = code[brace_start + 1:brace_end - 1]
result.append(code[fn_idx:brace_end])
if 'this.' in fn_body:
rest = code[brace_end:brace_end + 30]
if not rest.lstrip(' \t').startswith('.bind(this)'):
result.append('.bind(this)')
pos = brace_end
return ''.join(result)
def _skip_str_for_bind(code, pos):
"""跳过字符串/模板字面量,返回闭合引号的位置"""
quote = code[pos]
pos += 1
while pos < len(code):
ch = code[pos]
if ch == '\\':
pos += 2
continue
if quote == '`' and ch == '$' and pos + 1 < len(code) and code[pos + 1] == '{':
pos += 2
depth = 1
while pos < len(code) and depth > 0:
if code[pos] == '{':
depth += 1
elif code[pos] == '}':
depth -= 1
pos += 1
continue
if ch == quote:
return pos
pos += 1
return pos - 1
def _convert_esm_to_commonjs(code):
"""将 export function xxx 转换为宜搭 runtime 能识别的格式。
宜搭 runtimerender-engine)期望代码是一个包裹在 function 中的块,
通过 Babel 编译后产出 CommonJS 风格:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.renderJsx = renderJsx;
exports.didMount = didMount;
...
function renderJsx() { ... }
但实际上宜搭更简单:它直接 eval 代码后从 exports 对象取函数。
"""
lines = code.split('\n')
result_lines = ['"use strict";', '']
export_names = []
for line in lines:
m = re.match(r'^export\s+function\s+(\w+)', line)
if m:
fn_name = m.group(1)
export_names.append(fn_name)
result_lines.append(line.replace('export ', '', 1))
else:
result_lines.append(line)
if export_names:
result_lines.append('')
for name in export_names:
result_lines.append(f'exports.{name} = {name};')
return '\n'.join(result_lines)