├── src ├── mkdist.ts ├── index.ts ├── utils │ ├── string.ts │ ├── object.ts │ ├── script-setup.ts │ └── template.ts ├── block-loader │ ├── style.ts │ ├── script.ts │ ├── types.ts │ ├── default.ts │ └── template.ts ├── types │ └── mkdist.ts ├── sfc-transformer.ts └── plugins │ └── mkdist.ts ├── .gitignore ├── renovate.json ├── eslint.config.js ├── .editorconfig ├── playground ├── package.json └── index.js ├── vitest.config.ts ├── tsconfig.json ├── .github ├── workflows │ ├── release.yml │ ├── release-nightly.yml │ └── ci.yml └── ISSUE_TEMPLATE │ └── ---bug-report.yml ├── LICENCE ├── pnpm-workspace.yaml ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md └── test ├── template.test.ts ├── setup.test.ts └── mkdist.test.ts /src/mkdist.ts: -------------------------------------------------------------------------------- 1 | export { vueLoader } from './plugins/mkdist' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | .vscode 5 | .DS_Store 6 | .eslintcache 7 | *.log* 8 | *.env* 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { preTranspileScriptSetup } from './utils/script-setup' 2 | export { transpileVueTemplate } from './utils/template' 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>danielroe/renovate" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export function cleanupBreakLine(str: string): string { 2 | return str.replaceAll(/(\n\n)\n+/g, '\n\n').replace(/^\s*\n|\n\s*$/g, '') 3 | } 4 | -------------------------------------------------------------------------------- /src/block-loader/style.ts: -------------------------------------------------------------------------------- 1 | import { defineDefaultBlockLoader } from './default' 2 | 3 | export const styleLoader = defineDefaultBlockLoader({ 4 | defaultLang: 'css', 5 | type: 'style', 6 | }) 7 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu().append({ 4 | files: ['playground/**'], 5 | rules: { 6 | 'antfu/no-top-level-await': 'off', 7 | 'no-console': 'off', 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export function toOmit, K extends keyof R>(record: R, toRemove: K[]): Omit { 2 | return Object.fromEntries(Object.entries(record).filter(([key]) => !toRemove.includes(key as K))) as Omit 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "private": true, 4 | "scripts": { 5 | "dev": "node index.js" 6 | }, 7 | "dependencies": { 8 | "vue-sfc-transformer": "workspace:*" 9 | }, 10 | "devDependencies": { 11 | "@vue/compiler-sfc": "catalog:dev", 12 | "esbuild": "catalog:peer" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | 'vue-sfc-transformer': fileURLToPath( 8 | new URL('./src/index.ts', import.meta.url).href, 9 | ), 10 | }, 11 | }, 12 | test: { 13 | coverage: { 14 | enabled: true, 15 | include: ['src'], 16 | reporter: ['text', 'json', 'html'], 17 | }, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "lib": [ 5 | "es2022" 6 | ], 7 | "moduleDetection": "force", 8 | "module": "preserve", 9 | "resolveJsonModule": true, 10 | "allowJs": true, 11 | "strict": true, 12 | "noImplicitOverride": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noEmit": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "isolatedModules": true, 18 | "verbatimModuleSyntax": true, 19 | "skipLibCheck": true 20 | }, 21 | "include": [ 22 | "src", 23 | "test", 24 | "playground" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 0 18 | 19 | - run: npm i -g --force corepack && corepack enable 20 | - uses: actions/setup-node@v6 21 | with: 22 | node-version: lts/* 23 | cache: pnpm 24 | 25 | - name: 📦 Install dependencies 26 | run: pnpm install 27 | 28 | - run: pnpm changelogithub 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release-nightly.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | release-nightly: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 18 | - run: npm i -g --force corepack && corepack enable 19 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 20 | with: 21 | node-version: lts/* 22 | cache: pnpm 23 | 24 | - name: 📦 Install dependencies 25 | run: pnpm install 26 | 27 | - name: 🛠 Build project 28 | run: pnpm build 29 | 30 | - name: publish nightly release 31 | run: pnpm pkg-pr-new publish --compact 32 | -------------------------------------------------------------------------------- /src/types/mkdist.ts: -------------------------------------------------------------------------------- 1 | // TODO: export from mkdist 2 | 3 | import type { MkdistOptions } from 'mkdist' 4 | 5 | interface InputFile { 6 | path: string 7 | extension: string 8 | srcPath?: string 9 | getContents: () => Promise | string 10 | } 11 | 12 | interface OutputFile { 13 | /** 14 | * relative to distDir 15 | */ 16 | path: string 17 | srcPath?: string 18 | extension?: string 19 | contents?: string 20 | declaration?: boolean 21 | errors?: Error[] 22 | raw?: boolean 23 | skip?: boolean 24 | } 25 | 26 | type LoaderResult = OutputFile[] | undefined 27 | 28 | interface LoaderContext { 29 | loadFile: (input: InputFile) => LoaderResult | Promise 30 | options: MkdistOptions 31 | } 32 | 33 | export type Loader = (input: InputFile, context: LoaderContext) => LoaderResult | Promise 34 | -------------------------------------------------------------------------------- /src/block-loader/script.ts: -------------------------------------------------------------------------------- 1 | import type { BlockLoader, LoaderFile, LoadFileContext } from './types' 2 | import { toOmit } from '../utils/object' 3 | 4 | const scriptValidExtensions = new Set(['.js', '.cjs', '.mjs']) 5 | 6 | export const scriptLoader: BlockLoader = async (block, { isTs, loadFile }) => { 7 | if (block.type !== 'script') { 8 | return 9 | } 10 | 11 | const extension = isTs ? '.ts' : '.js' 12 | 13 | const input: LoaderFile = { extension, content: block.content } 14 | const context: LoadFileContext = { isTs, block } 15 | 16 | const files = await loadFile(input, context) || [] 17 | 18 | const output = files.find(file => scriptValidExtensions.has(file.extension)) 19 | if (!output) { 20 | return 21 | } 22 | 23 | return { 24 | type: block.type, 25 | attrs: toOmit(block.attrs, ['lang', 'generic']), 26 | content: output.content, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Something's not working 3 | labels: [bug] 4 | body: 5 | - type: textarea 6 | validations: 7 | required: true 8 | attributes: 9 | label: 🐛 The bug 10 | description: What isn't working? Describe what the bug is. 11 | - type: input 12 | validations: 13 | required: true 14 | attributes: 15 | label: 🛠️ To reproduce 16 | description: A reproduction of the bug via https://stackblitz.com/github/nuxt-contrib/vue-sfc-transformer/tree/main/playground 17 | placeholder: https://stackblitz.com/[...] 18 | - type: textarea 19 | validations: 20 | required: true 21 | attributes: 22 | label: 🌈 Expected behaviour 23 | description: What did you expect to happen? Is there a section in the docs about this? 24 | - type: textarea 25 | attributes: 26 | label: ℹ️ Additional context 27 | description: Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /src/block-loader/types.ts: -------------------------------------------------------------------------------- 1 | import type { SFCBlock, SFCParseResult } from 'vue/compiler-sfc' 2 | 3 | export interface LoaderFile { 4 | extension: string 5 | content: string 6 | } 7 | 8 | export interface LoadFileContext { 9 | isTs: boolean 10 | block: SFCBlock 11 | } 12 | 13 | export interface BlockLoaderContext { 14 | /** 15 | * Whether the SFC is using TypeScript 16 | */ 17 | isTs: boolean 18 | 19 | /** 20 | * Relative path to the SFC 21 | */ 22 | path: string 23 | 24 | /** 25 | * Absolute path to the SFC 26 | */ 27 | srcPath: string 28 | 29 | /** 30 | * Raw content of the SFC 31 | */ 32 | raw: string 33 | 34 | /** 35 | * Parsed SFC 36 | */ 37 | sfc: SFCParseResult 38 | 39 | loadFile: (input: LoaderFile, context: LoadFileContext) => Promise | LoaderFile[] 40 | } 41 | 42 | type BlockLoaderOutput = Pick 43 | 44 | export interface BlockLoader { 45 | ( 46 | block: SFCBlock, 47 | context: BlockLoaderContext, 48 | ): Promise 49 | } 50 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Daniel Roe 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 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | catalogMode: prefer 2 | shellEmulator: true 3 | trustPolicy: no-downgrade 4 | trustPolicyExclude: 5 | - '@octokit/action@6.1.0' 6 | - '@octokit/endpoint@9.0.6' 7 | - '@octokit/plugin-paginate-rest@9.2.2' 8 | - chokidar@4.0.3 9 | - undici@5.29.0 || 6.22.0 10 | packages: 11 | - playground 12 | catalogs: 13 | dev: 14 | '@antfu/eslint-config': 6.7.1 15 | '@babel/types': 7.28.5 16 | '@types/node': 24.10.4 17 | '@vitest/coverage-v8': 4.0.15 18 | '@vue/compiler-dom': 3.5.25 19 | '@vue/compiler-sfc': 3.5.25 20 | bumpp: 10.3.2 21 | changelogithub: 14.0.0 22 | eslint: 9.39.2 23 | exsolve: 1.0.8 24 | installed-check: 9.3.0 25 | knip: 5.73.4 26 | lint-staged: 16.2.7 27 | mkdist: 2.4.1 28 | pkg-pr-new: 0.0.62 29 | simple-git-hooks: 2.13.1 30 | typescript: 5.9.3 31 | unbuild: 3.6.1 32 | vitest: 4.0.15 33 | vue-tsc: 3.1.8 34 | peer: 35 | '@vue/compiler-core': 3.5.25 36 | esbuild: 0.27.1 37 | vue: 3.5.25 38 | prod: 39 | '@babel/parser': ^7.27.0 40 | ignoredBuiltDependencies: 41 | - '@parcel/watcher' 42 | - esbuild 43 | onlyBuiltDependencies: 44 | - simple-git-hooks 45 | -------------------------------------------------------------------------------- /src/block-loader/default.ts: -------------------------------------------------------------------------------- 1 | import type { BlockLoader, LoaderFile, LoadFileContext } from './types' 2 | import { toOmit } from '../utils/object' 3 | 4 | interface DefaultBlockLoaderOptions { 5 | type: 'script' | 'style' | 'template' | (string & {}) 6 | defaultLang: string 7 | validExtensions?: string[] 8 | } 9 | 10 | export function defineDefaultBlockLoader( 11 | options: DefaultBlockLoaderOptions, 12 | ): BlockLoader { 13 | return async (block, { isTs, loadFile }) => { 14 | if (options.type !== block.type) { 15 | return 16 | } 17 | 18 | const lang = typeof block.attrs.lang === 'string' 19 | ? block.attrs.lang 20 | : options.defaultLang 21 | const extension = `.${lang}` 22 | 23 | const input: LoaderFile = { extension, content: block.content } 24 | const context: LoadFileContext = { isTs, block } 25 | const files = await loadFile(input, context) || [] 26 | 27 | const output = files.find( 28 | file => file.extension === `.${options.defaultLang}` || options.validExtensions?.includes(file.extension), 29 | ) 30 | if (!output) { 31 | return 32 | } 33 | 34 | return { 35 | type: block.type, 36 | attrs: toOmit(block.attrs, ['lang', 'generic']), 37 | content: output.content, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/block-loader/template.ts: -------------------------------------------------------------------------------- 1 | import type { SFCTemplateBlock } from 'vue/compiler-sfc' 2 | import type { BlockLoader, LoadFileContext } from './types' 3 | import { transpileVueTemplate } from '../utils/template' 4 | 5 | const templateJsSnippetValidExtensions = new Set(['.js', '.cjs', '.mjs']) 6 | 7 | export const templateLoader: BlockLoader = async (block, { isTs, loadFile }) => { 8 | if (block.type !== 'template') { 9 | return 10 | } 11 | if (!isTs) { 12 | return 13 | } 14 | 15 | const typedBlock = block as SFCTemplateBlock 16 | 17 | const snippetExtension = isTs ? '.ts' : '.js' 18 | const context: LoadFileContext = { isTs, block } 19 | 20 | const transformed = await transpileVueTemplate( 21 | // for lower version of @vue/compiler-sfc, `ast.source` is the whole .vue file 22 | typedBlock.content, 23 | typedBlock.ast!, 24 | typedBlock.loc.start.offset, 25 | async (code) => { 26 | const res = await loadFile( 27 | { extension: snippetExtension, content: code }, 28 | context, 29 | ) 30 | 31 | return res?.find(f => templateJsSnippetValidExtensions.has(f.extension))?.content || code 32 | }, 33 | ) 34 | 35 | return { 36 | type: 'template', 37 | attrs: typedBlock.attrs, 38 | content: transformed, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /playground/index.js: -------------------------------------------------------------------------------- 1 | import { parse as parseSFC } from '@vue/compiler-sfc' 2 | import { transform } from 'esbuild' 3 | 4 | import { preTranspileScriptSetup, transpileVueTemplate } from 'vue-sfc-transformer' 5 | 6 | const src = ` 7 | 10 | 11 | 16 | ` 17 | 18 | const sfc = parseSFC(src, { 19 | filename: 'test.vue', 20 | ignoreEmpty: true, 21 | }) 22 | 23 | // transpile template block 24 | const templateBlockContents = await transpileVueTemplate( 25 | sfc.descriptor.template.content, 26 | sfc.descriptor.template.ast, 27 | sfc.descriptor.template.loc.start.offset, 28 | async (code) => { 29 | const res = await transform(code, { loader: 'ts', target: 'esnext' }) 30 | return res.code 31 | }, 32 | ) 33 | console.log(`transpiled