Merge pull request #11 from FatttSnake/refactor/compiler

Add support for loading css from ImportMap and loading binary file
This commit is contained in:
2024-09-15 18:44:48 +08:00
committed by GitHub
17 changed files with 1055 additions and 1127 deletions

View File

@@ -2,7 +2,7 @@ import MonacoEditor from '@monaco-editor/react'
import { Loader } from 'esbuild-wasm' import { Loader } from 'esbuild-wasm'
import '@/components/Playground/Output/Transform/transform.scss' import '@/components/Playground/Output/Transform/transform.scss'
import { IFile, ITheme } from '@/components/Playground/shared' import { IFile, ITheme } from '@/components/Playground/shared'
import { cssToJs, jsonToJs } from '@/components/Playground/files' import { cssToJsFromFile, jsonToJsFromFile } from '@/components/Playground/files'
import Compiler from '@/components/Playground/compiler' import Compiler from '@/components/Playground/compiler'
import { MonacoEditorConfig } from '@/components/Playground/CodeEditor/Editor/monacoConfig' import { MonacoEditorConfig } from '@/components/Playground/CodeEditor/Editor/monacoConfig'
@@ -39,10 +39,10 @@ const Transform = ({ file, theme }: OutputProps) => {
compile(code, 'jsx') compile(code, 'jsx')
break break
case 'css': case 'css':
setCompiledCode(cssToJs(file)) setCompiledCode(cssToJsFromFile(file))
break break
case 'json': case 'json':
setCompiledCode(jsonToJs(file)) setCompiledCode(jsonToJsFromFile(file))
break break
case 'xml': case 'xml':
setCompiledCode(code) setCompiledCode(code)

View File

@@ -1,8 +1,22 @@
import esbuild, { Loader, OnLoadArgs, Plugin, PluginBuild } from 'esbuild-wasm' import esbuild, {
Loader,
OnLoadArgs,
OnLoadResult,
OnResolveArgs,
OnResolveResult,
Plugin,
PluginBuild
} from 'esbuild-wasm'
import localforage from 'localforage' import localforage from 'localforage'
import axios from 'axios' import axios from 'axios'
import { IFile, IFiles, IImportMap } from '@/components/Playground/shared' import { IFile, IFiles, IImportMap } from '@/components/Playground/shared'
import { addReactImport, cssToJs, jsonToJs } from '@/components/Playground/files' import {
addReactImport,
cssToJs,
cssToJsFromFile,
jsonToJs,
jsonToJsFromFile
} from '@/components/Playground/files'
class Compiler { class Compiler {
private init = false private init = false
@@ -26,44 +40,47 @@ class Compiler {
} }
} }
transform = (code: string, loader: Loader) => private waitInit = async () => {
new Promise<void>((resolve) => { if (!this.init) {
if (this.init) { await new Promise<void>((resolve) => {
resolve() const checkInit = () => {
return if (this.init) {
} resolve()
const timer = setInterval(() => { } else {
if (this.init) { setTimeout(checkInit, 100)
clearInterval(timer) }
resolve()
} }
}, 100) checkInit()
}).then(() => {
return esbuild.transform(code, { loader })
})
compile = (files: IFiles, importMap: IImportMap, entryPoint: string) =>
new Promise<void>((resolve) => {
if (this.init) {
resolve()
return
}
const timer = setInterval(() => {
if (this.init) {
clearInterval(timer)
resolve()
}
}, 100)
}).then(() => {
return esbuild.build({
bundle: true,
entryPoints: [entryPoint],
format: 'esm',
metafile: true,
write: false,
plugins: [this.fileResolverPlugin(files, importMap)]
}) })
}
}
transform = async (code: string, loader: Loader) => {
await this.waitInit()
return esbuild.transform(code, { loader })
}
compile = async (files: IFiles, importMap: IImportMap, entryPoint: string) => {
await this.waitInit()
return esbuild.build({
bundle: true,
entryPoints: [entryPoint],
format: 'esm',
metafile: true,
write: false,
plugins: [this.fileResolverPlugin(files, importMap)]
}) })
}
compileCss = async (cssCode: string, basePath: string) => {
await this.waitInit()
return esbuild.build({
bundle: true,
entryPoints: [basePath],
write: false,
plugins: [this.cssCodeResolverPlugin(cssCode, basePath)]
})
}
stop = () => { stop = () => {
void esbuild.stop() void esbuild.stop()
@@ -72,7 +89,7 @@ class Compiler {
private fileResolverPlugin = (files: IFiles, importMap: IImportMap): Plugin => ({ private fileResolverPlugin = (files: IFiles, importMap: IImportMap): Plugin => ({
name: 'file-resolver-plugin', name: 'file-resolver-plugin',
setup: (build: PluginBuild) => { setup: (build: PluginBuild) => {
build.onResolve({ filter: /.*/ }, (args: esbuild.OnResolveArgs) => { build.onResolve({ filter: /.*/ }, (args: OnResolveArgs): OnResolveResult => {
if (args.kind === 'entry-point') { if (args.kind === 'entry-point') {
return { return {
namespace: 'oxygen', namespace: 'oxygen',
@@ -107,13 +124,13 @@ class Compiler {
} }
} }
let path = importMap.imports[args.path] let path = importMap[args.path]
let tempPath = args.path let tempPath = args.path
while (!path && tempPath.includes('/')) { while (!path && tempPath.includes('/')) {
tempPath = tempPath.substring(0, tempPath.lastIndexOf('/')) tempPath = tempPath.substring(0, tempPath.lastIndexOf('/'))
if (importMap.imports[tempPath]) { if (importMap[tempPath]) {
const suffix = args.path.replace(tempPath, '') const suffix = args.path.replace(tempPath, '')
const importUrl = new URL(importMap.imports[tempPath]) const importUrl = new URL(importMap[tempPath])
path = `${importUrl.origin}${importUrl.pathname}${suffix}${importUrl.search}` path = `${importUrl.origin}${importUrl.pathname}${suffix}${importUrl.search}`
} }
} }
@@ -122,7 +139,7 @@ class Compiler {
} }
const pathUrl = new URL(path) const pathUrl = new URL(path)
const externals = pathUrl.searchParams.get('external')?.split(',') ?? [] const externals = pathUrl.searchParams.get('external')?.split(',') ?? []
Object.keys(importMap.imports).forEach((item) => { Object.keys(importMap).forEach((item) => {
if (!(item in externals)) { if (!(item in externals)) {
externals.push(item) externals.push(item)
} }
@@ -134,54 +151,152 @@ class Compiler {
} }
}) })
build.onLoad({ filter: /.*\.css$/ }, (args: OnLoadArgs) => { build.onLoad({ filter: /.*\.css$/ }, (args: OnLoadArgs): OnLoadResult => {
const contents = cssToJs(files[args.path]) const contents = cssToJsFromFile(files[args.path])
return { return {
loader: 'js', loader: 'js',
contents contents
} }
}) })
build.onLoad({ filter: /.*\.json$/ }, (args: OnLoadArgs) => { build.onLoad({ filter: /.*\.json$/ }, (args: OnLoadArgs): OnLoadResult => {
const contents = jsonToJs(files[args.path]) const contents = jsonToJsFromFile(files[args.path])
return { return {
loader: 'js', loader: 'js',
contents contents
} }
}) })
build.onLoad({ namespace: 'oxygen', filter: /.*/ }, (args: OnLoadArgs) => { build.onLoad(
let file: IFile | undefined { namespace: 'oxygen', filter: /.*/ },
(args: OnLoadArgs): OnLoadResult | undefined => {
let file: IFile | undefined
void ['', '.tsx', '.jsx', '.ts', '.js'].forEach((suffix) => { void ['', '.tsx', '.jsx', '.ts', '.js'].forEach((suffix) => {
file = file || files[`${args.path}${suffix}`] file = file || files[`${args.path}${suffix}`]
}) })
if (file) { if (file) {
return { return {
loader: (() => { loader: (() => {
switch (file.language) { switch (file.language) {
case 'javascript': case 'javascript':
return 'jsx' return 'jsx'
default: default:
return 'tsx' return 'tsx'
} }
})(), })(),
contents: addReactImport(file.value) contents: addReactImport(file.value)
}
} }
} }
}) )
build.onLoad({ filter: /.*/ }, async (args: OnLoadArgs) => { build.onLoad({ filter: /.*/ }, async (args: OnLoadArgs): Promise<OnLoadResult> => {
const cached = await this.fileCache.getItem<esbuild.OnLoadResult>(args.path) const cached = await this.fileCache.getItem<OnLoadResult>(args.path)
if (cached) { if (cached) {
return cached return cached
} }
const axiosResponse = await axios.get<string>(args.path) const axiosResponse = await axios.get<ArrayBuffer>(args.path, {
const result: esbuild.OnLoadResult = { responseType: 'arraybuffer'
loader: 'js', })
contents: axiosResponse.data, const contentType = axiosResponse.headers['content-type'] as string
const utf8Decoder = new TextDecoder('utf-8')
const result: OnLoadResult = {
loader: (() => {
if (
contentType.includes('javascript') ||
contentType.includes('css') ||
contentType.includes('json')
) {
return 'js'
}
return 'base64'
})(),
contents: await (async () => {
if (contentType.includes('css')) {
return cssToJs(
(
await this.compileCss(
utf8Decoder.decode(axiosResponse.data),
args.path
)
).outputFiles[0].text
)
}
if (contentType.includes('json')) {
return jsonToJs(utf8Decoder.decode(axiosResponse.data))
}
return new Uint8Array(axiosResponse.data)
})(),
resolveDir: args.path
}
await this.fileCache.setItem(args.path, result)
return result
})
}
})
private cssCodeResolverPlugin = (cssCode: string, basePath: string): Plugin => ({
name: 'css-code-resolver-plugin',
setup: (build: PluginBuild) => {
build.onResolve({ filter: /.*/ }, (args: OnResolveArgs): OnResolveResult => {
if (args.kind === 'entry-point') {
return {
namespace: 'default',
path: basePath
}
}
return {
namespace: 'default',
path: args.resolveDir.length
? new URL(args.path, args.resolveDir.substring(1)).href
: new URL(args.path, basePath).href
}
})
build.onLoad({ filter: /.*/ }, async (args: OnLoadArgs): Promise<OnLoadResult> => {
if (args.path === basePath) {
return {
loader: 'css',
contents: cssCode,
resolveDir: basePath
}
}
const cached = await this.fileCache.getItem<OnLoadResult>(args.path)
if (cached) {
return cached
}
const axiosResponse = await axios.get<ArrayBuffer>(args.path, {
responseType: 'arraybuffer'
})
const contentType = axiosResponse.headers['content-type'] as string
const utf8Decoder = new TextDecoder('utf-8')
const result: OnLoadResult = {
loader: (() => {
if (contentType.includes('css')) {
return 'js'
}
return 'dataurl'
})(),
contents: await (async () => {
if (contentType.includes('css')) {
return cssToJs(
(
await this.compileCss(
utf8Decoder.decode(axiosResponse.data),
args.path
)
).outputFiles[0].text
)
}
return new Uint8Array(axiosResponse.data)
})(),
resolveDir: args.path resolveDir: args.path
} }

View File

@@ -83,21 +83,25 @@ export const jsToBlob = (code: string) => {
return URL.createObjectURL(new Blob([code], { type: 'application/javascript' })) return URL.createObjectURL(new Blob([code], { type: 'application/javascript' }))
} }
export const jsonToJs = (file: IFile) => { export const jsonToJs = (code: string) => {
return `export default ${file.value}` return `export default ${code}`
} }
export const cssToJs = (file: IFile) => { export const jsonToJsFromFile = (file: IFile) => {
return jsonToJs(file.value)
}
export const cssToJs = (code: string, fileName?: string) => {
const randomId = new Date().getTime() const randomId = new Date().getTime()
return `(() => { return `(() => {
let stylesheet = document.getElementById('style_${randomId}_${file.name}'); let stylesheet = document.getElementById('style_${randomId}${fileName ? `_${fileName}` : ''}');
if (!stylesheet) { if (!stylesheet) {
stylesheet = document.createElement('style') stylesheet = document.createElement('style')
stylesheet.setAttribute('id', 'style_${randomId}_${file.name}') stylesheet.setAttribute('id', 'style_${randomId}_${fileName ? `_${fileName}` : ''}')
document.head.appendChild(stylesheet) document.head.appendChild(stylesheet)
} }
const styles = document.createTextNode( const styles = document.createTextNode(
\`${file.value}\` \`${code}\`
) )
stylesheet.innerHTML = '' stylesheet.innerHTML = ''
stylesheet.appendChild(styles) stylesheet.appendChild(styles)
@@ -105,6 +109,10 @@ export const cssToJs = (file: IFile) => {
` `
} }
export const cssToJsFromFile = (file: IFile) => {
return cssToJs(file.value, file.name)
}
export const addReactImport = (code: string) => { export const addReactImport = (code: string) => {
if (!/^\s*import\s+React\s+/g.test(code)) { if (!/^\s*import\s+React\s+/g.test(code)) {
return `import React from 'react';\n${code}` return `import React from 'react';\n${code}`
@@ -118,18 +126,12 @@ export const tsconfigJsonDiagnosticsOptions: DiagnosticsOptions = {
{ {
uri: 'tsconfig.json', uri: 'tsconfig.json',
fileMatch: ['tsconfig.json'], fileMatch: ['tsconfig.json'],
schema: { schema: tsconfigSchema
type: 'object',
properties: tsconfigSchema
}
}, },
{ {
uri: 'import-map.json', uri: 'import-map.json',
fileMatch: ['import-map.json'], fileMatch: ['import-map.json'],
schema: { schema: importMapSchema
type: 'object',
properties: importMapSchema
}
} }
] ]
} }

View File

@@ -1,6 +1,4 @@
{ {
"imports": {
"type": "object", "type": "object",
"description": "Import map" "additionalProperties": {"type": "string"}
}
} }

View File

@@ -34,7 +34,7 @@ const Playground = ({
try { try {
setImportMap(JSON.parse(importMapRaw) as IImportMap) setImportMap(JSON.parse(importMapRaw) as IImportMap)
} catch (e) { } catch (e) {
setImportMap({ imports: {} }) setImportMap({})
} }
} }
if (!tsconfig) { if (!tsconfig) {

View File

@@ -14,20 +14,7 @@ export interface IFiles {
[key: string]: IFile [key: string]: IFile
} }
export interface ITemplate { export type IImportMap = Record<string, string>
name: string
tsconfig: ITsconfig
importMap: IImportMap
files: IFiles
}
export interface ITemplates {
[key: string]: ITemplate
}
export interface IImportMap {
imports: Record<string, string>
}
export interface ITsconfig { export interface ITsconfig {
compilerOptions: CompilerOptions compilerOptions: CompilerOptions

View File

@@ -1,59 +0,0 @@
import { ITemplates } from '@/components/Playground/shared'
import { ENTRY_FILE_NAME, MAIN_FILE_NAME } from '@/components/Playground/files'
import baseTsconfig from '@/components/Playground/templates/base/tsconfig.json'
import baseImportMap from '@/components/Playground/templates/base/import-map.json'
import baseMain from '@/components/Playground/templates/base/main.tsx?raw'
import baseApp from '@/components/Playground/templates/base/App.tsx?raw'
import demoTsconfig from '@/components/Playground/templates/demo/tsconfig.json'
import demoImportMap from '@/components/Playground/templates/demo/import-map.json'
import demoMain from '@/components/Playground/templates/demo/main.tsx?raw'
import demoApp from '@/components/Playground/templates/demo/App.tsx?raw'
import demoAppCSS from '@/components/Playground/templates/demo/App.css?raw'
const templates: ITemplates = {
base: {
name: '基础',
tsconfig: baseTsconfig,
importMap: baseImportMap,
files: {
[ENTRY_FILE_NAME]: {
name: ENTRY_FILE_NAME,
language: 'typescript',
value: baseMain,
hidden: true
},
[MAIN_FILE_NAME]: {
name: MAIN_FILE_NAME,
language: 'typescript',
value: baseApp
}
}
},
demo: {
name: 'Demo',
tsconfig: demoTsconfig,
importMap: demoImportMap,
files: {
[ENTRY_FILE_NAME]: {
name: ENTRY_FILE_NAME,
language: 'typescript',
value: demoMain,
hidden: true
},
[MAIN_FILE_NAME]: {
name: MAIN_FILE_NAME,
language: 'typescript',
value: demoApp
},
['App.css']: {
name: 'App.css',
language: 'css',
value: demoAppCSS
}
}
}
}
export default templates

View File

@@ -1,5 +0,0 @@
const App = () => {
return <></>
}
export default App

View File

@@ -1,6 +0,0 @@
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom/client": "https://esm.sh/react-dom@18.2.0"
}
}

View File

@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": 7,
"useDefineForClassFields": true,
"module": 99,
"skipLibCheck": true,
"moduleResolution": 2,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": 4,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"composite": true,
"types": ["node"],
"allowSyntheticDefaultImports": true
}
}

View File

@@ -1,65 +0,0 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-weight: 400;
line-height: 1.5;
color: rgb(255 255 255 / 87%);
text-rendering: optimizelegibility;
text-size-adjust: 100%;
background-color: #242424;
color-scheme: light dark;
font-synthesis: none;
}
#root {
max-width: 1280px;
padding: 2rem;
margin: 0 auto;
text-align: center;
}
body {
display: flex;
min-width: 320px;
min-height: 100vh;
margin: 0;
place-items: center;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
padding: 0.6em 1.2em;
font-family: inherit;
font-size: 1em;
font-weight: 500;
cursor: pointer;
background-color: #1a1a1a;
border: 1px solid transparent;
border-radius: 8px;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #fff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -1,17 +0,0 @@
import { useState } from 'react'
import './App.css'
const App = () => {
const [count, setCount] = useState(0)
return (
<>
<h1>Hello World</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
</div>
</>
)
}
export default App

View File

@@ -1,6 +0,0 @@
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom/client": "https://esm.sh/react-dom@18.2.0"
}
}

View File

@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": 7,
"useDefineForClassFields": true,
"module": 99,
"skipLibCheck": true,
"moduleResolution": 2,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": 4,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"composite": true,
"types": ["node"],
"allowSyntheticDefaultImports": true
}
}

File diff suppressed because it is too large Load Diff