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 })}
/>
)}