mirror of
https://github.com/KwiTsukasa/kt-template-online-playground.git
synced 2026-05-27 16:45:45 +08:00
600 lines
15 KiB
TypeScript
600 lines
15 KiB
TypeScript
import {
|
|
type ToRefs,
|
|
type UnwrapRef,
|
|
computed,
|
|
reactive,
|
|
ref,
|
|
shallowRef,
|
|
watch,
|
|
watchEffect,
|
|
} from 'vue'
|
|
import * as defaultCompiler from 'vue/compiler-sfc'
|
|
import { compileFile } from './transform'
|
|
import { atou, utoa } from './utils'
|
|
import type {
|
|
SFCAsyncStyleCompileOptions,
|
|
SFCScriptCompileOptions,
|
|
SFCTemplateCompileOptions,
|
|
} from 'vue/compiler-sfc'
|
|
import type { OutputModes } from './types'
|
|
import type { editor } from 'monaco-editor-core'
|
|
import { type ImportMap, mergeImportMap, useVueImportMap } from './import-map'
|
|
|
|
import welcomeSFCCode from './template/welcome.vue?raw'
|
|
import newSFCCode from './template/new-sfc.vue?raw'
|
|
|
|
export const importMapFile = 'import-map.json'
|
|
export const tsconfigFile = 'tsconfig.json'
|
|
export const builtinDependencyVersions: Record<string, string> = {
|
|
echarts: 'latest',
|
|
'ant-design-vue': 'latest',
|
|
'@ant-design/icons-vue': 'latest',
|
|
'element-plus': 'latest',
|
|
'@element-plus/icons-vue': 'latest',
|
|
}
|
|
|
|
export function useStore(
|
|
{
|
|
files = ref(Object.create(null)),
|
|
activeFilename = undefined!, // set later
|
|
mainFile = ref('src/App.vue'),
|
|
template = ref({
|
|
welcomeSFC: welcomeSFCCode,
|
|
newSFC: newSFCCode,
|
|
}),
|
|
builtinImportMap = undefined!, // set later
|
|
|
|
errors = ref([]),
|
|
showOutput = ref(false),
|
|
outputMode = ref('preview'),
|
|
sfcOptions = ref({}),
|
|
compiler = shallowRef(defaultCompiler),
|
|
vueVersion = ref(null),
|
|
|
|
locale = ref(),
|
|
typescriptVersion = ref('latest'),
|
|
dependencyVersion = ref({ ...builtinDependencyVersions }),
|
|
reloadLanguageTools = ref(),
|
|
resourceLinks = undefined,
|
|
}: Partial<StoreState> = {},
|
|
serializedState?: string,
|
|
): ReplStore {
|
|
if (!builtinImportMap) {
|
|
;({ importMap: builtinImportMap, vueVersion } = useVueImportMap({
|
|
vueVersion: vueVersion.value,
|
|
}))
|
|
}
|
|
const loading = ref(false)
|
|
|
|
function applyBuiltinImportMap() {
|
|
const importMap = mergeImportMap(builtinImportMap.value, getImportMap())
|
|
setImportMap(importMap)
|
|
}
|
|
|
|
function init() {
|
|
watchEffect(() => {
|
|
compileFile(store, activeFile.value).then((errs) => (errors.value = errs))
|
|
})
|
|
|
|
watch(
|
|
() => [
|
|
files.value[tsconfigFile]?.code,
|
|
typescriptVersion.value,
|
|
locale.value,
|
|
dependencyVersion.value,
|
|
vueVersion.value,
|
|
],
|
|
() => reloadLanguageTools.value?.(),
|
|
{ deep: true },
|
|
)
|
|
|
|
watch(
|
|
builtinImportMap,
|
|
() => {
|
|
setImportMap(mergeImportMap(getImportMap(), builtinImportMap.value))
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
watch(
|
|
vueVersion,
|
|
async (version) => {
|
|
if (version) {
|
|
const compilerUrl =
|
|
resourceLinks?.value?.vueCompilerUrl?.(version) ||
|
|
`https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@${version}/dist/compiler-sfc.esm-browser.js`
|
|
loading.value = true
|
|
compiler.value = await import(/* @vite-ignore */ compilerUrl).finally(
|
|
() => (loading.value = false),
|
|
)
|
|
console.info(`[@vue/repl] Now using Vue version: ${version}`)
|
|
} else {
|
|
// reset to default
|
|
compiler.value = defaultCompiler
|
|
console.info(`[@vue/repl] Now using default Vue version`)
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// Recompile all Vue SFC files when the compiler changes.
|
|
// This ensures that when switching Vue versions (e.g., from <3.6 to >=3.6),
|
|
// all vue sfc files are recompiled with the new compiler to correctly handle
|
|
// vapor components.
|
|
watch(compiler, (_, oldCompiler) => {
|
|
if (!oldCompiler) return
|
|
for (const file of Object.values(files.value)) {
|
|
if (file.filename.endsWith('.vue')) {
|
|
compileFile(store, file).then((errs) => errors.value.push(...errs))
|
|
}
|
|
}
|
|
})
|
|
|
|
watch(
|
|
sfcOptions,
|
|
() => {
|
|
sfcOptions.value.script ||= {}
|
|
sfcOptions.value.script.fs = {
|
|
fileExists(file: string) {
|
|
if (file.startsWith('/')) file = file.slice(1)
|
|
return !!store.files[file]
|
|
},
|
|
readFile(file: string) {
|
|
if (file.startsWith('/')) file = file.slice(1)
|
|
return store.files[file].code
|
|
},
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// init tsconfig
|
|
if (!files.value[tsconfigFile]) {
|
|
files.value[tsconfigFile] = new File(
|
|
tsconfigFile,
|
|
JSON.stringify(tsconfig, undefined, 2),
|
|
)
|
|
}
|
|
|
|
// compile rest of the files
|
|
errors.value = []
|
|
for (const [filename, file] of Object.entries(files.value)) {
|
|
if (filename !== mainFile.value) {
|
|
compileFile(store, file).then((errs) => errors.value.push(...errs))
|
|
}
|
|
}
|
|
}
|
|
|
|
function setImportMap(map: ImportMap, merge = false) {
|
|
if (merge) {
|
|
map = mergeImportMap(getImportMap(), map)
|
|
}
|
|
|
|
if (map.imports)
|
|
for (const [key, value] of Object.entries(map.imports)) {
|
|
if (value) {
|
|
map.imports![key] = fixURL(value)
|
|
}
|
|
}
|
|
|
|
const code = JSON.stringify(map, undefined, 2)
|
|
if (files.value[importMapFile]) {
|
|
files.value[importMapFile].code = code
|
|
} else {
|
|
files.value[importMapFile] = new File(importMapFile, code)
|
|
}
|
|
}
|
|
|
|
const setActive: Store['setActive'] = (filename) => {
|
|
activeFilename.value = filename
|
|
}
|
|
const addFile: Store['addFile'] = (fileOrFilename) => {
|
|
let file: File
|
|
if (typeof fileOrFilename === 'string') {
|
|
file = new File(
|
|
fileOrFilename,
|
|
fileOrFilename.endsWith('.vue') ? template.value.newSFC : '',
|
|
)
|
|
} else {
|
|
file = fileOrFilename
|
|
}
|
|
files.value[file.filename] = file
|
|
if (!file.hidden) setActive(file.filename)
|
|
}
|
|
const deleteFile: Store['deleteFile'] = (filename) => {
|
|
if (
|
|
!confirm(`Are you sure you want to delete ${stripSrcPrefix(filename)}?`)
|
|
) {
|
|
return
|
|
}
|
|
|
|
if (activeFilename.value === filename) {
|
|
activeFilename.value = mainFile.value
|
|
}
|
|
delete files.value[filename]
|
|
}
|
|
const renameFile: Store['renameFile'] = (oldFilename, newFilename) => {
|
|
const file = files.value[oldFilename]
|
|
|
|
if (!file) {
|
|
errors.value = [`Could not rename "${oldFilename}", file not found`]
|
|
return
|
|
}
|
|
|
|
if (!newFilename || oldFilename === newFilename) {
|
|
errors.value = [`Cannot rename "${oldFilename}" to "${newFilename}"`]
|
|
return
|
|
}
|
|
|
|
file.filename = newFilename
|
|
const newFiles: Record<string, File> = {}
|
|
|
|
// Preserve iteration order for files
|
|
for (const [name, file] of Object.entries(files.value)) {
|
|
if (name === oldFilename) {
|
|
newFiles[newFilename] = file
|
|
} else {
|
|
newFiles[name] = file
|
|
}
|
|
}
|
|
|
|
files.value = newFiles
|
|
|
|
if (mainFile.value === oldFilename) {
|
|
mainFile.value = newFilename
|
|
}
|
|
if (activeFilename.value === oldFilename) {
|
|
activeFilename.value = newFilename
|
|
} else {
|
|
compileFile(store, file).then((errs) => (errors.value = errs))
|
|
}
|
|
}
|
|
const getImportMap: Store['getImportMap'] = () => {
|
|
try {
|
|
return JSON.parse(files.value[importMapFile].code)
|
|
} catch (e) {
|
|
errors.value = [
|
|
`Syntax error in ${importMapFile}: ${(e as Error).message}`,
|
|
]
|
|
return {}
|
|
}
|
|
}
|
|
const getTsConfig: Store['getTsConfig'] = () => {
|
|
try {
|
|
return JSON.parse(files.value[tsconfigFile].code)
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
const serialize: ReplStore['serialize'] = () => {
|
|
const files = getFiles()
|
|
const importMap = files[importMapFile]
|
|
if (importMap) {
|
|
const parsed = JSON.parse(importMap)
|
|
const builtin = builtinImportMap.value.imports || {}
|
|
|
|
if (parsed.imports) {
|
|
for (const [key, value] of Object.entries(parsed.imports)) {
|
|
if (builtin[key] === value) {
|
|
delete parsed.imports[key]
|
|
}
|
|
}
|
|
if (parsed.imports && !Object.keys(parsed.imports).length) {
|
|
delete parsed.imports
|
|
}
|
|
}
|
|
if (parsed.scopes && !Object.keys(parsed.scopes).length) {
|
|
delete parsed.scopes
|
|
}
|
|
if (Object.keys(parsed).length) {
|
|
files[importMapFile] = JSON.stringify(parsed, null, 2)
|
|
} else {
|
|
delete files[importMapFile]
|
|
}
|
|
}
|
|
if (vueVersion.value) files._version = vueVersion.value
|
|
if (typescriptVersion.value !== 'latest' || files._tsVersion) {
|
|
files._tsVersion = typescriptVersion.value
|
|
}
|
|
return '#' + utoa(JSON.stringify(files))
|
|
}
|
|
const deserialize: ReplStore['deserialize'] = (
|
|
serializedState: string,
|
|
checkBuiltinImportMap = true,
|
|
) => {
|
|
if (serializedState.startsWith('#'))
|
|
serializedState = serializedState.slice(1)
|
|
let saved: any
|
|
try {
|
|
saved = JSON.parse(atou(serializedState))
|
|
} catch (err) {
|
|
console.error(err)
|
|
alert('Failed to load code from URL.')
|
|
return setDefaultFile()
|
|
}
|
|
for (const filename in saved) {
|
|
if (filename === '_version') {
|
|
vueVersion.value = saved[filename]
|
|
} else if (filename === '_tsVersion') {
|
|
typescriptVersion.value = saved[filename]
|
|
} else {
|
|
setFile(files.value, filename, saved[filename])
|
|
}
|
|
}
|
|
if (checkBuiltinImportMap) {
|
|
applyBuiltinImportMap()
|
|
}
|
|
}
|
|
const getFiles: ReplStore['getFiles'] = () => {
|
|
const exported: Record<string, string> = {}
|
|
for (const [filename, file] of Object.entries(files.value)) {
|
|
const normalized = stripSrcPrefix(filename)
|
|
exported[normalized] = file.code
|
|
}
|
|
return exported
|
|
}
|
|
const setFiles: ReplStore['setFiles'] = async (
|
|
newFiles,
|
|
mainFile = store.mainFile,
|
|
) => {
|
|
const files: Record<string, File> = Object.create(null)
|
|
|
|
mainFile = addSrcPrefix(mainFile)
|
|
if (!newFiles[mainFile]) {
|
|
setFile(files, mainFile, template.value.welcomeSFC || welcomeSFCCode)
|
|
}
|
|
for (const [filename, file] of Object.entries(newFiles)) {
|
|
setFile(files, filename, file)
|
|
}
|
|
|
|
const errors = []
|
|
for (const file of Object.values(files)) {
|
|
errors.push(...(await compileFile(store, file)))
|
|
}
|
|
|
|
store.mainFile = mainFile
|
|
store.files = files
|
|
store.errors = errors
|
|
applyBuiltinImportMap()
|
|
setActive(store.mainFile)
|
|
}
|
|
const setDefaultFile = (): void => {
|
|
setFile(
|
|
files.value,
|
|
mainFile.value,
|
|
template.value.welcomeSFC || welcomeSFCCode,
|
|
)
|
|
}
|
|
|
|
if (serializedState) {
|
|
deserialize(serializedState, false)
|
|
} else {
|
|
setDefaultFile()
|
|
}
|
|
if (!files.value[mainFile.value]) {
|
|
mainFile.value = Object.keys(files.value)[0]
|
|
}
|
|
activeFilename ||= ref(mainFile.value)
|
|
const activeFile = computed(() => files.value[activeFilename.value])
|
|
|
|
applyBuiltinImportMap()
|
|
|
|
const store: ReplStore = reactive({
|
|
files,
|
|
activeFile,
|
|
activeFilename,
|
|
mainFile,
|
|
template,
|
|
builtinImportMap,
|
|
|
|
errors,
|
|
showOutput,
|
|
outputMode,
|
|
sfcOptions,
|
|
ssrOutput: { html: '', context: '' },
|
|
compiler,
|
|
loading,
|
|
vueVersion,
|
|
|
|
locale,
|
|
typescriptVersion,
|
|
dependencyVersion,
|
|
reloadLanguageTools,
|
|
|
|
init,
|
|
setActive,
|
|
addFile,
|
|
deleteFile,
|
|
renameFile,
|
|
getImportMap,
|
|
setImportMap,
|
|
getTsConfig,
|
|
serialize,
|
|
deserialize,
|
|
getFiles,
|
|
setFiles,
|
|
|
|
resourceLinks,
|
|
})
|
|
return store
|
|
}
|
|
|
|
const tsconfig = {
|
|
compilerOptions: {
|
|
allowJs: true,
|
|
checkJs: true,
|
|
jsx: 'Preserve',
|
|
target: 'ESNext',
|
|
module: 'ESNext',
|
|
moduleResolution: 'Bundler',
|
|
allowImportingTsExtensions: true,
|
|
},
|
|
vueCompilerOptions: {
|
|
target: 3.4,
|
|
},
|
|
}
|
|
|
|
export interface SFCOptions {
|
|
script?: Partial<SFCScriptCompileOptions>
|
|
style?: Partial<SFCAsyncStyleCompileOptions>
|
|
template?: Partial<SFCTemplateCompileOptions>
|
|
}
|
|
|
|
export type ResourceLinkConfigs = {
|
|
esModuleShims?: string
|
|
vueCompilerUrl?: (version: string) => string
|
|
typescriptLib?: (version: string) => string
|
|
// for monaco
|
|
pkgLatestVersionUrl?: (pkgName: string) => string
|
|
pkgDirUrl?: (pkgName: string, pkgVersion: string, pkgPath: string) => string
|
|
pkgFileTextUrl?: (
|
|
pkgName: string,
|
|
pkgVersion: string | undefined,
|
|
pkgPath: string,
|
|
) => string
|
|
}
|
|
|
|
export type StoreState = ToRefs<{
|
|
files: Record<string, File>
|
|
activeFilename: string
|
|
mainFile: string
|
|
template: {
|
|
welcomeSFC?: string
|
|
newSFC?: string
|
|
}
|
|
builtinImportMap: ImportMap
|
|
|
|
// output
|
|
errors: (string | Error)[]
|
|
showOutput: boolean
|
|
outputMode: OutputModes
|
|
sfcOptions: SFCOptions
|
|
ssrOutput: {
|
|
html: string
|
|
context: unknown
|
|
}
|
|
/** `@vue/compiler-sfc` */
|
|
compiler: typeof defaultCompiler
|
|
/* only apply for compiler-sfc */
|
|
vueVersion: string | null
|
|
|
|
// volar-related
|
|
locale: string | undefined
|
|
typescriptVersion: string
|
|
/** \{ dependencyName: version \} */
|
|
dependencyVersion: Record<string, string>
|
|
reloadLanguageTools?: (() => void) | undefined
|
|
|
|
/** Custom online resources */
|
|
resourceLinks?: ResourceLinkConfigs
|
|
}>
|
|
|
|
export interface ReplStore extends UnwrapRef<StoreState> {
|
|
activeFile: File
|
|
/** Loading compiler */
|
|
loading: boolean
|
|
init(): void
|
|
setActive(filename: string): void
|
|
addFile(filename: string | File): void
|
|
deleteFile(filename: string): void
|
|
renameFile(oldFilename: string, newFilename: string): void
|
|
getImportMap(): ImportMap
|
|
setImportMap(map: ImportMap, merge?: boolean): void
|
|
getTsConfig(): Record<string, any>
|
|
serialize(): string
|
|
/**
|
|
* Deserializes the given string to restore the REPL store state.
|
|
* @param serializedState - The serialized state string.
|
|
* @param checkBuiltinImportMap - Whether to check the built-in import map. Default to true
|
|
*/
|
|
deserialize(serializedState: string, checkBuiltinImportMap?: boolean): void
|
|
getFiles(): Record<string, string>
|
|
setFiles(newFiles: Record<string, string>, mainFile?: string): Promise<void>
|
|
/** Custom online resources */
|
|
resourceLinks?: ResourceLinkConfigs
|
|
}
|
|
|
|
export type Store = Pick<
|
|
ReplStore,
|
|
| 'files'
|
|
| 'activeFile'
|
|
| 'mainFile'
|
|
| 'errors'
|
|
| 'showOutput'
|
|
| 'outputMode'
|
|
| 'sfcOptions'
|
|
| 'ssrOutput'
|
|
| 'compiler'
|
|
| 'vueVersion'
|
|
| 'locale'
|
|
| 'typescriptVersion'
|
|
| 'dependencyVersion'
|
|
| 'reloadLanguageTools'
|
|
| 'init'
|
|
| 'setActive'
|
|
| 'addFile'
|
|
| 'deleteFile'
|
|
| 'renameFile'
|
|
| 'getImportMap'
|
|
| 'getTsConfig'
|
|
| 'resourceLinks'
|
|
>
|
|
|
|
export class File {
|
|
compiled = {
|
|
js: '',
|
|
css: '',
|
|
ssr: '',
|
|
clientMap: '',
|
|
ssrMap: '',
|
|
}
|
|
editorViewState: editor.ICodeEditorViewState | null = null
|
|
|
|
constructor(
|
|
public filename: string,
|
|
public code = '',
|
|
public hidden = false,
|
|
) {}
|
|
|
|
get language() {
|
|
if (this.filename.endsWith('.vue')) {
|
|
return 'vue'
|
|
}
|
|
if (this.filename.endsWith('.html')) {
|
|
return 'html'
|
|
}
|
|
if (this.filename.endsWith('.css')) {
|
|
return 'css'
|
|
}
|
|
if (this.filename.endsWith('.ts')) {
|
|
return 'typescript'
|
|
}
|
|
return 'javascript'
|
|
}
|
|
}
|
|
|
|
function addSrcPrefix(file: string) {
|
|
return file === importMapFile ||
|
|
file === tsconfigFile ||
|
|
file.startsWith('src/')
|
|
? file
|
|
: `src/${file}`
|
|
}
|
|
|
|
export function stripSrcPrefix(file: string) {
|
|
return file.replace(/^src\//, '')
|
|
}
|
|
|
|
function fixURL(url: string) {
|
|
return url.replace('https://sfc.vuejs', 'https://play.vuejs')
|
|
}
|
|
|
|
function setFile(
|
|
files: Record<string, File>,
|
|
filename: string,
|
|
content: string,
|
|
) {
|
|
const normalized = addSrcPrefix(filename)
|
|
files[normalized] = new File(normalized, content)
|
|
}
|