@dongmingzhixiu/vuexir-compiler 0.5.7 → 0.7.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.
@@ -10,8 +10,73 @@
10
10
  * 不在子集内的属性 → 收集到 warnings(编译期提示,不静默忽略)。
11
11
  */
12
12
  import postcss from 'postcss';
13
+ import { parseLinearGradient, splitCssArgs, splitCssWords } from './visuals.js';
14
+ /** 支持标签、类、ID、通配符及按顺序匹配的后代/直接子代选择器。 */
15
+ function parseSelector(selector) {
16
+ if (!selector.trim() || /[+~:[\]]/.test(selector))
17
+ return null;
18
+ const tokens = selector.trim().replace(/\s*>\s*/g, ' > ').split(/\s+/);
19
+ const out = [];
20
+ let direct = false;
21
+ for (const token of tokens) {
22
+ if (token === '>') {
23
+ if (!out.length || direct)
24
+ return null;
25
+ direct = true;
26
+ continue;
27
+ }
28
+ if (!/^(?:[A-Za-z][\w-]*|\*)?(?:[.#][A-Za-z_][\w-]*)*$/.test(token))
29
+ return null;
30
+ out.push({ compound: token, direct });
31
+ direct = false;
32
+ }
33
+ return direct ? null : out;
34
+ }
35
+ function matchesCompound(compound, element) {
36
+ const tag = compound.match(/^[A-Za-z][\w-]*/)?.[0];
37
+ if (tag && tag.toLowerCase() !== element.tag.toLowerCase())
38
+ return false;
39
+ return [...compound.matchAll(/([.#])([A-Za-z_][\w-]*)/g)].every((m) => m[1] === '#' ? element.id === m[2] : element.classes.includes(m[2]));
40
+ }
41
+ /** ancestors 按根到父排列;回溯匹配,避免多层后代选择器被扁平化而误命中。 */
42
+ export function matchesCssSelector(selector, element, ancestors) {
43
+ const chain = [...ancestors, element];
44
+ const match = (part, at) => {
45
+ if (at < 0 || !matchesCompound(selector[part].compound, chain[at]))
46
+ return false;
47
+ if (part === 0)
48
+ return true;
49
+ if (selector[part].direct)
50
+ return match(part - 1, at - 1);
51
+ for (let i = at - 1; i >= 0; i--)
52
+ if (match(part - 1, i))
53
+ return true;
54
+ return false;
55
+ };
56
+ return selector.length > 0 && match(selector.length - 1, chain.length - 1);
57
+ }
58
+ export function resolveCssRules(rules, element, ancestors, inline, warnings) {
59
+ const matched = rules.filter((r) => matchesCssSelector(r.selector, element, ancestors));
60
+ if (inline.trim()) {
61
+ const root = postcss.parse(`x{${inline}}`);
62
+ root.walkDecls((decl) => {
63
+ const props = {};
64
+ for (const [k, val] of mapDecl(decl.prop, decl.value, warnings) ?? [])
65
+ mergeIrProps(props, { [k]: val });
66
+ matched.push({ selector: [], specificity: 1_000_000, important: decl.important === true, props });
67
+ });
68
+ }
69
+ matched.sort((a, b) => Number(a.important) - Number(b.important) || a.specificity - b.specificity);
70
+ const props = {};
71
+ for (const rule of matched)
72
+ mergeIrProps(props, rule.props);
73
+ return props;
74
+ }
13
75
  const px = (v) => {
14
76
  const s = v.trim();
77
+ const calc = s.replace(/\s+/g, '').match(/^calc\((-?[\d.]+)px([+-])([\d.]+)px\)$/);
78
+ if (calc)
79
+ return Number(calc[1]) + (calc[2] === '+' ? 1 : -1) * Number(calc[3]);
15
80
  const m = s.match(/^([\d.]+)px$/);
16
81
  if (m)
17
82
  return Number(m[1]);
@@ -50,6 +115,8 @@ function mapDecl(prop, value, warnings) {
50
115
  }
51
116
  function mapDeclInner(prop, value, warnings) {
52
117
  const v = value.trim();
118
+ if (prop.startsWith('--'))
119
+ return []; // 变量声明由 token 解析器处理,不是未支持的视觉属性。
53
120
  switch (prop) {
54
121
  case 'grid-template-columns': {
55
122
  // R136:CSS grid 的最小支持——`repeat(N, 1fr)` / `1fr 1fr 1fr` → 列数
@@ -64,6 +131,8 @@ function mapDeclInner(prop, value, warnings) {
64
131
  // R135:CSS 的 display:flex 默认 main axis 是 row(此前默认 column →
65
132
  // 横向工具栏/星期行被渲染成纵向堆叠,日历标题栏就是这么坏掉的)
66
133
  return v === 'flex' ? [['flexDirection', 'row']] : null;
134
+ case 'flex-wrap':
135
+ return v === 'wrap' ? [['flexWrap', true]] : v === 'nowrap' ? [] : null;
67
136
  case 'flex-direction':
68
137
  return [['flexDirection', v]];
69
138
  case 'flex': {
@@ -73,6 +142,10 @@ function mapDeclInner(prop, value, warnings) {
73
142
  return null;
74
143
  return n > 0 ? [['flex', n]] : [];
75
144
  }
145
+ case 'align-self': {
146
+ const value = v === 'flex-start' ? 'start' : v === 'flex-end' ? 'end' : v;
147
+ return ['start', 'center', 'end', 'stretch'].includes(value) ? [['alignSelf', value]] : null;
148
+ }
76
149
  case 'flex-grow':
77
150
  // flex-grow:N → IR flex(弹性占比);CSS 子项在剩余空间分配中按 grow 比例伸展
78
151
  return /^[0-9]+$/.test(v) && Number(v) > 0 ? [['flex', Number(v)]] : null;
@@ -80,6 +153,7 @@ function mapDeclInner(prop, value, warnings) {
80
153
  // flex-shrink:0 = 不压缩:iOS 端 flex:1 生成 maxHeight/.infinity 天然不压缩,
81
154
  // 默认内容自适应亦不压缩 → 语义已满足,无需 IR 字段(仅消除「不在子集」误报)
82
155
  return /^[0-9]+$/.test(v) ? [] : null;
156
+ case 'overflow-y':
83
157
  case 'overflow':
84
158
  // overflow:auto → 标记转 scroll 节点(vue2ir 消费后删除)
85
159
  // R71 overflow:hidden → IR clip(裁剪超出容器内容,对位 Web overflow 语义)
@@ -91,19 +165,21 @@ function mapDeclInner(prop, value, warnings) {
91
165
  case 'width': {
92
166
  if (v === '100%')
93
167
  return [['width', 'fill']];
168
+ if (/^[\d.]+%$/.test(v))
169
+ return [['widthFraction', Number(v.slice(0, -1)) / 100]];
94
170
  const n = px(v);
95
171
  return n !== null ? [['width', n]] : null;
96
172
  }
97
173
  case 'height': {
98
174
  // height:100% → 撑满父高(转 flex:1;父高度确定链上占满,root 撑满屏场景等价)
99
175
  if (v === '100%')
100
- return [['flex', 1]];
176
+ return [['height', 'fill']];
101
177
  const n = px(v);
102
178
  return n !== null ? [['height', n]] : null;
103
179
  }
104
180
  case 'padding': {
105
181
  // 支持 1~4 值简写:1=四边相等;2=上下/左右;3=上/左右/下;4=上右下左
106
- const parts = v.split(/\s+/).filter(Boolean).map(px);
182
+ const parts = splitCssWords(v).map(px);
107
183
  if (parts.length === 1 && parts[0] !== null)
108
184
  return [['padding', parts[0]]];
109
185
  if (parts.length >= 2 && parts.every((n) => n !== null)) {
@@ -123,6 +199,16 @@ function mapDeclInner(prop, value, warnings) {
123
199
  const side = prop.slice('padding-'.length);
124
200
  return [['padding', { [side]: n }]];
125
201
  }
202
+ case 'margin': {
203
+ const parts = splitCssWords(v);
204
+ if (parts.length < 1 || parts.length > 4)
205
+ return null;
206
+ const [t, r = t, b = t, l = r] = parts;
207
+ const values = [t, r, b, l].map(px);
208
+ if (values.some((n) => n === null))
209
+ return null;
210
+ return [['marginTop', values[0]], ['marginRight', values[1]], ['marginBottom', values[2]], ['marginLeft', values[3]]];
211
+ }
126
212
  case 'margin-bottom':
127
213
  case 'margin-right':
128
214
  case 'margin-left': {
@@ -175,8 +261,48 @@ function mapDeclInner(prop, value, warnings) {
175
261
  const ir = map[v];
176
262
  return ir ? [['alignItems', ir]] : null;
177
263
  }
264
+ case 'fill':
265
+ case 'stroke':
266
+ case 'stroke-linecap':
267
+ return [[prop, v]];
268
+ case 'stroke-width':
269
+ return [['stroke-width', Number(v.replace(/px$/, ''))]];
270
+ case 'min-width':
271
+ case 'min-height': {
272
+ const n = px(v);
273
+ return n !== null ? [[prop === 'min-width' ? 'minWidth' : 'minHeight', n]] : null;
274
+ }
275
+ case 'font-family':
276
+ return [['fontFamily', /(?:^|[,\s])(?:monospace|ui-monospace)(?:$|[,\s])/i.test(v) ? 'monospace' : /(?:^|[,\s])serif(?:$|[,\s])/i.test(v) ? 'serif' : 'sans']];
277
+ case 'letter-spacing': {
278
+ const m = v.match(/^(-?[\d.]+)px$/);
279
+ return m ? [['letterSpacing', Number(m[1])]] : null;
280
+ }
281
+ case 'box-shadow': {
282
+ if (v === 'none')
283
+ return [['shadow', undefined]];
284
+ const layers = splitCssArgs(v);
285
+ const layer = layers.find((p) => !/\binset\b/.test(p));
286
+ if (layers.some((p) => /\binset\b/.test(p)))
287
+ warnings.push('box-shadow inset 暂不支持,已保留外阴影');
288
+ if (!layer)
289
+ return [];
290
+ const m = layer.match(/^(-?[\d.]+)(?:px)?\s+(-?[\d.]+)(?:px)?(?:\s+([\d.]+)(?:px)?)?(?:\s+(-?[\d.]+)(?:px)?)?\s+(.+)$/);
291
+ if (!m || !isCssColor(m[5]))
292
+ return null;
293
+ if (m[4] && Number(m[4]) !== 0)
294
+ warnings.push('box-shadow spread 暂未映射,保留偏移和模糊');
295
+ return [['shadow', { x: Number(m[1]), y: Number(m[2]), blur: Number(m[3] ?? 0), color: m[5] }]];
296
+ }
297
+ case 'background-image':
178
298
  case 'background':
179
299
  case 'background-color':
300
+ if (/^(linear|radial)-gradient\(/.test(v)) {
301
+ const gradient = parseLinearGradient(v);
302
+ return gradient ? [['gradient', gradient]] : null;
303
+ }
304
+ if (v === 'transparent')
305
+ return [['background', 'rgba(0,0,0,0)']];
180
306
  // 颜色子集校验(named/var() 原生端不可解析)——拒绝并告警,不生成非法原生代码
181
307
  if (!isCssColor(v)) {
182
308
  warnings.push(`CSS 颜色值「${v}」仅支持 #rgb/#rrggbb/rgb()/hsl(),已忽略(named/var 暂不支持,R15)`);
@@ -185,8 +311,10 @@ function mapDeclInner(prop, value, warnings) {
185
311
  return [['background', v]];
186
312
  case 'border-radius': {
187
313
  const n = px(v);
314
+ if (v === '50%')
315
+ return [['radius', 9999]];
188
316
  if (n === null && v.endsWith('%')) {
189
- warnings.push(`border-radius 百分比「${v}」暂不支持(两端圆角语义不一致),已忽略`);
317
+ warnings.push(`border-radius ${v} 暂不支持(支持 50% 圆形)`);
190
318
  return [];
191
319
  }
192
320
  return n !== null ? [['radius', n]] : null;
@@ -250,7 +378,7 @@ function mapDeclInner(prop, value, warnings) {
250
378
  return [];
251
379
  case 'border': {
252
380
  // 仅支持 1px solid <color>(rgba(...) / #hex)
253
- const m = v.match(/^(\d+)px\s+solid\s+(.+)$/i);
381
+ const m = v.match(/^(\d*\.?\d+)px\s+solid\s+(.+)$/i);
254
382
  if (!m) {
255
383
  warnings.push(`border 仅支持「Npx solid <color>」简写,已忽略(可用 border-width / border-color)`);
256
384
  return [];
@@ -307,13 +435,11 @@ function mapDeclInner(prop, value, warnings) {
307
435
  return null;
308
436
  }
309
437
  case 'line-height': {
310
- // px 字面量直接映射;无单位比值(1.5 等)映射成 pt 会破坏行高 → 拒绝并提示用 px
311
- const n = num(v) ?? px(v);
312
- if (n !== null && n < 5) {
313
- warnings.push(`line-height 无单位比值「${v}」暂不支持(会按字面量生成过小行高),请改用 px 值,已忽略`);
314
- return [];
315
- }
316
- return n !== null ? [['lineHeight', n]] : null;
438
+ const absolute = px(v);
439
+ if (absolute !== null)
440
+ return [['lineHeight', absolute]];
441
+ const ratio = num(v);
442
+ return ratio !== null && ratio > 0 ? [['lineHeightMultiplier', ratio]] : null;
317
443
  }
318
444
  case 'text-align':
319
445
  return [['textAlign', v]];
@@ -324,6 +450,17 @@ function mapDeclInner(prop, value, warnings) {
324
450
  case 'position':
325
451
  // M5:position:absolute → 脱离文档流(ZStack 定位 + offset);其余值不映射
326
452
  return v === 'absolute' ? [['position', 'absolute']] : null;
453
+ case 'inset': {
454
+ const values = splitCssWords(v).map(px);
455
+ if (values.length < 1 || values.length > 4 || values.some((v) => v === null))
456
+ return null;
457
+ const [t, r = t, b = t, l = r] = values;
458
+ return [['absTop', t], ['absRight', r], ['absBottom', b], ['absLeft', l]];
459
+ }
460
+ case 'z-index': {
461
+ const n = Number(v);
462
+ return Number.isInteger(n) ? [['zIndex', n]] : null;
463
+ }
327
464
  case 'top':
328
465
  case 'left':
329
466
  case 'right':
@@ -411,13 +548,35 @@ export function mergeIrProps(target, src) {
411
548
  t.borderSides = out;
412
549
  continue;
413
550
  }
551
+ if (key === 'background')
552
+ delete t.gradient;
553
+ if (key === 'gradient')
554
+ delete t.background;
555
+ if (key === 'lineHeight')
556
+ delete t.lineHeightMultiplier;
557
+ if (key === 'lineHeightMultiplier')
558
+ delete t.lineHeight;
414
559
  t[key] = val;
415
560
  }
416
561
  return target;
417
562
  }
563
+ /** 只采集默认 :root,避免最后一个未启用皮肤覆盖当前默认值。 */
564
+ export function extractCssVariables(styles) {
565
+ const vars = {};
566
+ for (const source of styles)
567
+ postcss.parse(source).walkRules((rule) => {
568
+ if (rule.selectors.some((s) => s.trim() === ':root'))
569
+ rule.walkDecls((d) => {
570
+ if (d.prop.startsWith('--'))
571
+ vars[d.prop] = d.value;
572
+ });
573
+ });
574
+ return vars;
575
+ }
418
576
  /** 解析 `<style>` 内容 → 类规则表 */
419
- export function parseCss(source, warnings) {
577
+ export function parseCss(source, warnings, variables = {}) {
420
578
  const classProps = new Map();
579
+ const styleRules = [];
421
580
  const comboRules = [];
422
581
  const scopedRules = [];
423
582
  const pseudoRules = [];
@@ -429,6 +588,29 @@ export function parseCss(source, warnings) {
429
588
  // R15:语法错误必须显式失败(vue2ir 转为 VueCompileError),不能产出半份样式表
430
589
  throw new CssParseError(`CSS 解析失败:${e instanceof Error ? e.message : String(e)}`);
431
590
  }
591
+ const vars = { ...variables };
592
+ root.walkRules((rule) => {
593
+ if (!rule.selectors.some((s) => s.trim() === ':root'))
594
+ return;
595
+ rule.walkDecls((d) => { if (d.prop.startsWith('--'))
596
+ vars[d.prop] = d.value; });
597
+ });
598
+ const resolveValue = (value) => {
599
+ let out = value.replace(/env\(safe-area-inset-(?:top|bottom|left|right)(?:,\s*[^)]+)?\)/g, '0px');
600
+ for (let n = 0; n < 12; n++) {
601
+ const next = out.replace(/env\(safe-area-inset-(?:top|bottom|left|right)(?:,\s*[^)]+)?\)/g, '0px').replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]+))?\)/g, (all, key, fallback) => vars[key] ?? fallback ?? all);
602
+ if (next === out)
603
+ break;
604
+ out = next;
605
+ }
606
+ return out;
607
+ };
608
+ root.walkDecls((decl) => {
609
+ if (!decl.prop.startsWith('--') && !['color', 'background-color', 'border-color', 'background'].includes(decl.prop))
610
+ decl.value = resolveValue(decl.value);
611
+ if (decl.prop === 'background' && /gradient\(/.test(decl.value))
612
+ decl.value = resolveValue(decl.value);
613
+ });
432
614
  /** 规则声明块 → IR props */
433
615
  const collectProps = (rule, warnings) => {
434
616
  const props = {};
@@ -460,11 +642,29 @@ export function parseCss(source, warnings) {
460
642
  warnings.push(`@media (prefers-color-scheme: light) 不支持(App 强制深色),已忽略:${sel}`);
461
643
  return;
462
644
  }
645
+ else if (/^\(?max-width:\s*(\d+)px\)?$/.test(params) && Number(params.match(/(\d+)px/)[1]) >= 390) {
646
+ // 编译基准为手机视口 390px,命中移动端覆盖样式。
647
+ }
463
648
  else {
464
649
  warnings.push(`媒体查询「${params}」不在子集内(仅 prefers-color-scheme: dark),已忽略:${sel}`);
465
650
  return;
466
651
  }
467
652
  }
653
+ const selectors = rule.selectors.map((s) => ({ raw: s.trim(), parsed: parseSelector(s.replace(/::before\b/g, ' > .__vrnPseudoBefore').replace(/::after\b/g, ' > .__vrnPseudoAfter')) }));
654
+ for (const { parsed } of selectors) {
655
+ if (!parsed)
656
+ continue;
657
+ const raw = parsed.map((p) => p.compound).join(' ');
658
+ const specificity = (raw.match(/#/g)?.length ?? 0) * 10000 + (raw.match(/\./g)?.length ?? 0) * 100 +
659
+ parsed.filter((p) => /^[A-Za-z]/.test(p.compound)).length;
660
+ rule.walkDecls((decl) => {
661
+ // 兼容旧规则表;映射告警由 collectProps 统一收集,避免重复。
662
+ const props = {};
663
+ for (const [k, val] of mapDecl(decl.prop, decl.value, []) ?? [])
664
+ mergeIrProps(props, { [k]: val });
665
+ styleRules.push({ selector: parsed, specificity, important: decl.important === true, props });
666
+ });
667
+ }
468
668
  // M5 伪类:.cls:active / .cls:hover(仅 background 取作按压态)
469
669
  const pm = sel.match(/^\.([A-Za-z_][\w-]*):(active|hover)$/);
470
670
  if (pm) {
@@ -497,7 +697,10 @@ export function parseCss(source, warnings) {
497
697
  // 仅支持类选择器:.xxx 或 .a.b(多类叠加)
498
698
  const m = sel.match(/^\.([A-Za-z_][\w-]*)(?:\.[A-Za-z_][\w-]*)*$/);
499
699
  if (!m) {
500
- warnings.push(`选择器「${rule.selector}」非类选择器,已跳过(CSS 子集限制)`);
700
+ if (selectors.every((s) => s.parsed))
701
+ collectProps(rule, warnings);
702
+ else
703
+ warnings.push(`选择器「${rule.selector}」含不支持的选择器,已跳过不支持部分(CSS 子集限制)`);
501
704
  return;
502
705
  }
503
706
  const classes = compound(sel);
@@ -511,7 +714,7 @@ export function parseCss(source, warnings) {
511
714
  comboRules.push({ classes, props });
512
715
  }
513
716
  });
514
- return { classProps, comboRules, scopedRules, pseudoRules, warnings };
717
+ return { classProps, comboRules, scopedRules, pseudoRules, styleRules, warnings };
515
718
  }
516
719
  /** 解析内联 style="..."(分号分隔声明) */
517
720
  export function parseInlineStyle(styleText, warnings) {
@@ -0,0 +1,3 @@
1
+ import { type ElementNode } from '@vue/compiler-core';
2
+ /** SVG 动态路径、坐标、循环与文字保留为轻量绘图记录,运行时不依赖 DOM。 */
3
+ export declare function dynamicSvgExpression(root: ElementNode, style: (el: ElementNode, parents: ElementNode[]) => Record<string, unknown>, gradients: Record<string, string>, gradientOpacities?: Record<string, number>): string;
@@ -0,0 +1,53 @@
1
+ import { NodeTypes } from '@vue/compiler-core';
2
+ /** SVG 动态路径、坐标、循环与文字保留为轻量绘图记录,运行时不依赖 DOM。 */
3
+ export function dynamicSvgExpression(root, style, gradients, gradientOpacities = {}) {
4
+ const q = JSON.stringify;
5
+ const value = (el, key, fallback) => {
6
+ const dynamic = el.props.find((p) => p.type === NodeTypes.DIRECTIVE && p.name === 'bind' && p.arg?.type === NodeTypes.SIMPLE_EXPRESSION && p.arg.content === key);
7
+ if (dynamic && 'exp' in dynamic && dynamic.exp?.type === NodeTypes.SIMPLE_EXPRESSION)
8
+ return '(' + dynamic.exp.content + ')';
9
+ const attr = el.props.find((p) => p.type === NodeTypes.ATTRIBUTE && p.name === key);
10
+ return attr && 'value' in attr && attr.value ? q(attr.value.content) : fallback;
11
+ };
12
+ const walk = (el, parents, inherited) => {
13
+ const css = style(el, parents);
14
+ const own = { ...inherited };
15
+ for (const k of ['fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'stroke-opacity'])
16
+ own[k] = value(el, k, css[k] !== undefined ? q(String(css[k])) : own[k] ?? (k === 'fill' ? q('#000000') : k === 'stroke' ? q('none') : '1'));
17
+ const dynamicStyle = el.props.find((p) => p.type === NodeTypes.DIRECTIVE && p.name === 'bind' && p.arg?.type === NodeTypes.SIMPLE_EXPRESSION && p.arg.content === 'style');
18
+ if (dynamicStyle && 'exp' in dynamicStyle && dynamicStyle.exp?.type === NodeTypes.SIMPLE_EXPRESSION)
19
+ for (const [key, camel] of [['fill', 'fill'], ['stroke', 'stroke'], ['opacity', 'opacity'], ['fill-opacity', 'fillOpacity'], ['stroke-opacity', 'strokeOpacity'], ['stroke-width', 'strokeWidth']])
20
+ own[key] = `((${dynamicStyle.exp.content})[${q(camel)}] ?? ${own[key]})`;
21
+ const n = (key, fallback = '0') => 'Number(' + value(el, key, fallback) + ')';
22
+ let path = '';
23
+ if (el.tag === 'path')
24
+ path = value(el, 'd', q(''));
25
+ if (el.tag === 'line')
26
+ path = `'M'+${n('x1')}+','+${n('y1')}+' L'+${n('x2')}+','+${n('y2')}`;
27
+ if (el.tag === 'circle' || el.tag === 'ellipse') {
28
+ const x = n('cx'), y = n('cy'), rx = n(el.tag === 'circle' ? 'r' : 'rx'), ry = n(el.tag === 'circle' ? 'r' : 'ry');
29
+ path = `ellipsePath(${x},${y},${rx},${ry})`;
30
+ }
31
+ if (el.tag === 'rect')
32
+ path = `'M'+${n('x')}+','+${n('y')}+' h'+${n('width')}+' v'+${n('height')}+' h'+(-${n('width')})+' Z'`;
33
+ const paint = (v) => `(function(c){if(typeof c!=='string')return 'none';return c.replace(/url\\(#([^)]*)\\)/g,function(_,id){return (${q(gradients)})[id]||'none';});})(${v})`;
34
+ let body = path ? `out.push({d:(${path}),fill:${paint(own.fill)},stroke:${paint(own.stroke)},width:Number(${own['stroke-width']}),opacity:Number(${own.opacity}),fillOpacity:Number(${own['fill-opacity']})*(function(c){var m=String(c).match(/url\\(#([^)]*)\\)/);return m ? (${q(gradientOpacities)})[m[1]] ?? 1 : 1;})(${own.fill}),strokeOpacity:Number(${own['stroke-opacity']})});` : '';
35
+ if (el.tag === 'text') {
36
+ const text = el.children.map((child) => child.type === NodeTypes.TEXT ? q(child.content) : child.type === NodeTypes.INTERPOLATION && child.content.type === NodeTypes.SIMPLE_EXPRESSION ? 'String(' + child.content.content + ')' : q('')).join('+') || q('');
37
+ body += `out.push({text:(${text}),x:${n('x')},y:${n('y')},fill:${paint(own.fill)},size:${Number(css.fontSize ?? 10)},anchor:${value(el, 'text-anchor', q('start'))}});`;
38
+ }
39
+ if (!['defs', 'linearGradient', 'radialGradient', 'stop'].includes(el.tag))
40
+ body += el.children.filter((c) => c.type === NodeTypes.ELEMENT).map((child) => walk(child, [...parents, el], own)).join('\n');
41
+ const cond = el.props.find((p) => p.type === NodeTypes.DIRECTIVE && p.name === 'if');
42
+ if (cond && 'exp' in cond && cond.exp?.type === NodeTypes.SIMPLE_EXPRESSION)
43
+ body = `if(${cond.exp.content}){${body}}`;
44
+ const loop = el.props.find((p) => p.type === NodeTypes.DIRECTIVE && p.name === 'for');
45
+ if (loop && 'exp' in loop && loop.exp?.type === NodeTypes.SIMPLE_EXPRESSION) {
46
+ const m = loop.exp.content.match(/^\s*(?:\(([^)]+)\)|([\w$]+))\s+(?:in|of)\s+([\s\S]+)$/);
47
+ if (m)
48
+ body = `(${m[3]} || []).forEach(function(${m[1] ?? m[2]}){${body}});`;
49
+ }
50
+ return body;
51
+ };
52
+ return `JSON.stringify((function(){var out=[];function ellipsePath(x,y,rx,ry){var k=.5522847498;return 'M'+(x+rx)+','+y+' C'+(x+rx)+','+(y+k*ry)+' '+(x+k*rx)+','+(y+ry)+' '+x+','+(y+ry)+' C'+(x-k*rx)+','+(y+ry)+' '+(x-rx)+','+(y+k*ry)+' '+(x-rx)+','+y+' C'+(x-rx)+','+(y-k*ry)+' '+(x-k*rx)+','+(y-ry)+' '+x+','+(y-ry)+' C'+(x+k*rx)+','+(y-ry)+' '+(x+rx)+','+(y-k*ry)+' '+(x+rx)+','+y+' Z';}${walk(root, [], {})};return out;})())`;
53
+ }
@@ -0,0 +1,16 @@
1
+ import { type ElementNode } from '@vue/compiler-core';
2
+ export interface VectorData {
3
+ viewBox: number[];
4
+ dynamic?: string;
5
+ colors?: string[];
6
+ shapes: {
7
+ commands: (string | number)[][];
8
+ fill?: string;
9
+ stroke?: string;
10
+ strokeWidth: number;
11
+ lineCap?: string;
12
+ trim?: string;
13
+ }[];
14
+ }
15
+ /** SVG 路径/圆/矩形转平台无关路径;弧线、相对坐标、平滑曲线在 IR 前归一化。 */
16
+ export declare function parseSvg(node: ElementNode, style: (el: ElementNode, parents: ElementNode[]) => Record<string, unknown>, warnings: string[], registerExpression?: (expression: string) => string): VectorData;
@@ -0,0 +1,96 @@
1
+ import { dynamicSvgExpression } from './dynamic-svg.js';
2
+ import svgpath from 'svgpath';
3
+ import { NodeTypes } from '@vue/compiler-core';
4
+ /** SVG 路径/圆/矩形转平台无关路径;弧线、相对坐标、平滑曲线在 IR 前归一化。 */
5
+ export function parseSvg(node, style, warnings, registerExpression) {
6
+ const attrs = (el) => {
7
+ const values = Object.fromEntries(el.props.filter((p) => p.type === NodeTypes.ATTRIBUTE).map((p) => [p.name, 'value' in p ? p.value?.content ?? '' : '']));
8
+ for (const p of el.props)
9
+ if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind' && p.arg?.type === NodeTypes.SIMPLE_EXPRESSION && p.exp?.type === NodeTypes.SIMPLE_EXPRESSION) {
10
+ const value = p.exp.content.trim().replace(/^\(+|\)+$/g, '');
11
+ if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(value))
12
+ values[p.arg.content] = value;
13
+ }
14
+ return values;
15
+ };
16
+ const gradientColors = {};
17
+ const gradientOpacities = {};
18
+ const findGradient = (el) => {
19
+ if (el.tag === 'linearGradient' || el.tag === 'radialGradient') {
20
+ const id = attrs(el).id;
21
+ const stop = el.children.find((c) => c.type === NodeTypes.ELEMENT && c.tag === 'stop');
22
+ if (id && stop) {
23
+ const dynamic = stop.props.find((p) => p.type === NodeTypes.DIRECTIVE && p.name === 'bind' && p.arg?.type === NodeTypes.SIMPLE_EXPRESSION && p.arg.content === 'style');
24
+ const expression = dynamic && 'exp' in dynamic && dynamic.exp?.type === NodeTypes.SIMPLE_EXPRESSION ? dynamic.exp.content : '';
25
+ gradientOpacities[id] = Number(attrs(stop)['stop-opacity'] ?? expression.match(/stopOpacity\s*:\s*([\d.]+)/)?.[1] ?? 1);
26
+ gradientColors[id] = attrs(stop)['stop-color'] ?? expression.match(/stopColor\s*:\s*\(?['"]([^'"]+)['"]/)?.[1] ?? 'currentColor';
27
+ }
28
+ }
29
+ el.children.forEach((c) => { if (c.type === NodeTypes.ELEMENT)
30
+ findGradient(c); });
31
+ };
32
+ findGradient(node);
33
+ const root = attrs(node);
34
+ const viewBox = (root.viewBox ?? `0 0 ${root.width ?? 24} ${root.height ?? 24}`).split(/[ ,]+/).map(Number);
35
+ const data = { viewBox: viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0 ? viewBox : [0, 0, 24, 24], shapes: [] };
36
+ const hasDynamic = (el) => el.props.some((p) => p.type === NodeTypes.DIRECTIVE && ((p.name === 'bind' && p.arg?.type === NodeTypes.SIMPLE_EXPRESSION && ['d', 'viewBox'].includes(p.arg.content)) || p.name === 'for')) || el.children.some((c) => c.type === NodeTypes.ELEMENT && hasDynamic(c));
37
+ if (registerExpression && hasDynamic(node)) {
38
+ const expression = dynamicSvgExpression(node, style, gradientColors, gradientOpacities);
39
+ data.colors = [...new Set(expression.match(/var\(--[\w-]+\)|rgba?\([^)]+\)|#[\da-fA-F]{3,8}/g) ?? [])];
40
+ data.dynamic = registerExpression(expression);
41
+ const width = root.width ? Number(root.width) : 320, height = root.height ? Number(root.height) : 190;
42
+ if (!root.viewBox)
43
+ data.viewBox = [0, 0, width, height];
44
+ return data;
45
+ }
46
+ const walk = (el, inherited, parents) => {
47
+ const a = { ...inherited, ...attrs(el) };
48
+ for (const [k, v] of Object.entries(style(el, parents)))
49
+ if (['fill', 'stroke', 'stroke-width', 'stroke-linecap'].includes(k))
50
+ a[k] = String(v);
51
+ if (['defs', 'linearGradient', 'radialGradient', 'clipPath', 'mask'].includes(el.tag))
52
+ return;
53
+ let d = '';
54
+ const n = (key, fallback = 0) => Number(a[key] ?? fallback);
55
+ if (el.tag === 'path')
56
+ d = a.d ?? '';
57
+ else if (el.tag === 'circle' || el.tag === 'ellipse') {
58
+ const x = n('cx'), y = n('cy'), rx = n('rx', n('r')), ry = n('ry', n('r'));
59
+ d = `M ${x + rx} ${y} A ${rx} ${ry} 0 1 0 ${x - rx} ${y} A ${rx} ${ry} 0 1 0 ${x + rx} ${y} Z`;
60
+ }
61
+ else if (el.tag === 'line')
62
+ d = `M ${n('x1')} ${n('y1')} L ${n('x2')} ${n('y2')}`;
63
+ else if (el.tag === 'polyline' || el.tag === 'polygon')
64
+ d = `M ${a.points ?? ''}${el.tag === 'polygon' ? ' Z' : ''}`;
65
+ else if (el.tag === 'rect') {
66
+ const x = n('x'), y = n('y'), w = n('width'), h = n('height'), r = Math.min(n('rx'), w / 2, h / 2);
67
+ d = r ? `M ${x + r} ${y} H ${x + w - r} Q ${x + w} ${y} ${x + w} ${y + r} V ${y + h - r} Q ${x + w} ${y + h} ${x + w - r} ${y + h} H ${x + r} Q ${x} ${y + h} ${x} ${y + h - r} V ${y + r} Q ${x} ${y} ${x + r} ${y} Z`
68
+ : `M ${x} ${y} h ${w} v ${h} h ${-w} Z`;
69
+ }
70
+ if (d) {
71
+ const path = svgpath(d).abs().unshort().unarc();
72
+ if (a.transform)
73
+ path.transform(a.transform);
74
+ path.abs();
75
+ const commands = [];
76
+ path.iterate((segment) => { commands.push([...segment]); });
77
+ if (!commands.length)
78
+ warnings.push('SVG path 为空或无法解析');
79
+ else {
80
+ const color = (v) => v === 'none' ? undefined : /^url\(#/.test(v) ? gradientColors[v.slice(5, -1)] ?? 'currentColor' : v;
81
+ const dynamicAttr = (name) => {
82
+ const p = el.props.find((p) => p.type === NodeTypes.DIRECTIVE && p.name === 'bind' && p.arg?.type === NodeTypes.SIMPLE_EXPRESSION && p.arg.content === name);
83
+ return p && 'exp' in p && p.exp?.type === NodeTypes.SIMPLE_EXPRESSION ? p.exp.content : a[name];
84
+ };
85
+ const offset = dynamicAttr('stroke-dashoffset'), length = dynamicAttr('stroke-dasharray');
86
+ const trim = registerExpression && offset && length ? registerExpression(`String(Math.max(0,Math.min(1,1-(${offset})/(${length}))))`) : undefined;
87
+ data.shapes.push({ commands, trim, fill: color(a.fill ?? '#000000'), stroke: color(a.stroke ?? 'none'), strokeWidth: n('stroke-width', 1), lineCap: a['stroke-linecap'] });
88
+ }
89
+ }
90
+ for (const child of el.children)
91
+ if (child.type === NodeTypes.ELEMENT)
92
+ walk(child, a, [...parents, el]);
93
+ };
94
+ walk(node, {}, []);
95
+ return data;
96
+ }
@@ -0,0 +1,17 @@
1
+ import { type VectorData } from './svg.js';
2
+ import type { IrProps } from '../ir/schema.js';
3
+ export interface TabBarSpec {
4
+ style: IrProps;
5
+ itemStyle: IrProps;
6
+ activeStyle: IrProps;
7
+ items: {
8
+ id: string;
9
+ label: string;
10
+ icon: VectorData;
11
+ }[];
12
+ widthInset: number;
13
+ maxWidth?: number;
14
+ bottom: number;
15
+ }
16
+ /** 从声明式 Vue tabbar 的静态数据、SVG 和 CSS 提取原生导航外观。 */
17
+ export declare function parseTabBar(source: string, globalStyles?: string[]): TabBarSpec;
@@ -0,0 +1,44 @@
1
+ import { parse, babelParse } from '@vue/compiler-sfc';
2
+ import { NodeTypes } from '@vue/compiler-core';
3
+ import { extractCssVariables, parseCss, resolveCssRules } from './css.js';
4
+ import { parseSvg } from './svg.js';
5
+ /** 从声明式 Vue tabbar 的静态数据、SVG 和 CSS 提取原生导航外观。 */
6
+ export function parseTabBar(source, globalStyles = []) {
7
+ const { descriptor } = parse(source);
8
+ const script = descriptor.scriptSetup?.content ?? '';
9
+ const ast = babelParse(script, { sourceType: 'module', plugins: ['typescript'] });
10
+ const items = [];
11
+ for (const declaration of ast.program.body)
12
+ if (declaration.type === 'VariableDeclaration') {
13
+ for (const d of declaration.declarations)
14
+ if (d.init?.type === 'ArrayExpression')
15
+ for (const item of d.init.elements) {
16
+ if (item?.type !== 'ObjectExpression')
17
+ continue;
18
+ const values = {};
19
+ for (const p of item.properties)
20
+ if (p.type === 'ObjectProperty' && !p.computed && p.value.type === 'StringLiteral') {
21
+ const key = p.key.type === 'Identifier' ? p.key.name : p.key.type === 'StringLiteral' ? p.key.value : '';
22
+ values[key] = p.value.value;
23
+ }
24
+ if (!values.key || !values.label || !values.icon)
25
+ continue;
26
+ const svg = parse(`<template><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${values.icon}</svg></template>`).descriptor.template.ast.children[0];
27
+ items.push({ id: values.key, label: values.label, icon: parseSvg(svg, () => ({}), []) });
28
+ }
29
+ }
30
+ if (!items.length)
31
+ throw new Error('tabBarComponent 需要包含 {key,label,icon} 字符串项的静态数组');
32
+ const root = descriptor.template?.ast?.children.find((n) => n.type === NodeTypes.ELEMENT);
33
+ const classAttr = root?.props.find((p) => p.type === NodeTypes.ATTRIBUTE && p.name === 'class');
34
+ const classes = classAttr && 'value' in classAttr ? classAttr.value?.content.split(/\s+/) ?? [] : [];
35
+ const own = descriptor.styles.map((s) => s.content).join('\n');
36
+ const rules = parseCss(globalStyles.join('\n') + '\n' + own, [], extractCssVariables(globalStyles)).styleRules;
37
+ const container = { tag: root?.tag ?? 'nav', classes };
38
+ const style = resolveCssRules(rules, container, [], '', []);
39
+ const itemStyle = resolveCssRules(rules, { tag: 'button', classes: ['tab'] }, [container], '', []);
40
+ const activeStyle = resolveCssRules(rules, { tag: 'button', classes: ['tab', 'on'] }, [container], '', []);
41
+ const width = own.match(/width:\s*min\(calc\(100%\s*-\s*([\d.]+)px\),\s*([\d.]+)px\)/);
42
+ const bottom = own.match(/margin:[^;]*?calc\(([\d.]+)px/);
43
+ return { style, itemStyle, activeStyle, items, widthInset: Number(width?.[1] ?? 0), maxWidth: width ? Number(width[2]) : undefined, bottom: Number(bottom?.[1] ?? 0) };
44
+ }
@@ -0,0 +1,17 @@
1
+ /** CSS 函数参数分割,保留 rgba()/var() 内的逗号。 */
2
+ export declare function splitCssArgs(value: string): string[];
3
+ export interface LinearGradientValue {
4
+ kind?: 'linear' | 'radial';
5
+ center?: {
6
+ x: number;
7
+ y: number;
8
+ };
9
+ angle: number;
10
+ stops: {
11
+ color: string;
12
+ offset: number;
13
+ }[];
14
+ }
15
+ export declare function parseLinearGradient(value: string): LinearGradientValue | null;
16
+ /** 按顶层空白拆 CSS 简写,calc()/rgba() 内的空格不拆开。 */
17
+ export declare function splitCssWords(value: string): string[];