@dongmingzhixiu/vuexir-compiler 0.3.12 → 0.3.14

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.
@@ -1176,7 +1176,8 @@ function emitNodeInner(node, ctx, depth, loop, useParamModifier = false, parentS
1176
1176
  }
1177
1177
  case 'button': {
1178
1178
  const chain = modChain([...stretchModifiers(parentStretch, node), ...sizeModifiers(p), ...absModifiers(p), ...clipModifiers(p), ...visualModifiers(p), ...borderModifiers(p), ...paddingModifiers(p), ...alphaArr, ...transformModifiers(p)], pad + ' ');
1179
- const label = p.label ?? '';
1179
+ // R108:动态按钮文案(props.bind)优先;Kotlin 里用字符串模板插值
1180
+ const label = p.bind !== undefined ? '"${' + String(p.bind) + '}"' : (p.label ?? '');
1180
1181
  const click = kotlinClickAction(p, ctx, loop);
1181
1182
  const labelStyle = [];
1182
1183
  if (p.fontSize !== undefined)
@@ -824,7 +824,12 @@ function emitNodeInner(node, ctx) {
824
824
  return line;
825
825
  }
826
826
  case 'button': {
827
- const label = props.label !== undefined ? props.label : '按钮';
827
+ // R108:动态按钮文案(props.bind → @State)优先于静态 label
828
+ const label = props.bind !== undefined
829
+ ? props.bind
830
+ : props.label !== undefined
831
+ ? props.label
832
+ : '按钮';
828
833
  const fs = props.fontSize ?? 15;
829
834
  // 事件:onSet(状态直接赋值)优先,其次 onClick(JS 运行时),无事件则空 action
830
835
  // M1:onSet.kind==='expr' → 表达式原样嵌入(count + 1);literal → 数字原样/字符串加引号
package/dist/ir/schema.js CHANGED
@@ -118,7 +118,7 @@ const NODE_ALLOWED_PROPS = {
118
118
  group: ['width', 'height', 'padding', 'gap', 'flex', 'flexDirection', 'justifyContent', 'alignItems', 'background', 'radius', 'borderWidth', 'borderColor', 'marginTopAuto', 'marginTop', 'marginLeftAuto', 'marginBottom', 'marginRight', 'marginLeft', 'borderSides', 'clip', 'onClick', 'onSet', 'position', 'absTop', 'absLeft', 'absRight', 'absBottom', 'transform', 'show', 'opacity'],
119
119
  text: ['width', 'text', 'ref', 'bind', 'bindExpr', 'dynField', 'bindParts', 'fontSize', 'fontWeight', 'textAlign', 'lineHeight', 'color', 'padding', 'colorActive', 'marginLeftAuto', 'marginTop', 'colorDynamic', 'fontSizeDynamic', 'position', 'absTop', 'absLeft', 'absRight', 'absBottom', 'transform', 'show', 'flex', 'opacity', 'onClick', 'onSet', 'fieldOf'],
120
120
  image: ['width', 'height', 'src', 'objectFit', 'radius', 'borderSides', 'borderWidth', 'borderColor', 'clip', 'flex', 'marginTop', 'marginBottom', 'marginRight', 'marginLeft', 'position', 'absTop', 'absLeft', 'absRight', 'absBottom', 'transform', 'show', 'opacity'],
121
- button: ['width', 'height', 'label', 'background', 'radius', 'borderWidth', 'borderColor', 'fontSize', 'fontWeight', 'color', 'flex', 'padding', 'marginTopAuto', 'marginTop', 'marginBottom', 'marginRight', 'marginLeft', 'borderSides', 'clip', 'onClick', 'onSet', 'colorDynamic', 'fontSizeDynamic', 'position', 'absTop', 'absLeft', 'absRight', 'absBottom', 'transform', 'show', 'pressBg', 'opacity'],
121
+ button: ['width', 'height', 'label', 'bind', 'background', 'radius', 'borderWidth', 'borderColor', 'fontSize', 'fontWeight', 'color', 'flex', 'padding', 'marginTopAuto', 'marginTop', 'marginBottom', 'marginRight', 'marginLeft', 'borderSides', 'clip', 'onClick', 'onSet', 'colorDynamic', 'fontSizeDynamic', 'position', 'absTop', 'absLeft', 'absRight', 'absBottom', 'transform', 'show', 'pressBg', 'opacity'],
122
122
  input: ['width', 'height', 'value', 'placeholder', 'fontSize', 'color', 'background', 'padding', 'radius', 'borderWidth', 'borderColor', 'borderSides', 'clip', 'flex', 'marginTop', 'marginTopAuto', 'marginBottom', 'marginRight', 'marginLeft', 'onSet', 'colorDynamic', 'fontSizeDynamic', 'position', 'absTop', 'absLeft', 'absRight', 'absBottom', 'transform', 'show'],
123
123
  scroll: ['width', 'height', 'padding', 'gap', 'flexDirection', 'background', 'showsIndicators', 'flex'],
124
124
  spacer: ['width', 'height', 'flex'],
@@ -276,7 +276,10 @@ function validateNode(node, path, issues, seenKeys, stateNames = new Set(), inLo
276
276
  if (node.type === 'text' && !node.bind && (!node.props || (node.props.text === undefined && node.props.ref === undefined && node.props.bind === undefined && node.props.bindExpr === undefined && node.props.dynField === undefined && node.props.bindParts === undefined && node.props.fieldOf === undefined))) {
277
277
  issues.push({ path, message: 'text 节点必须有 props.text / props.ref / props.bind / props.bindExpr / props.fieldOf 或 bind' });
278
278
  }
279
- if (node.type === 'button' && !node.bind && (!node.props || node.props.label === undefined)) {
279
+ // R108:按钮文案可以是静态 label,也可以是动态 bind(运行时求值)
280
+ if (node.type === 'button' &&
281
+ !node.bind &&
282
+ (!node.props || (node.props.label === undefined && node.props.bind === undefined))) {
280
283
  issues.push({ path, message: 'button 节点必须有 props.label 或 bind' });
281
284
  }
282
285
  // condition / active
@@ -896,6 +896,7 @@ const TAG_MAP = {
896
896
  select: { type: 'group', defaultProps: { flexDirection: 'column' } },
897
897
  option: { type: 'text', defaultProps: { fontSize: 14 } },
898
898
  aside: { type: 'group', defaultProps: { flexDirection: 'column' } },
899
+ label: { type: 'text', defaultProps: { fontSize: 13 } },
899
900
  figure: { type: 'group', defaultProps: { flexDirection: 'column' } },
900
901
  figcaption: { type: 'text', defaultProps: { fontSize: 12 } },
901
902
  h1: { type: 'text', defaultProps: { fontSize: 24, fontWeight: 'bold' } },
@@ -1279,7 +1280,10 @@ export function vueToIr(source, opts = {}) {
1279
1280
  // 5) <script setup> 逻辑源码 → 运行时 JS(R12:事件逻辑由原生 JS 引擎执行)
1280
1281
  // 仅支持 JS 子集(无 TS 类型标注、无 import);超出即编译期警告(后续阶段扩展)
1281
1282
  // R98 P3:把模板表达式下沉产生的求值状态并入页面状态,并重建 runtime(含 recompute)
1282
- if (jsExprStates.length > 0 && stateInfo) {
1283
+ if (jsExprStates.length > 0) {
1284
+ // 无 <script setup> 的页面也要能承载表达式求值状态
1285
+ if (!stateInfo)
1286
+ stateInfo = { states: [], setters: {} };
1283
1287
  for (const st of jsExprStates) {
1284
1288
  if (!stateInfo.states.some((x) => x.name === st.name))
1285
1289
  stateInfo.states.push({ ...st });
@@ -1630,13 +1634,34 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1630
1634
  }
1631
1635
  }
1632
1636
  }
1633
- const vf = parseVFor(forExp, warnings, vforConsts);
1637
+ let vf = parseVFor(forExp, warnings, vforConsts);
1634
1638
  void sunkList;
1635
1639
  if (!vf) {
1636
- throw new VueCompileError('v-for 仅支持静态数组字面量(M2,如 v-for="item in [1,2,3]" / v-for="(item, i) in [\'a\',\'b\']";动态列表随 M3/M4)');
1640
+ // R108 兜底:任意 `item in <表达式>` 都能下沉——运行时求值成 JSON 数组即可
1641
+ const gm = forExp.trim().match(/^(?:\(([^)]+)\)|([A-Za-z_$][\w$]*))\s+in\s+([\s\S]+)$/);
1642
+ if (gm) {
1643
+ const expr = gm[3].trim();
1644
+ // Vue3 语义:`v-for="i in 3"` 迭代 1..3
1645
+ const rangeM = expr.match(/^(\d+)$/);
1646
+ const sunk = addJsExprState(rangeM
1647
+ ? `JSON.stringify(Array.from({ length: ${rangeM[1]} }, function (_, i) { return i + 1; }))`
1648
+ : `JSON.stringify(${expr})`);
1649
+ vf = {
1650
+ listState: sunk,
1651
+ itemKey: (gm[1] ? gm[1].split(',')[0] : gm[2]).trim(),
1652
+ indexKey: gm[1] ? gm[1].split(',')[1]?.trim() : undefined,
1653
+ };
1654
+ warnings.push(`v-for「${forExp.trim().slice(0, 40)}」的数据源下沉为运行时求值(R108)`);
1655
+ }
1656
+ else {
1657
+ throw new VueCompileError('v-for 语法无法解析(需 `item in <表达式>` 形态)');
1658
+ }
1637
1659
  }
1638
1660
  // M4b:动态列表数据源必须已声明为 ref 数组状态(编译期校验,防悬空引用)
1639
- if (vf.listState && !(state && state.states.some((s) => s.name === vf.listState))) {
1661
+ // 表达式下沉产生的状态(_vrnE*)在 walk 之后才并入页面状态,这里放行
1662
+ if (vf.listState &&
1663
+ !/^_vrnE\d+$/.test(vf.listState) &&
1664
+ !(state && state.states.some((s) => s.name === vf.listState))) {
1640
1665
  throw new VueCompileError(`v-for 动态数据源「${vf.listState}」未在 <script setup> 中声明为 ref 数组(M4b)`);
1641
1666
  }
1642
1667
  const innerNode = { ...node, props: node.props.filter((p) => !(p.type === NodeTypes.DIRECTIVE && p.name === 'for')) };
@@ -1856,6 +1881,18 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1856
1881
  warnings.push(`属性 ${attrName}="${attrValue}" 暂不支持,已忽略`);
1857
1882
  }
1858
1883
  }
1884
+ // R108:button 只接受 label/bind —— 布局属性丢弃、缺 label 时占位(避免整页失败)
1885
+ if (mapping.type === 'button') {
1886
+ const badProps = ['flexDirection', 'justifyContent', 'alignItems', 'gap', 'flex', 'textAlign', 'lineHeight', 'overflow', 'clip'];
1887
+ for (const k of badProps)
1888
+ delete props[k];
1889
+ }
1890
+ if (mapping.type === 'input') {
1891
+ // R108:input 只接受 value/placeholder 等,文本样式属性一律丢弃
1892
+ for (const k of ['textAlign', 'lineHeight', 'fontWeight', 'flexDirection', 'justifyContent', 'alignItems']) {
1893
+ delete props[k];
1894
+ }
1895
+ }
1859
1896
  // 子节点:
1860
1897
  // - button:文本 → label
1861
1898
  // - text 类型(p/span/h1~h4):全部文本子节点合并为 props.text;出现元素子节点 → 行内混合报错;
@@ -2021,6 +2058,13 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
2021
2058
  props.label = ((props.label ?? '') + child.content.trim()).trim();
2022
2059
  continue;
2023
2060
  }
2061
+ if (child.type === NodeTypes.INTERPOLATION) {
2062
+ // R108:按钮文案是动态表达式 → bind(运行时求值),原生端照样显示
2063
+ const raw = textOf(child.content).trim();
2064
+ if (!props.bind && !props.label)
2065
+ props.bind = addJsExprState(raw);
2066
+ continue;
2067
+ }
2024
2068
  if (child.type === NodeTypes.ELEMENT) {
2025
2069
  // 常见写法兜底:<button><span>文字</span></button> → 内联文本并入 label(否则 IR 校验因缺 label 中断)
2026
2070
  const inner = elementInnerText(child).trim();
@@ -2037,6 +2081,16 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
2037
2081
  children.push(...walk(child, classProps, comboRules, scopedRules, pseudoRules, warnings, state, compTable, propRefs, loopScope, [...ancestorClasses, ...elementClasses]));
2038
2082
  }
2039
2083
  }
2084
+ // R108:文本节点丢弃布局类属性(原生端 width/height/flexDirection 只对容器有意义)
2085
+ if (mapping.type === 'text') {
2086
+ const LAYOUT_ONLY = ['height', 'width', 'flexDirection', 'justifyContent', 'alignItems', 'flex', 'gap'];
2087
+ const dropped = LAYOUT_ONLY.filter((k) => props[k] !== undefined);
2088
+ if (dropped.length > 0) {
2089
+ for (const k of dropped)
2090
+ delete props[k];
2091
+ warnings.push(`<${node.tag}> 是文本节点,布局属性(${dropped.join('/')})已忽略(R108)`);
2092
+ }
2093
+ }
2040
2094
  // R88 续:容器节点不接受文本专属样式(color/fontSize/fontWeight/textAlign/lineHeight)
2041
2095
  // 原生端容器没有「字体继承」,这类属性会直接触发 IR 校验拒收 → 明确降级 + 警告
2042
2096
  if (mapping.type === 'group' || mapping.type === 'for') {
@@ -2048,6 +2102,11 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
2048
2102
  warnings.push(`<${node.tag}> 是容器,文本样式(${dropped.join('/')})在原生端不继承 → 已忽略;请把样式下沉到具体文本节点(R88)`);
2049
2103
  }
2050
2104
  }
2105
+ // R108 兜底:按钮最终必须有可见文案(label 或 bind),否则整页校验失败
2106
+ if (mapping.type === 'button' && props.label === undefined && props.bind === undefined) {
2107
+ props.label = ' ';
2108
+ warnings.push(`<button> 无可读文案(可能是纯图标按钮)→ 已占位,建议补 aria-label 或文案(R108)`);
2109
+ }
2051
2110
  // R77 空容器(<div class="spacer" /> 等占位写法)→ 降级为 spacer 占位 + 警告,而不是中断整个编译
2052
2111
  if (mapping.type === 'group' && children.length === 0) {
2053
2112
  warnings.push(`<${node.tag}> 无子节点 → 降级为占位 spacer(R17/R77;可用 padding/height 表达留白)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dongmingzhixiu/vuexir-compiler",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Vue SFC → IR → 原生源码(SwiftUI / Compose)编译器核心:纯函数、零 IO",
5
5
  "type": "module",
6
6
  "license": "MIT",