import * as monaco from 'monaco-editor' import { names as namedColors, fromRatio } from '@ctrl/tinycolor' export * from 'monaco-editor' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker' import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import debounce from 'just-debounce' import { injectGlobal } from '@twind/core' import transpile from './transpile' import intellisense from './intellisense' import { withVersion } from './versions' import type { Manifest } from './types' self.MonacoEnvironment = { getWorker: function (_, label) { switch (label) { case 'json': return new jsonWorker() case 'css': case 'scss': case 'less': return new cssWorker() case 'html': case 'handlebars': case 'razor': return new htmlWorker() case 'typescript': case 'javascript': return new tsWorker() default: return new editorWorker() } }, } monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true) monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ ...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(), module: monaco.languages.typescript.ModuleKind.ESNext, target: monaco.languages.typescript.ScriptTarget.ESNext, moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, isolatedModules: true, allowJs: true, strict: true, skipLibCheck: true, }) monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ noSuggestionDiagnostics: true, }) if (import.meta.hot) { const disposables = (import.meta.hot.data.disposables ??= new Set()) for (const disposable of disposables) { disposable.dispose() } disposables.clear() } function track(disposable: monaco.IDisposable) { if (import.meta.hot) { import.meta.hot.data.disposables.add(disposable) } } // Provide collapsable range for @layer comments of output.css -> /* @layer base */ track( monaco.languages.registerFoldingRangeProvider('css', { provideFoldingRanges(model) { const ranges: monaco.languages.FoldingRange[] = [] let start = -1 for (const { range } of model.findMatches('/* @layer ', false, false, false, null, false)) { if (start !== -1) { ranges.push({ start, end: range.startLineNumber - 1, kind: monaco.languages.FoldingRangeKind.Region, }) } start = range.startLineNumber } if (start !== -1) { ranges.push({ start, end: model.getLineCount(), kind: monaco.languages.FoldingRangeKind.Region, }) } return ranges }, }), ) // Provide autocompletion track( monaco.languages.registerCompletionItemProvider('html', { triggerCharacters: [' ', '"', ':', '!', '/', '-', '('], async provideCompletionItems(model, position) { const suggestionAt = await intellisense.suggestAt( model.getValue(), model.getOffsetAt(position), model.getLanguageId(), ) if (!suggestionAt) { return { suggestions: [], incomplete: false, } } const { lineNumber: startLineNumber, column: startColumn } = model.getPositionAt( suggestionAt.start, ) const { lineNumber: endLineNumber, column: endColumn } = model.getPositionAt(suggestionAt.end) const range = { startLineNumber, startColumn, endLineNumber, endColumn } return { suggestions: suggestionAt.suggestions.map((suggestion, index) => { if (suggestion.type === 'variant') { return { label: { label: suggestion.value, detail: suggestion.detail || (suggestion.value.endsWith('[') ? '…]' : suggestion.value.endsWith('/') ? '…' : undefined), description: suggestion.description, }, detail: suggestion.color || suggestion.detail, kind: suggestion.color ? monaco.languages.CompletionItemKind.Color : monaco.languages.CompletionItemKind.Module, sortText: index.toString().padStart(8, '0'), filterText: suggestion.name, insertText: suggestion.value, range, command: { id: 'editor.action.triggerSuggest', title: '', }, } } return { label: { label: suggestion.value, detail: suggestion.detail || (suggestion.value.endsWith('[') ? '…]' : suggestion.value.endsWith('/') ? '…' : undefined), description: suggestion.description, }, detail: suggestion.color || suggestion.detail, kind: suggestion.color ? monaco.languages.CompletionItemKind.Color : suggestion.value.endsWith('[') ? monaco.languages.CompletionItemKind.Variable : suggestion.value.endsWith('/') ? monaco.languages.CompletionItemKind.Class // TypeParameter : monaco.languages.CompletionItemKind.Constant, sortText: index.toString().padStart(8, '0'), filterText: suggestion.name, insertText: suggestion.value, range, command: suggestion.value.endsWith('/') ? { id: 'editor.action.triggerSuggest', title: '', } : undefined, } }), incomplete: true, // So that autocompletion request is sent on every char } }, async resolveCompletionItem(item) { const documentation = await intellisense.documentationFor(item.filterText || item.insertText) if (documentation) { item.documentation = { value: documentation, isTrusted: true, supportHtml: true, supportThemeIcons: true, } } return item }, }), ) // Provide inline colors { // Based on https://github.com/tailwindlabs/play.tailwindcss.com/blob/master/src/monaco/html.js#L99 const colorNames = Object.keys(namedColors) const editabelColorRe = new RegExp( `-\\[(${colorNames.join('|')}|(?:(?:#|(?:(?:hsl|rgb)a?|hwb|lab|lch|color)\\())[^]\\(]+)\\]$`, 'i', ) const oldDecorationsMap = new WeakMap() track( monaco.languages.registerColorProvider('html', { async provideDocumentColors(model) { const colors = await intellisense.collectColors(model.getValue(), model.getLanguageId()) oldDecorationsMap.set( model, model.deltaDecorations( oldDecorationsMap.get(model) || [], colors .filter((color) => !color.editable) .map(({ start, end, rgba }) => { // We must use injectGlobal because the class names are "sanitized" in some way by monaco-editor const className = `_${rgba.r}_${rgba.g}_${rgba.b}_${rgba.a}` injectGlobal({ '._monaco-color-decoration': { '&::before': { content: "' '", boxSizing: 'border-box', display: 'inline-block', width: '0.8em', height: '0.8em', margin: '0.1em 0.2em 0', border: '0.1em solid black', '.vs-dark &': { borderColor: 'rgb(238,238,238)', }, }, [`&.${className}::before`]: { backgroundColor: `rgba(${rgba.r},${rgba.g},${rgba.b},${rgba.a})`, }, }, }) const { lineNumber: startLineNumber, column: startColumn } = model.getPositionAt(start) const { lineNumber: endLineNumber, column: endColumn } = model.getPositionAt(end) return { range: { startLineNumber, startColumn, endLineNumber, endColumn, }, options: { beforeContentClassName: `_monaco-color-decoration ${className}`, }, } }), ), ) return colors .filter((color) => color.editable) .map(({ start, end, rgba }) => { const { r: red, g: green, b: blue, a: alpha } = rgba const { lineNumber: startLineNumber, column: startColumn } = model.getPositionAt(start) const { lineNumber: endLineNumber, column: endColumn } = model.getPositionAt(end) return { range: { startLineNumber, startColumn, endLineNumber, endColumn, }, color: { red, green, blue, alpha }, } }) }, provideColorPresentations(model, colorInfo) { const className = model.getValueInRange(colorInfo.range) const match = className.match(editabelColorRe) if (match === null) return [] const currentColor = match[1] const isNamedColor = colorNames.includes(currentColor) const color = fromRatio({ r: colorInfo.color.red, g: colorInfo.color.green, b: colorInfo.color.blue, a: colorInfo.color.alpha, }) let hexValue = color.toHex8String( !isNamedColor && (currentColor.length === 4 || currentColor.length === 5), ) if (hexValue.length === 5) { hexValue = hexValue.replace(/f$/, '') } else if (hexValue.length === 9) { hexValue = hexValue.replace(/ff$/, '') } const prefix = className.substr(0, match.index) return [ hexValue, color.toRgbString().replace(/ /g, ''), color.toHslString().replace(/ /g, ''), ].map((value) => ({ label: `${prefix}-[${value}]` })) }, }), ) } // Provide hovers track( monaco.languages.registerHoverProvider('html', { async provideHover(model, position) { const documentationAt = await intellisense.documentationAt( model.getValue(), model.getOffsetAt(position), model.getLanguageId(), ) if (documentationAt) { const { lineNumber: startLineNumber, column: startColumn } = model.getPositionAt( documentationAt.start, ) const { lineNumber: endLineNumber, column: endColumn } = model.getPositionAt( documentationAt.end, ) return { contents: [ { value: documentationAt.value, isTrusted: true, supportHtml: true, supportThemeIcons: true, }, ], range: { startLineNumber, startColumn, endLineNumber, endColumn }, } } }, }), ) // Provide diagnostics track( monaco.editor.onDidCreateModel((model) => { const updateDecorations = debounce(() => { if (model.isDisposed()) return if (!model.isAttachedToEditor()) return const value = model.getValue() const language = model.getLanguageId() intellisense .validate(value, language) .then((diagnostics) => { if (model.isDisposed()) return if (!model.isAttachedToEditor()) return if (value !== model.getValue() || language !== model.getLanguageId()) return monaco.editor.setModelMarkers( model, 'twind', diagnostics.map((diagnostic) => { const { lineNumber: startLineNumber, column: startColumn } = model.getPositionAt( diagnostic.start, ) const { lineNumber: endLineNumber, column: endColumn } = model.getPositionAt( diagnostic.end, ) // TODO: diagnostic.suggestions for quick fixes return { code: diagnostic.code, message: diagnostic.message, severity: diagnostic.severity === 'hint' ? monaco.MarkerSeverity.Hint : diagnostic.severity === 'info' ? monaco.MarkerSeverity.Info : diagnostic.severity === 'warning' ? monaco.MarkerSeverity.Warning : monaco.MarkerSeverity.Error, startLineNumber, startColumn, endLineNumber, endColumn, relatedInformation: diagnostic.related?.map((related) => { const { lineNumber: startLineNumber, column: startColumn } = model.getPositionAt( diagnostic.start, ) const { lineNumber: endLineNumber, column: endColumn } = model.getPositionAt( diagnostic.end, ) return { resource: monaco.Uri.parse(related.resource), message: related.message, startLineNumber, startColumn, endLineNumber, endColumn, } }), } }), ) }) .catch((error) => { console.error(`Failed to update markers for ${model.uri.toString(true)}`, error) }) }, 750) const didChangeContent = model.onDidChangeContent(updateDecorations) const didChangeLanguage = model.onDidChangeLanguage(updateDecorations) const didChangeAttached = model.onDidChangeAttached(updateDecorations) track( model.onWillDispose(() => { didChangeContent.dispose() didChangeLanguage.dispose() didChangeAttached.dispose() }), ) track({ dispose() { monaco.editor.removeAllMarkers('twind') }, }) updateDecorations() }), ) const cdn = 'https://cdn.skypack.dev' const fetchedURLs = new Set() const workspacePrefix = 'file:///' // files within /node_modules const extraLibs = new Map() // const nodeModulesPrefix = workspacePrefix + 'node_modules/' // const LOCAL_STORAGE_PREFIX = 'monaco.languages.typescript.extraLibs:' // if (browser) { // try { // for (let i = 0; i < localStorage.length; i++) { // const key = localStorage.key(i) // if (key?.startsWith(LOCAL_STORAGE_PREFIX)) { // const content = localStorage.getItem(key) // if (content) { // const filePath = key.slice(LOCAL_STORAGE_PREFIX.length) // fetchedURLs.add(`${cdn}/-/${filePath}`) // addExtraLib(content, filePath) // } // } // } // } catch (error) { // // ignore // console.error(error) // } // console.debug(monaco.languages.typescript.typescriptDefaults.getExtraLibs()) // } export function getOrCreateModel(path: string, defaultValue = '', defaultLanguage?: string) { const uri = monaco.Uri.parse(new URL(path, workspacePrefix).href) return ( monaco.editor.getModel(uri) || monaco.editor.createModel(defaultValue, defaultLanguage || detectLanguage(path), uri) ) } function detectLanguage(path: string) { const ext = path.replace(/^.+\.([^.]+)$/, '$1') if (/^[cm]?[jt]sx?$/.test(ext)) { return 'typescript' } return ext } export function cleanupWorkspace() { const extraLibs = monaco.languages.typescript.typescriptDefaults.getExtraLibs() for (const model of monaco.editor.getModels()) { const uri = model.uri.toString(true) if (!model.isDisposed() && uri.startsWith(workspacePrefix) && !extraLibs[uri]) { try { model.dispose() } catch {} } } } export function loadTypeDeclarations(value: string, path: string, manifest: Manifest) { transpile .findImports(value, { manifest }) .then((imports) => imports.map((moduleName) => loadModuleTypeDeclarations(moduleName, manifest)), ) .catch((error) => { console.error(`Failed to fetch types for '${path}'`, error) }) } export function loadModelTypeDeclarations( model: monaco.editor.ITextModel | null, manifest: Manifest, ) { if (!model) return if (model.isDisposed()) return if (!['javascript', 'typescript'].includes(model.getLanguageId())) return loadTypeDeclarations(model.getValue(), model.uri.toString(true), manifest) } export function loadModuleTypeDeclarations(moduleName: string, manifest: Manifest): void { fetchModuleTypeDeclarations(moduleName, manifest).catch((error) => { console.error(`Failed to fetch type declarations for ${moduleName}`, error) }) } async function fetchModuleTypeDeclarations(moduleName: string, manifest: Manifest): Promise { // validate moduleName if (/[*\s]/.test(moduleName)) { return } const { specifier: id } = withVersion(moduleName, manifest) const url = `${cdn}/${id}?dts` if (fetchedURLs.has(url)) return fetchedURLs.add(url) console.debug(`Fetching type declarations for ${moduleName} from ${url}`) let response = await fetch(url, { method: 'HEAD', redirect: 'follow', }) .then((response) => { if ( response.ok && response.status === 200 && response.headers.get('x-import-status') !== 'SUCCESS' ) { return fetch(url, { method: 'HEAD', redirect: 'follow', }) } return response }) .catch((error) => { // do not try to load this url again for 5 minutes setTimeout(() => { fetchedURLs.delete(url) }, 5 * 60 * 1000) throw error }) // do not try to load this url again for 3 minutes setTimeout(() => { fetchedURLs.delete(url) }, 3 * 60 * 1000) if (!(response.ok && response.status === 200)) { return } if (response.redirected && fetchedURLs.has(response.url)) return fetchedURLs.add(response.url) const pathname = response.headers.get('x-typescript-types') if (pathname) { const url = new URL(pathname, response.url).href const dts = await fetchDTS(url) if (dts) { await saveExtraLib(dts, url) // not adding to cache as it depends on current workspace manifest const importFrom = JSON.stringify(new URL(url).pathname.replace(/^\/-\/|\.d\.ts$/g, '')) const model = getOrCreateModel(`node_modules/${moduleName}.d.ts`) model.setValue( [`export * from ${importFrom};`, `export { default } from ${importFrom};`].join('\n'), ) console.debug( `Installed type declarations facade for ${id} from ${model.uri.toString( true, )} to ${importFrom}`, ) } } } async function saveExtraLib(dts: string, url: string): Promise { addExtraLib( dts.replace(/((?:import|from)\s+)(["'])\/-\/([^\s"']+)\.d\.ts\2/g, '$1$2$3$2'), new URL(url).pathname.replace('/-/', ''), ) const re = /\b(?:import|export)\s[\s\S]+?\sfrom\s+(["'])(.+?)\1|\bimport\s+(["'])(.+?)\3|\bimport\((["'])(.+?)\5\)|\/+\s*/g let match const imports = [] while ((match = re.exec(dts))) { const pathname = match[2] || match[4] || match[6] || match[8] if (pathname.endsWith('.d.ts')) { imports.push(pathname) } } await Promise.all( imports.map(async (pathname) => { const importUrl = new URL(pathname, url).href try { const dts = await fetchDTS(importUrl) if (dts) { await saveExtraLib(dts, importUrl) } } catch (error) { console.error(`Failed to fetch dts from ${importUrl}:`, error) } }), ) } function addExtraLib(content: string, filePath: string): void { const path = `node_modules/${filePath}` const model = getOrCreateModel(path) model.setValue(content) const uri = model.uri.toString(true) monaco.languages.typescript.typescriptDefaults.addExtraLib(content, uri) extraLibs.set(filePath, model) console.debug(`Added extra lib "${uri}"`) // if (browser) { // try { // localStorage.setItem(LOCAL_STORAGE_PREFIX + filePath, content) // } catch { // // ignore // } // } } async function fetchDTS(url: string): Promise { if (fetchedURLs.has(url)) return fetchedURLs.add(url) const response = await fetch(url, { redirect: 'follow' }).catch((error) => { // do not try to load this url again for 5 minutes setTimeout(() => { fetchedURLs.delete(url) }, 5 * 60 * 1000) throw error }) if (!(response.ok && response.status === 200)) { // do not try to load this url again for 5 minutes setTimeout(() => { fetchedURLs.delete(url) }, 5 * 60 * 1000) return } if (response.redirected && fetchedURLs.has(response.url)) return fetchedURLs.add(response.url) const dts = await response.text().catch((error) => { // do not try to load this url again for 5 minutes setTimeout(() => { fetchedURLs.delete(url) fetchedURLs.delete(response.url) }, 5 * 60 * 1000) throw error }) return dts }