Skip to content

Commit 8f04d96

Browse files
committed
feat: support static Vue attrs and HTML attrs (whitelist)
1 parent b61131e commit 8f04d96

6 files changed

Lines changed: 143 additions & 7 deletions

File tree

packages/i18n-migrate-cli/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ tmigrate stats src/modules/order
208208
- **译文来源**:glossary / machine / manual 的来源分布。
209209
- **重点文件 Top 5**:只列待补译、待校对或废弃条目最多的文件,避免大项目刷屏。
210210
- **孤儿/损坏 Top 5**:只展示最需要清理或修复的异常 map 文件。
211-
- **下一步**:根据当前队列给出补译、校对、回写、清理等建议。
211+
- **建议**:根据当前队列给出补译、校对、回写、清理等建议。
212212

213213
说明:统计口径是 map 条目数,不是源码中的文本出现次数。
214214

packages/i18n-migrate-cli/src/__tests__/replacer.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,50 @@ describe('replacer syntax-aware writeback', () => {
132132
])).toContain('<template v-if="fileMax > 1">with maximum uploads of {{ fileMax }}</template>')
133133
})
134134

135+
it('extracts static Vue tab attributes while ignoring dynamic bindings', () => {
136+
const content = [
137+
'<template>',
138+
' <ATabs>',
139+
' <ATabPane key="1" tab="消费记录" />',
140+
' <ATabPane key="2" tab="积分明细" />',
141+
' <ATabPane key="3" :tab="dynamicTab" />',
142+
' </ATabs>',
143+
'</template>',
144+
].join('\n')
145+
const extractor = new Extractor(config)
146+
const segments = extractor.extract(content, 'src/DetailDrawer.vue')
147+
148+
expect(segments.map(segment => segment.text)).toEqual(['消费记录', '积分明细'])
149+
const next = replace(content, 'src/DetailDrawer.vue', [
150+
['消费记录', 'Consumption Records'],
151+
['积分明细', 'Points Details'],
152+
['dynamicTab', 'Should not replace'],
153+
])
154+
expect(next).toContain('tab="Consumption Records"')
155+
expect(next).toContain('tab="Points Details"')
156+
expect(next).toContain(':tab="dynamicTab"')
157+
})
158+
159+
it('extracts static Vue component props generically without HTML attr whitelist', () => {
160+
const content = [
161+
'<template>',
162+
' <CustomWidget panel-title="账户安全" emptyText="暂无数据" data-test="测试ID" class="中文类名" :panel-title="dynamicTitle" />',
163+
'</template>',
164+
].join('\n')
165+
const extractor = new Extractor(config)
166+
const segments = extractor.extract(content, 'src/CustomWidget.vue')
167+
168+
expect(segments.map(segment => segment.text)).toEqual(['账户安全', '暂无数据'])
169+
const next = replace(content, 'src/CustomWidget.vue', [
170+
['账户安全', 'Account Security'],
171+
['暂无数据', 'No Data'],
172+
['dynamicTitle', 'Should not replace'],
173+
])
174+
expect(next).toContain('panel-title="Account Security"')
175+
expect(next).toContain('emptyText="No Data"')
176+
expect(next).toContain(':panel-title="dynamicTitle"')
177+
})
178+
135179
it('extracts and replaces column titles in Vue TSX script setup blocks', () => {
136180
const content = [
137181
'<template><BasicTable /></template>',
@@ -186,6 +230,14 @@ describe('replacer syntax-aware writeback', () => {
186230
])).toContain('data-test="fileMax > 1">Maximum uploads {{ fileMax }} images</template>')
187231
})
188232

233+
it('extracts common static HTML attrs including tab', () => {
234+
const content = '<tab-pane tab="消费记录" :tab="dynamicTab" title="详情"></tab-pane>'
235+
const extractor = new Extractor(config)
236+
const segments = extractor.extract(content, 'src/template.html')
237+
238+
expect(segments.map(segment => segment.text)).toEqual(['消费记录', '详情'])
239+
})
240+
189241
it('quotes unsafe yaml translations and preserves quoted yaml scalars', () => {
190242
expect(replace('title: 账号安全\n', 'src/messages.yaml', [
191243
['账号安全', 'Account: secure #1'],

packages/i18n-migrate-cli/src/parsers/html.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { FileParser, TextContext, TextSegment, TranslationEntry } from '../
22
import type { RangeSegment } from './range'
33
import { dedupeSegments, finalizeSegments, leadingSpaces, lineColumn, replaceTranslations } from './range'
44

5+
export const TRANSLATABLE_ATTR_NAMES = new Set(['title', 'alt', 'placeholder', 'aria-label', 'label', 'tab'])
6+
57
export const htmlParser: FileParser = {
68
supportedExtensions: ['.html'],
79
extract: (content: string, filePath: string): TextSegment[] => finalizeSegments(extractHtmlSegments(content, filePath), filePath),
@@ -45,13 +47,12 @@ function extractHtmlText(content: string, filePath: string, offset: number, cont
4547
}
4648

4749
export function extractHtmlAttrSegments(content: string, filePath: string, offset: number): RangeSegment[] {
48-
const attrNames = new Set(['title', 'alt', 'placeholder', 'aria-label', 'label'])
4950
const segments: RangeSegment[] = []
5051
const attrRe = /\s([\w-]+)=["'][^"']*["']/g
5152
for (const match of content.matchAll(attrRe)) {
5253
const name = match[1]
5354
const rawAttr = match[0]
54-
if (!name || !attrNames.has(name))
55+
if (!name || !TRANSLATABLE_ATTR_NAMES.has(name))
5556
continue
5657
const quoteIndex = rawAttr.search(/["']/)
5758
const raw = rawAttr.slice(quoteIndex + 1, -1)

packages/i18n-migrate-cli/src/parsers/vue.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { FileParser, TextSegment, TranslationEntry } from '../types'
22
import type { RangeSegment } from './range'
33
import { parse as parseVue } from '@vue/compiler-sfc'
44
import { extractStyleSegments } from './css'
5-
import { extractHtmlAttrSegments } from './html'
65
import { dedupeSegments, finalizeSegments, leadingSpaces, lineColumn, replaceTranslations } from './range'
76
import { extractScriptSegments } from './script'
87

@@ -21,9 +20,8 @@ export function extractVueSegments(content: string, filePath: string): RangeSegm
2120
const segments: RangeSegment[] = []
2221

2322
if (descriptor.template) {
24-
const offset = descriptor.template.loc.start.offset
2523
segments.push(...extractVueTemplateTextSegments(descriptor.template.ast, content, filePath))
26-
segments.push(...extractHtmlAttrSegments(descriptor.template.content, filePath, offset))
24+
segments.push(...extractVueTemplateAttrSegments(descriptor.template.ast, content, filePath))
2725
}
2826

2927
for (const block of [descriptor.script, descriptor.scriptSetup]) {
@@ -64,6 +62,59 @@ interface VueTemplateNode {
6462
source: string
6563
}
6664
children?: unknown[]
65+
props?: unknown[]
66+
}
67+
68+
interface VueTemplateAttr {
69+
type: number
70+
name?: string
71+
value?: {
72+
content?: string
73+
loc?: {
74+
start: { offset: number }
75+
end: { offset: number }
76+
source: string
77+
}
78+
}
79+
}
80+
81+
function extractVueTemplateAttrSegments(ast: unknown, content: string, filePath: string): RangeSegment[] {
82+
const segments: RangeSegment[] = []
83+
84+
collectVueTemplateAttrSegments(ast, content, segments)
85+
return dedupeSegments(segments, filePath)
86+
}
87+
88+
function collectVueTemplateAttrSegments(
89+
node: unknown,
90+
content: string,
91+
segments: RangeSegment[],
92+
): void {
93+
if (!isVueTemplateNode(node))
94+
return
95+
96+
for (const prop of node.props ?? []) {
97+
if (!isVueStaticAttr(prop) || !prop.name || shouldSkipVueStaticAttr(prop.name) || !prop.value?.content || !prop.value.loc)
98+
continue
99+
100+
const raw = prop.value.loc.source
101+
const quoteOffset = isQuoted(raw) ? 1 : 0
102+
const start = prop.value.loc.start.offset + quoteOffset
103+
const end = prop.value.loc.end.offset - quoteOffset
104+
const position = lineColumn(content, start)
105+
segments.push({
106+
text: prop.value.content,
107+
start,
108+
end,
109+
line: position.line,
110+
column: position.column,
111+
context: 'html-attr',
112+
nodeType: 'VueStaticAttribute',
113+
})
114+
}
115+
116+
for (const child of node.children ?? [])
117+
collectVueTemplateAttrSegments(child, content, segments)
67118
}
68119

69120
function extractVueTemplateTextSegments(ast: unknown, content: string, filePath: string): RangeSegment[] {
@@ -151,3 +202,21 @@ function isInlineTemplateTextNode(node: unknown): node is VueTemplateNode {
151202
function isVueTemplateNode(value: unknown): value is VueTemplateNode {
152203
return Boolean(value && typeof value === 'object' && 'type' in value)
153204
}
205+
206+
function isVueStaticAttr(value: unknown): value is VueTemplateAttr {
207+
return Boolean(value && typeof value === 'object' && (value as { type?: unknown }).type === 6)
208+
}
209+
210+
function isQuoted(value: string): boolean {
211+
return (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))
212+
}
213+
214+
function shouldSkipVueStaticAttr(name: string): boolean {
215+
return name === 'class'
216+
|| name === 'style'
217+
|| name === 'id'
218+
|| name === 'key'
219+
|| name === 'ref'
220+
|| name.startsWith('data-')
221+
|| name.startsWith('aria-')
222+
}

packages/i18n-migrate-cli/src/stats.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export function formatMapStatsReport(report: MapStatsReport): string {
131131
}
132132

133133
lines.push('')
134-
lines.push(pc.cyan('下一步'))
134+
lines.push(pc.cyan('建议'))
135135
if (report.current.untranslatedEntries > 0)
136136
lines.push(`- 先补齐 ${formatCount(report.current.untranslatedEntries)} 条待翻译文本。`)
137137
if (report.current.pendingReviewEntries > 0)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<template>
2+
<ATabs v-model:activeKey="activeKey">
3+
<ATabPane key="1" tab="消费记录" />
4+
<ATabPane key="2" tab="积分明细" />
5+
<ATabPane key="3" :tab="dynamicTab" />
6+
<CustomPane panel-title="账户安全" emptyText="暂无数据" :panel-title="dynamicTitle" />
7+
</ATabs>
8+
</template>
9+
10+
<script setup lang="ts">
11+
const activeKey = '1'
12+
const dynamicTab = '动态标签'
13+
const dynamicTitle = '动态标题'
14+
</script>

0 commit comments

Comments
 (0)