diff --git a/src/routes/tutorial/[slug]/Editor.svelte b/src/routes/tutorial/[slug]/Editor.svelte index 982ece3c7..4778bdcdb 100644 --- a/src/routes/tutorial/[slug]/Editor.svelte +++ b/src/routes/tutorial/[slug]/Editor.svelte @@ -17,6 +17,7 @@ import { afterNavigate, beforeNavigate } from '$app/navigation'; import { files, selected_file, selected_name, update_file } from './state.js'; import { warnings } from './adapter.js'; + import { autocomplete_for_svelte } from './autocompletion.js'; import './codemirror.css'; /** @type {HTMLDivElement} */ @@ -127,16 +128,16 @@ let lang; if (file.name.endsWith('.js') || file.name.endsWith('.json')) { - lang = javascript(); + lang = [javascript()]; } else if (file.name.endsWith('.html')) { - lang = html(); + lang = [html()]; } else if (file.name.endsWith('.svelte')) { - lang = svelte(); + lang = [svelte(), ...autocomplete_for_svelte()]; } state = EditorState.create({ doc: file.contents, - extensions: lang ? [...extensions, lang] : extensions + extensions: lang ? [...extensions, ...lang] : extensions }); editor_states.set(file.name, state); diff --git a/src/routes/tutorial/[slug]/autocompletion.js b/src/routes/tutorial/[slug]/autocompletion.js new file mode 100644 index 000000000..ce9aa0c5c --- /dev/null +++ b/src/routes/tutorial/[slug]/autocompletion.js @@ -0,0 +1,235 @@ +import { svelteLanguage } from '@replit/codemirror-lang-svelte'; +import { javascriptLanguage } from '@codemirror/lang-javascript'; +import { syntaxTree } from '@codemirror/language'; +import { snippetCompletion } from '@codemirror/autocomplete'; +import { + addAttributes, + svelteAttributes, + svelteTags, + sveltekitAttributes, + svelteEvents +} from './autocompletionDataProvider.js'; + +const logic_block_snippets = [ + snippetCompletion('#if ${}}\n\n{/if', { label: '#if', type: 'keyword' }), + snippetCompletion('#each ${} as }\n\n{/each', { + label: '#each', + type: 'keyword' + }), + snippetCompletion('#await ${} then }\n\n{/await', { + label: '#await then', + type: 'keyword' + }), + snippetCompletion('#await ${}}\n\n{:then }\n\n{/await', { + label: '#await :then', + type: 'keyword' + }), + snippetCompletion('#key ${}}\n\n{/key', { label: '#key', type: 'keyword' }) +]; + +const special_tag_snippets = [ + snippetCompletion('@html ${}', { label: '@html', type: 'keyword' }), + snippetCompletion('@debug ${}', { label: '@debug', type: 'keyword' }), + snippetCompletion('@const ${}', { label: '@const', type: 'keyword' }) +]; + +/** + * @param {import('@codemirror/autocomplete').CompletionContext} context + * @param {import("@lezer/common").SyntaxNode} node + */ +function completion_for_block(context, node) { + const prefix = context.state.doc.sliceString(node.from, node.from + 1); + + const from = node.from; + const to = context.pos; + + const type = 'keyword'; + + if (prefix === '/') { + /** @param {string} label */ + const completion = (label) => ({ from, to, options: [{ label, type }], validFor: /^\/\w*$/ }); + + const parent = node.parent; + const block = node.parent?.parent; + + if (parent?.name === 'EachBlockClose' || block?.name === 'EachBlock') { + return completion('/each'); + } else if (parent?.name === 'IfBlockClose' || block?.name === 'IfBlock') { + return completion('/if'); + } else if (parent?.name === 'AwaitBlockClose' || block?.name === 'AwaitBlock') { + return completion('/await'); + } else if (parent?.name === 'KeyBlockClose' || block?.name === 'KeyBlock') { + return completion('/key'); + } + } else if (prefix === ':') { + /** @param {import('@codemirror/autocomplete').Completion[]} options */ + const completion = (options) => ({ from, to, options, validFor: /^\:\w*$/ }); + + const parent = node.parent; + const block = node.parent?.parent; + + if (parent?.name === 'ElseBlock' || block?.name === 'IfBlock') { + return completion([ + { label: ':else', type }, + { label: ':else if ', type } + ]); + } else if (parent?.name === 'ThenBlock' || block?.name === 'AwaitBlock') { + return completion([ + { label: ':then', type }, + { label: ':catch', type } + ]); + } + } else if (prefix === '#') { + return { from, to, options: logic_block_snippets, validFor: /^#(\w)*$/ }; + } else if (prefix === '@') { + return { from, to, options: special_tag_snippets, validFor: /^@(\w)*$/ }; + } + + return null; +} + +const options_for_svelte_events = svelteEvents.map((event) => + snippetCompletion(event.name + '={${}}', { + label: event.name, + info: event.description, + type: 'keyword' + }) +); + +const options_for_sveltekit_attributes = sveltekitAttributes.map((attr) => ({ + label: attr.name, + info: attr.description, + type: 'keyword' +})); + +const options_for_sveltekit_attr_values = sveltekitAttributes.reduce((prev, cur) => { + prev[cur.name] = cur.values.map((value) => ({ label: value.name, type: 'keyword' })); + return prev; +}, /** @type {Record} */ ({})); + +/** + * @param {{ name: string, description?: string}[]} attributes + */ +function snippet_for_attribute(attributes) { + return attributes.map((attr) => + snippetCompletion(attr.name + '={${}}', { + label: attr.name, + info: attr.description, + type: 'keyword' + }) + ); +} + +const options_for_svelte_attributes = snippet_for_attribute(svelteAttributes); + +const options_for_svelte_tags = svelteTags.reduce((tags, tag) => { + tags[tag.name] = snippet_for_attribute(tag.attributes); + return tags; +}, /** @type {Record} */ ({})); + +/** + * @param {import('@codemirror/autocomplete').CompletionContext} context + * @param {import("@lezer/common").SyntaxNode} node + */ +function completion_for_attributes(context, node) { + /** @param {import('@codemirror/autocomplete').Completion[]} options */ + const completion = (options) => { + return { from: node.from, to: context.pos, options, validFor: /^\w*$/ }; + }; + + const global_options = [ + ...options_for_svelte_events, + ...options_for_svelte_attributes, + ...options_for_sveltekit_attributes + ]; + + if (node.parent?.parent?.firstChild?.nextSibling?.name === 'SvelteElementName') { + const tag_node = node.parent.parent.firstChild.nextSibling; + const tag = context.state.doc.sliceString(tag_node.from, tag_node.to); + return completion([...global_options, ...options_for_svelte_tags[tag]]); + } else if (node.parent?.parent?.firstChild?.nextSibling?.name === 'TagName') { + const tag_node = node.parent.parent.firstChild.nextSibling; + const tag = context.state.doc.sliceString(tag_node.from, tag_node.to); + if (addAttributes[tag]) { + const completions_attributes = snippet_for_attribute(addAttributes[tag]); + return completion([...global_options, ...completions_attributes]); + } + } + + return completion(global_options); +} + +/** + * @param {import('@codemirror/autocomplete').CompletionContext} context + * @param {import("@lezer/common").SyntaxNode} node + * @param {string} attr + */ +function completion_for_sveltekit_attr_values(context, node, attr) { + const options = options_for_sveltekit_attr_values[attr]; + if (options) { + return { + from: node.name === 'AttributeValueContent' ? node.from : node.from + 1, + to: context.pos, + options, + validFor: /^\w*$/ + }; + } + + return null; +} + +/** + * @param {import('@codemirror/autocomplete').CompletionContext} context + * @returns {import('@codemirror/autocomplete').CompletionResult | null} + */ +function completion_for_markup(context) { + const node_before = syntaxTree(context.state).resolveInner(context.pos, -1); + + if (node_before.name === 'BlockPrefix') { + return completion_for_block(context, node_before); + } else if (node_before.prevSibling?.name === 'BlockPrefix') { + return completion_for_block(context, node_before.prevSibling); + } else if (node_before.name === 'AttributeName') { + return completion_for_attributes(context, node_before); + } else if ( + node_before.name === 'DirectiveOn' || + node_before.name === 'DirectiveBind' || + node_before.name === 'DirectiveTarget' + ) { + if (node_before.parent) { + return completion_for_attributes(context, node_before.parent); + } + } else if (node_before.parent?.name === 'AttributeValue') { + if (node_before.parent?.parent?.firstChild) { + const attr_name_node = node_before.parent.parent.firstChild; + const attr_name = context.state.doc.sliceString(attr_name_node.from, attr_name_node.to); + + if (attr_name.startsWith('data-sveltekit-')) { + return completion_for_sveltekit_attr_values(context, node_before, attr_name); + } + } + } + + return null; +} + +/** + * @param {import('@codemirror/autocomplete').CompletionContext} context + * @returns {import('@codemirror/autocomplete').CompletionResult | null} + */ +function completion_for_javascript(context) { + // TODO autocompletion for import source + + return null; +} + +export function autocomplete_for_svelte() { + return [ + svelteLanguage.data.of({ + autocomplete: completion_for_markup + }), + javascriptLanguage.data.of({ + autocomplete: completion_for_javascript + }) + ]; +} diff --git a/src/routes/tutorial/[slug]/autocompletionDataProvider.js b/src/routes/tutorial/[slug]/autocompletionDataProvider.js new file mode 100644 index 000000000..d91e627c8 --- /dev/null +++ b/src/routes/tutorial/[slug]/autocompletionDataProvider.js @@ -0,0 +1,475 @@ +/** + * this file is based on [dataProvider.ts from sveltejs/language-tools](https://github.com/sveltejs/language-tools/blob/master/packages/language-server/src/plugins/html/dataProvider.ts) + */ + +export const globalEvents = [ + { name: 'on:abort' }, + { name: 'on:animationcancel' }, + { name: 'on:animationend' }, + { name: 'on:animationiteration' }, + { name: 'on:animationstart' }, + { name: 'on:auxclick' }, + { name: 'on:beforeinput' }, + { name: 'on:blur' }, + { name: 'on:cancel' }, + { name: 'on:canplay' }, + { name: 'on:canplaythrough' }, + { name: 'on:change' }, + { name: 'on:click' }, + { name: 'on:close' }, + { name: 'on:contextmenu' }, + { name: 'on:copy' }, + { name: 'on:cuechange' }, + { name: 'on:cut' }, + { name: 'on:dblclick' }, + { name: 'on:drag' }, + { name: 'on:dragend' }, + { name: 'on:dragenter' }, + { name: 'on:dragleave' }, + { name: 'on:dragover' }, + { name: 'on:dragstart' }, + { name: 'on:drop' }, + { name: 'on:durationchange' }, + { name: 'on:emptied' }, + { name: 'on:ended' }, + { name: 'on:error' }, + { name: 'on:focus' }, + { name: 'on:formdata' }, + { name: 'on:gotpointercapture' }, + { name: 'on:input' }, + { name: 'on:invalid' }, + { name: 'on:keydown' }, + { name: 'on:keypress' }, + { name: 'on:keyup' }, + { name: 'on:load' }, + { name: 'on:loadeddata' }, + { name: 'on:loadedmetadata' }, + { name: 'on:loadstart' }, + { name: 'on:lostpointercapture' }, + { name: 'on:mousedown' }, + { name: 'on:mouseenter' }, + { name: 'on:mouseleave' }, + { name: 'on:mousemove' }, + { name: 'on:mouseout' }, + { name: 'on:mouseover' }, + { name: 'on:mouseup' }, + { name: 'on:paste' }, + { name: 'on:pause' }, + { name: 'on:play' }, + { name: 'on:playing' }, + { name: 'on:pointercancel' }, + { name: 'on:pointerdown' }, + { name: 'on:pointerenter' }, + { name: 'on:pointerleave' }, + { name: 'on:pointermove' }, + { name: 'on:pointerout' }, + { name: 'on:pointerover' }, + { name: 'on:pointerup' }, + { name: 'on:progress' }, + { name: 'on:ratechange' }, + { name: 'on:reset' }, + { name: 'on:resize' }, + { name: 'on:scroll' }, + { name: 'on:securitypolicyviolation' }, + { name: 'on:seeked' }, + { name: 'on:seeking' }, + { name: 'on:select' }, + { name: 'on:selectionchange' }, + { name: 'on:selectstart' }, + { name: 'on:slotchange' }, + { name: 'on:stalled' }, + { name: 'on:submit' }, + { name: 'on:suspend' }, + { name: 'on:timeupdate' }, + { name: 'on:toggle' }, + { name: 'on:touchcancel' }, + { name: 'on:touchend' }, + { name: 'on:touchmove' }, + { name: 'on:touchstart' }, + { name: 'on:transitioncancel' }, + { name: 'on:transitionend' }, + { name: 'on:transitionrun' }, + { name: 'on:transitionstart' }, + { name: 'on:volumechange' }, + { name: 'on:waiting' }, + { name: 'on:webkitanimationend' }, + { name: 'on:webkitanimationiteration' }, + { name: 'on:webkitanimationstart' }, + { name: 'on:webkittransitionend' }, + { name: 'on:wheel' } +]; + +/** @type {{ name: string, description?: string }[]} */ +export const svelteEvents = [ + ...globalEvents, + { + name: 'on:introstart', + description: 'Available when element has transition' + }, + { + name: 'on:introend', + description: 'Available when element has transition' + }, + { + name: 'on:outrostart', + description: 'Available when element has transition' + }, + { + name: 'on:outroend', + description: 'Available when element has transition' + } +]; + +export const svelteAttributes = [ + { + name: 'bind:innerHTML', + description: 'Available when contenteditable=true' + }, + { + name: 'bind:textContent', + description: 'Available when contenteditable=true' + }, + { + name: 'bind:innerText', + description: 'Available when contenteditable=true' + }, + { + name: 'bind:clientWidth', + description: 'Available for block level elements. (read-only)' + }, + { + name: 'bind:clientHeight', + description: 'Available for block level elements. (read-only)' + }, + { + name: 'bind:offsetWidth', + description: 'Available for block level elements. (read-only)' + }, + { + name: 'bind:offsetHeight', + description: 'Available for block level elements. (read-only)' + }, + { + name: 'bind:this', + description: + 'To get a reference to a DOM node, use bind:this. If used on a component, gets a reference to that component instance.' + } +]; + +export const sveltekitAttributes = [ + { + name: 'data-sveltekit-keepfocus', + description: + 'SvelteKit-specific attribute. Currently focused element will retain focus after navigation. Otherwise, focus will be reset to the body.', + valueSet: 'v', + values: [{ name: 'off' }] + }, + { + name: 'data-sveltekit-noscroll', + description: 'SvelteKit-specific attribute. Will prevent scrolling after the link is clicked.', + valueSet: 'v', + values: [{ name: 'off' }] + }, + { + name: 'data-sveltekit-preload-code', + description: + "SvelteKit-specific attribute. Will cause SvelteKit to run the page's load function as soon as the user hovers over the link (on a desktop) or touches it (on mobile), rather than waiting for the click event to trigger navigation.", + valueSet: 'v', + values: [ + { name: 'eager' }, + { name: 'viewport' }, + { name: 'hover' }, + { name: 'tap' }, + { name: 'off' } + ] + }, + { + name: 'data-sveltekit-preload-data', + description: + "SvelteKit-specific attribute. Will cause SvelteKit to run the page's load function as soon as the user hovers over the link (on a desktop) or touches it (on mobile), rather than waiting for the click event to trigger navigation.", + valueSet: 'v', + values: [{ name: 'hover' }, { name: 'tap' }, { name: 'off' }] + }, + { + name: 'data-sveltekit-reload', + description: + 'SvelteKit-specific attribute. Will cause SvelteKit to do a normal browser navigation which results in a full page reload.', + valueSet: 'v', + values: [{ name: 'off' }] + }, + { + name: 'data-sveltekit-replacestate', + description: + 'SvelteKit-specific attribute. Will replace the current `history` entry rather than creating a new one with `pushState` when the link is clicked.', + valueSet: 'v', + values: [{ name: 'off' }] + } +]; + +export const svelteTags = [ + { + name: 'svelte:self', + description: + 'Allows a component to include itself, recursively.\n\nIt cannot appear at the top level of your markup; it must be inside an if or each block to prevent an infinite loop.', + attributes: [] + }, + { + name: 'svelte:component', + description: + 'Renders a component dynamically, using the component constructor specified as the this property. When the property changes, the component is destroyed and recreated.\n\nIf this is falsy, no component is rendered.', + attributes: [ + { + name: 'this', + description: + 'Component to render.\n\nWhen this property changes, the component is destroyed and recreated.\nIf this is falsy, no component is rendered.' + } + ] + }, + { + name: 'svelte:element', + description: + 'Renders a DOM element dynamically, using the string as the this property. When the property changes, the element is destroyed and recreated.\n\nIf this is falsy, no element is rendered.', + attributes: [ + { + name: 'this', + description: + 'DOM element to render.\n\nWhen this property changes, the element is destroyed and recreated.\nIf this is falsy, no element is rendered.' + } + ] + }, + { + name: 'svelte:window', + description: + 'Allows you to add event listeners to the window object without worrying about removing them when the component is destroyed, or checking for the existence of window when server-side rendering.', + attributes: [ + { + name: 'bind:innerWidth', + description: 'Bind to the inner width of the window. (read-only)' + }, + { + name: 'bind:innerHeight', + description: 'Bind to the inner height of the window. (read-only)' + }, + { + name: 'bind:outerWidth', + description: 'Bind to the outer width of the window. (read-only)' + }, + { + name: 'bind:outerHeight', + description: 'Bind to the outer height of the window. (read-only)' + }, + { + name: 'bind:scrollX', + description: 'Bind to the scroll x position of the window.' + }, + { + name: 'bind:scrollY', + description: 'Bind to the scroll y position of the window.' + }, + { + name: 'bind:online', + description: 'An alias for window.navigator.onLine' + }, + // window events + { name: 'on:afterprint' }, + { name: 'on:beforeprint' }, + { name: 'on:beforeunload' }, + { name: 'on:gamepadconnected' }, + { name: 'on:gamepaddisconnected' }, + { name: 'on:hashchange' }, + { name: 'on:languagechange' }, + { name: 'on:message' }, + { name: 'on:messageerror' }, + { name: 'on:offline' }, + { name: 'on:online' }, + { name: 'on:pagehide' }, + { name: 'on:pageshow' }, + { name: 'on:popstate' }, + { name: 'on:rejectionhandled' }, + { name: 'on:storage' }, + { name: 'on:unhandledrejection' }, + { name: 'on:unload' } + ] + }, + { + name: 'svelte:document', + description: + "As with , this element allows you to add listeners to events on document, such as visibilitychange, which don't fire on window.", + attributes: [ + // document events + { name: 'on:fullscreenchange' }, + { name: 'on:fullscreenerror' }, + { name: 'on:pointerlockchange' }, + { name: 'on:pointerlockerror' }, + { name: 'on:readystatechange' }, + { name: 'on:visibilitychange' } + ] + }, + { + name: 'svelte:body', + description: + "As with , this element allows you to add listeners to events on document.body, such as mouseenter and mouseleave which don't fire on window.", + attributes: [] + }, + { + name: 'svelte:head', + description: + 'This element makes it possible to insert elements into document.head. During server-side rendering, head content exposed separately to the main html content.', + attributes: [] + }, + { + name: 'svelte:options', + description: 'Provides a place to specify per-component compiler options', + attributes: [ + { + name: 'immutable', + description: + 'If true, tells the compiler that you promise not to mutate any objects. This allows it to be less conservative about checking whether values have changed.', + values: [ + { + name: '{true}', + description: + 'You never use mutable data, so the compiler can do simple referential equality checks to determine if values have changed' + }, + { + name: '{false}', + description: + 'The default. Svelte will be more conservative about whether or not mutable objects have changed' + } + ] + }, + { + name: 'accessors', + description: + "If true, getters and setters will be created for the component's props. If false, they will only be created for readonly exported values (i.e. those declared with const, class and function). If compiling with customElement: true this option defaults to true.", + values: [ + { + name: '{true}', + description: "Adds getters and setters for the component's props" + }, + { + name: '{false}', + description: 'The default.' + } + ] + }, + { + name: 'namespace', + description: 'The namespace where this component will be used, most commonly "svg"' + }, + { + name: 'tag', + description: 'The name to use when compiling this component as a custom element' + } + ] + }, + { + name: 'svelte:fragment', + description: + 'This element is useful if you want to assign a component to a named slot without creating a wrapper DOM element.', + attributes: [ + { + name: 'slot', + description: 'The name of the named slot that should be targeted.' + } + ] + }, + { + name: 'slot', + description: + 'Components can have child content, in the same way that elements can.\n\nThe content is exposed in the child component using the element, which can contain fallback content that is rendered if no children are provided.', + attributes: [ + { + name: 'name', + description: + 'Named slots allow consumers to target specific areas. They can also have fallback content.' + } + ] + } +]; + +const mediaAttributes = [ + { + name: 'bind:duration', + description: 'The total duration of the video, in seconds. (readonly)' + }, + { + name: 'bind:buffered', + description: 'An array of {start, end} objects. (readonly)' + }, + { + name: 'bind:seekable', + description: 'An array of {start, end} objects. (readonly)' + }, + { + name: 'bind:played', + description: 'An array of {start, end} objects. (readonly)' + }, + { + name: 'bind:seeking', + description: 'boolean. (readonly)' + }, + { + name: 'bind:ended', + description: 'boolean. (readonly)' + }, + { + name: 'bind:currentTime', + description: 'The current point in the video, in seconds.' + }, + { + name: 'bind:playbackRate', + description: "how fast or slow to play the video, where 1 is 'normal'" + }, + { + name: 'bind:paused' + }, + { + name: 'bind:volume', + description: 'A value between 0 and 1' + }, + { + name: 'bind:muted' + }, + { + name: 'bind:readyState' + } +]; + +const videoAttributes = [ + { + name: 'bind:videoWidth', + description: 'readonly' + }, + { + name: 'bind:videoHeight', + description: 'readonly' + } +]; + +const indeterminateAttribute = { + name: 'indeterminate', + description: 'Available for type="checkbox"' +}; + +/** @type {Record} */ +export const addAttributes = { + select: [{ name: 'bind:value' }], + input: [ + { name: 'bind:value' }, + { name: 'bind:group', description: 'Available for type="radio" and type="checkbox"' }, + { name: 'bind:checked', description: 'Available for type="checkbox"' }, + { name: 'bind:files', description: 'Available for type="file" (readonly)' }, + indeterminateAttribute, + { ...indeterminateAttribute, name: 'bind:indeterminate' } + ], + img: [{ name: 'bind:naturalWidth' }, { name: 'bind:naturalHeight' }], + textarea: [{ name: 'bind:value' }], + video: [...mediaAttributes, ...videoAttributes], + audio: [...mediaAttributes], + details: [ + { + name: 'bind:open' + } + ] +};