consecteturquia
Version:
727 lines (617 loc) • 21.9 kB
text/typescript
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<monaco.editor.ITextModel, string[]>()
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<string>()
const workspacePrefix = 'file:///'
// files within /node_modules
const extraLibs = new Map<string, monaco.editor.ITextModel>()
// 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<void> {
// 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<void> {
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*<reference\s+path=["'](.+?)\7\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<string | undefined> {
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
}