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('
+{{ label }}
`
+
+ 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(``))
})
+ describe('parseExistingProps', () => {
+ it('parses Vue attributes and static bindings', () => {
+ expect(parseExistingProps('BaseButton', ``)).toEqual({
+ single: 'hello',
+ unquoted: 'world',
+ count: 'foo > 1',
+ bool: true
+ })
+ })
+
+ it('ignores component-looking text in scripts', () => {
+ expect(parseExistingProps('BaseButton', `\n`)).toEqual({ label: 'right' })
+ })
+ })
+
describe('updateComponentCode', () => {
it('works', () => {
const code = ` `
@@ -60,5 +75,22 @@ describe('codegen', () => {
const code = ` `
expect(updateComponentCode('BaseButton', code, { label: 'foo' }, { label: 'foo' })).toMatch(` `)
})
+
+ it('changes only the first target component and its attributes', () => {
+ const code = `
+
+
+
`
+ const result = updateComponentCode('BaseButton', code, { label: 'new', count: 2 })
+
+ expect(result).toContain('')
+ expect(result).toContain(``)
+ expect(result).toContain('')
+ })
+
+ it('returns source unchanged when the component is absent', () => {
+ const code = ''
+ expect(updateComponentCode('BaseButton', code, { label: 'new' })).toBe(code)
+ })
})
})
diff --git a/packages/vue/package.json b/packages/vue/package.json
index fff67d17..6143d43f 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -42,10 +42,12 @@
"dependencies": {
"@compodium/core": "workspace:^",
"@compodium/examples": "workspace:^",
+ "@vue/compiler-dom": "^3.5.39",
"@vue/devtools-kit": "^8.1.5",
"consola": "^3.4.2",
"defu": "^6.1.7",
"mlly": "^1.8.2",
+ "oxc-parser": "^0.139.0",
"pathe": "^2.0.3",
"ufo": "^1.6.4",
"unplugin": "^3.3.0"
diff --git a/packages/vue/src/plugins/renderer.ts b/packages/vue/src/plugins/renderer.ts
index 64be675e..fd252088 100644
--- a/packages/vue/src/plugins/renderer.ts
+++ b/packages/vue/src/plugins/renderer.ts
@@ -1,22 +1,173 @@
import type { VitePlugin } from 'unplugin'
import type { PluginOptions } from '@compodium/core'
+import { NodeTypes, parse as parseHtml, type ElementNode, type RootNode, type TemplateChildNode } from '@vue/compiler-dom'
+import { parse as parseAst, type CallExpression, type Node } from 'oxc-parser'
import { readFileSync, existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { joinURL } from 'ufo'
import { resolvePathSync } from 'mlly'
+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 visitElements(root: RootNode | ElementNode, visit: (node: ElementNode) => boolean | undefined): ElementNode | undefined {
+ for (const child of root.children as TemplateChildNode[]) {
+ if (child.type !== NodeTypes.ELEMENT) continue
+ if (visit(child)) return child
+ const match = visitElements(child, visit)
+ if (match) return match
+ }
+}
+
+function findElement(root: RootNode, predicate: (node: ElementNode) => boolean) {
+ return visitElements(root, node => predicate(node))
+}
+
+function getAttribute(node: ElementNode, name: string) {
+ const attribute = node.props.find(prop => prop.type === NodeTypes.ATTRIBUTE && prop.name.toLowerCase() === name)
+ return attribute?.type === NodeTypes.ATTRIBUTE ? attribute.value?.content : undefined
+}
+
+function walkAst(value: unknown, visit: (node: Node) => void) {
+ if (!value || typeof value !== 'object') return
+ if (Array.isArray(value)) {
+ for (const child of value) walkAst(child, visit)
+ return
+ }
+
+ const record = value as Record
+ if (typeof record.type === 'string' && typeof record.start === 'number' && typeof record.end === 'number') {
+ visit(value as Node)
+ }
+
+ for (const child of Object.values(record)) walkAst(child, visit)
+}
+
+function replaceCallArguments(source: string, call: CallExpression, content: string): TextEdit {
+ const openingParen = source.indexOf('(', call.callee.end)
+ const closingParen = source.lastIndexOf(')', call.end - 1)
+ if (openingParen === -1 || closingParen < openingParen || closingParen >= call.end) {
+ throw new Error('[Compodium] Failed to locate call arguments')
+ }
+ return { start: openingParen + 1, end: closingParen, content }
+}
+
+function editsOverlap(first: TextEdit, second: TextEdit) {
+ return first.start < second.end && second.start < first.end
+}
+
export function inferMainPath(indexContent: string) {
- // Find all script tags
- const scriptTags = indexContent.match(/
+
]*>[\s\S]*<\/body>/i,
- `
-
-
- `
- )
+ const rendererIndex = renderRendererIndex(index, baseUrl)
res.setHeader('Content-Type', 'text/html')
res.end(rendererIndex)
} catch {
@@ -61,7 +206,7 @@ export function rendererPlugin(options: PluginOptions): VitePlugin {
return '\0@compodium/renderer.ts'
}
},
- load(id) {
+ async load(id) {
if (id === '\0@compodium/renderer.ts') {
// Read the user's main entrypoint file
if (!mainPath) {
@@ -72,17 +217,7 @@ export function rendererPlugin(options: PluginOptions): VitePlugin {
throw new Error(`[Compodium] failed to resolve main file ${mainPath}. Use the mainPath option to specify the path to your Vue script file containing createApp().`)
}
- const mainDir = dirname(mainPath)
- const mainContent: string = readFileSync(mainPath, 'utf-8').replace(
- /createApp\([^)]*\)/,
- 'createApp(CompodiumRoot)'
- ).replace(
- /\.mount\([^)]*\)/,
- '.mount("#compodium")'
- ).replace(/(from|import)\s+(['"])([./])/g, (_match, keyword, quote, path) => {
- // Resolve the relative import based on the main directory
- return `${keyword} ${quote}${resolve(mainDir, path)}/`
- })
+ const mainContent = await transformMainModule(mainPath, readFileSync(mainPath, 'utf-8'))
const rootVuePath = resolvePathSync('@compodium/core/runtime/root.vue', { extensions: ['.vue'], url: import.meta.url })
return `import CompodiumRoot from '${rootVuePath}';\n${mainContent}`
diff --git a/packages/vue/test/renderer.spec.ts b/packages/vue/test/renderer.spec.ts
index e34e644e..219f69ba 100644
--- a/packages/vue/test/renderer.spec.ts
+++ b/packages/vue/test/renderer.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createViteServer } from './utils'
-import { inferMainPath } from '../src/plugins/renderer'
+import { inferMainPath, renderRendererIndex, transformMainModule } from '../src/plugins/renderer'
describe('renderer', async () => {
const server = await createViteServer('./fixtures/basic')
@@ -72,4 +72,76 @@ describe('inferMainPath', async () => {