Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 110 additions & 11 deletions packages/core/src/plugins/examples.ts
Original file line number Diff line number Diff line change
@@ -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('<script', start)) {
throw new Error('[Compodium] Failed to locate example script block')
}

return { start, end: closingTagEnd + 1 }
}

export async function transformExampleCode(filename: string, source: string, removeVueImport = false) {
const { descriptor, errors } = parseSFC(source, { filename })
if (errors.length) {
throw new Error(`[Compodium] Failed to parse example component ${filename}`)
}

const edits: TextEdit[] = []
for (const block of [descriptor.script, descriptor.scriptSetup]) {
if (!block) continue

const transformed = await transformScript(filename, block.content, block.lang, removeVueImport)
if (transformed.trim()) {
edits.push({
start: block.loc.start.offset,
end: block.loc.end.offset,
content: transformed
})
} else {
edits.push(getScriptBlockRange(source, block))
}
}

return applyTextEdits(source, edits)
}

export function examplePlugin(options: PluginOptions): VitePlugin {
let collections: Collection[]

Expand Down Expand Up @@ -37,17 +145,8 @@ export function examplePlugin(options: PluginOptions): VitePlugin {
return
}

const exampleCode = await fs.readFile(canonicalPath)

let result = exampleCode.toString()
.replace(/extendCompodiumMeta\s*\([\s\S]*?\)\s*;?/g, '')

if (options._nuxt) {
result = result
.replace(/import .* from 'vue'/, '')
}

result = result.replace(/<script[^>]*>\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)
Expand Down
58 changes: 58 additions & 0 deletions packages/core/test/examples.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<script setup lang="ts">
const label = 'extendCompodiumMeta({ keep: true })'
// extendCompodiumMeta({ keep: true })
extendCompodiumMeta({ defaults: makeDefaults({ nested: true }) })
</script>
<template><div>{{ label }}</div></template>`

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('<script setup lang="ts">')
})

it('removes script blocks made empty by metadata cleanup', async () => {
const source = `<template><Example /></template>
<script setup lang="ts">
extendCompodiumMeta({ defaults: { count: 1 } })
</script>
`

await expect(transformExampleCode('Example.vue', source)).resolves.toBe(`<template><Example /></template>

`)
})

it('uses the parsed content boundary when script attributes contain tag-like text', async () => {
const source = `<template><Example /></template>
<script setup lang="ts" data-label="<script">
extendCompodiumMeta({ defaults: true })
</script>`

await expect(transformExampleCode('Example.vue', source)).resolves.toBe('<template><Example /></template>\n')
})

it('removes Vue imports structurally for Nuxt examples', async () => {
const source = `<script setup lang="ts">
import {
computed,
ref
} from "vue"
import { helper } from './helper'
const value = computed(() => helper(ref(1)))
</script>`

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')
})
})
3 changes: 3 additions & 0 deletions packages/core/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({})
117 changes: 87 additions & 30 deletions packages/devtools/app/utils/codegen.ts
Original file line number Diff line number Diff line change
@@ -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<string>): 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, '&apos;').replace(/"/g, '&quot;')}'`
return `'${escapeString(value).replaceAll('\'', '&apos;').replaceAll('"', '&quot;')}'`
}
if (value instanceof Date) {
const year = value.getFullYear()
Expand Down Expand Up @@ -45,39 +91,50 @@ export function generateComponentCode(componentName: string, props?: Record<stri
}

export function parseExistingProps(componentName: string, code: string) {
const propRegex = new RegExp(`<${pascalCase(componentName)}\\s+([^>]*[^/])/?>|<${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<string, any>, defaultProps?: Record<string, any>) {
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)
}
1 change: 1 addition & 0 deletions packages/devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 33 additions & 1 deletion packages/devtools/test/codegen.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -25,6 +25,21 @@ describe('codegen', () => {
).toEqual(`<Button foo='bar' />`))
})

describe('parseExistingProps', () => {
it('parses Vue attributes and static bindings', () => {
expect(parseExistingProps('BaseButton', `<BaseButton single='hello' unquoted=world :count="foo > 1" bool v-bind="$attrs" />`)).toEqual({
single: 'hello',
unquoted: 'world',
count: 'foo > 1',
bool: true
})
})

it('ignores component-looking text in scripts', () => {
expect(parseExistingProps('BaseButton', `<script>const source = '<BaseButton label="wrong" />'</script>\n<BaseButton label="right" />`)).toEqual({ label: 'right' })
})
})

describe('updateComponentCode', () => {
it('works', () => {
const code = `<BaseButton> </BaseButton>`
Expand Down Expand Up @@ -60,5 +75,22 @@ describe('codegen', () => {
const code = `<BaseButton label="Click me!"> </BaseButton>`
expect(updateComponentCode('BaseButton', code, { label: 'foo' }, { label: 'foo' })).toMatch(`<BaseButton > </BaseButton>`)
})

it('changes only the first target component and its attributes', () => {
const code = `<div label="keep" v-bind="$attrs">
<BaseButton label='old' :count="old" bool v-bind='$attrs' />
<BaseButton label="second" />
</div>`
const result = updateComponentCode('BaseButton', code, { label: 'new', count: 2 })

expect(result).toContain('<div label="keep" v-bind="$attrs">')
expect(result).toContain(`<BaseButton label='new'\n:count="2" bool />`)
expect(result).toContain('<BaseButton label="second" />')
})

it('returns source unchanged when the component is absent', () => {
const code = '<OtherComponent label="keep" />'
expect(updateComponentCode('BaseButton', code, { label: 'new' })).toBe(code)
})
})
})
Loading