diff --git a/package-lock.json b/package-lock.json index 192dd7894..e707736bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "comlink": "^4.4.1", "decko": "^1.2.0", "htm": "^3.1.1", - "linkstate": "^1.1.1", "magic-string": "^0.25.7", "marked": "^0.8.0", "preact": "10.15.1", @@ -4653,11 +4652,6 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" }, - "node_modules/linkstate": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/linkstate/-/linkstate-1.1.1.tgz", - "integrity": "sha512-5SICdxQG9FpWk44wSEoM2WOCUNuYfClp10t51XAIV5E7vUILF/dTYxf0vJw6bW2dUd2wcQon+LkNtRijpNLrig==" - }, "node_modules/lint-staged": { "version": "15.2.0", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.0.tgz", diff --git a/package.json b/package.json index c69776373..a4a8f8e58 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "comlink": "^4.4.1", "decko": "^1.2.0", "htm": "^3.1.1", - "linkstate": "^1.1.1", "magic-string": "^0.25.7", "marked": "^0.8.0", "preact": "10.15.1", diff --git a/plugins/html-routing-middleware.js b/plugins/html-routing-middleware.js index 73942d1f9..fcf5003f4 100644 --- a/plugins/html-routing-middleware.js +++ b/plugins/html-routing-middleware.js @@ -33,7 +33,7 @@ export function htmlRoutingMiddlewarePlugin() { try { await fs.access(file); - req.url += '/index.html'; + req.url = url.pathname + '/index.html' + url.search; } catch { req.url = '/404/index.html'; } diff --git a/src/components/code-editor/index.jsx b/src/components/code-editor/index.jsx index c35bec739..1cfd54df6 100644 --- a/src/components/code-editor/index.jsx +++ b/src/components/code-editor/index.jsx @@ -62,7 +62,7 @@ export default class CodeEditor extends Component { this.value = this.editor.getValue(); let { onInput } = this.props; - if (onInput) onInput({ value: this.value }); + if (onInput) onInput(this.value); }); } diff --git a/src/components/controllers/repl-page.jsx b/src/components/controllers/repl-page.jsx index a91b6b647..11854998e 100644 --- a/src/components/controllers/repl-page.jsx +++ b/src/components/controllers/repl-page.jsx @@ -1,9 +1,12 @@ import { useRoute } from 'preact-iso'; import { Repl } from './repl'; -import { useContent } from '../../lib/use-resource'; +import { useExample } from './repl/examples'; +import { useContent, useResource } from '../../lib/use-resource'; import { useTitle, useDescription } from './utils'; import { useLanguage } from '../../lib/i18n'; +import style from './repl/style.module.css'; + export default function ReplPage() { const { query } = useRoute(); const [lang] = useLanguage(); @@ -12,5 +15,74 @@ export default function ReplPage() { useTitle(meta.title); useDescription(meta.description); - return ; + const [code, slug] = initialCode(query); + + return ( +
+ + +
+ ); +} + +/** + * Go down the list of fallbacks to get initial code + * + * ?code -> ?example -> localStorage -> simple counter example + */ +function initialCode(query) { + let code, slug; + if (query.code) { + try { + code = useResource(() => querySafetyCheck(atob(query.code)), [query.code]); + } catch (e) {} + } else if (query.example) { + code = useExample([query.example]); + if (code) { + slug = query.example; + history.replaceState( + null, + null, + `/repl?example=${encodeURIComponent(slug)}` + ); + } + else history.replaceState(null, null, '/repl'); + } + + if (!code) { + if (typeof window !== 'undefined' && localStorage.getItem('preact-www-repl-code')) { + code = localStorage.getItem('preact-www-repl-code'); + } else { + slug = 'counter'; + if (typeof window !== 'undefined') { + history.replaceState( + null, + null, + `/repl?example=${encodeURIComponent(slug)}` + ); + } + code = useExample([slug]); + } + } + + return [code, slug]; +} + +async function querySafetyCheck(code) { + return ( + !document.referrer || + document.referrer.indexOf(location.origin) === 0 || + // eslint-disable-next-line no-alert + confirm('Are you sure you want to run the code contained in this link?') + ) + ? code + : undefined; } diff --git a/src/components/controllers/repl/examples.js b/src/components/controllers/repl/examples.js new file mode 100644 index 000000000..26a5f0512 --- /dev/null +++ b/src/components/controllers/repl/examples.js @@ -0,0 +1,83 @@ +import { useResource } from '../../../lib/use-resource'; + +import simpleCounterExample from './examples/simple-counter.txt?url'; +import counterWithHtmExample from './examples/counter-with-htm.txt?url'; +import todoExample from './examples/todo-list.txt?url'; +import todoExampleSignal from './examples/todo-list-signal.txt?url'; +import repoListExample from './examples/github-repo-list.txt?url'; +import contextExample from './examples/context.txt?url'; +import spiralExample from './examples/spiral.txt?url'; + +export const EXAMPLES = [ + { + name: 'Simple Counter', + slug: 'counter', + url: simpleCounterExample + }, + { + name: 'Todo List', + slug: 'todo', + url: todoExample + }, + { + name: 'Todo List (Signals)', + slug: 'todo-list-signals', + url: todoExampleSignal + }, + { + name: 'Github Repo List', + slug: 'github-repo-list', + url: repoListExample + }, + { + group: 'Advanced', + items: [ + { + name: 'Counter using HTM', + slug: 'counter-htm', + url: counterWithHtmExample + }, + { + name: 'Context', + slug: 'context', + url: contextExample + } + ] + }, + { + group: 'Animation', + items: [ + { + name: 'Spiral', + slug: 'spiral', + url: spiralExample + } + ] + } +]; + +export function getExample(slug, list = EXAMPLES) { + for (let i = 0; i < list.length; i++) { + let item = list[i]; + if (item.group) { + let found = getExample(slug, item.items); + if (found) return found; + } else if (item.slug.toLowerCase() === slug.toLowerCase()) { + return item; + } + } +} + +/** + * @param {[ slug: string ]} args + * @returns {string | undefined} + */ +export function useExample([slug]) { + const example = getExample(slug); + if (!example) return; + return useResource(() => loadExample(example.url), ['example', slug]); +} + +export async function loadExample(exampleUrl) { + return await fetch(exampleUrl).then(r => r.text()); +} diff --git a/src/components/controllers/repl/index.jsx b/src/components/controllers/repl/index.jsx index aa695d568..5b91ae557 100644 --- a/src/components/controllers/repl/index.jsx +++ b/src/components/controllers/repl/index.jsx @@ -1,143 +1,68 @@ -import { Component } from 'preact'; -import linkState from 'linkstate'; -import { debounce } from 'decko'; +import { useState, useEffect } from 'preact/hooks'; +import { Splitter } from '../../splitter'; +import { EXAMPLES, getExample, loadExample } from './examples'; import { ErrorOverlay } from './error-overlay'; -import { localStorageGet, localStorageSet } from '../../../lib/localstorage'; +import { useStoredValue } from '../../../lib/localstorage'; +import { useResource } from '../../../lib/use-resource'; import { parseStackTrace } from './errors'; import style from './style.module.css'; import REPL_CSS from './examples.css?raw'; -import simpleCounterExample from './examples/simple-counter.txt?url'; -import counterWithHtmExample from './examples/counter-with-htm.txt?url'; -import todoExample from './examples/todo-list.txt?url'; -import todoExampleSignal from './examples/todo-list-signal.txt?url'; -import repoListExample from './examples/github-repo-list.txt?url'; -import contextExample from './examples/context.txt?url'; -import spiralExample from './examples/spiral.txt?url'; -import { Splitter } from '../../splitter'; - -const EXAMPLES = [ - { - name: 'Simple Counter', - slug: 'counter', - url: simpleCounterExample - }, - { - name: 'Todo List', - slug: 'todo', - url: todoExample - }, - { - name: 'Todo List (Signals)', - slug: 'todo-list-signals', - url: todoExampleSignal - }, - { - name: 'Github Repo List', - slug: 'github-repo-list', - url: repoListExample - }, - { - group: 'Advanced', - items: [ - { - name: 'Counter using HTM', - slug: 'counter-htm', - url: counterWithHtmExample - }, - { - name: 'Context', - slug: 'context', - url: contextExample - } - ] - }, - { - group: 'Animation', - items: [ - { - name: 'Spiral', - slug: 'spiral', - url: spiralExample - } - ] - } -]; - -function getExample(slug, list) { - for (let i = 0; i < list.length; i++) { - let item = list[i]; - if (item.group) { - let found = getExample(slug, item.items); - if (found) return found; - } else if (item.slug.toLowerCase() === slug.toLowerCase()) { - return item; - } - } -} - -export class Repl extends Component { - state = { - loading: 'Loading REPL...', - code: '', - exampleSlug: '' +/** + * @param {Object} props + * @param {string} props.code + * @param {string} [props.slug] + */ +export function Repl({ code, slug }) { + const [editorCode, setEditorCode] = useStoredValue('preact-www-repl-code', code); + const [exampleSlug, setExampleSlug] = useState(slug || ''); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + // TODO: CodeMirror v5 cannot load in Node, and loading only the runner + // causes some bad jumping/pop-in. For the moment, this is the best option + if (typeof window === 'undefined') return null; + + const { Runner, CodeEditor } = useResource(() => Promise.all([ + import('../../code-editor'), + import('./runner') + ]).then(([CodeEditor, Runner]) => ({ CodeEditor: CodeEditor.default, Runner: Runner.default })), ['repl']); + + const applyExample = (e) => { + const slug = e.target.value; + loadExample(getExample(slug).url) + .then(code => { + setEditorCode(code); + setExampleSlug(slug); + history.replaceState( + null, + null, + `/repl?example=${encodeURIComponent(slug)}` + ); + }); }; - constructor(props) { - super(props); - - // Only load from local storage if no url param is set - if (typeof window !== 'undefined') { - const params = new URLSearchParams(window.location.search); - const exampleParam = params.get('example'); - let example = exampleParam ? getExample(exampleParam, EXAMPLES) : null; - + useEffect(() => { + const example = getExample(exampleSlug); + (async function () { if (example) { - this.state.exampleSlug = example.slug; - } else if (!example) { - // Remove ?example param - history.replaceState(null, null, '/repl'); - - // No example param was present, try to load from localStorage - const code = localStorageGet('preact-www-repl-code'); - if (code) { - this.state.code = code; - } else { - // Nothing found in localStorage either, pick first example - this.state.exampleSlug = EXAMPLES[0].slug; + const code = await loadExample(example.url); + if (location.search && code !== editorCode) { + setExampleSlug(''); + history.replaceState(null, null, '/repl'); } } + })(); + }, [editorCode]); + + const share = () => { + if (!exampleSlug) { + history.replaceState( + null, + null, + `/repl?code=${encodeURIComponent(btoa(editorCode))}` + ); } - } - - componentDidMount() { - Promise.all([ - import('../../code-editor'), - import('./runner') - ]).then(([CodeEditor, Runner]) => { - this.CodeEditor = CodeEditor.default; - this.Runner = Runner.default; - - // Load transpiler - this.setState({ loading: 'Initializing REPL...' }); - this.Runner.worker.ping().then(() => { - this.setState({ loading: false }); - let example = this.state.exampleSlug; - if (this.props.query.code) { - this.receiveCode(this.props.query.code); - } else if (example) { - this.applyExample(example); - } else if (!this.state.code) { - this.applyExample(EXAMPLES[0].slug); - } - }); - }); - } - - share = () => { - let { code } = this.state; - const url = `/repl?code=${encodeURIComponent(btoa(code))}`; - history.replaceState(null, null, url); try { let input = document.createElement('input'); @@ -148,175 +73,74 @@ export class Repl extends Component { document.execCommand('copy'); input.blur(); document.body.removeChild(input); - this.setState({ copied: true }); - setTimeout(() => this.setState({ copied: false }), 1000); + setCopied(true); + setTimeout(() => setCopied(false), 2500); } catch (err) { // eslint-disable-next-line no-console console.log(err); } }; - loadExample = e => { - this.applyExample(e.target.value); - }; - - async applyExample(name) { - let example = getExample(name, EXAMPLES); - if (!example) return; - if (!example.code) { - if (example.url) { - example.code = await (await fetch(example.url)).text(); - } else if (example.load) { - example.code = await example.load(); - } - } - - history.replaceState( - null, - null, - `/repl?example=${encodeURIComponent(example.slug)}` - ); - this.setState({ code: example.code, exampleSlug: example.slug }); - } - - onRealm = realm => { + const onRealm = realm => { realm.globalThis.githubStars = window.githubStars; }; - onSuccess = () => { - this.setState({ error: null }); - }; - - componentDidUpdate = debounce(500, () => { - let { code } = this.state; - if (code === repoListExample) code = ''; - // Reset select when code is changed from example - if (this.state.exampleSlug) { - const example = getExample(this.state.exampleSlug, EXAMPLES); - if (code !== example.code && this.state.exampleSlug !== '') { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ exampleSlug: '' }); - history.replaceState(null, null, '/repl'); - } - } - localStorageSet('preact-www-repl-code', code || ''); - }); - - componentWillReceiveProps({ code }) { - if (code && code !== this.props.query.code) { - this.receiveCode(code); - } - } - - receiveCode(code) { - try { - code = atob(code); - } catch (e) {} - if (code && code !== this.state.code) { - if ( - !document.referrer || - document.referrer.indexOf(location.origin) === 0 - ) { - this.setState({ code }); - } else { - setTimeout(() => { - if ( - // eslint-disable-next-line no-alert - confirm( - 'Are you sure you want to run the code contained in this link?' - ) - ) { - this.setState({ code }); - } - }, 20); - } - } - } - - render(_, { loading, code, error, exampleSlug, copied }) { - if (loading) { - return ( - -
-

{loading}

-
-
- ); - } - - return ( - -
- - -
-
- - {error && ( - - )} - +
+ + +
+
+ + {error && ( + -
- } - > - -
-
-
- ); - } + )} + setError(null)} + css={REPL_CSS} + code={editorCode} + /> + + } + > + + + + + ); } - -const ReplWrapper = ({ loading, children }) => ( -
- - - {children} -
-); diff --git a/src/components/controllers/repl/runner.jsx b/src/components/controllers/repl/runner.jsx index 72e38577c..96f201e52 100644 --- a/src/components/controllers/repl/runner.jsx +++ b/src/components/controllers/repl/runner.jsx @@ -39,7 +39,7 @@ export default class Runner extends Component { } this.didError = true; if (this.props.onError) { - this.props.onError({ error }); + this.props.onError(error); } }; diff --git a/src/components/controllers/tutorial/index.jsx b/src/components/controllers/tutorial/index.jsx index 503ef5d6f..fb714fb0f 100644 --- a/src/components/controllers/tutorial/index.jsx +++ b/src/components/controllers/tutorial/index.jsx @@ -9,7 +9,6 @@ import { useCallback } from 'preact/hooks'; import { useRoute } from 'preact-iso'; -import linkState from 'linkstate'; import { TutorialContext, SolutionContext } from './contexts'; import cx from '../../../lib/cx'; import style from './style.module.css'; @@ -256,7 +255,7 @@ function TutorialView({ class={style.code} value={code} error={error} - onInput={linkState(tutorial, 'code', 'value')} + onInput={(value) => tutorial.setState({ code: value })} /> )}