From e62fed4582d26ff4a1b13eb2fe449e9f5f8a5702 Mon Sep 17 00:00:00 2001 From: Romain Hamel Date: Sat, 25 Jul 2026 18:54:34 +0200 Subject: [PATCH] refactor: replace regexp transformations with oxc-parser --- packages/core/src/plugins/examples.ts | 121 ++++++++++++++-- packages/core/test/examples.spec.ts | 58 ++++++++ packages/core/vitest.config.ts | 3 + packages/devtools/app/utils/codegen.ts | 117 +++++++++++---- packages/devtools/package.json | 1 + packages/devtools/test/codegen.test.ts | 34 ++++- packages/vue/package.json | 2 + packages/vue/src/plugins/renderer.ts | 191 +++++++++++++++++++++---- packages/vue/test/renderer.spec.ts | 74 +++++++++- pnpm-lock.yaml | 9 ++ 10 files changed, 539 insertions(+), 71 deletions(-) create mode 100644 packages/core/test/examples.spec.ts create mode 100644 packages/core/vitest.config.ts diff --git a/packages/core/src/plugins/examples.ts b/packages/core/src/plugins/examples.ts index f0a86768..8a0b4b66 100644 --- a/packages/core/src/plugins/examples.ts +++ b/packages/core/src/plugins/examples.ts @@ -1,9 +1,117 @@ import fs from 'node:fs/promises' + import type { VitePlugin } from 'unplugin' import type { Collection, PluginOptions } from '../types' import { resolveCollections } from './collections' import { getRealPath, isPathInside } from './utils' +import type { SFCScriptBlock } from '@vue/compiler-sfc' +import { parse as parseSFC } from '@vue/compiler-sfc' +import { parse as parseAst } from 'oxc-parser' + +interface TextEdit { + start: number + end: number + content?: string +} + +function applyTextEdits(source: string, edits: TextEdit[]) { + return edits + .toSorted((a, b) => b.start - a.start || b.end - a.end) + .reduce((result, edit) => `${result.slice(0, edit.start)}${edit.content ?? ''}${result.slice(edit.end)}`, source) +} + +async function transformScript(filename: string, source: string, lang: SFCScriptBlock['lang'], removeVueImport: boolean) { + let parserLang: 'js' | 'jsx' | 'ts' | 'tsx' + switch (lang) { + case undefined: + case 'js': + parserLang = 'js' + break + case 'jsx': + case 'ts': + case 'tsx': + parserLang = lang + break + default: + return source + } + + const parsed = await parseAst(filename, source, { lang: parserLang }) + + const [parseError] = parsed.errors + if (parseError) { + throw new Error(`[Compodium] Failed to parse example script in ${filename}: ${parseError.message}`) + } + + const edits: TextEdit[] = [] + for (const statement of parsed.program.body) { + const isCompodiumMetaCall = statement.type === 'ExpressionStatement' + && statement.expression.type === 'CallExpression' + && statement.expression.callee.type === 'Identifier' + && statement.expression.callee.name === 'extendCompodiumMeta' + + const isVueImport = removeVueImport + && statement.type === 'ImportDeclaration' + && statement.source.value === 'vue' + + if (isCompodiumMetaCall || isVueImport) { + edits.push({ start: statement.start, end: statement.end }) + } + } + + return applyTextEdits(source, edits) +} + +function getOpeningTagStart(source: string, contentStart: number) { + let quote: '"' | '\'' | undefined + for (let index = contentStart - 2; index >= 0; index--) { + const char = source[index] + if (char === '"' || char === '\'') { + quote = quote === char ? undefined : quote ?? char + } else if (char === '<' && !quote) { + return index + } + } + return -1 +} + +function getScriptBlockRange(source: string, block: SFCScriptBlock) { + const start = getOpeningTagStart(source, block.loc.start.offset) + const closingTagEnd = source.indexOf('>', block.loc.end.offset) + + if (start === -1 || closingTagEnd === -1 || !source.startsWith(']*>\s*<\/script>/g, '') + const exampleCode = await fs.readFile(canonicalPath, 'utf-8') + const result = await transformExampleCode(canonicalPath, exampleCode, options._nuxt) res.setHeader('Content-Type', 'text/plain') res.end(result) diff --git a/packages/core/test/examples.spec.ts b/packages/core/test/examples.spec.ts new file mode 100644 index 00000000..b5bdf2f4 --- /dev/null +++ b/packages/core/test/examples.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { transformExampleCode } from '../src/plugins/examples' + +describe('transformExampleCode', () => { + it('removes metadata calls with nested expressions without touching strings or comments', async () => { + const source = ` +` + + const result = await transformExampleCode('Example.vue', source) + + expect(result).toContain(`const label = 'extendCompodiumMeta({ keep: true })'`) + expect(result).toContain('// extendCompodiumMeta({ keep: true })') + expect(result).not.toContain('defaults: makeDefaults') + expect(result).toContain(' +` + + await expect(transformExampleCode('Example.vue', source)).resolves.toBe(` + +`) + }) + + it('uses the parsed content boundary when script attributes contain tag-like text', async () => { + const source = ` +` + + await expect(transformExampleCode('Example.vue', source)).resolves.toBe('\n') + }) + + it('removes Vue imports structurally for Nuxt examples', async () => { + const source = `` + + const result = await transformExampleCode('Example.vue', source, true) + + expect(result).not.toContain('from "vue"') + expect(result).toContain(`import { helper } from './helper'`) + expect(result).toContain('const value = computed') + }) +}) diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 00000000..abed6b21 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({}) diff --git a/packages/devtools/app/utils/codegen.ts b/packages/devtools/app/utils/codegen.ts index d667c1a2..bd41b998 100644 --- a/packages/devtools/app/utils/codegen.ts +++ b/packages/devtools/app/utils/codegen.ts @@ -1,11 +1,57 @@ +import { NodeTypes, parse, type ElementNode, type RootNode, type TemplateChildNode } from '@vue/compiler-dom' import { camelCase, kebabCase, pascalCase } from 'scule' import { escapeString } from 'knitwork' import deepEqual from 'deep-eql' -// TODO: Might want to use vue/language-tools to refactor this and get rid of horrible RegExp. +interface TextEdit { + start: number + end: number + content?: string +} + +function applyTextEdits(source: string, edits: TextEdit[]) { + return edits + .toSorted((a, b) => b.start - a.start || b.end - a.end) + .reduce((result, edit) => `${result.slice(0, edit.start)}${edit.content ?? ''}${result.slice(edit.end)}`, source) +} + +function parseComponentSource(code: string) { + let hasError = false + const root = parse(code, { + onError: () => { + hasError = true + } + }) + return hasError ? undefined : root +} + +function findComponent(root: RootNode | ElementNode, names: Set): ElementNode | undefined { + for (const child of root.children as TemplateChildNode[]) { + if (child.type !== NodeTypes.ELEMENT) continue + if (names.has(child.tag)) return child + const match = findComponent(child, names) + if (match) return match + } +} + +function isWhitespace(char: string | undefined) { + return char === ' ' || char === '\n' || char === '\r' || char === '\t' +} + +function getPropName(prop: ElementNode['props'][number]) { + if (prop.type === NodeTypes.ATTRIBUTE) return prop.name + if (prop.name !== 'bind' || !prop.arg || prop.arg.type !== NodeTypes.SIMPLE_EXPRESSION || !prop.arg.isStatic) return + return prop.arg.content +} + +function getPropValue(prop: ElementNode['props'][number]) { + if (prop.type === NodeTypes.ATTRIBUTE) return prop.value?.content ?? true + return prop.exp?.type === NodeTypes.SIMPLE_EXPRESSION ? prop.exp.content : true +} + export function genPropValue(value: any): string { if (typeof value === 'string') { - return `'${escapeString(value).replace(/'/g, ''').replace(/"/g, '"')}'` + return `'${escapeString(value).replaceAll('\'', ''').replaceAll('"', '"')}'` } if (value instanceof Date) { const year = value.getFullYear() @@ -45,39 +91,50 @@ export function generateComponentCode(componentName: string, props?: Record]*[^/])/?>|<${kebabCase(componentName)}\\s+([^>]*[^/])/?>`, 's') - const match = code.match(propRegex) - if (!match) return {} - - const propString = match[1] ?? match[2] - if (!propString) return {} - const propEntries = propString.split(/\s(?=(?:[^"]*"[^"]*")*[^"]*$)/g) - const parsedProps = propEntries.flatMap((prop: any) => { - const match = prop.match(/:?(\S+)="([^"]*)"/) - if (match) { - return [[match[1], match[2]]] - } - if (prop.trim()?.length) { - return [[prop, true]] - } - return [] - }) - return Object.fromEntries(parsedProps) + const root = parseComponentSource(code) + if (!root) return {} + const component = findComponent(root, new Set([pascalCase(componentName), kebabCase(componentName)])) + if (!component) return {} + + return Object.fromEntries(component.props.flatMap((prop) => { + const name = getPropName(prop) + return name ? [[name, getPropValue(prop)]] : [] + })) } export function updateComponentCode(componentName: string, code: string, props?: Record, defaultProps?: Record) { const propsTemplate = generatePropsTemplate(props, defaultProps) + const root = parseComponentSource(code) + if (!root) return code + const component = findComponent(root, new Set([pascalCase(componentName), kebabCase(componentName)])) + if (!component) return code - const existingProps = parseExistingProps(componentName, code) - const propsToRemove = Object.keys(existingProps).filter(key => !props || props?.[camelCase(key)] !== undefined) - const removePropsRegex = propsToRemove?.length ? new RegExp(`\\s+:?(${propsToRemove.join('|')})(?:="[^"]*")?`, 'g') : '' + const existingProps = Object.fromEntries(component.props.flatMap((prop) => { + const name = getPropName(prop) + return name ? [[name, getPropValue(prop)]] : [] + })) + const propsToRemove = new Set(Object.keys(existingProps).filter(key => !props || props[camelCase(key)] !== undefined)) + const tagNameEnd = component.loc.start.offset + component.tag.length + 1 + const edits: TextEdit[] = [{ + start: tagNameEnd, + end: tagNameEnd, + content: ` ${propsTemplate}` + }] - const pascalComponentRegexp = new RegExp(`<${pascalCase(componentName)}(\\s|\\r|>)`) - const kebabComponentRegexp = new RegExp(`<${kebabCase(componentName)}(\\s|\\r|>)`) + for (const prop of component.props) { + const name = getPropName(prop) + const isAttrsBinding = prop.type === NodeTypes.DIRECTIVE + && prop.name === 'bind' + && !prop.arg + && prop.exp?.type === NodeTypes.SIMPLE_EXPRESSION + && prop.exp.content === '$attrs' + + if (!isAttrsBinding && (!name || !propsToRemove.has(name))) continue + + let start = prop.loc.start.offset + while (start > tagNameEnd && isWhitespace(code[start - 1])) start-- + edits.push({ start, end: prop.loc.end.offset }) + } - return code - .replace(removePropsRegex, '') - .replace(pascalComponentRegexp, `<${pascalCase(componentName)} ${propsTemplate}$1`) - .replace(kebabComponentRegexp, `<${kebabCase(componentName)} ${propsTemplate}$1`) - .replace('v-bind="$attrs"', '') + return applyTextEdits(code, edits) } diff --git a/packages/devtools/package.json b/packages/devtools/package.json index a4d522fb..b9748f9b 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -11,6 +11,7 @@ "dependencies": { "@comark/nuxt": "0.5.1", "@nuxt/ui": "catalog:", + "@vue/compiler-dom": "^3.5.39", "@vueuse/core": "^14.3.0", "@vueuse/integrations": "^14.3.0", "deep-eql": "^5.0.2", diff --git a/packages/devtools/test/codegen.test.ts b/packages/devtools/test/codegen.test.ts index 203eb6a3..08ee2e88 100644 --- a/packages/devtools/test/codegen.test.ts +++ b/packages/devtools/test/codegen.test.ts @@ -1,5 +1,5 @@ import { it, describe, expect } from 'vitest' -import { generateComponentCode, generatePropsTemplate, genPropValue, updateComponentCode } from '../app/utils/codegen' +import { generateComponentCode, generatePropsTemplate, genPropValue, parseExistingProps, updateComponentCode } from '../app/utils/codegen' describe('codegen', () => { describe('genPropValue', () => { @@ -25,6 +25,21 @@ describe('codegen', () => { ).toEqual(`