Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add streaming type definitions #121

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"monaco-textmate": "^3.0.1",
"onigasm": "^2.2.5",
"prettier": "^2.7.1",
"resolve.exports": "^1.1.0",
"rollup": "^2.78.1",
"solid-dismiss": "^1.2.1",
"solid-heroicons": "^2.0.3",
Expand Down
13 changes: 11 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions src/components/editor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Component, createEffect, onMount, onCleanup } from 'solid-js';
import { Uri, languages, editor as mEditor, KeyMod, KeyCode } from 'monaco-editor';
import { liftOff } from './setupSolid';
import { useZoom } from '../../hooks/useZoom';
import type { Repl } from 'solid-repl/lib/repl';
import loadDefinitions from './loadDefinitions';
import setupMonaco from './setupMonaco';

const Editor: Component<{
url: string;
Expand Down Expand Up @@ -70,13 +71,14 @@ const Editor: Component<{

editor.onDidChangeModelContent(() => {
props.onDocChange?.(editor.getValue());
loadDefinitions(editor.getValue());
});
});
onCleanup(() => editor?.dispose());

createEffect(() => {
editor.setModel(model());
liftOff();
setupMonaco();
});

createEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3055,4 +3055,4 @@
},
"jsx-tag-attributes-illegal": { "name": "invalid.illegal.attribute.tsx", "match": "\\S+" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1075,4 +1075,4 @@
]
}
}
}
}
169 changes: 169 additions & 0 deletions src/components/editor/loadDefinitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { editor, languages, Uri } from 'monaco-editor';
import { resolve } from 'resolve.exports';

const UNPKG = 'https://unpkg.com';

interface PackageJSON {
types?: string;
typings?: string;
}

const GLOBAL_CACHE = new Set();

function matchURLS(str: string): string[] {
// Find all "from" expression
const fromMatches = str.match(/from ((".*")|('.*'))/g) ?? [];
// Find all "dynamic import" expression
const importMatches = str.match(/import\(((".*")|('.*'))\)/g) ?? [];

const matches = [
...fromMatches.map((item) => item.replace('from ', '')),
...importMatches
.map((item) => item.replace('import', ''))
.map((item) => item.substring(1, item.length - 1)),
].map((item) => item.substring(1, item.length - 1));

return matches;
}

function getPackageName(source: string) {
const pathname = source.split('/');
if (source.startsWith('@')) {
return `${pathname[0]}/${pathname[1]}`;
}
return pathname[0];
}

function getTypes(packageName: string) {
// TODO consider namespaced packages
return `@types/${packageName}`;
}

function resolveTypings(pkg: PackageJSON, entry: string, isSubpackage = false) {
if ('exports' in pkg) {
const result = resolve(pkg, entry, {
unsafe: true,
conditions: ['types'],
}) ?? resolve(pkg, entry, {
unsafe: true,
conditions: ['typings'],
});
if (result) {
return result;
}
}
if (!isSubpackage) {
return pkg.types ?? pkg.typings
}
return undefined;
}

function addDefinition(
// Content of the file
content: string,
// Path to file
uri: string,
// File type
type: string,
) {
languages.typescript.typescriptDefaults.addExtraLib(
content,
uri,
);

editor.createModel(
content,
type,
Uri.parse(uri),
);
}

const DTS_CACHE = new Set();

class DefLoader {
static async loadTSFile(source: string) {
// this.loadDTS(`${source}.ts`);
this.loadDTS(`${source}.d.ts`);
}

static async loadDTS(
source: string,
) {
if (DTS_CACHE.has(source)) {
return;
}
DTS_CACHE.add(source);
const targetPath = new URL(source, UNPKG);
const response = await fetch(targetPath);
if (response.ok) {
const dts = await response.text();

addDefinition(dts, `file:///node_modules/${source}`, 'typescript');

const imports = matchURLS(dts) ?? [];

const splitPath = source.split('/');
const directory = splitPath.slice(0, -1).join('/');

await Promise.all(imports.map((item) => {
if (item) {
if (item.startsWith('./') || item.startsWith('../')) {
const clean = item.endsWith('.js') ? item.substring(0, item.length - 3) : item;
const resolved = new URL(`${directory}/${clean}`, 'file://').pathname.substring(1);
this.loadTSFile(resolved);
this.loadTSFile(`${resolved}/index`);
} else {
this.loadPackage(item);
}
}
}));
}
}

static async loadPackage(
// The import URL
source: string,
// referral URL
original = source,
) {
if (GLOBAL_CACHE.has(source)) {
return;
}
GLOBAL_CACHE.add(source);
const packageName = getPackageName(source);
// Get the package.json
const targetUnpkg = new URL(packageName, UNPKG);
const response = await fetch(`${targetUnpkg}/package.json`);
const pkg = await response.json() as PackageJSON;
if (packageName !== source) {
// Attempt to resolve types
const typeDeclarations = resolveTypings(pkg, source, true);
if (typeDeclarations) {
await this.loadDTS(`${packageName}/${typeDeclarations}`);
} else {
this.loadPackage(packageName);
}
} else {
// Check for `types` or `typings`
const typeDeclarations = resolveTypings(pkg, packageName);
if (typeDeclarations) {
addDefinition(JSON.stringify(pkg), `file:///node_modules/${original}/package.json`, 'json');
await this.loadDTS(`${packageName}/${typeDeclarations}`);
return;
}
await this.loadPackage(getTypes(packageName), original);
}
}
}

export default async function loadDefinitions(
source: string,
): Promise<void> {
const imports = matchURLS(source) ?? [];

await Promise.all(imports.map((item) => {
if (item && !item.startsWith('.')) {
DefLoader.loadPackage(item);
}
}));
}
65 changes: 65 additions & 0 deletions src/components/editor/setupLanguages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Registry } from 'monaco-textmate';
import { wireTmGrammars } from 'monaco-editor-textmate';
import * as monaco from 'monaco-editor';
import cssDefinition from './languages/css.tmLanguage.json?url';
import tsxDefinition from './languages/TypeScriptReact.tmLanguage.json?url';

const grammars = new Map();
grammars.set('css', 'source.css');
// grammars.set('javascript', 'source.js');
grammars.set('javascript', 'source.js.jsx');
// grammars.set('jsx', 'source.js.jsx');
// grammars.set('tsx', 'source.tsx');
// grammars.set('typescript', 'source.ts');
grammars.set('typescript', 'source.tsx');

const inverseGrammars: Record<string, string> = {
'source.css': 'css',
'source.js': 'jsx',
// 'source.js': 'javascript',
'source.js.jsx': 'jsx',
Copy link
Member

@milomg milomg Sep 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used? Edit: nvm, I see above, but why not just make 'javascript' also map to source.tsx?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBF we can just remove it. jsx and tsx has different grammars.

'source.tsx': 'tsx',
// 'source.ts': 'typescript',
};


function createRegistry(): Registry {
return new Registry({
getGrammarDefinition: async (scopeName) => {
console.log(scopeName);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I forgot to remove this 🤣

switch (inverseGrammars[scopeName]) {
case 'css':
return {
format: 'json',
content: await (await fetch(cssDefinition)).text(),
};
case 'jsx':
case 'typescript':
case 'javascript':
case 'tsx':
default:
return {
format: 'json',
content: await (await fetch(tsxDefinition)).text(),
};
}
},
});
}

let LOADED = false;

export default async function setupLanguages(
editor: monaco.editor.ICodeEditor,
): Promise<void> {
if (LOADED) {
return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this changes semantics... I thought we needed to wireTmGrammars to every new editor instance (i.e. toggling output code tab)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't recall having to wire it all the time, but if it does cause issues then we can remove the guard

}
LOADED = true;
await wireTmGrammars(
monaco,
createRegistry(),
grammars,
editor,
);
}
14 changes: 14 additions & 0 deletions src/components/editor/setupMonaco.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@

import { loadWASM } from 'onigasm';
import onigasm from 'onigasm/lib/onigasm.wasm?url';
import './setupThemes';
import './setupTypescript';

let LOADED = false;

export default async function setupMonaco(): Promise<void> {
if (!LOADED) {
LOADED = true;
await loadWASM(onigasm);
}
}
Loading