diff --git a/apps/svelte.dev/src/routes/tutorial/[...slug]/+page.svelte b/apps/svelte.dev/src/routes/tutorial/[...slug]/+page.svelte index 2a92105af2..f2aa2078da 100644 --- a/apps/svelte.dev/src/routes/tutorial/[...slug]/+page.svelte +++ b/apps/svelte.dev/src/routes/tutorial/[...slug]/+page.svelte @@ -291,7 +291,7 @@
- + {#if mobile && show_filetree} diff --git a/apps/svelte.dev/src/routes/tutorial/[...slug]/Editor.svelte b/apps/svelte.dev/src/routes/tutorial/[...slug]/Editor.svelte index 4a9d7b0481..7b7f753121 100644 --- a/apps/svelte.dev/src/routes/tutorial/[...slug]/Editor.svelte +++ b/apps/svelte.dev/src/routes/tutorial/[...slug]/Editor.svelte @@ -14,10 +14,13 @@ import { basicSetup } from 'codemirror'; import { onMount, tick } from 'svelte'; import { adapter_state } from './adapter.svelte'; - import { autocomplete_for_svelte } from './autocompletion.js'; import './codemirror.css'; import { files, selected_file, selected_name, update_file } from './state.js'; import { toStore } from 'svelte/store'; + import { autocomplete_for_svelte } from '@sveltejs/site-kit/codemirror'; + + /** @type {import('$lib/tutorial').Exercise}*/ + export let exercise; /** @type {HTMLDivElement} */ let container; @@ -120,7 +123,23 @@ } else if (file.name.endsWith('.html')) { lang = [html()]; } else if (file.name.endsWith('.svelte')) { - lang = [svelte(), ...autocomplete_for_svelte()]; + lang = [ + svelte(), + ...autocomplete_for_svelte( + () => /** @type {import('$lib/tutorial').FileStub} */ ($selected_file).name, + () => + $files + .filter( + (file) => + file.type === 'file' && + file.name.startsWith('/src') && + file.name.startsWith(exercise.scope.prefix) && + file.name !== '/src/__client.js' && + file.name !== '/src/app.html' + ) + .map((file) => file.name) + ) + ]; } state = EditorState.create({ diff --git a/packages/repl/src/lib/CodeMirror.svelte b/packages/repl/src/lib/CodeMirror.svelte index 9154566436..f19a80c3c3 100644 --- a/packages/repl/src/lib/CodeMirror.svelte +++ b/packages/repl/src/lib/CodeMirror.svelte @@ -14,11 +14,11 @@ import { get_repl_context } from './context'; import Message from './Message.svelte'; import { svelteTheme } from './theme'; - import { autocomplete } from './autocomplete'; + import { completion_for_javascript } from '@sveltejs/site-kit/codemirror'; import type { LintSource } from '@codemirror/lint'; import type { Extension } from '@codemirror/state'; import { CompletionContext } from '@codemirror/autocomplete'; - import type { Lang } from './types'; + import type { File, Lang } from './types'; export let diagnostics: LintSource | undefined = undefined; export let readonly = false; @@ -206,12 +206,18 @@ const { files, selected } = get_repl_context(); + function get_basename(file: File) { + return `${file.name}.${file.type}`; + } + const svelte_rune_completions = svelteLanguage.data.of({ - autocomplete: (context: CompletionContext) => autocomplete(context, $selected!, $files) + autocomplete: (context: CompletionContext) => + completion_for_javascript(context, get_basename($selected!), $files.map(get_basename)) }); const js_rune_completions = javascriptLanguage.data.of({ - autocomplete: (context: CompletionContext) => autocomplete(context, $selected!, $files) + autocomplete: (context: CompletionContext) => + completion_for_javascript(context, get_basename($selected!), $files.map(get_basename)) }); diff --git a/packages/repl/src/lib/autocomplete.ts b/packages/repl/src/lib/autocomplete.ts deleted file mode 100644 index 34ba6e7469..0000000000 --- a/packages/repl/src/lib/autocomplete.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { CompletionContext, snippetCompletion } from '@codemirror/autocomplete'; -import { syntaxTree } from '@codemirror/language'; -import type { SyntaxNode } from '@lezer/common'; -import type { File } from './types'; - -interface Test { - (node: SyntaxNode, context: CompletionContext, selected: File): boolean; -} - -/** Returns `true` if `$bindable()` is valid */ -const is_bindable: Test = (node, context) => { - // disallow outside `let { x = $bindable }` - if (node.parent?.name !== 'PatternProperty') return false; - if (node.parent.parent?.name !== 'ObjectPattern') return false; - if (node.parent.parent.parent?.name !== 'VariableDeclaration') return false; - - let last = node.parent.parent.parent.lastChild; - if (!last) return true; - - // if the declaration is incomplete, assume the best - if (last.name === 'ObjectPattern' || last.name === 'Equals' || last.name === '⚠') { - return true; - } - - if (last.name === ';') { - last = last.prevSibling; - if (!last || last.name === '⚠') return true; - } - - // if the declaration is complete, only return true if it is a `$props()` declaration - return ( - last.name === 'CallExpression' && - last.firstChild?.name === 'VariableName' && - context.state.sliceDoc(last.firstChild.from, last.firstChild.to) === '$props' - ); -}; - -/** - * Returns `true` if `$props()` is valid - * TODO only allow in `.svelte` files, and only at the top level - */ -const is_props: Test = (node, _, selected) => { - if (selected.type !== 'svelte') return false; - - return ( - node.name === 'VariableName' && - node.parent?.name === 'VariableDeclaration' && - node.parent.parent?.name === 'Script' - ); -}; - -/** - * Returns `true` is this is a valid place to declare state - */ -const is_state: Test = (node) => { - let parent = node.parent; - - if (node.name === '.' || node.name === 'PropertyName') { - if (parent?.name !== 'MemberExpression') return false; - parent = parent.parent; - } - - if (!parent) return false; - - return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration'; -}; - -/** - * Returns `true` if we're already in a valid call expression, e.g. - * changing an existing `$state()` to `$state.frozen()` - */ -const is_state_call: Test = (node) => { - let parent = node.parent; - - if (node.name === '.' || node.name === 'PropertyName') { - if (parent?.name !== 'MemberExpression') return false; - parent = parent.parent; - } - - if (parent?.name !== 'CallExpression') { - return false; - } - - parent = parent.parent; - if (!parent) return false; - - return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration'; -}; - -const is_statement: Test = (node) => { - if (node.name === 'VariableName') { - return node.parent?.name === 'ExpressionStatement'; - } - - if (node.name === '.' || node.name === 'PropertyName') { - return node.parent?.parent?.name === 'ExpressionStatement'; - } - - return false; -}; - -const runes: Array<{ snippet: string; test?: Test }> = [ - { snippet: '$state(${})', test: is_state }, - { snippet: '$state', test: is_state_call }, - { snippet: '$props()', test: is_props }, - { snippet: '$derived(${});', test: is_state }, - { snippet: '$derived', test: is_state_call }, - { snippet: '$derived.by(() => {\n\t${}\n});', test: is_state }, - { snippet: '$derived.by', test: is_state_call }, - { snippet: '$effect(() => {\n\t${}\n});', test: is_statement }, - { snippet: '$effect.pre(() => {\n\t${}\n});', test: is_statement }, - { snippet: '$state.frozen(${});', test: is_state }, - { snippet: '$state.frozen', test: is_state_call }, - { snippet: '$bindable()', test: is_bindable }, - { snippet: '$effect.root(() => {\n\t${}\n})' }, - { snippet: '$state.snapshot(${})' }, - { snippet: '$state.is(${})' }, - { snippet: '$effect.active()' }, - { snippet: '$inspect(${});', test: is_statement } -]; - -const options = runes.map(({ snippet, test }, i) => ({ - option: snippetCompletion(snippet, { - type: 'keyword', - boost: runes.length - i, - label: snippet.includes('(') ? snippet.slice(0, snippet.indexOf('(')) : snippet - }), - test -})); - -export function autocomplete(context: CompletionContext, selected: File, files: File[]) { - let node = syntaxTree(context.state).resolveInner(context.pos, -1); - - if (node.name === 'String' && node.parent?.name === 'ImportDeclaration') { - const modules = [ - 'svelte', - 'svelte/animate', - 'svelte/easing', - 'svelte/legacy', - 'svelte/motion', - 'svelte/reactivity', - 'svelte/store', - 'svelte/transition' - ]; - - for (const file of files) { - if (file === selected) continue; - modules.push(`./${file.name}.${file.type}`); - } - - return { - from: node.from + 1, - options: modules.map((label) => ({ - label, - type: 'string' - })) - }; - } - - if ( - selected.type !== 'svelte' && - (selected.type !== 'js' || !selected.name.endsWith('.svelte')) - ) { - return false; - } - - if (node.name === 'VariableName' || node.name === 'PropertyName' || node.name === '.') { - // special case — `$inspect(...).with(...)` is the only rune that 'returns' - // an 'object' with a 'method' - if (node.name === 'PropertyName' || node.name === '.') { - if ( - node.parent?.name === 'MemberExpression' && - node.parent.firstChild?.name === 'CallExpression' && - node.parent.firstChild.firstChild?.name === 'VariableName' && - context.state.sliceDoc( - node.parent.firstChild.firstChild.from, - node.parent.firstChild.firstChild.to - ) === '$inspect' - ) { - const open = context.matchBefore(/\.\w*/); - if (!open) return null; - - return { - from: open.from, - options: [snippetCompletion('.with(${})', { type: 'keyword', label: '.with' })] - }; - } - } - - const open = context.matchBefore(/\$[\w\.]*/); - if (!open) return null; - - return { - from: open.from, - options: options - .filter((option) => (option.test ? option.test(node, context, selected) : true)) - .map((option) => option.option) - }; - } -} diff --git a/packages/site-kit/package.json b/packages/site-kit/package.json index f638a4be65..1098259759 100644 --- a/packages/site-kit/package.json +++ b/packages/site-kit/package.json @@ -22,6 +22,17 @@ }, "homepage": "https://github.com/sveltejs/svelte.dev/tree/main/packages/site-kit#readme", "dependencies": { + "@lezer/common": "^1.0.4", + "@codemirror/autocomplete": "^6.9.0", + "@codemirror/commands": "^6.2.5", + "@codemirror/lang-css": "^6.2.1", + "@codemirror/lang-html": "^6.4.6", + "@codemirror/lang-javascript": "^6.2.1", + "@codemirror/language": "^6.9.0", + "@codemirror/lint": "^6.4.1", + "@codemirror/search": "^6.5.2", + "@codemirror/state": "^6.2.1", + "@codemirror/view": "^6.17.1", "@fontsource/dm-serif-display": "^5.1.0", "@fontsource/eb-garamond": "^5.1.0", "@fontsource/fira-mono": "^5.1.0", @@ -30,7 +41,8 @@ "esm-env": "^1.0.0", "json5": "^2.2.3", "shiki": "^1.22.0", - "svelte-persisted-store": "^0.9.2" + "svelte-persisted-store": "^0.9.2", + "@replit/codemirror-lang-svelte": "^6.0.0" }, "devDependencies": { "@sveltejs/kit": "^2.6.3", @@ -63,6 +75,9 @@ "./stores": { "default": "./src/lib/stores/index.ts" }, + "./codemirror": { + "default": "./src/lib/codemirror/index.js" + }, "./branding/svelte-logo.svg": "./src/lib/branding/svelte-logo.svg", "./components": { "default": "./src/lib/components/index.ts", diff --git a/apps/svelte.dev/src/routes/tutorial/[...slug]/autocompletionDataProvider.js b/packages/site-kit/src/lib/codemirror/autocompletionDataProvider.js similarity index 79% rename from apps/svelte.dev/src/routes/tutorial/[...slug]/autocompletionDataProvider.js rename to packages/site-kit/src/lib/codemirror/autocompletionDataProvider.js index d91e627c8e..b7579d39ac 100644 --- a/apps/svelte.dev/src/routes/tutorial/[...slug]/autocompletionDataProvider.js +++ b/packages/site-kit/src/lib/codemirror/autocompletionDataProvider.js @@ -473,3 +473,122 @@ export const addAttributes = { } ] }; + +/** + * Returns `true` is this is a valid place to declare state + * @type {import("./types").Test} + */ +const is_state = (node) => { + let parent = node.parent; + + if (node.name === '.' || node.name === 'PropertyName') { + if (parent?.name !== 'MemberExpression') return false; + parent = parent.parent; + } + + if (!parent) return false; + + return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration'; +}; + +/** + * Returns `true` if we're already in a valid call expression, e.g. + * changing an existing `$state()` to `$state.frozen()` + * @type {import("./types").Test} + */ +const is_state_call = (node) => { + let parent = node.parent; + + if (node.name === '.' || node.name === 'PropertyName') { + if (parent?.name !== 'MemberExpression') return false; + parent = parent.parent; + } + + if (parent?.name !== 'CallExpression') { + return false; + } + + parent = parent.parent; + if (!parent) return false; + + return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration'; +}; + +/** @type {import("./types").Test} */ +const is_statement = (node) => { + if (node.name === 'VariableName') { + return node.parent?.name === 'ExpressionStatement'; + } + + if (node.name === '.' || node.name === 'PropertyName') { + return node.parent?.parent?.name === 'ExpressionStatement'; + } + + return false; +}; + +/** + * Returns `true` if `$props()` is valid + * TODO only allow in `.svelte` files, and only at the top level + * @type {import("./types").Test} + */ +const is_props = (node, _, selected) => { + if (selected.type !== 'svelte') return false; + + return ( + node.name === 'VariableName' && + node.parent?.name === 'VariableDeclaration' && + node.parent.parent?.name === 'Script' + ); +}; + +/** + * Returns `true` if `$bindable()` is valid + * @type {import("./types").Test} + * */ +const is_bindable = (node, context) => { + // disallow outside `let { x = $bindable }` + if (node.parent?.name !== 'PatternProperty') return false; + if (node.parent.parent?.name !== 'ObjectPattern') return false; + if (node.parent.parent.parent?.name !== 'VariableDeclaration') return false; + + let last = node.parent.parent.parent.lastChild; + if (!last) return true; + + // if the declaration is incomplete, assume the best + if (last.name === 'ObjectPattern' || last.name === 'Equals' || last.name === '⚠') { + return true; + } + + if (last.name === ';') { + last = last.prevSibling; + if (!last || last.name === '⚠') return true; + } + + // if the declaration is complete, only return true if it is a `$props()` declaration + return ( + last.name === 'CallExpression' && + last.firstChild?.name === 'VariableName' && + context.state.sliceDoc(last.firstChild.from, last.firstChild.to) === '$props' + ); +}; + +export const runes = [ + { snippet: '$state(${})', test: is_state }, + { snippet: '$state', test: is_state_call }, + { snippet: '$props()', test: is_props }, + { snippet: '$derived(${});', test: is_state }, + { snippet: '$derived', test: is_state_call }, + { snippet: '$derived.by(() => {\n\t${}\n});', test: is_state }, + { snippet: '$derived.by', test: is_state_call }, + { snippet: '$effect(() => {\n\t${}\n});', test: is_statement }, + { snippet: '$effect.pre(() => {\n\t${}\n});', test: is_statement }, + { snippet: '$state.frozen(${});', test: is_state }, + { snippet: '$state.frozen', test: is_state_call }, + { snippet: '$bindable()', test: is_bindable }, + { snippet: '$effect.root(() => {\n\t${}\n})' }, + { snippet: '$state.snapshot(${})' }, + { snippet: '$state.is(${})' }, + { snippet: '$effect.active()' }, + { snippet: '$inspect(${});', test: is_statement } +]; diff --git a/apps/svelte.dev/src/routes/tutorial/[...slug]/autocompletion.js b/packages/site-kit/src/lib/codemirror/index.js similarity index 73% rename from apps/svelte.dev/src/routes/tutorial/[...slug]/autocompletion.js rename to packages/site-kit/src/lib/codemirror/index.js index ce9aa0c5c3..7073e9911b 100644 --- a/apps/svelte.dev/src/routes/tutorial/[...slug]/autocompletion.js +++ b/packages/site-kit/src/lib/codemirror/index.js @@ -1,13 +1,14 @@ import { svelteLanguage } from '@replit/codemirror-lang-svelte'; import { javascriptLanguage } from '@codemirror/lang-javascript'; import { syntaxTree } from '@codemirror/language'; -import { snippetCompletion } from '@codemirror/autocomplete'; +import { CompletionContext, snippetCompletion } from '@codemirror/autocomplete'; import { addAttributes, svelteAttributes, svelteTags, sveltekitAttributes, - svelteEvents + svelteEvents, + runes } from './autocompletionDataProvider.js'; const logic_block_snippets = [ @@ -213,23 +214,113 @@ function completion_for_markup(context) { return null; } +const options = runes.map(({ snippet, test }, i) => ({ + option: snippetCompletion(snippet, { + type: 'keyword', + boost: runes.length - i, + label: snippet.includes('(') ? snippet.slice(0, snippet.indexOf('(')) : snippet + }), + test +})); + /** * @param {import('@codemirror/autocomplete').CompletionContext} context - * @returns {import('@codemirror/autocomplete').CompletionResult | null} + * @param {string} selected + * @param {string[]} files + * @returns {import('@codemirror/autocomplete').CompletionResult | null | false} */ -function completion_for_javascript(context) { - // TODO autocompletion for import source +export function completion_for_javascript(context, selected, files) { + let node = syntaxTree(context.state).resolveInner(context.pos, -1); + + if (node.name === 'String' && node.parent?.name === 'ImportDeclaration') { + const modules = [ + 'svelte', + 'svelte/animate', + 'svelte/easing', + 'svelte/legacy', + 'svelte/motion', + 'svelte/reactivity', + 'svelte/store', + 'svelte/transition' + ]; + + for (const file of files) { + if (file === selected) continue; + + const from = selected.split('/'); + const to = file.split('/'); + + while (from[0] === to[0]) { + from.shift(); + to.shift(); + } + + const prefix = from.length === 1 ? './' : '../'.repeat(from.length - 1); + modules.push(prefix + to.join('/')); + } + + return { + from: node.from + 1, + options: modules.map((label) => ({ + label, + type: 'string' + })) + }; + } + + if (!selected.endsWith('.svelte.js') && !selected.endsWith('.svelte')) { + return false; + } + + if (node.name === 'VariableName' || node.name === 'PropertyName' || node.name === '.') { + // special case — `$inspect(...).with(...)` is the only rune that 'returns' + // an 'object' with a 'method' + if (node.name === 'PropertyName' || node.name === '.') { + if ( + node.parent?.name === 'MemberExpression' && + node.parent.firstChild?.name === 'CallExpression' && + node.parent.firstChild.firstChild?.name === 'VariableName' && + context.state.sliceDoc( + node.parent.firstChild.firstChild.from, + node.parent.firstChild.firstChild.to + ) === '$inspect' + ) { + const open = context.matchBefore(/\.\w*/); + if (!open) return null; + + return { + from: open.from, + options: [snippetCompletion('.with(${})', { type: 'keyword', label: '.with' })] + }; + } + } + + const open = context.matchBefore(/\$[\w\.]*/); + if (!open) return null; + + return { + from: open.from, + options: options + .filter((option) => (option.test ? option.test(node, context, selected) : true)) + .map((option) => option.option) + }; + } return null; } -export function autocomplete_for_svelte() { +/** + * @param {() => string} selected + * @param {() => string[]} files + */ +export function autocomplete_for_svelte(selected, files) { return [ svelteLanguage.data.of({ autocomplete: completion_for_markup }), javascriptLanguage.data.of({ - autocomplete: completion_for_javascript + autocomplete: (/** @type {CompletionContext} */ context) => + completion_for_javascript(context, selected(), files()) }) ]; } diff --git a/packages/site-kit/src/lib/codemirror/types.d.ts b/packages/site-kit/src/lib/codemirror/types.d.ts new file mode 100644 index 0000000000..cc0e3ec50f --- /dev/null +++ b/packages/site-kit/src/lib/codemirror/types.d.ts @@ -0,0 +1,9 @@ +import { CompletionContext, snippetCompletion } from '@codemirror/autocomplete'; +import type { SyntaxNode } from '@lezer/common'; +import type { File } from './types'; + +export interface Test { + (node: SyntaxNode, context: CompletionContext, selected: File): boolean; +} + +export type { File, SyntaxNode }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b197e877bb..8f95cb5bfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,6 +352,36 @@ importers: packages/site-kit: dependencies: + '@codemirror/autocomplete': + specifier: ^6.9.0 + version: 6.16.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.28.0)(@lezer/common@1.2.2) + '@codemirror/commands': + specifier: ^6.2.5 + version: 6.5.0 + '@codemirror/lang-css': + specifier: ^6.2.1 + version: 6.2.1(@codemirror/view@6.28.0) + '@codemirror/lang-html': + specifier: ^6.4.6 + version: 6.4.9 + '@codemirror/lang-javascript': + specifier: ^6.2.1 + version: 6.2.2 + '@codemirror/language': + specifier: ^6.9.0 + version: 6.10.1 + '@codemirror/lint': + specifier: ^6.4.1 + version: 6.7.0 + '@codemirror/search': + specifier: ^6.5.2 + version: 6.5.6 + '@codemirror/state': + specifier: ^6.2.1 + version: 6.4.1 + '@codemirror/view': + specifier: ^6.17.1 + version: 6.28.0 '@fontsource/dm-serif-display': specifier: ^5.1.0 version: 5.1.0 @@ -364,6 +394,12 @@ importers: '@fontsource/fira-sans': specifier: ^5.1.0 version: 5.1.0 + '@lezer/common': + specifier: ^1.0.4 + version: 1.2.2 + '@replit/codemirror-lang-svelte': + specifier: ^6.0.0 + version: 6.0.0(@codemirror/autocomplete@6.16.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.28.0)(@lezer/common@1.2.2))(@codemirror/lang-css@6.2.1(@codemirror/view@6.28.0))(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.28.0)(@lezer/common@1.2.2)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.17)(@lezer/lr@1.4.1) '@shikijs/twoslash': specifier: ^1.22.0 version: 1.22.0(typescript@5.5.4)