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') +})