19 | ? ExtractPropTypes
20 | : P
21 |
22 | export type LooseRequiredRenderComponentInputProps
23 | = LooseRequired>
25 | ? ExtractPropTypes
26 | : T
27 | > & {}>
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 [fullname]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/core/src/render-browser/markdown.browser.test.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from 'node:fs/promises'
2 | import { dirname, join } from 'node:path'
3 | import { fileURLToPath } from 'node:url'
4 |
5 | import { describe, expect, it } from 'vitest'
6 |
7 | import { renderMarkdownString } from './markdown'
8 |
9 | // TODO: Browser testing
10 | describe.todo('renderMarkdownString', async () => {
11 | it('should be able to render simple SFC', async () => {
12 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'simple.velin.md'), 'utf-8')
13 | const result = await renderMarkdownString(content)
14 | expect(result).toBeDefined()
15 | expect(result).not.toBe('')
16 | expect(result).toBe('# Hello, world!\n')
17 | })
18 |
19 | it('should be able to render script setup SFC', async () => {
20 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup.velin.md'), 'utf-8')
21 | const result = await renderMarkdownString(content)
22 | expect(result).toBeDefined()
23 | expect(result).not.toBe('')
24 | expect(result).toBe('Count: 0\n')
25 | })
26 | })
27 |
--------------------------------------------------------------------------------
/docs/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/utils/src/from-md/index.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 |
3 | import { scriptFrom } from '.'
4 |
5 | describe('scriptFrom', () => {
6 | it('should be able to parse', () => {
7 | const result = scriptFrom(`
8 |
9 |
10 |
Hello, world!
11 |
12 |
13 | `)
14 |
15 | expect(result).toMatchObject({
16 | template: `
17 |
18 |
19 |
Hello, world!
20 |
21 |
22 | `,
23 | })
24 | })
25 |
26 | it('should be able to parse with script', () => {
27 | const result = scriptFrom(`
28 |
32 |
33 |
34 |
35 |
Hello, world!
36 |
Count: {{ count }}
37 |
38 |
39 | `)
40 |
41 | expect(result).toMatchObject({
42 | script: `
43 | import { ref } from 'vue'
44 | const count = ref(0)
45 | `,
46 | template: `
47 |
48 |
49 |
50 |
51 |
Hello, world!
52 |
Count: {{ count }}
53 |
54 |
55 | `,
56 | })
57 | })
58 | })
59 |
--------------------------------------------------------------------------------
/apps/playground/src/components/Editor/highlight.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/vuejs/repl/blob/5e092b6111118f5bb5fc419f0f8f3f84cd539366/src/monaco/highlight.ts
2 |
3 | import langJsx from '@shikijs/langs/jsx'
4 | import langTsx from '@shikijs/langs/tsx'
5 | import langVue from '@shikijs/langs/vue'
6 | import themeLight from '@shikijs/themes/catppuccin-latte'
7 | import themeDark from '@shikijs/themes/catppuccin-mocha'
8 |
9 | import { createHighlighterCoreSync } from '@shikijs/core'
10 | import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
11 | import { shikiToMonaco } from '@shikijs/monaco'
12 |
13 | import * as monaco from 'monaco-editor-core'
14 |
15 | let registered = false
16 | export function registerHighlighter() {
17 | if (!registered) {
18 | const highlighter = createHighlighterCoreSync({
19 | themes: [themeDark, themeLight],
20 | langs: [langVue, langTsx, langJsx],
21 | engine: createJavaScriptRegexEngine(),
22 | })
23 | monaco.languages.register({ id: 'vue' })
24 | shikiToMonaco(highlighter, monaco)
25 | registered = true
26 | }
27 |
28 | return {
29 | light: themeLight.name!,
30 | dark: themeDark.name!,
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/apps/playground/src/utils/vue-repl.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/vuejs/repl/blob/5e092b6111118f5bb5fc419f0f8f3f84cd539366/src/utils.ts
2 |
3 | import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate'
4 |
5 | export function debounce(fn: (...args: any[]) => any, n = 100) {
6 | let handle: any
7 | return (...args: any[]) => {
8 | if (handle)
9 | clearTimeout(handle)
10 | handle = setTimeout(() => {
11 | fn(...args)
12 | }, n)
13 | }
14 | }
15 |
16 | export function utoa(data: string): string {
17 | const buffer = strToU8(data)
18 | const zipped = zlibSync(buffer, { level: 9 })
19 | const binary = strFromU8(zipped, true)
20 | return btoa(binary)
21 | }
22 |
23 | export function atou(base64: string): string {
24 | const binary = atob(base64)
25 |
26 | // zlib header (x78), level 9 (xDA)
27 | if (binary.startsWith('\x78\xDA')) {
28 | const buffer = strToU8(binary, true)
29 | const unzipped = unzlibSync(buffer)
30 | return strFromU8(unzipped)
31 | }
32 |
33 | // old unicode hacks for backward compatibility
34 | // https://base64.guru/developers/javascript/examples/unicode-strings
35 | return decodeURIComponent(escape(binary))
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 | workflow_dispatch:
8 |
9 | concurrency: ${{ github.workflow }}-${{ github.ref }}
10 |
11 | jobs:
12 | publish:
13 | runs-on: ubuntu-latest
14 | permissions:
15 | contents: write
16 | id-token: write
17 | steps:
18 | - uses: actions/checkout@v6
19 | with:
20 | fetch-depth: 0
21 | - uses: pnpm/action-setup@v4
22 | with:
23 | run_install: false
24 | - uses: actions/setup-node@v6
25 | with:
26 | cache: pnpm
27 | node-version: latest
28 | registry-url: https://registry.npmjs.org
29 |
30 | # npm 11.5.1 or later is required so update to latest to use OIDC trusted publisher
31 | # https://github.com/eslint/config-inspector/pull/174/files
32 | # https://github.com/e18e/ecosystem-issues/issues/201
33 | - run: npm install -g npm@latest
34 | - run: pnpm i
35 | - run: pnpm dlx changelogithub
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | - run: pnpm -r build
39 | - run: pnpm -r publish --no-git-checks --access public
40 |
--------------------------------------------------------------------------------
/apps/playground/src/components/Input.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
30 |
31 |
--------------------------------------------------------------------------------
/packages/utils/src/from-md/index.ts:
--------------------------------------------------------------------------------
1 | import type { Element, Text } from 'hast'
2 |
3 | import markdownIt from 'markdown-it'
4 |
5 | import { fromHtml } from 'hast-util-from-html'
6 | import { select } from 'hast-util-select'
7 | import { toHtml } from 'hast-util-to-html'
8 | import { remove } from 'unist-util-remove'
9 |
10 | export function fromMarkdown(markdownString: string): string {
11 | const md = markdownIt({ html: true })
12 | return md.render(markdownString)
13 | }
14 |
15 | function asHTMLElement(input?: any): Element | undefined {
16 | return input
17 | }
18 |
19 | function asHTMLText(input?: any): Text | undefined {
20 | return input
21 | }
22 |
23 | export function scriptFrom(html: string): { script: string, template: string, lang: string } {
24 | const hastTree = fromHtml(html, { fragment: true })
25 | const scriptNode = asHTMLElement(select('script[setup]', hastTree))
26 | const lang = String(scriptNode?.properties?.lang || 'js')
27 | const script = scriptNode ? asHTMLText(scriptNode.children[0]).value : ''
28 | if (scriptNode) {
29 | remove(hastTree, scriptNode)
30 | }
31 |
32 | const template = toHtml(hastTree)
33 | return { script, template, lang }
34 | }
35 |
--------------------------------------------------------------------------------
/apps/playground/src/components/Editor/utils.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/vuejs/repl/blob/69c2ed1dca84132708c3b9a1d0a008e11be2be74/src/utils.ts
2 |
3 | import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate'
4 |
5 | // eslint-disable-next-line ts/no-unsafe-function-type
6 | export function debounce(fn: Function, n = 100) {
7 | let handle: any
8 | return (...args: any[]) => {
9 | if (handle)
10 | clearTimeout(handle)
11 | handle = setTimeout(() => {
12 | fn(...args)
13 | }, n)
14 | }
15 | }
16 |
17 | export function utoa(data: string): string {
18 | const buffer = strToU8(data)
19 | const zipped = zlibSync(buffer, { level: 9 })
20 | const binary = strFromU8(zipped, true)
21 | return btoa(binary)
22 | }
23 |
24 | export function atou(base64: string): string {
25 | const binary = atob(base64)
26 |
27 | // zlib header (x78), level 9 (xDA)
28 | if (binary.startsWith('\x78\xDA')) {
29 | const buffer = strToU8(binary, true)
30 | const unzipped = unzlibSync(buffer)
31 | return strFromU8(unzipped)
32 | }
33 |
34 | // old unicode hacks for backward compatibility
35 | // https://base64.guru/developers/javascript/examples/unicode-strings
36 | return decodeURIComponent(escape(binary))
37 | }
38 |
--------------------------------------------------------------------------------
/apps/playground/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Velin Playground
6 |
7 |
8 |
9 |
10 |
11 |
12 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/packages/core/src/render-node/index.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentProp } from '../render-shared/props'
2 | import type { InputProps } from '../types'
3 |
4 | import { fromHtml } from 'hast-util-from-html'
5 |
6 | import { renderMarkdownString } from './markdown'
7 | import { renderSFCString } from './sfc'
8 |
9 | export { renderComponent, resolveProps } from '../render-shared'
10 | export { renderMarkdownString } from './markdown'
11 | export { renderSFCString } from './sfc'
12 |
13 | export async function render(
14 | source: string,
15 | data?: InputProps,
16 | basePath?: string,
17 | ): Promise<{
18 | props: ComponentProp[]
19 | rendered: string
20 | }> {
21 | const hastRoot = fromHtml(source, { fragment: true })
22 |
23 | const hasTemplate = hastRoot.children.some(node => node.type === 'element' && node.tagName === 'template')
24 | const hasScript = hastRoot.children.some(node => node.type === 'element' && node.tagName === 'script')
25 |
26 | if (!hasTemplate && !hasScript && source) {
27 | return await renderMarkdownString(source, data, basePath)
28 | }
29 | if (hasScript && !hasTemplate && source) {
30 | return await renderMarkdownString(source, data, basePath)
31 | }
32 |
33 | return await renderSFCString(source, data, basePath)
34 | }
35 |
--------------------------------------------------------------------------------
/packages/vue/README.md:
--------------------------------------------------------------------------------
1 | # `@velin-dev/vue`
2 |
3 | [![npm version][npm-version-src]][npm-version-href]
4 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
5 | [![bundle][bundle-src]][bundle-href]
6 | [![JSDocs][jsdocs-src]][jsdocs-href]
7 | [![License][license-src]][license-href]
8 |
9 | Refer to [README.md](https://github.com/moeru-ai/velin/blob/main/README.md) for more information.
10 |
11 | ## License
12 |
13 | MIT
14 |
15 | [npm-version-src]: https://img.shields.io/npm/v/@velin-dev/vue?style=flat&colorA=080f12&colorB=1fa669
16 | [npm-version-href]: https://npmjs.com/package/@velin-dev/vue
17 | [npm-downloads-src]: https://img.shields.io/npm/dm/@velin-dev/vue?style=flat&colorA=080f12&colorB=1fa669
18 | [npm-downloads-href]: https://npmjs.com/package/@velin-dev/vue
19 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/@velin-dev/vue?style=flat&colorA=080f12&colorB=1fa669&label=minzip
20 | [bundle-href]: https://bundlephobia.com/result?p=@velin-dev/vue
21 | [license-src]: https://img.shields.io/github/license/moeru-ai/velin.svg?style=flat&colorA=080f12&colorB=1fa669
22 | [license-href]: https://github.com/moeru-ai/velin/blob/main/LICENSE
23 | [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
24 | [jsdocs-href]: https://www.jsdocs.io/package/@velin-dev/vue
25 |
--------------------------------------------------------------------------------
/packages/core/README.md:
--------------------------------------------------------------------------------
1 | # `@velin-dev/core`
2 |
3 | [![npm version][npm-version-src]][npm-version-href]
4 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
5 | [![bundle][bundle-src]][bundle-href]
6 | [![JSDocs][jsdocs-src]][jsdocs-href]
7 | [![License][license-src]][license-href]
8 |
9 | Refer to [README.md](https://github.com/moeru-ai/velin/blob/main/README.md) for more information.
10 |
11 | ## License
12 |
13 | MIT
14 |
15 | [npm-version-src]: https://img.shields.io/npm/v/@velin-dev/core?style=flat&colorA=080f12&colorB=1fa669
16 | [npm-version-href]: https://npmjs.com/package/@velin-dev/core
17 | [npm-downloads-src]: https://img.shields.io/npm/dm/@velin-dev/core?style=flat&colorA=080f12&colorB=1fa669
18 | [npm-downloads-href]: https://npmjs.com/package/@velin-dev/core
19 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/@velin-dev/vue?style=flat&colorA=080f12&colorB=1fa669&label=minzip
20 | [bundle-href]: https://bundlephobia.com/result?p=@velin-dev/vue
21 | [license-src]: https://img.shields.io/github/license/moeru-ai/velin.svg?style=flat&colorA=080f12&colorB=1fa669
22 | [license-href]: https://github.com/moeru-ai/velin/blob/main/LICENSE
23 | [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
24 | [jsdocs-href]: https://www.jsdocs.io/package/@velin-dev/core
25 |
--------------------------------------------------------------------------------
/packages/utils/README.md:
--------------------------------------------------------------------------------
1 | # `@velin-dev/utils`
2 |
3 | [![npm version][npm-version-src]][npm-version-href]
4 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
5 | [![bundle][bundle-src]][bundle-href]
6 | [![JSDocs][jsdocs-src]][jsdocs-href]
7 | [![License][license-src]][license-href]
8 |
9 | Refer to [README.md](https://github.com/moeru-ai/velin/blob/main/README.md) for more information.
10 |
11 | ## License
12 |
13 | MIT
14 |
15 | [npm-version-src]: https://img.shields.io/npm/v/@velin-dev/utils?style=flat&colorA=080f12&colorB=1fa669
16 | [npm-version-href]: https://npmjs.com/package/@velin-dev/utils
17 | [npm-downloads-src]: https://img.shields.io/npm/dm/@velin-dev/utils?style=flat&colorA=080f12&colorB=1fa669
18 | [npm-downloads-href]: https://npmjs.com/package/@velin-dev/utils
19 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/@velin-dev/utils?style=flat&colorA=080f12&colorB=1fa669&label=minzip
20 | [bundle-href]: https://bundlephobia.com/result?p=@velin-dev/utils
21 | [license-src]: https://img.shields.io/github/license/moeru-ai/velin.svg?style=flat&colorA=080f12&colorB=1fa669
22 | [license-href]: https://github.com/moeru-ai/velin/blob/main/LICENSE
23 | [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
24 | [jsdocs-href]: https://www.jsdocs.io/package/@velin-dev/utils
25 |
--------------------------------------------------------------------------------
/packages/core/src/render-repl/index.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentProp } from '../render-shared/props'
2 | import type { InputProps } from '../types'
3 |
4 | import { fromHtml } from 'hast-util-from-html'
5 |
6 | import { renderMarkdownString } from './markdown'
7 | import { renderSFCString } from './sfc'
8 |
9 | export * from './sfc'
10 |
11 | export async function render(
12 | source: string,
13 | data?: InputProps,
14 | basePath?: string,
15 | ): Promise<{
16 | props: ComponentProp[]
17 | rendered: string
18 | }> {
19 | const hastRoot = fromHtml(source, { fragment: true })
20 |
21 | const hasTemplate = hastRoot.children.some(node => node.type === 'element' && node.tagName === 'template')
22 | const hasScript = hastRoot.children.some(node => node.type === 'element' && node.tagName === 'script')
23 |
24 | if (!hasTemplate && !hasScript && source) {
25 | return await renderMarkdownString(source, data, basePath)
26 | }
27 | if (hasScript && !hasTemplate && source) {
28 | return await renderMarkdownString(source, data, basePath)
29 | }
30 | if (!hasTemplate && !source) {
31 | source = `${source}\n`
32 | }
33 | if (!hasScript) {
34 | source = `${source}\n`
35 | }
36 |
37 | return await renderSFCString(source, data, basePath)
38 | }
39 |
--------------------------------------------------------------------------------
/apps/playground/src/pages/index.vue:
--------------------------------------------------------------------------------
1 |
35 |
36 |
37 |
38 |
41 |
42 | Editor
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/packages/core/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentProp } from './render-shared'
2 | import type { InputProps } from './types'
3 |
4 | import { isNode } from 'std-env'
5 |
6 | import {
7 | renderMarkdownString as renderMarkdownStringBrowser,
8 | renderSFCString as renderSFCStringBrowser,
9 | } from './render-browser'
10 |
11 | export async function renderMarkdownString(
12 | source: string,
13 | data?: InputProps,
14 | basePath?: string,
15 | ): Promise<{
16 | props: ComponentProp[]
17 | rendered: string
18 | }> {
19 | if (isNode) {
20 | const { renderMarkdownString } = await import('./render-node')
21 | return renderMarkdownString(source, data, basePath)
22 | }
23 |
24 | return renderMarkdownStringBrowser(source, data, basePath)
25 | }
26 |
27 | export async function renderSFCString(
28 | source: string,
29 | data?: InputProps,
30 | basePath?: string,
31 | ): Promise<{
32 | props: ComponentProp[]
33 | rendered: string
34 | }> {
35 | if (isNode) {
36 | const { renderSFCString } = await import('./render-node')
37 | return renderSFCString(source, data, basePath)
38 | }
39 |
40 | return renderSFCStringBrowser(source, data, basePath)
41 | }
42 |
43 | export {
44 | onlyRender,
45 | onlySetup,
46 | renderComponent,
47 | resolveProps,
48 | } from './render-shared'
49 | export * from './types'
50 |
--------------------------------------------------------------------------------
/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import antfu from '@antfu/eslint-config'
2 |
3 | export default antfu({
4 | vue: true,
5 | pnpm: true,
6 | ignores: [
7 | '**/vue-global-types.d.ts',
8 | ],
9 | rules: {
10 | 'vue/prefer-separate-static-class': 'off',
11 | 'yaml/plain-scalar': 'off',
12 | 'import/order': 'off',
13 | 'antfu/import-dedupe': 'error',
14 | 'style/padding-line-between-statements': 'error',
15 | 'perfectionist/sort-imports': [
16 | 'error',
17 | {
18 | groups: [
19 | 'type-builtin',
20 | 'type-import',
21 | 'type-internal',
22 | ['type-parent', 'type-sibling', 'type-index'],
23 | 'default-value-builtin',
24 | 'named-value-builtin',
25 | 'value-builtin',
26 | 'default-value-external',
27 | 'named-value-external',
28 | 'value-external',
29 | 'default-value-internal',
30 | 'named-value-internal',
31 | 'value-internal',
32 | ['default-value-parent', 'default-value-sibling', 'default-value-index'],
33 | ['named-value-parent', 'named-value-sibling', 'named-value-index'],
34 | ['wildcard-value-parent', 'wildcard-value-sibling', 'wildcard-value-index'],
35 | ['value-parent', 'value-sibling', 'value-index'],
36 | 'side-effect',
37 | 'style',
38 | ],
39 | newlinesBetween: 'always',
40 | },
41 | ],
42 | },
43 | })
44 |
--------------------------------------------------------------------------------
/apps/playground/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/vite-browser/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/core/src/render-browser/index.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentProp } from '../render-shared/props'
2 | import type { InputProps } from '../types'
3 |
4 | import { fromHtml } from 'hast-util-from-html'
5 |
6 | import { renderMarkdownString } from './markdown'
7 | import { renderSFCString } from './sfc'
8 |
9 | export { renderComponent, resolveProps } from '../render-shared'
10 | export { renderMarkdownString } from './markdown'
11 | export { renderSFCString } from './sfc'
12 |
13 | export async function render(
14 | source: string,
15 | data?: InputProps,
16 | basePath?: string,
17 | ): Promise<{
18 | props: ComponentProp[]
19 | rendered: string
20 | }> {
21 | const hastRoot = fromHtml(source, { fragment: true })
22 |
23 | const hasTemplate = hastRoot.children.some(node => node.type === 'element' && node.tagName === 'template')
24 | const hasScript = hastRoot.children.some(node => node.type === 'element' && node.tagName === 'script')
25 |
26 | if (!hasTemplate && !hasScript && source) {
27 | return await renderMarkdownString(source, data, basePath)
28 | }
29 | if (hasScript && !hasTemplate && source) {
30 | return await renderMarkdownString(source, data, basePath)
31 | }
32 | if (!hasTemplate && !source) {
33 | source = `${source}\n`
34 | }
35 | if (!hasScript) {
36 | source = `${source}\n`
37 | }
38 |
39 | return await renderSFCString(source, data, basePath)
40 | }
41 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // Disable the default formatter, use eslint instead
3 | "prettier.enable": false,
4 | "editor.formatOnSave": false,
5 |
6 | // Auto fix
7 | "editor.codeActionsOnSave": {
8 | "source.fixAll.eslint": "explicit",
9 | "source.organizeImports": "never"
10 | },
11 |
12 | // Silent the stylistic rules in you IDE, but still auto fix them
13 | "eslint.rules.customizations": [
14 | { "rule": "style/*", "severity": "off", "fixable": true },
15 | { "rule": "format/*", "severity": "off", "fixable": true },
16 | { "rule": "*-indent", "severity": "off", "fixable": true },
17 | { "rule": "*-spacing", "severity": "off", "fixable": true },
18 | { "rule": "*-spaces", "severity": "off", "fixable": true },
19 | { "rule": "*-order", "severity": "off", "fixable": true },
20 | { "rule": "*-dangle", "severity": "off", "fixable": true },
21 | { "rule": "*-newline", "severity": "off", "fixable": true },
22 | { "rule": "*quotes", "severity": "off", "fixable": true },
23 | { "rule": "*semi", "severity": "off", "fixable": true }
24 | ],
25 |
26 | // Enable eslint for all supported languages
27 | "eslint.validate": [
28 | "javascript",
29 | "javascriptreact",
30 | "typescript",
31 | "typescriptreact",
32 | "vue",
33 | "html",
34 | "markdown",
35 | "json",
36 | "json5",
37 | "jsonc",
38 | "yaml",
39 | "toml",
40 | "xml",
41 | "gql",
42 | "graphql",
43 | "astro",
44 | "svelte",
45 | "css",
46 | "less",
47 | "scss",
48 | "pcss",
49 | "postcss"
50 | ]
51 | }
52 |
--------------------------------------------------------------------------------
/packages/vue/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@velin-dev/vue",
3 | "type": "module",
4 | "version": "0.3.4",
5 | "description": "Develop prompts with Vue SFC or Markdown like pro.",
6 | "author": {
7 | "name": "RainbowBird",
8 | "email": "rbxin2003@outlook.com",
9 | "url": "https://github.com/luoling8192"
10 | },
11 | "contributors": [
12 | {
13 | "name": "Neko Ayaka",
14 | "email": "neko@ayaka.moe",
15 | "url": "https://github.com/nekomeowww"
16 | }
17 | ],
18 | "license": "MIT",
19 | "homepage": "https://github.com/moeru-ai/velin",
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/moeru-ai/velin.git",
23 | "directory": "packages/vue"
24 | },
25 | "bugs": "https://github.com/moeru-ai/velin/issues",
26 | "exports": {
27 | ".": {
28 | "types": "./dist/index.d.mts",
29 | "import": "./dist/index.mjs"
30 | },
31 | "./repl": {
32 | "types": "./dist/repl/index.d.mts",
33 | "import": "./dist/repl/index.mjs"
34 | }
35 | },
36 | "main": "./dist/index.mjs",
37 | "module": "./dist/index.mjs",
38 | "types": "./dist/index.d.mts",
39 | "files": [
40 | "README.md",
41 | "dist",
42 | "package.json"
43 | ],
44 | "scripts": {
45 | "dev": "tsdown",
46 | "stub": "tsdown",
47 | "build": "tsdown",
48 | "typecheck": "tsc --noEmit",
49 | "attw": "attw --pack . --profile esm-only --ignore-rules cjs-resolves-to-esm"
50 | },
51 | "dependencies": {
52 | "@velin-dev/core": "workspace:^",
53 | "@velin-dev/utils": "workspace:^",
54 | "vue": "^3.5.25"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/apps/playground/src/components/Switch.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
27 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/apps/playground/src/types/vue-repl.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/vuejs/repl/blob/5e092b6111118f5bb5fc419f0f8f3f84cd539366/src/types.ts
2 |
3 | import type { editor } from 'monaco-editor-core'
4 | import type { Component, InjectionKey, ToRefs } from 'vue'
5 |
6 | import type { Store } from '../components/Editor/store'
7 |
8 | export type EditorMode = 'js' | 'css' | 'ssr'
9 | export interface EditorProps {
10 | value: string
11 | filename: string
12 | readonly?: boolean
13 | mode?: EditorMode
14 | }
15 | export interface EditorEmits {
16 | (e: 'change', code: string): void
17 | }
18 | export type EditorComponentType = Component
19 |
20 | export type OutputModes = 'preview' | EditorMode
21 |
22 | export const injectKeyProps: InjectionKey>> = Symbol('props')
56 |
--------------------------------------------------------------------------------
/packages/core/src/render-shared/compile.ts:
--------------------------------------------------------------------------------
1 | /// @vue/repl: https://github.com/vuejs/repl/blob/5e092b6111118f5bb5fc419f0f8f3f84cd539366/src/transform.ts
2 |
3 | import type { CompilerOptions, SFCScriptBlock, SFCTemplateCompileResults } from '@vue/compiler-sfc'
4 |
5 | import { testTs, transformTS } from '@velin-dev/utils/transformers/typescript'
6 | import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
7 |
8 | import { isUseInlineTemplate } from './template'
9 |
10 | export interface CompiledResult {
11 | template: SFCTemplateCompileResults
12 | script: SFCScriptBlock
13 | }
14 |
15 | export async function compileSFC(source: string): Promise {
16 | const { descriptor } = parse(source)
17 |
18 | if (!descriptor.template) {
19 | return await compileSFC(`${source}\n`)
20 | }
21 |
22 | const templateOptions = {
23 | source: descriptor.template.content,
24 | filename: 'temp.vue',
25 | id: `vue-component-${Date.now()}`,
26 | compilerOptions: { runtimeModuleName: 'vue' },
27 | }
28 |
29 | const templateResult = compileTemplate(templateOptions)
30 |
31 | const scriptLang = descriptor.script?.lang || descriptor.scriptSetup?.lang
32 | const isTS = testTs(scriptLang)
33 |
34 | const expressionPlugins: CompilerOptions['expressionPlugins'] = []
35 | if (isTS) {
36 | expressionPlugins.push('typescript')
37 | }
38 |
39 | const scriptResult = compileScript(descriptor, {
40 | id: `vue-component-${Date.now()}`,
41 | inlineTemplate: isUseInlineTemplate(descriptor),
42 | ...{
43 | ...templateOptions,
44 | expressionPlugins,
45 | },
46 | })
47 |
48 | if (isTS) {
49 | scriptResult.content = transformTS(scriptResult.content)
50 | }
51 |
52 | return {
53 | template: templateResult,
54 | script: scriptResult,
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/packages/core/src/render-shared/component.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentPropsOptions } from '@vue/runtime-core'
2 |
3 | import type {
4 | InputProps,
5 | LooseRequiredRenderComponentInputProps,
6 | RenderComponentInputComponent,
7 | ResolveRenderComponentInputProps,
8 | } from '../types'
9 |
10 | import { toMarkdown } from '@velin-dev/utils/to-md'
11 | import { toValue } from '@vue/reactivity'
12 | import { renderToString } from '@vue/server-renderer'
13 | import { createSSRApp } from 'vue'
14 |
15 | export function onlySetup<
16 | RawProps = any,
17 | ComponentProps = ComponentPropsOptions,
18 | ResolvedProps = ResolveRenderComponentInputProps,
19 | >(
20 | promptComponent: RenderComponentInputComponent,
21 | props: InputProps,
22 | ) {
23 | return promptComponent.setup?.(
24 | toValue(props) as unknown as LooseRequiredRenderComponentInputProps,
25 | { attrs: {}, slots: {}, emit: () => { }, expose: () => { } },
26 | )
27 | }
28 |
29 | export function onlyRender<
30 | RawProps = any,
31 | ComponentProps = ComponentPropsOptions,
32 | ResolvedProps = ResolveRenderComponentInputProps,
33 | >(
34 | promptComponent: RenderComponentInputComponent,
35 | props: InputProps,
36 | ) {
37 | return createSSRApp(promptComponent, toValue(props) as Record)
38 | }
39 |
40 | export function renderComponent<
41 | RawProps = any,
42 | ComponentProps = ComponentPropsOptions,
43 | ResolvedProps = ResolveRenderComponentInputProps,
44 | >(
45 | promptComponent: RenderComponentInputComponent,
46 | props: InputProps,
47 | ) {
48 | return new Promise((resolve, reject) => {
49 | renderToString(onlyRender(promptComponent, props))
50 | .then(toMarkdown)
51 | .then(resolve)
52 | .catch(reject)
53 | })
54 | }
55 |
--------------------------------------------------------------------------------
/apps/playground/src/App.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 | Velin Playground
14 |
15 |
21 |
28 |
29 |
30 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
69 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 | branches:
10 | - main
11 |
12 | jobs:
13 | lint:
14 | name: Lint
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 | - uses: pnpm/action-setup@v3
19 | - uses: actions/setup-node@v4
20 | with:
21 | node-version: lts/*
22 | cache: pnpm
23 |
24 | - name: Install
25 | run: pnpm install
26 |
27 | - name: Lint
28 | run: pnpm run lint
29 |
30 | build-test:
31 | name: Build Test
32 | runs-on: ubuntu-latest
33 | steps:
34 | - uses: actions/checkout@v4
35 | - uses: pnpm/action-setup@v3
36 | - uses: actions/setup-node@v4
37 | with:
38 | node-version: lts/*
39 | cache: pnpm
40 |
41 | - name: Install
42 | run: pnpm install
43 |
44 | - name: Build
45 | run: |
46 | pnpm run build
47 | pnpm run attw:packages
48 |
49 | unit-test:
50 | name: Unit Test
51 | runs-on: ubuntu-latest
52 | steps:
53 | - uses: actions/checkout@v4
54 | - uses: pnpm/action-setup@v3
55 | - uses: actions/setup-node@v4
56 | with:
57 | node-version: lts/*
58 | cache: pnpm
59 |
60 | - name: Install
61 | run: pnpm install
62 |
63 | - name: Build
64 | run: |
65 | pnpm run build
66 |
67 | - name: Unit Test
68 | run: pnpm run test:run
69 |
70 | typecheck:
71 | name: Type Check
72 | runs-on: ubuntu-latest
73 | steps:
74 | - uses: actions/checkout@v4
75 | - uses: pnpm/action-setup@v3
76 | - uses: actions/setup-node@v4
77 | with:
78 | node-version: lts/*
79 | cache: pnpm
80 |
81 | - name: Install
82 | run: pnpm install
83 |
84 | - name: Build
85 | run: pnpm run build:packages
86 |
87 | - name: Typecheck
88 | run: pnpm run typecheck
89 |
--------------------------------------------------------------------------------
/packages/core/src/render-browser/sfc.browser.test.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from 'node:fs/promises'
2 | import { dirname, join } from 'node:path'
3 | import { fileURLToPath } from 'node:url'
4 |
5 | import { describe, expect, it } from 'vitest'
6 |
7 | import { evaluateSFC, renderSFCString, resolvePropsFromString } from './sfc'
8 |
9 | // TODO: Browser testing
10 | describe.todo('renderSFCString', async () => {
11 | it('should be able to render simple SFC', async () => {
12 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'simple.velin.vue'), 'utf-8')
13 | const result = await renderSFCString(content)
14 | expect(result).toBeDefined()
15 | expect(result).not.toBe('')
16 | expect(result).toBe('# Hello, world!\n')
17 | })
18 |
19 | it('should be able to render script setup SFC', async () => {
20 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup.velin.vue'), 'utf-8')
21 | const result = await renderSFCString(content)
22 | expect(result).toBeDefined()
23 | expect(result).not.toBe('')
24 | expect(result).toBe('# Count: 0\n')
25 | })
26 | })
27 |
28 | // TODO: Browser testing
29 | describe.todo('evaluateSFC', async () => {
30 | it('should be able to evaluate script setup SFC', async () => {
31 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup.velin.vue'), 'utf-8')
32 | const component = await evaluateSFC(content)
33 | expect(component).toBeDefined()
34 | expect(component.setup).toBeDefined()
35 | expect(typeof component.setup).toBe('function')
36 | })
37 | })
38 |
39 | describe.todo('resolvePropsFromString', async () => {
40 | it('should be able to render script setup SFC', async () => {
41 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup-with-props.velin.vue'), 'utf-8')
42 | const props = await resolvePropsFromString(content)
43 | expect(props).toEqual([
44 | { key: 'date', type: 'string', title: 'date' },
45 | ])
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/packages/utils/src/transformers/typescript/transform.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 |
3 | import { transformTS } from './transform'
4 |
5 | describe('transformTS', async () => {
6 | it('should transform TS', () => {
7 | const result = transformTS(`import { defineComponent as _defineComponent } from 'vue'\nimport { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, unref as _unref, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"\n\nimport { useCounter } from '@vueuse/core'\n\n\nexport default /*@__PURE__*/_defineComponent({\n props: {\n text: String,\n number: Number,\n check: Boolean,\n},\n setup(__props) {\n\n\n\nconst { count } = useCounter()\n\nreturn (_ctx: any,_cache: any) => {\n return (_openBlock(), _createElementBlock("div", null, [\n _createElementVNode("div", null, _toDisplayString(__props.text), 1 /* TEXT */),\n _createElementVNode("div", null, _toDisplayString(__props.number), 1 /* TEXT */),\n _createElementVNode("div", null, _toDisplayString(__props.check), 1 /* TEXT */),\n _createElementVNode("div", null, "Internal count: " + _toDisplayString(_unref(count)), 1 /* TEXT */)\n ]))\n}\n}\n\n})`)
8 | expect(result).toBeDefined()
9 | expect(result).toEqual(`import { defineComponent as _defineComponent } from 'vue'\nimport { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, unref as _unref, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"\n\nimport { useCounter } from '@vueuse/core'\n\n\nexport default /*@__PURE__*/_defineComponent({\n props: {\n text: String,\n number: Number,\n check: Boolean,\n},\n setup(__props) {\n\n\n\nconst { count } = useCounter()\n\nreturn (_ctx,_cache) => {\n return (_openBlock(), _createElementBlock("div", null, [\n _createElementVNode("div", null, _toDisplayString(__props.text), 1 /* TEXT */),\n _createElementVNode("div", null, _toDisplayString(__props.number), 1 /* TEXT */),\n _createElementVNode("div", null, _toDisplayString(__props.check), 1 /* TEXT */),\n _createElementVNode("div", null, "Internal count: " + _toDisplayString(_unref(count)), 1 /* TEXT */)\n ]))\n}\n}\n\n})`)
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/packages/core/src/render-browser/sfc.ts:
--------------------------------------------------------------------------------
1 | import type { DefineComponent } from '@vue/runtime-core'
2 |
3 | import type { ComponentProp } from '../render-shared'
4 | import type { InputProps } from '../types'
5 |
6 | import ErrorStackParser from 'error-stack-parser'
7 | import path from 'path-browserify-esm'
8 |
9 | import { evaluate } from '@unrteljs/eval/browser'
10 | import { toMarkdown } from '@velin-dev/utils/to-md'
11 | import { renderToString } from '@vue/server-renderer'
12 |
13 | import { compileSFC, onlyRender, resolveProps } from '../render-shared'
14 | import { normalizeSFCSource } from '../render-shared/sfc'
15 |
16 | export async function evaluateSFC(
17 | source: string,
18 | basePath?: string,
19 | ) {
20 | const { script } = await compileSFC(source)
21 |
22 | if (!basePath) {
23 | // eslint-disable-next-line unicorn/error-message
24 | const stack = ErrorStackParser.parse(new Error())
25 | basePath = path.dirname(stack[1].fileName?.replace('async', '').trim() || '')
26 | }
27 |
28 | // TODO: evaluate setup when not
50 |
51 |
52 |
53 |
58 |
updateItem(index, (e.target as HTMLInputElement).value)"
70 | >
71 |
79 |
80 |
91 |
92 | Empty array. Click "Add Item" to start.
93 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .Trash/
2 | .trash/
3 | .DS_Store
4 | **/.obsidian/
5 | .postgres
6 |
7 | # Node.js
8 | .idea
9 | .nuxt
10 | .temp
11 | .vite-inspect
12 | components.d.ts
13 | **/typed-router.d.ts
14 | node_modules
15 | .eslintcache
16 | **/tsconfig.tsbuildinfo
17 | **/vue-global-types.d.ts
18 |
19 | dist
20 | out/
21 | *.local
22 | *.local.*
23 | *.log
24 | **/.cache/**
25 | **/temp/
26 | .cache
27 |
28 | .coverage
29 | .coverage.*
30 | coverage/
31 | cover/
32 | htmlcov/
33 |
34 | *.pcm
35 | *.wav
36 | *.ogg
37 | *.mp3
38 |
39 | # Make it easy for devenv users to override their local environment.
40 | # See: https://github.com/moeru-ai/airi/pull/110#discussion_r2024378953
41 | .direnv
42 | .pre-commit-config.yaml
43 | .envrc
44 | .devenv*
45 | devenv.*
46 |
47 | # pixi environments
48 | .pixi
49 | *.egg-info
50 |
51 | # Byte-compiled / optimized / DLL files
52 | __pycache__/
53 | *.py[cod]
54 | *$py.class
55 |
56 | # C extensions
57 | *.so
58 |
59 | # Distribution / packaging
60 | .Python
61 | build/
62 | develop-eggs/
63 | dist/
64 | downloads/
65 | eggs/
66 | .eggs/
67 | lib/
68 | lib64/
69 | parts/
70 | sdist/
71 | var/
72 | wheels/
73 | share/python-wheels/
74 | *.egg-info/
75 | .installed.cfg
76 | *.egg
77 | MANIFEST
78 |
79 | # PyInstaller
80 | # Usually these files are written by a python script from a template
81 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
82 | *.manifest
83 | *.spec
84 |
85 | # Installer logs
86 | pip-log.txt
87 | pip-delete-this-directory.txt
88 |
89 | # Unit test / coverage reports
90 | .tox/
91 | .nox/
92 | nosetests.xml
93 | coverage.xml
94 | *.cover
95 | *.py,cover
96 | .pytest_cache/
97 | .hypothesis/
98 |
99 | # Translations
100 | *.mo
101 | *.pot
102 |
103 | # Django stuff:
104 | *.log
105 | local_settings.py
106 | db.sqlite3
107 | db.sqlite3-journal
108 |
109 | # Flask stuff:
110 | instance/
111 | .webassets-cache
112 |
113 | # Scrapy stuff:
114 | .scrapy
115 |
116 | # Sphinx documentation
117 | docs/_build/
118 |
119 | # PyBuilder
120 | .pybuilder/
121 | target/
122 |
123 | # Jupyter Notebook
124 | .ipynb_checkpoints
125 |
126 | # IPython
127 | profile_default/
128 | ipython_config.py
129 |
130 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
131 | __pypackages__/
132 |
133 | # Celery stuff
134 | celerybeat-schedule
135 | celerybeat.pid
136 |
137 | # SageMath parsed files
138 | *.sage.py
139 |
140 | # Environments
141 | .venv
142 | env/
143 | venv/
144 | ENV/
145 | env.bak/
146 | venv.bak/
147 |
148 | # Spyder project settings
149 | .spyderproject
150 | .spyproject
151 |
152 | # Rope project settings
153 | .ropeproject
154 |
155 | # mkdocs documentation
156 | /site
157 |
158 | # mypy
159 | .mypy_cache/
160 | .dmypy.json
161 | dmypy.json
162 |
163 | # Pyre type checker
164 | .pyre/
165 |
166 | # pytype static type analyzer
167 | .pytype/
168 |
169 | # Cython debug symbols
170 | cython_debug/
171 |
172 | # PyCharm
173 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
174 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
175 | # and can be added to the global gitignore or merged into this file. For a more nuclear
176 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
177 | #.idea/
178 |
179 | # Ruff stuff:
180 | .ruff_cache/
181 |
182 | # PyPI configuration file
183 | .pypirc
184 |
--------------------------------------------------------------------------------
/apps/playground/src/components/Editor/sourcemap.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/vuejs/repl/blob/69c2ed1dca84132708c3b9a1d0a008e11be2be74/src/sourcemap.ts
2 |
3 | import type { EncodedSourceMap as GenEncodedSourceMap } from '@jridgewell/gen-mapping'
4 | import type { EncodedSourceMap as TraceEncodedSourceMap } from '@jridgewell/trace-mapping'
5 | import type { RawSourceMap } from 'source-map-js'
6 |
7 | import { addMapping, fromMap, toEncodedMap } from '@jridgewell/gen-mapping'
8 | import { eachMapping, TraceMap } from '@jridgewell/trace-mapping'
9 |
10 | // trim analyzed bindings comment
11 | export function trimAnalyzedBindings(scriptCode: string) {
12 | return scriptCode.replace(/\/\*[\s\S]*?\*\/\n/, '').trim()
13 | }
14 | /**
15 | * The merge logic of sourcemap is consistent with the logic in vite-plugin-vue
16 | */
17 | export function getSourceMap(
18 | filename: string,
19 | scriptCode: string,
20 | scriptMap: any,
21 | templateMap: any,
22 | ): RawSourceMap {
23 | let resolvedMap: RawSourceMap | undefined
24 | if (templateMap) {
25 | // if the template is inlined into the main module (indicated by the presence
26 | // of templateMap), we need to concatenate the two source maps.
27 | const from = scriptMap ?? {
28 | file: filename,
29 | sourceRoot: '',
30 | version: 3,
31 | sources: [],
32 | sourcesContent: [],
33 | names: [],
34 | mappings: '',
35 | }
36 | const gen = fromMap(
37 | // version property of result.map is declared as string
38 | // but actually it is `3`
39 | from as Omit as TraceEncodedSourceMap,
40 | )
41 | const tracer = new TraceMap(
42 | // same above
43 | templateMap as Omit as TraceEncodedSourceMap,
44 | )
45 | const offset
46 | = (trimAnalyzedBindings(scriptCode).match(/\r?\n/g)?.length ?? 0)
47 | eachMapping(tracer, (m) => {
48 | if (m.source == null)
49 | return
50 | addMapping(gen, {
51 | source: m.source,
52 | original: { line: m.originalLine, column: m.originalColumn },
53 | generated: {
54 | line: m.generatedLine + offset,
55 | column: m.generatedColumn,
56 | },
57 | })
58 | })
59 |
60 | // same above
61 | resolvedMap = toEncodedMap(gen) as Omit<
62 | GenEncodedSourceMap,
63 | 'version'
64 | > as RawSourceMap
65 | // if this is a template only update, we will be reusing a cached version
66 | // of the main module compile result, which has outdated sourcesContent.
67 | resolvedMap.sourcesContent = templateMap.sourcesContent
68 | }
69 | else {
70 | resolvedMap = scriptMap
71 | }
72 |
73 | return resolvedMap!
74 | }
75 |
76 | /*
77 | * Slightly modified version of https://github.com/AriPerkkio/vite-plugin-source-map-visualizer/blob/main/src/generate-link.ts
78 | */
79 | export function toVisualizer(code: string, sourceMap: RawSourceMap) {
80 | const map = JSON.stringify(sourceMap)
81 | const encoder = new TextEncoder()
82 |
83 | // Convert the strings to Uint8Array
84 | const codeArray = encoder.encode(code)
85 | const mapArray = encoder.encode(map)
86 |
87 | // Create Uint8Array for the lengths
88 | const codeLengthArray = encoder.encode(codeArray.length.toString())
89 | const mapLengthArray = encoder.encode(mapArray.length.toString())
90 |
91 | // Combine the lengths and the data
92 | const combinedArray = new Uint8Array(
93 | codeLengthArray.length
94 | + 1
95 | + codeArray.length
96 | + mapLengthArray.length
97 | + 1
98 | + mapArray.length,
99 | )
100 |
101 | combinedArray.set(codeLengthArray)
102 | combinedArray.set([0], codeLengthArray.length)
103 | combinedArray.set(codeArray, codeLengthArray.length + 1)
104 | combinedArray.set(
105 | mapLengthArray,
106 | codeLengthArray.length + 1 + codeArray.length,
107 | )
108 | combinedArray.set(
109 | [0],
110 | codeLengthArray.length + 1 + codeArray.length + mapLengthArray.length,
111 | )
112 | combinedArray.set(
113 | mapArray,
114 | codeLengthArray.length + 1 + codeArray.length + mapLengthArray.length + 1,
115 | )
116 |
117 | // Convert the Uint8Array to a binary string
118 | let binary = ''
119 | const len = combinedArray.byteLength
120 | for (let i = 0; i < len; i++) binary += String.fromCharCode(combinedArray[i])
121 |
122 | // Convert the binary string to a base64 string and return it
123 | return `https://evanw.github.io/source-map-visualization#${btoa(binary)}`
124 | }
125 |
--------------------------------------------------------------------------------
/packages/core/src/render-shared/props.ts:
--------------------------------------------------------------------------------
1 | import type { App, ComponentInternalInstance, DefineComponent } from 'vue'
2 |
3 | export interface ComponentPropText {
4 | type: 'string'
5 | value?: string
6 | }
7 |
8 | export interface ComponentPropBool {
9 | type: 'boolean'
10 | value?: boolean
11 | }
12 |
13 | export interface ComponentPropNumber {
14 | type: 'number'
15 | value?: number
16 | }
17 |
18 | export interface ComponentPropUnknown {
19 | type: 'unknown'
20 | value?: unknown
21 | }
22 |
23 | export interface ComponentPropArray {
24 | type: 'array'
25 | value?: unknown[]
26 | }
27 |
28 | export type ComponentProp = (ComponentPropText | ComponentPropBool | ComponentPropNumber | ComponentPropArray | ComponentPropUnknown) & {
29 | title: string
30 | key: string
31 | }
32 |
33 | function willTurnIntoNumber(value: unknown): boolean {
34 | if (value === Number) {
35 | return true
36 | }
37 |
38 | // it is possible value is { type: Number() }
39 | if (typeof value === 'object' && value !== null && 'type' in value) {
40 | // check if value.type is Number()
41 | if (typeof (value as { type: unknown }).type === 'function' && (value as { type: unknown }).type === Number) {
42 | return true
43 | }
44 | }
45 |
46 | return false
47 | }
48 |
49 | function willTurnIntoBoolean(value: unknown): boolean {
50 | if (value === Boolean) {
51 | return true
52 | }
53 |
54 | // it is possible value is { type: Boolean() }
55 | if (typeof value === 'object' && value !== null && 'type' in value) {
56 | // check if value.type is Boolean()
57 | if (typeof (value as { type: unknown }).type === 'function' && (value as { type: unknown }).type === Boolean) {
58 | return true
59 | }
60 | }
61 |
62 | return false
63 | }
64 |
65 | function willTurnIntoString(value: unknown): boolean {
66 | if (value === String) {
67 | return true
68 | }
69 |
70 | // it is possible value is { type: String() }
71 | if (typeof value === 'object' && value !== null && 'type' in value) {
72 | // check if value.type is String()
73 | if (typeof (value as { type: unknown }).type === 'function' && (value as { type: unknown }).type === String) {
74 | return true
75 | }
76 | }
77 |
78 | return false
79 | }
80 |
81 | function willTurnIntoArray(value: unknown): boolean {
82 | if (value === Array) {
83 | return true
84 | }
85 |
86 | // it is possible value is { type: Array() }
87 | if (typeof value === 'object' && value !== null && 'type' in value) {
88 | // check if value.type is Array()
89 | if (typeof (value as { type: unknown }).type === 'function' && (value as { type: unknown }).type === Array) {
90 | return true
91 | }
92 | }
93 |
94 | return false
95 | }
96 |
97 | function inferType(
98 | propDef:
99 | | {
100 | // eslint-disable-next-line ts/no-unsafe-function-type
101 | type: Function
102 | }
103 | | typeof String
104 | | typeof Number
105 | | typeof Boolean
106 | | typeof Array
107 | | unknown,
108 | ) {
109 | let type: 'unknown' | 'string' | 'number' | 'boolean' | 'array' = 'unknown'
110 | if (willTurnIntoString(propDef)) {
111 | type = 'string'
112 | }
113 | else if (willTurnIntoNumber(propDef)) {
114 | type = 'number'
115 | }
116 | else if (willTurnIntoBoolean(propDef)) {
117 | type = 'boolean'
118 | }
119 | else if (willTurnIntoArray(propDef)) {
120 | type = 'array'
121 | }
122 | return type
123 | }
124 |
125 | /**
126 | * @see https://github.com/vuejs/devtools/blob/e7dffa24fe98b212404a1451818b6c66739f88ee/packages/devtools-kit/src/core/component/state/process.ts#L62
127 | * @see https://github.com/vuejs/devtools/blob/e7dffa24fe98b212404a1451818b6c66739f88ee/packages/devtools-kit/src/core/app/index.ts#L14
128 | *
129 | * @param component
130 | */
131 | export function resolveProps(component: DefineComponent | App): ComponentProp[] {
132 | if (component._component && component._component.props && typeof component._component.props === 'object') {
133 | return Object.entries(component._component.props).map(([key, propDef]) => {
134 | return {
135 | key,
136 | title: key,
137 | type: inferType(propDef),
138 | }
139 | })
140 | }
141 | else if ((component as unknown as ComponentInternalInstance).props && typeof (component as unknown as ComponentInternalInstance).props === 'object') {
142 | return Object.entries((component as unknown as ComponentInternalInstance).props).map(([key, propDef]) => {
143 | return {
144 | key,
145 | title: key,
146 | type: inferType(propDef),
147 | }
148 | })
149 | }
150 | else {
151 | return []
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Velin
4 |
5 | [![npm version][npm-version-src]][npm-version-href]
6 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
7 | [![bundle][bundle-src]][bundle-href]
8 | [![JSDocs][jsdocs-src]][jsdocs-href]
9 | [![License][license-src]][license-href]
10 |
11 | > Have you wondered how it feels if you can develop the prompts of agents and MCP servers with the power of Vue?
12 |
13 | Develop prompts with Vue SFC or Markdown like pro.
14 |
15 | We got a playground too, check it out:
16 |
17 |
18 |
19 |
23 |
27 |
28 |
29 |
30 |
31 | ### Quick Start
32 |
33 | Try it by running following command under your `pnpm`/`npm` project.
34 |
35 | ```bash
36 | # For browser users
37 | npm i @velin-dev/vue
38 |
39 | # For Node.js, CI, server rendering and backend users
40 | npm i @velin-dev/core
41 | ```
42 |
43 | ## Features
44 |
45 | - No longer need to fight and format with the non-supported DSL of templating language!
46 | - Use HTML elements like `` for block elements, `
` for inline elements.
47 | - Directives with native Vue template syntax, `v-if`, `v-else` all works.
48 | - Compositing other open sourced prompt component or composables over memory system.
49 |
50 | All included...
51 |
52 | ## How it feels
53 |
54 | ```html
55 |
56 |
61 |
62 |
63 |
64 | Hello world, this is {{ name }}!
65 |
66 |
67 | ```
68 |
69 | ### In Node.js
70 |
71 | ```ts
72 | import { readFile } from 'node:fs/promises'
73 |
74 | import { renderSFCString } from '@velin-dev/core'
75 | import { ref } from 'vue'
76 |
77 | const source = await readFile('./Prompt.vue', 'utf-8')
78 | const name = ref('Velin')
79 | const result = await renderSFCString(source, { name })
80 |
81 | console.log(result)
82 | // Hello world, this is Velin!
83 | ```
84 |
85 | ### In Vue / Browser
86 |
87 | ```vue
88 |
102 | ```
103 |
104 | ## Similar projects
105 |
106 | - [poml](https://github.com/microsoft/poml) / [pomljs](https://github.com/microsoft/poml)
107 |
108 | ## Development
109 |
110 | ### Clone
111 |
112 | ```shell
113 | git clone https://github.com/moeru-ai/velin.git
114 | cd airi
115 | ```
116 |
117 | ### Install dependencies
118 |
119 | ```shell
120 | corepack enable
121 | pnpm install
122 | ```
123 |
124 | > [!NOTE]
125 | >
126 | > We would recommend to install [@antfu/ni](https://github.com/antfu-collective/ni) to make your script simpler.
127 | >
128 | > ```shell
129 | > corepack enable
130 | > npm i -g @antfu/ni
131 | > ```
132 | >
133 | > Once installed, you can
134 | >
135 | > - use `ni` for `pnpm install`, `npm install` and `yarn install`.
136 | > - use `nr` for `pnpm run`, `npm run` and `yarn run`.
137 | >
138 | > You don't need to care about the package manager, `ni` will help you choose the right one.
139 |
140 | ```shell
141 | pnpm dev
142 | ```
143 |
144 | > [!NOTE]
145 | >
146 | > For [@antfu/ni](https://github.com/antfu-collective/ni) users, you can
147 | >
148 | > ```shell
149 | > nr dev
150 | > ```
151 |
152 | ### Build
153 |
154 | ```shell
155 | pnpm build
156 | ```
157 |
158 | > [!NOTE]
159 | >
160 | > For [@antfu/ni](https://github.com/antfu-collective/ni) users, you can
161 | >
162 | > ```shell
163 | > nr build
164 | > ```
165 |
166 | ## License
167 |
168 | MIT
169 |
170 | [npm-version-src]: https://img.shields.io/npm/v/@velin-dev/core?style=flat&colorA=080f12&colorB=1fa669
171 | [npm-version-href]: https://npmjs.com/package/@velin-dev/core
172 | [npm-downloads-src]: https://img.shields.io/npm/dm/@velin-dev/core?style=flat&colorA=080f12&colorB=1fa669
173 | [npm-downloads-href]: https://npmjs.com/package/@velin-dev/core
174 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/@velin-dev/vue?style=flat&colorA=080f12&colorB=1fa669&label=minzip
175 | [bundle-href]: https://bundlephobia.com/result?p=@velin-dev/vue
176 | [license-src]: https://img.shields.io/github/license/moeru-ai/velin.svg?style=flat&colorA=080f12&colorB=1fa669
177 | [license-href]: https://github.com/moeru-ai/velin/blob/main/LICENSE
178 | [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
179 | [jsdocs-href]: https://www.jsdocs.io/package/@velin-dev/core
180 |
--------------------------------------------------------------------------------
/packages/core/src/render-node/sfc.test.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from 'node:fs/promises'
2 | import { dirname, join } from 'node:path'
3 | import { fileURLToPath } from 'node:url'
4 |
5 | import { describe, expect, it } from 'vitest'
6 |
7 | import { evaluateSFC, renderSFCString, resolvePropsFromString } from './sfc'
8 |
9 | describe('renderSFCString', async () => {
10 | it('should be able to render simple SFC', async () => {
11 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'simple.velin.vue'), 'utf-8')
12 | const { props, rendered } = await renderSFCString(content)
13 | expect(props).toBeDefined()
14 | expect(props.length).toBe(0)
15 | expect(rendered).toBeDefined()
16 | expect(rendered).not.toBe('')
17 | expect(rendered).toBe('# Hello, world!\n')
18 | })
19 |
20 | it('should be able to render script setup SFC with', async () => {
21 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup.velin.vue'), 'utf-8')
22 | const { props, rendered } = await renderSFCString(content)
23 | expect(props).toBeDefined()
24 | expect(props.length).toBe(0)
25 | expect(rendered).toBeDefined()
26 | expect(rendered).not.toBe('')
27 | expect(rendered).toBe('# Count: 0\n')
28 | })
29 |
30 | it('should be able to render script setup SFC lang="ts"', async () => {
31 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup.ts.velin.vue'), 'utf-8')
32 | const { props, rendered } = await renderSFCString(content)
33 | expect(props).toBeDefined()
34 | expect(props.length).toBe(0)
35 | expect(rendered).toBeDefined()
36 | expect(rendered).not.toBe('')
37 | expect(rendered).toBe('# Count: 0\n')
38 | })
39 |
40 | it('should be able to render script setup SFC with props', async () => {
41 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup-with-props.velin.vue'), 'utf-8')
42 | const { props, rendered } = await renderSFCString(content, { date: '2025-07-01' })
43 | expect(props).toBeDefined()
44 | expect(props.length).toBe(1)
45 | expect(rendered).toBeDefined()
46 | expect(rendered).not.toBe('')
47 | expect(rendered).toBe('# Count: 0\n\n2025-07-01\n')
48 | })
49 |
50 | it('should be able to render script setup SFC with props with lang="ts"', async () => {
51 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup-with-props.ts.velin.vue'), 'utf-8')
52 | const { props, rendered } = await renderSFCString(content, { date: '2025-07-01' })
53 | expect(props).toBeDefined()
54 | expect(props.length).toBe(1)
55 | expect(rendered).toBeDefined()
56 | expect(rendered).not.toBe('')
57 | expect(rendered).toBe('# Count: 0\n\n2025-07-01\n')
58 | })
59 | })
60 |
61 | describe('evaluateSFC', async () => {
62 | it('should be able to evaluate script setup SFC', async () => {
63 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup.velin.vue'), 'utf-8')
64 | const component = await evaluateSFC(content)
65 | expect(component).toBeDefined()
66 | expect(component.setup).toBeDefined()
67 | expect(typeof component.setup).toBe('function')
68 | })
69 |
70 | it('should be able to evaluate script setup SFC with lang="ts"', async () => {
71 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup.ts.velin.vue'), 'utf-8')
72 | const component = await evaluateSFC(content)
73 | expect(component).toBeDefined()
74 | expect(component.setup).toBeDefined()
75 | expect(typeof component.setup).toBe('function')
76 | })
77 |
78 | it('should be able to evaluate script setup SFC with props', async () => {
79 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup-with-props.velin.vue'), 'utf-8')
80 | const component = await evaluateSFC(content)
81 | expect(component).toBeDefined()
82 | expect(component.setup).toBeDefined()
83 | expect(typeof component.setup).toBe('function')
84 | })
85 |
86 | it('should be able to evaluate script setup SFC with props with lang="ts"', async () => {
87 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup-with-props.ts.velin.vue'), 'utf-8')
88 | const component = await evaluateSFC(content)
89 | expect(component).toBeDefined()
90 | expect(component.setup).toBeDefined()
91 | expect(typeof component.setup).toBe('function')
92 | })
93 | })
94 |
95 | describe('resolvePropsFromString', async () => {
96 | it('should be able to render script setup SFC', async () => {
97 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup-with-props.velin.vue'), 'utf-8')
98 | const props = await resolvePropsFromString(content)
99 | expect(props).toEqual([
100 | { key: 'date', type: 'string', title: 'date' },
101 | ])
102 | })
103 |
104 | it('should be able to render script setup SFC with lang="ts"', async () => {
105 | const content = await readFile(join(dirname(fileURLToPath(import.meta.url)), 'testdata', 'script-setup-with-props.ts.velin.vue'), 'utf-8')
106 | const props = await resolvePropsFromString(content)
107 | expect(props).toEqual([
108 | { key: 'date', type: 'string', title: 'date' },
109 | ])
110 | })
111 | })
112 |
--------------------------------------------------------------------------------
/apps/playground/src/components/Editor/index.vue:
--------------------------------------------------------------------------------
1 |
182 |
183 |
184 |
190 |
191 |
192 |
208 |
--------------------------------------------------------------------------------
/packages/core/src/render-shared/props.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 |
3 | import { resolveProps } from './props'
4 |
5 | describe('resolveProps', () => {
6 | describe('string type', () => {
7 | it('should resolve String constructor as string type', () => {
8 | const component = {
9 | _component: {
10 | props: {
11 | name: String,
12 | },
13 | },
14 | }
15 | const props = resolveProps(component as any)
16 | expect(props).toEqual([
17 | { key: 'name', title: 'name', type: 'string' },
18 | ])
19 | })
20 |
21 | it('should resolve { type: String } as string type', () => {
22 | const component = {
23 | _component: {
24 | props: {
25 | name: { type: String },
26 | },
27 | },
28 | }
29 | const props = resolveProps(component as any)
30 | expect(props).toEqual([
31 | { key: 'name', title: 'name', type: 'string' },
32 | ])
33 | })
34 | })
35 |
36 | describe('number type', () => {
37 | it('should resolve Number constructor as number type', () => {
38 | const component = {
39 | _component: {
40 | props: {
41 | age: Number,
42 | },
43 | },
44 | }
45 | const props = resolveProps(component as any)
46 | expect(props).toEqual([
47 | { key: 'age', title: 'age', type: 'number' },
48 | ])
49 | })
50 |
51 | it('should resolve { type: Number } as number type', () => {
52 | const component = {
53 | _component: {
54 | props: {
55 | age: { type: Number },
56 | },
57 | },
58 | }
59 | const props = resolveProps(component as any)
60 | expect(props).toEqual([
61 | { key: 'age', title: 'age', type: 'number' },
62 | ])
63 | })
64 | })
65 |
66 | describe('boolean type', () => {
67 | it('should resolve Boolean constructor as boolean type', () => {
68 | const component = {
69 | _component: {
70 | props: {
71 | active: Boolean,
72 | },
73 | },
74 | }
75 | const props = resolveProps(component as any)
76 | expect(props).toEqual([
77 | { key: 'active', title: 'active', type: 'boolean' },
78 | ])
79 | })
80 |
81 | it('should resolve { type: Boolean } as boolean type', () => {
82 | const component = {
83 | _component: {
84 | props: {
85 | active: { type: Boolean },
86 | },
87 | },
88 | }
89 | const props = resolveProps(component as any)
90 | expect(props).toEqual([
91 | { key: 'active', title: 'active', type: 'boolean' },
92 | ])
93 | })
94 | })
95 |
96 | describe('array type', () => {
97 | it('should resolve Array constructor as array type', () => {
98 | const component = {
99 | _component: {
100 | props: {
101 | items: Array,
102 | },
103 | },
104 | }
105 | const props = resolveProps(component as any)
106 | expect(props).toEqual([
107 | { key: 'items', title: 'items', type: 'array' },
108 | ])
109 | })
110 |
111 | it('should resolve { type: Array } as array type', () => {
112 | const component = {
113 | _component: {
114 | props: {
115 | items: { type: Array },
116 | },
117 | },
118 | }
119 | const props = resolveProps(component as any)
120 | expect(props).toEqual([
121 | { key: 'items', title: 'items', type: 'array' },
122 | ])
123 | })
124 |
125 | it('should resolve { type: Array, default: () => [] } as array type', () => {
126 | const component = {
127 | _component: {
128 | props: {
129 | items: { type: Array, default: () => [] },
130 | },
131 | },
132 | }
133 | const props = resolveProps(component as any)
134 | expect(props).toEqual([
135 | { key: 'items', title: 'items', type: 'array' },
136 | ])
137 | })
138 | })
139 |
140 | describe('mixed types', () => {
141 | it('should resolve multiple props with different types', () => {
142 | const component = {
143 | _component: {
144 | props: {
145 | name: String,
146 | age: Number,
147 | active: Boolean,
148 | tags: Array,
149 | },
150 | },
151 | }
152 | const props = resolveProps(component as any)
153 | expect(props).toEqual([
154 | { key: 'name', title: 'name', type: 'string' },
155 | { key: 'age', title: 'age', type: 'number' },
156 | { key: 'active', title: 'active', type: 'boolean' },
157 | { key: 'tags', title: 'tags', type: 'array' },
158 | ])
159 | })
160 |
161 | it('should resolve props from ComponentInternalInstance', () => {
162 | const component = {
163 | props: {
164 | name: String,
165 | items: Array,
166 | },
167 | }
168 | const props = resolveProps(component as any)
169 | expect(props).toEqual([
170 | { key: 'name', title: 'name', type: 'string' },
171 | { key: 'items', title: 'items', type: 'array' },
172 | ])
173 | })
174 | })
175 |
176 | describe('unknown type', () => {
177 | it('should resolve unknown constructor as unknown type', () => {
178 | const component = {
179 | _component: {
180 | props: {
181 | custom: Object,
182 | },
183 | },
184 | }
185 | const props = resolveProps(component as any)
186 | expect(props).toEqual([
187 | { key: 'custom', title: 'custom', type: 'unknown' },
188 | ])
189 | })
190 | })
191 |
192 | describe('empty props', () => {
193 | it('should return empty array when no props defined', () => {
194 | const component = {}
195 | const props = resolveProps(component as any)
196 | expect(props).toEqual([])
197 | })
198 |
199 | it('should return empty array when props is null', () => {
200 | const component = {
201 | _component: {
202 | props: null,
203 | },
204 | }
205 | const props = resolveProps(component as any)
206 | expect(props).toEqual([])
207 | })
208 | })
209 | })
210 |
--------------------------------------------------------------------------------
/apps/playground/src/components/Editor/env.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/vuejs/repl/blob/f2b38cf978abb9c21c6c788589b4599b4ff85a7d/src/monaco/env.ts
2 |
3 | import type { WorkerLanguageService } from '@volar/monaco/worker'
4 |
5 | import type { Store } from './store'
6 | import type { CreateData } from './vue.worker'
7 |
8 | import EditorWorker from 'monaco-editor-core/esm/vs/editor/editor.worker?worker'
9 |
10 | import { editor, languages, Uri } from 'monaco-editor-core'
11 | import { watchEffect } from 'vue'
12 |
13 | import * as volar from '@volar/monaco'
14 |
15 | import VueWorker from './vue.worker?worker'
16 |
17 | import { debounce } from '../../utils/vue-repl'
18 | import { getOrCreateModel } from './monaco/utils'
19 |
20 | import * as languageConfigs from './language-configs'
21 |
22 | let initted = false
23 | export function initMonaco(store: Store) {
24 | if (initted)
25 | return
26 | loadMonacoEnv(store)
27 |
28 | watchEffect(() => {
29 | // create a model for each file in the store
30 | for (const filename in store.files) {
31 | const file = store.files[filename]
32 | if (editor.getModel(Uri.parse(`file:///${filename}`)))
33 | continue
34 | getOrCreateModel(
35 | Uri.parse(`file:///${filename}`),
36 | file.language,
37 | file.code,
38 | )
39 | }
40 |
41 | // dispose of any models that are not in the store
42 | for (const model of editor.getModels()) {
43 | const uri = model.uri.toString()
44 | if (store.files[uri.substring('file:///'.length)])
45 | continue
46 |
47 | if (uri.startsWith('file:///node_modules'))
48 | continue
49 | if (uri.startsWith('inmemory://'))
50 | continue
51 |
52 | model.dispose()
53 | }
54 | })
55 |
56 | initted = true
57 | }
58 |
59 | export class WorkerHost {
60 | onFetchCdnFile(uri: string, text: string) {
61 | getOrCreateModel(Uri.parse(uri), undefined, text)
62 | }
63 | }
64 |
65 | let disposeVue: undefined | (() => void)
66 | export async function reloadLanguageTools(store: Store) {
67 | disposeVue?.()
68 |
69 | let dependencies: Record = {
70 | ...store.dependencyVersion,
71 | }
72 |
73 | if (store.vueVersion) {
74 | dependencies = {
75 | ...dependencies,
76 | 'vue': store.vueVersion,
77 | '@vue/compiler-core': store.vueVersion,
78 | '@vue/compiler-dom': store.vueVersion,
79 | '@vue/compiler-sfc': store.vueVersion,
80 | '@vue/compiler-ssr': store.vueVersion,
81 | '@vue/reactivity': store.vueVersion,
82 | '@vue/runtime-core': store.vueVersion,
83 | '@vue/runtime-dom': store.vueVersion,
84 | '@vue/shared': store.vueVersion,
85 | }
86 | }
87 |
88 | if (store.typescriptVersion) {
89 | dependencies = {
90 | ...dependencies,
91 | typescript: store.typescriptVersion,
92 | }
93 | }
94 |
95 | const worker = editor.createWebWorker({
96 | moduleId: 'vs/language/vue/vueWorker',
97 | label: 'vue',
98 | host: new WorkerHost(),
99 | createData: {
100 | tsconfig: store.getTsConfig?.() || {},
101 | dependencies,
102 | } satisfies CreateData,
103 | })
104 | const languageId = ['vue', 'javascript', 'typescript']
105 | const getSyncUris = () =>
106 | Object.keys(store.files).map(filename => Uri.parse(`file:///${filename}`))
107 |
108 | const { dispose: disposeMarkers } = volar.activateMarkers(
109 | worker,
110 | languageId,
111 | 'vue',
112 | getSyncUris,
113 | editor,
114 | )
115 | const { dispose: disposeAutoInsertion } = volar.activateAutoInsertion(
116 | worker,
117 | languageId,
118 | getSyncUris,
119 | editor,
120 | )
121 | const { dispose: disposeProvides } = await volar.registerProviders(
122 | worker,
123 | languageId,
124 | getSyncUris,
125 | languages,
126 | )
127 |
128 | disposeVue = () => {
129 | disposeMarkers()
130 | disposeAutoInsertion()
131 | disposeProvides()
132 | }
133 | }
134 |
135 | export interface WorkerMessage {
136 | event: 'init'
137 | tsVersion: string
138 | tsLocale?: string
139 | }
140 |
141 | export function loadMonacoEnv(store: Store) {
142 | // eslint-disable-next-line no-restricted-globals
143 | ;(self as any).MonacoEnvironment = {
144 | async getWorker(_: any, label: string) {
145 | if (label === 'vue') {
146 | const worker = new VueWorker()
147 | const init = new Promise((resolve) => {
148 | worker.addEventListener('message', (data) => {
149 | if (data.data === 'inited') {
150 | resolve()
151 | }
152 | })
153 | worker.postMessage({
154 | event: 'init',
155 | tsVersion: store.typescriptVersion,
156 | tsLocale: store.locale,
157 | } satisfies WorkerMessage)
158 | })
159 | await init
160 | return worker
161 | }
162 | return new EditorWorker()
163 | },
164 | }
165 | languages.register({ id: 'vue', extensions: ['.vue'] })
166 | languages.register({ id: 'javascript', extensions: ['.js'] })
167 | languages.register({ id: 'typescript', extensions: ['.ts'] })
168 | languages.register({ id: 'css', extensions: ['.css'] })
169 | languages.setLanguageConfiguration('vue', languageConfigs.vue)
170 | languages.setLanguageConfiguration('javascript', languageConfigs.js)
171 | languages.setLanguageConfiguration('typescript', languageConfigs.ts)
172 | languages.setLanguageConfiguration('css', languageConfigs.css)
173 |
174 | let languageToolsPromise: Promise | undefined
175 | store.reloadLanguageTools = debounce(async () => {
176 | ;(languageToolsPromise ||= reloadLanguageTools(store)).finally(() => {
177 | languageToolsPromise = undefined
178 | })
179 | }, 250)
180 | languages.onLanguage('vue', () => store.reloadLanguageTools!())
181 |
182 | // Support for go to definition
183 | editor.registerEditorOpener({
184 | openCodeEditor(_, resource) {
185 | if (resource.toString().startsWith('file:///node_modules')) {
186 | return true
187 | }
188 |
189 | const path = resource.path
190 | if (/^\//.test(path)) {
191 | const fileName = path.replace('/', '')
192 | if (fileName !== store.activeFile.filename) {
193 | store.setActive(fileName)
194 | return true
195 | }
196 | }
197 |
198 | return false
199 | },
200 | })
201 | }
202 |
--------------------------------------------------------------------------------
/apps/playground/src/components/Playground.vue:
--------------------------------------------------------------------------------
1 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
149 |
150 |
151 |
152 |
153 | Props
154 |
155 |
156 |
157 | {{ component.key }}
158 |
159 |
160 | { formValues[component.title] = val }"
164 | />
165 |
166 |
167 |
168 | { formValues[component.title] = val }"
171 | />
172 |
173 |
174 |
175 | { formValues[component.title] = Number(val) }"
179 | />
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
--------------------------------------------------------------------------------
/packages/utils/src/transformers/vue/moduleCompiler.ts:
--------------------------------------------------------------------------------
1 | import type { ExportSpecifier, Identifier, Node } from '@babel/types'
2 |
3 | import type { File } from './shared'
4 |
5 | import {
6 | babelParse,
7 | extractIdentifiers,
8 | isInDestructureAssignment,
9 | isStaticProperty,
10 | MagicString,
11 | walk,
12 | walkIdentifiers,
13 | } from '@vue/compiler-sfc'
14 |
15 | export function compileModulesForPreview(options: {
16 | files: Record
17 | mainFile: string
18 | }, isSSR = false) {
19 | const seen = new Set()
20 | const processed: string[] = []
21 | processFile(options, options.files[options.mainFile], processed, seen, isSSR)
22 |
23 | if (!isSSR) {
24 | // also add css files that are not imported
25 | for (const filename in options.files) {
26 | if (filename.endsWith('.css')) {
27 | const file = options.files[filename]
28 | if (!seen.has(file)) {
29 | processed.push(
30 | `\nwindow.__css__.push(${JSON.stringify(file.compiled.css)})`,
31 | )
32 | }
33 | }
34 | }
35 | }
36 |
37 | // return the default export of the main module
38 | processed.push(`\nreturn __modules__["${options.mainFile}"].default`)
39 |
40 | return processed
41 | }
42 |
43 | const modulesKey = `__modules__`
44 | const exportKey = `__export__`
45 | const dynamicImportKey = `__dynamic_import__`
46 | const moduleKey = `__module__`
47 |
48 | // similar logic with Vite's SSR transform, except this is targeting the browser
49 | function processFile(
50 | options: {
51 | files: Record
52 | mainFile: string
53 | },
54 | file: File,
55 | processed: string[],
56 | seen: Set,
57 | isSSR: boolean,
58 | ) {
59 | if (seen.has(file)) {
60 | return []
61 | }
62 | seen.add(file)
63 |
64 | if (!isSSR && file.filename.endsWith('.html')) {
65 | return processHtmlFile(options, file.code, file.filename, processed, seen)
66 | }
67 |
68 | let {
69 | code: js,
70 | importedFiles,
71 | hasDynamicImport,
72 | } = processModule(
73 | options,
74 | isSSR ? file.compiled.ssr : file.compiled.js,
75 | file.filename,
76 | )
77 | processChildFiles(
78 | options,
79 | importedFiles,
80 | hasDynamicImport,
81 | processed,
82 | seen,
83 | isSSR,
84 | )
85 | // append css
86 | if (file.compiled.css && !isSSR) {
87 | js += `\nwindow.__css__.push(${JSON.stringify(file.compiled.css)})`
88 | }
89 |
90 | // push self
91 | processed.push(js)
92 | }
93 |
94 | function processChildFiles(
95 | options: {
96 | files: Record
97 | mainFile: string
98 | },
99 | importedFiles: Set,
100 | hasDynamicImport: boolean,
101 | processed: string[],
102 | seen: Set,
103 | isSSR: boolean,
104 | ) {
105 | if (hasDynamicImport) {
106 | // process all files
107 | for (const file of Object.values(options.files)) {
108 | if (seen.has(file))
109 | continue
110 | processFile(options, file, processed, seen, isSSR)
111 | }
112 | }
113 | else if (importedFiles.size > 0) {
114 | // crawl child imports
115 | for (const imported of importedFiles) {
116 | processFile(options, options.files[imported], processed, seen, isSSR)
117 | }
118 | }
119 | }
120 |
121 | function processModule(options: {
122 | files: Record
123 | mainFile: string
124 | }, src: string, filename: string) {
125 | const s = new MagicString(src)
126 |
127 | const ast = babelParse(src, {
128 | sourceFilename: filename,
129 | sourceType: 'module',
130 | }).program.body
131 |
132 | const idToImportMap = new Map()
133 | const declaredConst = new Set()
134 | const importedFiles = new Set()
135 | const importToIdMap = new Map()
136 |
137 | function resolveImport(raw: string): string | undefined {
138 | const files = options.files
139 | let resolved = raw
140 | const file
141 | = files[resolved]
142 | || files[(resolved = `${raw}.ts`)]
143 | || files[(resolved = `${raw}.js`)]
144 | return file ? resolved : undefined
145 | }
146 |
147 | function defineImport(node: Node, source: string) {
148 | const filename = resolveImport(source.replace(/^\.\/+/, 'src/'))
149 | if (!filename) {
150 | throw new Error(`File "${source}" does not exist.`)
151 | }
152 | if (importedFiles.has(filename)) {
153 | return importToIdMap.get(filename)!
154 | }
155 | importedFiles.add(filename)
156 | const id = `__import_${importedFiles.size}__`
157 | importToIdMap.set(filename, id)
158 | s.appendLeft(
159 | node.start!,
160 | `const ${id} = ${modulesKey}[${JSON.stringify(filename)}]\n`,
161 | )
162 | return id
163 | }
164 |
165 | function defineExport(name: string, local = name) {
166 | s.append(`\n${exportKey}(${moduleKey}, "${name}", () => ${local})`)
167 | }
168 |
169 | // 0. instantiate module
170 | s.prepend(
171 | `const ${moduleKey} = ${modulesKey}[${JSON.stringify(
172 | filename,
173 | )}] = { [Symbol.toStringTag]: "Module" }\n\n`,
174 | )
175 |
176 | // 1. check all import statements and record id -> importName map
177 | for (const node of ast) {
178 | // import foo from 'foo' --> foo -> __import_foo__.default
179 | // import { baz } from 'foo' --> baz -> __import_foo__.baz
180 | // import * as ok from 'foo' --> ok -> __import_foo__
181 | if (node.type === 'ImportDeclaration') {
182 | const source = node.source.value
183 | if (source.startsWith('./')) {
184 | const importId = defineImport(node, node.source.value)
185 | for (const spec of node.specifiers) {
186 | if (spec.type === 'ImportSpecifier') {
187 | idToImportMap.set(
188 | spec.local.name,
189 | `${importId}.${(spec.imported as Identifier).name}`,
190 | )
191 | }
192 | else if (spec.type === 'ImportDefaultSpecifier') {
193 | idToImportMap.set(spec.local.name, `${importId}.default`)
194 | }
195 | else {
196 | // namespace specifier
197 | idToImportMap.set(spec.local.name, importId)
198 | }
199 | }
200 | s.remove(node.start!, node.end!)
201 | }
202 | }
203 | }
204 |
205 | // 2. check all export statements and define exports
206 | for (const node of ast) {
207 | // named exports
208 | if (node.type === 'ExportNamedDeclaration') {
209 | if (node.declaration) {
210 | if (
211 | node.declaration.type === 'FunctionDeclaration'
212 | || node.declaration.type === 'ClassDeclaration'
213 | ) {
214 | // export function foo() {}
215 | defineExport(node.declaration.id!.name)
216 | }
217 | else if (node.declaration.type === 'VariableDeclaration') {
218 | // export const foo = 1, bar = 2
219 | for (const decl of node.declaration.declarations) {
220 | for (const id of extractIdentifiers(decl.id)) {
221 | defineExport(id.name)
222 | }
223 | }
224 | }
225 | s.remove(node.start!, node.declaration.start!)
226 | }
227 | else if (node.source) {
228 | // export { foo, bar } from './foo'
229 | const importId = defineImport(node, node.source.value)
230 | for (const spec of node.specifiers) {
231 | defineExport(
232 | (spec.exported as Identifier).name,
233 | `${importId}.${(spec as ExportSpecifier).local.name}`,
234 | )
235 | }
236 | s.remove(node.start!, node.end!)
237 | }
238 | else {
239 | // export { foo, bar }
240 | for (const spec of node.specifiers) {
241 | const local = (spec as ExportSpecifier).local.name
242 | const binding = idToImportMap.get(local)
243 | defineExport((spec.exported as Identifier).name, binding || local)
244 | }
245 | s.remove(node.start!, node.end!)
246 | }
247 | }
248 |
249 | // default export
250 | if (node.type === 'ExportDefaultDeclaration') {
251 | if ('id' in node.declaration && node.declaration.id) {
252 | // named hoistable/class exports
253 | // export default function foo() {}
254 | // export default class A {}
255 | const { name } = node.declaration.id
256 | s.remove(node.start!, node.start! + 15)
257 | s.append(`\n${exportKey}(${moduleKey}, "default", () => ${name})`)
258 | }
259 | else {
260 | // anonymous default exports
261 | s.overwrite(node.start!, node.start! + 14, `${moduleKey}.default =`)
262 | }
263 | }
264 |
265 | // export * from './foo'
266 | if (node.type === 'ExportAllDeclaration') {
267 | const importId = defineImport(node, node.source.value)
268 | s.remove(node.start!, node.end!)
269 | s.append(`\nfor (const key in ${importId}) {
270 | if (key !== 'default') {
271 | ${exportKey}(${moduleKey}, key, () => ${importId}[key])
272 | }
273 | }`)
274 | }
275 | }
276 |
277 | // 3. convert references to import bindings
278 | for (const node of ast) {
279 | if (node.type === 'ImportDeclaration')
280 | continue
281 | walkIdentifiers(node, (id, parent, parentStack) => {
282 | const binding = idToImportMap.get(id.name)
283 | if (!binding) {
284 | return
285 | }
286 | if (parent && isStaticProperty(parent) && parent.shorthand) {
287 | // let binding used in a property shorthand
288 | // { foo } -> { foo: __import_x__.foo }
289 | // skip for destructure patterns
290 | if (
291 | !(parent as any).inPattern
292 | || isInDestructureAssignment(parent, parentStack)
293 | ) {
294 | s.appendLeft(id.end!, `: ${binding}`)
295 | }
296 | }
297 | else if (
298 | parent
299 | && parent.type === 'ClassDeclaration'
300 | && id === parent.superClass
301 | ) {
302 | if (!declaredConst.has(id.name)) {
303 | declaredConst.add(id.name)
304 | // locate the top-most node containing the class declaration
305 | const topNode = parentStack[1]
306 | s.prependRight(topNode.start!, `const ${id.name} = ${binding};\n`)
307 | }
308 | }
309 | else {
310 | s.overwrite(id.start!, id.end!, binding)
311 | }
312 | })
313 | }
314 |
315 | // 4. convert dynamic imports
316 | let hasDynamicImport = false
317 | walk(ast, {
318 | enter(node: Node, parent: Node) {
319 | if (node.type === 'Import' && parent.type === 'CallExpression') {
320 | const arg = parent.arguments[0]
321 | if (arg.type === 'StringLiteral' && arg.value.startsWith('./')) {
322 | hasDynamicImport = true
323 | s.overwrite(node.start!, node.start! + 6, dynamicImportKey)
324 | s.overwrite(
325 | arg.start!,
326 | arg.end!,
327 | JSON.stringify(arg.value.replace(/^\.\/+/, 'src/')),
328 | )
329 | }
330 | }
331 | },
332 | })
333 |
334 | return {
335 | code: s.toString(),
336 | importedFiles,
337 | hasDynamicImport,
338 | }
339 | }
340 |
341 | // eslint-disable-next-line regexp/no-useless-assertions, regexp/match-any
342 | const scriptRE = /