diff --git a/packages/vue-generator/src/generator/vue/sfc/generateAttribute.js b/packages/vue-generator/src/generator/vue/sfc/generateAttribute.js
index 901617be5d..189d0ef09f 100644
--- a/packages/vue-generator/src/generator/vue/sfc/generateAttribute.js
+++ b/packages/vue-generator/src/generator/vue/sfc/generateAttribute.js
@@ -85,7 +85,10 @@ const handleJSExpressionBinding = (key, value, isJSX) => {
}
// expression 使用 v-bind 绑定
- return `:${key}="${expressValue}"`
+ // 如果包含双引号,通过 " 编码避免与属性分隔符冲突
+ // 比如绑定的值为:[{ "name": "test" }]
+ // 则转换为: :key="[{ "name": "test" }]"
+ return `:${key}="${expressValue.replaceAll(/"/g, '"')}"`
}
const handleBindI18n = (key, value, isJSX) => {
@@ -176,13 +179,13 @@ export const handleLoopAttrHook = (schemaData = {}, globalHooks, config) => {
if (loop?.value && loop?.type) {
source = loop.value.replace(isJSX ? thisRegexp : thisPropsBindRe, '')
} else {
- source = JSON.stringify(loop).replaceAll("'", "\\'").replaceAll(/"/g, "'")
+ source = JSON.stringify(loop)
}
const iterVar = [...loopArgs]
if (!isJSX) {
- attributes.push(`v-for="(${iterVar.join(',')}) in ${source}"`)
+ attributes.push(`v-for="(${iterVar.join(',')}) in ${source.replaceAll(/"/g, '"')}"`)
return
}
@@ -455,7 +458,11 @@ const transformObjValue = (renderKey, value, globalHooks, config, transformObjTy
const result = { shouldBindToState: false, res: null }
if (typeof value === 'string') {
- result.res = `${renderKey}"${value.replaceAll("'", "\\'").replaceAll(/"/g, "'")}"`
+ result.res = `${renderKey}"${value
+ .replaceAll(/\\/g, '\\\\')
+ .replaceAll(/\n/g, '\\n')
+ .replaceAll(/\r/g, '\\r')
+ .replaceAll(/"/g, '\\"')}"`
return result
}
@@ -575,15 +582,21 @@ export const handleObjBindAttrHook = (schemaData, globalHooks, config) => {
if (shouldBindToState && !isJSX) {
let stateKey = key
let addSuccess = globalHooks.addState(stateKey, `${stateKey}:${res}`)
+ let retryCount = 0
- while (!addSuccess) {
+ while (!addSuccess && retryCount++ < 100) {
stateKey = `${key}${randomString()}`
addSuccess = globalHooks.addState(stateKey, `${stateKey}:${res}`)
}
- attributes.push(`:${key}="state.${stateKey}"`)
+ if (addSuccess) {
+ attributes.push(`:${key}="state.${stateKey}"`)
+ } else {
+ // state 注册失败,回退到内联绑定
+ attributes.push(`:${key}="${res.replaceAll(/"/g, '"')}"`)
+ }
} else {
- attributes.push(isJSX ? `${key}={${res}}` : `:${key}="${res.replaceAll(/"/g, "'")}"`)
+ attributes.push(isJSX ? `${key}={${res}}` : `:${key}="${res.replaceAll(/"/g, '"')}"`)
}
delete props[key]
@@ -600,7 +613,7 @@ export const handlePrimitiveAttributeHook = (schemaData, globalHooks, config) =>
const valueType = typeof value
if (valueType === 'string') {
- attributes.push(`${key}="${value.replaceAll(/"/g, "'")}"`)
+ attributes.push(`${key}="${value.replaceAll(/"/g, '"')}"`)
delete props[key]
}
diff --git a/packages/vue-generator/test/testcases/sfc/accessor/expected/Accessor.vue b/packages/vue-generator/test/testcases/sfc/accessor/expected/Accessor.vue
index c515a9711d..b524540906 100644
--- a/packages/vue-generator/test/testcases/sfc/accessor/expected/Accessor.vue
+++ b/packages/vue-generator/test/testcases/sfc/accessor/expected/Accessor.vue
@@ -26,7 +26,7 @@ const state = vue.reactive({
nullValue: null,
numberValue: 0,
emptyStr: '',
- strVal: 'i am str.',
+ strVal: "i am 'str'.",
trueVal: true,
falseVal: false,
arrVal: [1, '2', { aaa: 'aaa' }, [3, 4], true, false],
diff --git a/packages/vue-generator/test/testcases/sfc/accessor/schema.json b/packages/vue-generator/test/testcases/sfc/accessor/schema.json
index 204121eec1..0935a984a8 100644
--- a/packages/vue-generator/test/testcases/sfc/accessor/schema.json
+++ b/packages/vue-generator/test/testcases/sfc/accessor/schema.json
@@ -44,7 +44,7 @@
}
},
"strVal": {
- "defaultValue": "i am str.",
+ "defaultValue": "i am 'str'.",
"accessor": {
"getter": {
"type": "JSFunction",
diff --git a/packages/vue-generator/test/testcases/sfc/case01/expected/FormTable.vue b/packages/vue-generator/test/testcases/sfc/case01/expected/FormTable.vue
index dfdb69494b..5b494b970f 100644
--- a/packages/vue-generator/test/testcases/sfc/case01/expected/FormTable.vue
+++ b/packages/vue-generator/test/testcases/sfc/case01/expected/FormTable.vue
@@ -118,7 +118,7 @@ const { utils } = wrap(function () {
})()
const state = vue.reactive({
IconPlusSquare: utils.IconPlusSquare(),
- theme: "{ 'id': 22, 'name': '@cloud/tinybuilder-theme-dark', 'description': '黑暗主题' }",
+ theme: '{ "id": 22, "name": "@cloud/tinybuilder-theme-dark", "description": "黑暗主题" }',
companyName: '',
companyOptions: null,
companyCity: '',
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/components-map.json b/packages/vue-generator/test/testcases/sfc/templateQuote/components-map.json
new file mode 100644
index 0000000000..b84fe3b474
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/components-map.json
@@ -0,0 +1,9 @@
+[
+ {
+ "componentName": "TinyButton",
+ "exportName": "Button",
+ "package": "@opentiny/vue",
+ "version": "^3.10.0",
+ "destructuring": true
+ }
+]
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/expected/jsxQuote.vue b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/jsxQuote.vue
new file mode 100644
index 0000000000..49e5490e77
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/jsxQuote.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/expected/multiline.vue b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/multiline.vue
new file mode 100644
index 0000000000..2269d8b75c
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/multiline.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/expected/primitiveQuote.vue b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/primitiveQuote.vue
new file mode 100644
index 0000000000..191d978010
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/primitiveQuote.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/expected/quoteScope.vue b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/quoteScope.vue
new file mode 100644
index 0000000000..ad5ed0d31e
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/quoteScope.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/expected/templateQuote.vue b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/templateQuote.vue
new file mode 100644
index 0000000000..1ad616adb2
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/expected/templateQuote.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/jsxQuote.components-map.json b/packages/vue-generator/test/testcases/sfc/templateQuote/jsxQuote.components-map.json
new file mode 100644
index 0000000000..72caecd53c
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/jsxQuote.components-map.json
@@ -0,0 +1,9 @@
+[
+ {
+ "componentName": "TinyGrid",
+ "exportName": "Grid",
+ "package": "@opentiny/vue",
+ "version": "^3.10.0",
+ "destructuring": true
+ }
+]
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/jsxQuote.schema.json b/packages/vue-generator/test/testcases/sfc/templateQuote/jsxQuote.schema.json
new file mode 100644
index 0000000000..0f9f81cc8c
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/jsxQuote.schema.json
@@ -0,0 +1,45 @@
+{
+ "state": {
+ "columns": [
+ {
+ "field": "info",
+ "title": "信息",
+ "slots": {
+ "default": {
+ "type": "JSSlot",
+ "value": [
+ {
+ "componentName": "span",
+ "props": {
+ "title": "{\"key\": \"value\"}"
+ },
+ "id": "jsx-span-001",
+ "children": []
+ }
+ ],
+ "params": ["row"]
+ }
+ }
+ }
+ ]
+ },
+ "methods": {},
+ "componentName": "Page",
+ "css": "",
+ "props": {},
+ "lifeCycles": {},
+ "children": [
+ {
+ "componentName": "TinyGrid",
+ "props": {
+ "columns": {
+ "type": "JSExpression",
+ "value": "state.columns"
+ }
+ },
+ "id": "jsx-test-001",
+ "children": []
+ }
+ ],
+ "fileName": "testJsxQuote"
+}
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/multiline.schema.json b/packages/vue-generator/test/testcases/sfc/templateQuote/multiline.schema.json
new file mode 100644
index 0000000000..cbadf42bac
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/multiline.schema.json
@@ -0,0 +1,23 @@
+{
+ "state": {},
+ "methods": {},
+ "componentName": "Page",
+ "css": "",
+ "props": {},
+ "lifeCycles": {},
+ "children": [
+ {
+ "componentName": "TinyButton",
+ "props": {
+ "multilineJson": "{\n \"name\": \"test\",\n \"value\": \"data\"\n}",
+ "objWithMultiline": {
+ "template": "line1\nline2",
+ "label": "normal"
+ }
+ },
+ "id": "multiline-test-001",
+ "children": []
+ }
+ ],
+ "fileName": "testMultiline"
+}
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/page.schema.json b/packages/vue-generator/test/testcases/sfc/templateQuote/page.schema.json
new file mode 100644
index 0000000000..534af857a8
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/page.schema.json
@@ -0,0 +1,46 @@
+{
+ "state": {
+ "customAttrTest": {
+ "value": [
+ {
+ "defaultValue": "{\"class\": \"test-class\", \"id\": \"test-id\"}"
+ }
+ ]
+ }
+ },
+ "methods": {},
+ "componentName": "Page",
+ "css": "",
+ "props": {},
+ "lifeCycles": {},
+ "children": [
+ {
+ "componentName": "TinyButton",
+ "props": {
+ "type": "primary",
+ "text": "test",
+ "subStr": "pri\"ma\"ry'subStr'",
+ "customAttrTest": {
+ "value": [
+ {
+ "defaultValue": "{\"class\": \"test-class\", \"id\": \"test-id\", \"class2\": \"te'st'-class2\"}",
+ "subStr": "test-'cl'ass2"
+ }
+ ]
+ },
+ "customExpressionTest": {
+ "type": "JSExpression",
+ "value": "{\n \"value\": [\n {\n \"defaultValue\": \"{\\\"class\\\": \\\"test-class\\\", \\\"id\\\": \\\"test-id\\\"}\"\n }\n ]\n}"
+ }
+ },
+ "loopArgs": ["item", "index"],
+ "loop": {
+ "type": "JSExpression",
+ "value": "[\n {\n type: 'primary'\n, subStr: \"primary'subStr'\"\n },\n {\n type: \"\"\n },\n {\n type: \"info\"\n },\n {\n type: \"success\"\n },\n {\n type: \"warning\"\n },\n {\n type: \"danger\"\n }\n]"
+ },
+ "id": "63623253",
+ "children": []
+ }
+ ],
+ "fileName": "testTemplateQuote"
+}
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/primitiveQuote.schema.json b/packages/vue-generator/test/testcases/sfc/templateQuote/primitiveQuote.schema.json
new file mode 100644
index 0000000000..891716a030
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/primitiveQuote.schema.json
@@ -0,0 +1,24 @@
+{
+ "state": {},
+ "methods": {},
+ "componentName": "Page",
+ "css": "",
+ "props": {},
+ "lifeCycles": {},
+ "children": [
+ {
+ "componentName": "TinyButton",
+ "props": {
+ "noQuotes": "plain text value",
+ "onlyDouble": "{\"name\": \"test\", \"id\": \"main\"}",
+ "onlySingle": "it's a 'test' value",
+ "bothQuotes": "She said \"hello\" and it's fine",
+ "htmlAttr": "class=\"primary\" id=\"btn-1\"",
+ "emptyStr": ""
+ },
+ "id": "primitive-quote-001",
+ "children": []
+ }
+ ],
+ "fileName": "testPrimitiveQuote"
+}
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/scope.schema.json b/packages/vue-generator/test/testcases/sfc/templateQuote/scope.schema.json
new file mode 100644
index 0000000000..19aae6d8f4
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/scope.schema.json
@@ -0,0 +1,29 @@
+{
+ "state": {
+ "items": [
+ { "name": "test", "tag": "featured" },
+ { "name": "hello", "tag": "normal" }
+ ]
+ },
+ "methods": {},
+ "componentName": "Page",
+ "css": "",
+ "props": {},
+ "lifeCycles": {},
+ "children": [
+ {
+ "componentName": "TinyButton",
+ "props": {
+ "jsonContent": "{\"key\": \"value\", \"nested\": \"data\"}",
+ "mixedQuotes": "She said \"hello\" and he said 'hi'",
+ "filteredItems": {
+ "type": "JSExpression",
+ "value": "this.state.items.filter(i => i.name === \"test\" && i.tag === 'featured')"
+ }
+ },
+ "id": "scope-test-001",
+ "children": []
+ }
+ ],
+ "fileName": "testQuoteScope"
+}
diff --git a/packages/vue-generator/test/testcases/sfc/templateQuote/templateQuote.test.js b/packages/vue-generator/test/testcases/sfc/templateQuote/templateQuote.test.js
new file mode 100644
index 0000000000..b329d2d0fc
--- /dev/null
+++ b/packages/vue-generator/test/testcases/sfc/templateQuote/templateQuote.test.js
@@ -0,0 +1,49 @@
+import { expect, test } from 'vitest'
+import { genSFCWithDefaultPlugin } from '@/generator/vue/sfc'
+import pageSchema from './page.schema.json'
+import scopeSchema from './scope.schema.json'
+import multilineSchema from './multiline.schema.json'
+import jsxQuoteSchema from './jsxQuote.schema.json'
+import jsxQuoteComponentsMap from './jsxQuote.components-map.json'
+import primitiveQuoteSchema from './primitiveQuote.schema.json'
+import { formatCode } from '@/utils/formatCode'
+
+test('should generate template quote correctly', async () => {
+ const res = genSFCWithDefaultPlugin(pageSchema, [])
+
+ const formattedCode = formatCode(res, 'vue')
+
+ await expect(formattedCode).toMatchFileSnapshot('./expected/templateQuote.vue')
+})
+
+test('should preserve expression scope and string content with quotes', async () => {
+ const res = genSFCWithDefaultPlugin(scopeSchema, [])
+
+ const formattedCode = formatCode(res, 'vue')
+
+ await expect(formattedCode).toMatchFileSnapshot('./expected/quoteScope.vue')
+})
+
+test('should escape newlines in v-bind string literals for multiline strings with double quotes', async () => {
+ const res = genSFCWithDefaultPlugin(multilineSchema, [])
+
+ const formattedCode = formatCode(res, 'vue')
+
+ await expect(formattedCode).toMatchFileSnapshot('./expected/multiline.vue')
+})
+
+test('should not use v-bind string literal syntax in JSX slot mode', async () => {
+ const res = genSFCWithDefaultPlugin(jsxQuoteSchema, jsxQuoteComponentsMap)
+
+ const formattedCode = formatCode(res, 'vue')
+
+ await expect(formattedCode).toMatchFileSnapshot('./expected/jsxQuote.vue')
+})
+
+test('should encode double quotes as " in primitive string attributes', async () => {
+ const res = genSFCWithDefaultPlugin(primitiveQuoteSchema, [])
+
+ const formattedCode = formatCode(res, 'vue')
+
+ await expect(formattedCode).toMatchFileSnapshot('./expected/primitiveQuote.vue')
+})