diff --git a/content/en/tutorial/01-vdom.md b/content/en/tutorial/01-vdom.md index 5d862bf78..72122946b 100644 --- a/content/en/tutorial/01-vdom.md +++ b/content/en/tutorial/01-vdom.md @@ -195,7 +195,7 @@ useResult(function(result) { var p = result.output.querySelector('p'); var hasColor = p && p.style && p.style.color === 'purple'; if (hasEm && hasColor) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } }, []); ``` diff --git a/content/en/tutorial/02-events.md b/content/en/tutorial/02-events.md index 5fc74afae..dc2b74ba6 100644 --- a/content/en/tutorial/02-events.md +++ b/content/en/tutorial/02-events.md @@ -60,7 +60,7 @@ useRealm(function (realm) { var win = realm.globalThis; var prevConsoleLog = win.console.log; win.console.log = function() { - store.setState({ solved: true }); + solutionCtx.setSolved(true); return prevConsoleLog.apply(win.console, arguments); }; diff --git a/content/en/tutorial/03-components.md b/content/en/tutorial/03-components.md index ad5477238..950914227 100644 --- a/content/en/tutorial/03-components.md +++ b/content/en/tutorial/03-components.md @@ -253,7 +253,7 @@ useRealm(function (realm) { win.console.log = function() { if (hasComponent && check) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } return prevConsoleLog.apply(win.console, arguments); }; diff --git a/content/en/tutorial/04-state.md b/content/en/tutorial/04-state.md index 2409c9e16..0f387d783 100644 --- a/content/en/tutorial/04-state.md +++ b/content/en/tutorial/04-state.md @@ -160,7 +160,7 @@ useResult(function () { } if (Number(text2[1]) === Number(text[1]) + 1) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } }, 10); } diff --git a/content/en/tutorial/05-refs.md b/content/en/tutorial/05-refs.md index ddee07ca5..93dc01796 100644 --- a/content/en/tutorial/05-refs.md +++ b/content/en/tutorial/05-refs.md @@ -132,7 +132,7 @@ function patch(input) { input.__patched = true; var old = input.focus; input.focus = function() { - store.setState({ solved: true }); + solutionCtx.setSolved(true); return old.call(this); }; } diff --git a/content/en/tutorial/07-side-effects.md b/content/en/tutorial/07-side-effects.md index 95c1f69ba..1f874a4ea 100644 --- a/content/en/tutorial/07-side-effects.md +++ b/content/en/tutorial/07-side-effects.md @@ -137,7 +137,7 @@ useRealm(function (realm) { var prevConsoleLog = win.console.log; win.console.log = function(m, s) { if (/Count is now/.test(m) && s === 1) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } return prevConsoleLog.apply(win.console, arguments); }; diff --git a/content/en/tutorial/08-keys.md b/content/en/tutorial/08-keys.md index 2aaadb43c..aa1a91af2 100644 --- a/content/en/tutorial/08-keys.md +++ b/content/en/tutorial/08-keys.md @@ -306,7 +306,7 @@ useRealm(function (realm) { /learn preact/i.test(c[0].textContent) && /make an awesome app/i.test(c[1].textContent) ) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } } diff --git a/content/en/tutorial/09-error-handling.md b/content/en/tutorial/09-error-handling.md index 81214cb8d..dc7abfa71 100644 --- a/content/en/tutorial/09-error-handling.md +++ b/content/en/tutorial/09-error-handling.md @@ -97,7 +97,7 @@ useResult(function(result) { oe.apply(this, arguments); setTimeout(function() { if (result.output.textContent.match(/error/i)) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } }, 10); }; diff --git a/content/kr/tutorial/01-vdom.md b/content/kr/tutorial/01-vdom.md index ffef02ab3..2a0b37528 100644 --- a/content/kr/tutorial/01-vdom.md +++ b/content/kr/tutorial/01-vdom.md @@ -144,7 +144,7 @@ useResult(function(result) { var p = result.output.querySelector('p'); var hasColor = p && p.style && p.style.color === 'purple'; if (hasEm && hasColor) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } }, []); ``` diff --git a/content/kr/tutorial/02-events.md b/content/kr/tutorial/02-events.md index 6fa9b4867..86e9d3b0b 100644 --- a/content/kr/tutorial/02-events.md +++ b/content/kr/tutorial/02-events.md @@ -53,7 +53,7 @@ useRealm(function (realm) { var win = realm.globalThis; var prevConsoleLog = win.console.log; win.console.log = function() { - store.setState({ solved: true }); + solutionCtx.setSolved(true); return prevConsoleLog.apply(win.console, arguments); }; diff --git a/content/ru/tutorial/01-vdom.md b/content/ru/tutorial/01-vdom.md index 8bc56f7b9..a86184837 100644 --- a/content/ru/tutorial/01-vdom.md +++ b/content/ru/tutorial/01-vdom.md @@ -151,7 +151,7 @@ useResult(function(result) { var p = result.output.querySelector('p'); var hasColor = p && p.style && p.style.color === 'purple'; if (hasEm && hasColor) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } }, []); ``` diff --git a/content/ru/tutorial/02-events.md b/content/ru/tutorial/02-events.md index 44a2f1d1b..9dc696202 100644 --- a/content/ru/tutorial/02-events.md +++ b/content/ru/tutorial/02-events.md @@ -47,7 +47,7 @@ useRealm(function (realm) { var win = realm.globalThis; var prevConsoleLog = win.console.log; win.console.log = function() { - store.setState({ solved: true }); + solutionCtx.setSolved(true); return prevConsoleLog.apply(win.console, arguments); }; diff --git a/content/ru/tutorial/03-components.md b/content/ru/tutorial/03-components.md index 2dab0beff..61207a51d 100644 --- a/content/ru/tutorial/03-components.md +++ b/content/ru/tutorial/03-components.md @@ -187,7 +187,7 @@ useRealm(function (realm) { win.console.log = function() { if (hasComponent && check) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } return prevConsoleLog.apply(win.console, arguments); }; diff --git a/content/ru/tutorial/04-state.md b/content/ru/tutorial/04-state.md index f3fc7840a..870f92915 100644 --- a/content/ru/tutorial/04-state.md +++ b/content/ru/tutorial/04-state.md @@ -112,7 +112,7 @@ useResult(function () { } if (Number(text2[1]) === Number(text[1]) + 1) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } }, 10); } diff --git a/content/ru/tutorial/05-refs.md b/content/ru/tutorial/05-refs.md index cab2f81c9..6f5c60674 100644 --- a/content/ru/tutorial/05-refs.md +++ b/content/ru/tutorial/05-refs.md @@ -102,7 +102,7 @@ function patch(input) { input.__patched = true; var old = input.focus; input.focus = function() { - store.setState({ solved: true }); + solutionCtx.setSolved(true); return old.call(this); }; } diff --git a/content/ru/tutorial/07-side-effects.md b/content/ru/tutorial/07-side-effects.md index c6f5e2a87..79303e2a9 100644 --- a/content/ru/tutorial/07-side-effects.md +++ b/content/ru/tutorial/07-side-effects.md @@ -112,7 +112,7 @@ useRealm(function (realm) { var prevConsoleLog = win.console.log; win.console.log = function(m, s) { if (/Счётчик: /.test(m) && s === 1) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } return prevConsoleLog.apply(win.console, arguments); }; diff --git a/content/ru/tutorial/08-keys.md b/content/ru/tutorial/08-keys.md index d4eeff73d..15e07ba18 100644 --- a/content/ru/tutorial/08-keys.md +++ b/content/ru/tutorial/08-keys.md @@ -239,7 +239,7 @@ useRealm(function (realm) { /изучить preact/i.test(c[0].textContent) && /сделать крутое приложение/i.test(c[1].textContent) ) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } } diff --git a/content/ru/tutorial/09-error-handling.md b/content/ru/tutorial/09-error-handling.md index 6dfcd1b9c..f697324d5 100644 --- a/content/ru/tutorial/09-error-handling.md +++ b/content/ru/tutorial/09-error-handling.md @@ -72,7 +72,7 @@ useResult(function(result) { oe.apply(this, arguments); setTimeout(function() { if (result.output.textContent.match(/ошибка/i)) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } }, 10); }; diff --git a/content/zh/tutorial/01-vdom.md b/content/zh/tutorial/01-vdom.md index 73b6c2c3a..6b1dc4b1c 100644 --- a/content/zh/tutorial/01-vdom.md +++ b/content/zh/tutorial/01-vdom.md @@ -160,7 +160,7 @@ useResult(function(result) { var p = result.output.querySelector('p'); var hasColor = p && p.style && p.style.color === 'purple'; if (hasEm && hasColor) { - store.setState({ solved: true }); + solutionCtx.setSolved(true); } }, []); ``` diff --git a/content/zh/tutorial/02-events.md b/content/zh/tutorial/02-events.md index e8aa7e99a..7b01391bb 100644 --- a/content/zh/tutorial/02-events.md +++ b/content/zh/tutorial/02-events.md @@ -47,7 +47,7 @@ useRealm(function (realm) { var win = realm.globalThis; var prevConsoleLog = win.console.log; win.console.log = function() { - store.setState({ solved: true }); + solutionCtx.setSolved(true); return prevConsoleLog.apply(win.console, arguments); }; diff --git a/jsconfig.json b/jsconfig.json index caaa651a3..e401488e3 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ESNext", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Node", "resolveJsonModule": true, "jsx": "react-jsx", "jsxImportSource": "preact", diff --git a/package-lock.json b/package-lock.json index a672460fa..b8a7bd3c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,11 @@ "node-fetch": "^2.6.1", "preact": "10.15.1", "preact-custom-element": "^4.3.0", + "preact-iso": "2.2.0", "preact-markup": "^2.1.1", "preact-render-to-string": "^5.2.6", - "preact-router": "^3.1.0", "rollup": "^2.79.1", "sucrase": "^3.32.0", - "unistore": "^3.5.1", "yaml": "^1.7.2" }, "devDependencies": { @@ -14694,6 +14693,15 @@ "preact": "10.x" } }, + "node_modules/preact-iso": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/preact-iso/-/preact-iso-2.2.0.tgz", + "integrity": "sha512-BIUOYJWnfmOcgFiuAqGTLkqvC2Rqlpn3aLP/ik8kT8afKdLrG3CvBEpSQivMrFY1o2InGX/jEL44ofmoQdoieA==", + "peerDependencies": { + "preact": ">=10", + "preact-render-to-string": ">=5" + } + }, "node_modules/preact-markup": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/preact-markup/-/preact-markup-2.1.1.tgz", @@ -14713,14 +14721,6 @@ "preact": ">=10" } }, - "node_modules/preact-router": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/preact-router/-/preact-router-3.2.1.tgz", - "integrity": "sha512-KEN2VN1DxUlTwzW5IFkF13YIA2OdQ2OvgJTkQREF+AA2NrHRLaGbB68EjS4IeZOa1shvQ1FvEm3bSLta4sXBhg==", - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -18225,19 +18225,6 @@ "node": ">=8" } }, - "node_modules/unistore": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/unistore/-/unistore-3.5.2.tgz", - "integrity": "sha512-2Aa4eX0Ua1umyiI3Eai6Li+wXYOHgaDBGOPB3Hvw7PAVuD30TAyh5kS4yNKb2fLDbQgizvPhKQRcYnOdfsm4VQ==", - "peerDependenciesMeta": { - "preact": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -31308,6 +31295,12 @@ "integrity": "sha512-5hG7nQhU4e7RNfCEQklaUqYQiiyibLuJ2wbhR+E2v1m8m9NDsJok5MykW/Nx0YLLBcXr8xnkap6DwByGy2TzDA==", "requires": {} }, + "preact-iso": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/preact-iso/-/preact-iso-2.2.0.tgz", + "integrity": "sha512-BIUOYJWnfmOcgFiuAqGTLkqvC2Rqlpn3aLP/ik8kT8afKdLrG3CvBEpSQivMrFY1o2InGX/jEL44ofmoQdoieA==", + "requires": {} + }, "preact-markup": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/preact-markup/-/preact-markup-2.1.1.tgz", @@ -31322,12 +31315,6 @@ "pretty-format": "^3.8.0" } }, - "preact-router": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/preact-router/-/preact-router-3.2.1.tgz", - "integrity": "sha512-KEN2VN1DxUlTwzW5IFkF13YIA2OdQ2OvgJTkQREF+AA2NrHRLaGbB68EjS4IeZOa1shvQ1FvEm3bSLta4sXBhg==", - "requires": {} - }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -34096,11 +34083,6 @@ "crypto-random-string": "^2.0.0" } }, - "unistore": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/unistore/-/unistore-3.5.2.tgz", - "integrity": "sha512-2Aa4eX0Ua1umyiI3Eai6Li+wXYOHgaDBGOPB3Hvw7PAVuD30TAyh5kS4yNKb2fLDbQgizvPhKQRcYnOdfsm4VQ==" - }, "universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/package.json b/package.json index 4c754f882..0aacadebf 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "prebuild": "rimraf build/assets && rimraf build/content", "lint": "eslint src test", "format": "prettier --write \"{src,test}/**/*.{css,js,json}\"", - "prepare": "husky install" + "prepare": "husky install", + "postinstall": "patch-package" }, "eslintConfig": { "extends": "developit", @@ -64,6 +65,7 @@ "jsdom": "^15.2.1", "lint-staged": "^15.2.0", "netlify-lambda": "^2.0.16", + "patch-package": "^8.0.0", "postcss-custom-properties": "^13.3.2", "postcss-import": "^14.0.0", "postcss-nesting": "^12.0.1", @@ -88,12 +90,11 @@ "node-fetch": "^2.6.1", "preact": "10.15.1", "preact-custom-element": "^4.3.0", + "preact-iso": "2.2.0", "preact-markup": "^2.1.1", "preact-render-to-string": "^5.2.6", - "preact-router": "^3.2.1", "rollup": "^2.79.1", "sucrase": "^3.32.0", - "unistore": "^3.5.1", "yaml": "^1.7.2" } } diff --git a/patches/preact-cli+4.0.0-next.6.patch b/patches/preact-cli+4.0.0-next.6.patch new file mode 100644 index 000000000..2836e08b2 --- /dev/null +++ b/patches/preact-cli+4.0.0-next.6.patch @@ -0,0 +1,57 @@ +diff --git a/node_modules/preact-cli/src/lib/webpack/prerender.js b/node_modules/preact-cli/src/lib/webpack/prerender.js +index 945a4e0..e8323d3 100644 +--- a/node_modules/preact-cli/src/lib/webpack/prerender.js ++++ b/node_modules/preact-cli/src/lib/webpack/prerender.js +@@ -2,7 +2,7 @@ const { red, yellow } = require('kleur'); + const { resolve } = require('path'); + const { readFileSync } = require('fs'); + const stackTrace = require('stack-trace'); +-const URL = require('url'); ++const { URL } = require('url'); + const { SourceMapConsumer } = require('source-map'); + + module.exports = async function (config, params) { +@@ -11,14 +11,14 @@ module.exports = async function (config, params) { + let entry = resolve(config.dest, './ssr-build/ssr-bundle.js'); + let url = params.url || '/'; + +- global.history = {}; +- global.location = { ...URL.parse(url) }; ++ global.history = /** @type {object} */ ({}); ++ global.location = /** @type {object} */ (new URL(url, 'http://localhost')); + + try { + let m = require(entry), +- app = (m && m.default) || m; ++ vnode = (m && m.default) || m; + +- if (typeof app !== 'function') { ++ if (typeof vnode !== 'function') { + // eslint-disable-next-line no-console + console.warn( + 'Entry does not export a Component function/class, aborting prerendering.' +@@ -29,7 +29,23 @@ module.exports = async function (config, params) { + const renderToString = require(require.resolve('preact-render-to-string', { + paths: [config.cwd], + })); +- return renderToString(preact.h(app, { ...params, url })); ++ ++ vnode = preact.h(vnode, { ...params, url }); ++ ++ // Slightly modified version of preact-iso's `prerender()` ++ let tries; ++ const maxDepth = 10; ++ const render = () => { ++ if (++tries > maxDepth) return; ++ try { ++ return renderToString(vnode); ++ } catch (e) { ++ if (e && e.then) return e.then(render); ++ throw e; ++ } ++ }; ++ ++ return await render(); + } catch (err) { + let stack = stackTrace.parse(err).filter(s => s.getFileName() === entry)[0]; + if (!stack) { diff --git a/src/components/app.js b/src/components/app.js index caf615d1d..fa588e000 100644 --- a/src/components/app.js +++ b/src/components/app.js @@ -1,46 +1,24 @@ -import { Component } from 'preact'; -import { Provider } from 'unistore/preact'; -import createStore from '../store'; -import Routes from './routes'; +import { LocationProvider, ErrorBoundary } from 'preact-iso'; +import { LanguageProvider } from '../lib/i18n'; +// TODO: SolutionProvider should really just wrap the tutorial, +// but that requires a bit of refactoring +import { SolutionProvider } from './controllers/tutorial/index.js'; import Header from './header'; -import { storeCtx } from './store-adapter'; -import { getCurrentDocVersion } from '../lib/docs'; - -export default class App extends Component { - store = createStore({ - url: this.props.url || location.pathname + location.search, - lang: 'en', - preactVersion: this.props.preactVersion || '10.11.3', - docVersion: getCurrentDocVersion(location.pathname), - toc: null - }); - - handleUrlChange = ({ url }) => { - let prev = this.store.getState().url || '/'; - if (url !== prev) { - this.store.setState({ - ...this.store.getState(), - toc: null, - url, - docVersion: getCurrentDocVersion(url) - }); - if (typeof ga === 'function') { - ga('send', 'pageview', url); - } - } - }; +import Routes from './routes'; - render() { - const { url } = this.store.getState(); - return ( - - -
-
- -
-
-
- ); - } +export default function App({ preactVersion }) { + return ( + + + + +
+
+ +
+
+
+
+
+ ); } diff --git a/src/components/blog-meta/index.js b/src/components/blog-meta/index.js new file mode 100644 index 000000000..70100d986 --- /dev/null +++ b/src/components/blog-meta/index.js @@ -0,0 +1,62 @@ +import { Time } from '../time'; +import config from '../../config.json'; +import style from './style.module.css'; + +export default function BlogMeta({ meta }) { + return ( +
+ {meta.date &&
+ ); +} + +function AuthorLinks({ authorData, author, i, arr }) { + return ( + + {authorData ? ( + + {author} + + ) : ( + {author} + )} + {i < arr.length - 2 ? ', ' : i === arr.length - 2 ? ' and ' : null} + + ); +} diff --git a/src/components/blog-meta/style.module.css b/src/components/blog-meta/style.module.css new file mode 100644 index 000000000..cd7ccb07e --- /dev/null +++ b/src/components/blog-meta/style.module.css @@ -0,0 +1,12 @@ +.blogMeta { + /* Negative bottom margin negates content-region's top margin */ + margin: 2.5rem auto -3rem; + z-index: 10; + padding: 0.5em 1rem 0.25rem; + width: 100%; + max-width: var(--content-width); + + .authors { + display: inline-block; + } +} diff --git a/src/components/blog-overview/index.js b/src/components/blog-overview/index.js index 435a1be26..f09723d90 100644 --- a/src/components/blog-overview/index.js +++ b/src/components/blog-overview/index.js @@ -1,12 +1,11 @@ import config from '../../config.json'; -import { useTranslation } from '../../lib/i18n'; +import { useLanguage, useTranslation } from '../../lib/i18n'; import { getRouteName } from '../header'; -import { useStore } from '../store-adapter'; import { Time } from '../time'; import s from './style.module.css'; export default function BlogOverview() { - const { lang } = useStore(['lang']).state; + const [lang] = useLanguage(); const continueReading = useTranslation('continueReading'); return ( diff --git a/src/components/branding/index.js b/src/components/branding/index.js index c10c9878b..d292ca407 100644 --- a/src/components/branding/index.js +++ b/src/components/branding/index.js @@ -21,10 +21,18 @@ function LogoVariation({ name, alt }) { height="64" />
- + SVG - + PNG
diff --git a/src/components/code-block/index.js b/src/components/code-block/index.js index 303c6f32c..26e85b9da 100644 --- a/src/components/code-block/index.js +++ b/src/components/code-block/index.js @@ -1,72 +1,29 @@ -import { useState, useMemo, useRef, useEffect } from 'preact/hooks'; -import { Link } from 'preact-router'; +import { useMemo } from 'preact/hooks'; import * as Comlink from 'comlink'; -import cx from '../../lib/cx'; - -let highlight; -(async function initHighlight() { - ({ highlight } = PRERENDER - ? import('./prism.worker.js') - : Comlink.wrap( - new Worker( - /* webpackChunkName: "prism-worker" */ new URL( - './prism.worker.js', - import.meta.url - ) +import { FakeSuspense, useResource } from '../../lib/use-resource'; + +const { highlight } = PRERENDER + ? require('./prism.worker.js') + : Comlink.wrap( + new Worker( + /* webpackChunkName: "prism-worker" */ new URL( + './prism.worker.js', + import.meta.url ) - )); -})(); - -function useFuture(initializer, params) { - const getInitialState = () => { - try { - const value = initializer(); - if (value && value.then) { - if ('_value' in value) return [value._value]; - if ('_error' in value) return [undefined, value._error]; - return [undefined, undefined, value]; - } - return [value]; - } catch (err) { - return [undefined, err]; - } - }; - - const [pair, setValue] = useState(getInitialState); - - // only run on changes, not initial mount - const isFirstRun = useRef(true); - useEffect(() => { - if (isFirstRun.current) return (isFirstRun.current = false); - setValue(getInitialState()); - }, params || []); - - const pending = pair[2]; - if (pending) { - if (!pending._processing) { - pending._processing = true; - pending - .then(value => { - pending._value = value; - setValue([value]); - }) - .catch(err => { - pending._error = err; - setValue([undefined, err]); - }); - } - } - return pair; -} + ) + ); + +/** + * @param {{ code: string, lang: string }} props + */ +function HighlightedCode({ code, lang }) { + const highlighted = useResource(() => highlight(code, lang), [code, lang]); -const CACHE = {}; -function cachedHighlight(code, lang) { - const id = lang + '\n' + code; - return CACHE[id] || (CACHE[id] = highlight(code, lang)); + const htmlObj = useMemo(() => ({ __html: highlighted }), [highlighted]); + return ; } -function HighlightedCodeBlock({ code, lang, ...props }) { - let repl = false; +function processRepl(code, repl) { let source = code; if (code.startsWith('// --repl')) { repl = true; @@ -102,29 +59,32 @@ function HighlightedCodeBlock({ code, lang, ...props }) { } } - const [highlighted, error, pending] = useFuture( - () => cachedHighlight(code, lang), - [code, lang] - ); + return [code, source, repl]; +} + +/** + * @param {{ code: string, lang: string, repl?: string }} props + */ +function HighlightedCodeBlock({ code, lang }) { + let repl = false, + source = code; + + [code, source, repl] = processRepl(source, repl); - const canHighlight = !!pending || !error; - const html = - (canHighlight && highlighted) || - code.replace(//g, '>'); - const htmlObj = useMemo(() => ({ __html: html }), [html]); + // Show unhighlighted code as a fallback until we're ready + const fallback = {code}; return ( -
+
-				
+				
+					
+				
 			
{repl && ( - + Run in REPL - + )}
); @@ -143,14 +103,7 @@ const CodeBlock = props => { )[1]; const firstChild = getChild(child.props); const code = String(firstChild || '').replace(/(^\s+|\s+$)/g, ''); - return ( - - ); + return ; } return
;
diff --git a/src/components/code-block/prism.worker.js b/src/components/code-block/prism.worker.js
index 05f1342d0..0354a1c92 100644
--- a/src/components/code-block/prism.worker.js
+++ b/src/components/code-block/prism.worker.js
@@ -7,7 +7,8 @@ export function highlight(code, lang) {
 	if (prism.languages[lang] != null) {
 		return prism.highlight(code, prism.languages[lang], lang);
 	}
-	throw Error(`Unknown language: ${lang}`);
+	//console.error(`Unknown language: ${lang}`);
+	return code;
 }
 
 // .expose will throw in SSR env
diff --git a/src/components/content-region/index.js b/src/components/content-region/index.js
index 995d001b2..d9fdde6be 100644
--- a/src/components/content-region/index.js
+++ b/src/components/content-region/index.js
@@ -2,6 +2,7 @@ import Markup from 'preact-markup';
 import widgets from '../widgets';
 import style from './style.module.css';
 import { useTranslation } from '../../lib/i18n';
+import { TocContext } from '../table-of-contents';
 
 const COMPONENTS = {
 	...widgets,
@@ -60,13 +61,15 @@ export default function ContentRegion({ content, components, ...props }) {
 	return (
 		
 			{content && (
-				
+				
+					
+				
 			)}
 			{hasNav && (
 				
diff --git a/src/components/controllers/blog-page.js b/src/components/controllers/blog-page.js new file mode 100644 index 000000000..dc23d7d8b --- /dev/null +++ b/src/components/controllers/blog-page.js @@ -0,0 +1,40 @@ +import { useRoute, useLocation } from 'preact-iso'; +import { useContent } from '../../lib/use-resource'; +import { useTitle, useDescription } from './utils'; +import { NotFound } from './not-found'; +import { useLanguage } from '../../lib/i18n'; +import { MarkdownRegion } from './markdown-region'; +import Footer from '../footer/index'; +import { blogRoutes } from '../../lib/route-utils'; +import style from './style.module.css'; + +export default function BlogPage() { + const { params } = useRoute(); + const { slug } = params; + + if (!blogRoutes[`/blog/${slug}`]) { + return ; + } + + return ; +} + +function BlogLayout() { + const { url } = useLocation(); + const [lang] = useLanguage(); + + const { html, meta } = useContent([lang, url === '/' ? 'index' : url]); + useTitle(meta.title); + useDescription(meta.description); + + return ( +
+
+
+ +
+
+
+
+ ); +} diff --git a/src/components/controllers/doc-page.js b/src/components/controllers/doc-page.js new file mode 100644 index 000000000..e24f89e49 --- /dev/null +++ b/src/components/controllers/doc-page.js @@ -0,0 +1,85 @@ +import { useRoute, useLocation } from 'preact-iso'; +import { useContent } from '../../lib/use-resource'; +import { useTitle, useDescription } from './utils'; +import config from '../../config.json'; +import { NotFound } from './not-found'; +import cx from '../../lib/cx'; +import { LATEST_MAJOR, isDocPage } from '../../lib/docs'; +import { useLanguage } from '../../lib/i18n'; +import { MarkdownRegion } from './markdown-region'; +import Sidebar from '../sidebar'; +import Footer from '../footer/index'; +import { docRoutes } from '../../lib/route-utils'; +import style from './style.module.css'; + +export function DocPage() { + const { params } = useRoute(); + const { version, name } = params; + + if (!docRoutes[version]['/' + name]) { + return ; + } + + return ; +} + +export function DocLayout() { + const { url } = useLocation(); + const [lang] = useLanguage(); + + const { html, meta } = useContent([lang, url === '/' ? 'index' : url]); + useTitle(meta.title); + useDescription(meta.description); + + const hasSidebar = meta.toc !== false && isDocPage(url); + + return ( +
+
+ {hasSidebar && ( +
+ +
+ )} +
+ + +
+
+
+
+ ); +} + +function OldDocsWarning() { + const { name, version } = useRoute().params; + const { url } = useLocation(); + + if (!isDocPage(url) || version === LATEST_MAJOR) { + return null; + } + + const latestExists = config.docs[LATEST_MAJOR].some(section => + section.routes.some(route => route.path === '/' + name) + ); + + return ( +
+ You are viewing the documentation for an older version of Preact. + {latestExists ? ( + <> + {' '} + Switch to the{' '} + current version. + + ) : ( + <> + {' '} + Get started with the{' '} + current version + . + + )} +
+ ); +} diff --git a/src/components/controllers/index.js b/src/components/controllers/index.js deleted file mode 100644 index b80fe0cc2..000000000 --- a/src/components/controllers/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import Page from './page'; -import Repl from 'async!./repl'; -import Tutorial from 'async!./tutorial'; - -export default { - default: Page, - error: Page, - Repl, - Blog: Page, - Tutorial -}; diff --git a/src/components/controllers/markdown-region.js b/src/components/controllers/markdown-region.js new file mode 100644 index 000000000..2c0328214 --- /dev/null +++ b/src/components/controllers/markdown-region.js @@ -0,0 +1,30 @@ +import { useLocation } from 'preact-iso'; +import EditThisPage from '../edit-button'; +import ContentRegion from '../content-region'; +import BlogMeta from '../blog-meta'; + +/** + * @param {object} props + * @propery {string} html + * @propery {any} meta + * @propery {any} [components] + */ +export function MarkdownRegion({ html, meta, components }) { + const { url } = useLocation(); + + const canEdit = url !== '/' && !url.startsWith('/tutorial'); + const isBlogArticle = url.startsWith('/blog/'); + + return ( + <> + {canEdit && } + {isBlogArticle && } + + + ); +} diff --git a/src/components/controllers/not-found.js b/src/components/controllers/not-found.js new file mode 100644 index 000000000..6a64acbc9 --- /dev/null +++ b/src/components/controllers/not-found.js @@ -0,0 +1,25 @@ +import { useLanguage } from '../../lib/i18n'; +import { useContent } from '../../lib/use-resource'; +import { useTitle, useDescription } from './utils'; +import Footer from '../footer'; +import { MarkdownRegion } from './markdown-region'; +import style from './style.module.css'; + +export function NotFound() { + const [lang] = useLanguage(); + + const { html, meta } = useContent([lang, '404']); + useTitle(meta.title); + useDescription(meta.description); + + return ( +
+
+
+ +
+
+
+
+ ); +} diff --git a/src/components/controllers/page.js b/src/components/controllers/page.js new file mode 100644 index 000000000..72744473e --- /dev/null +++ b/src/components/controllers/page.js @@ -0,0 +1,14 @@ +import { useRoute } from 'preact-iso'; +import { navRoutes } from '../../lib/route-utils'; +import { NotFound } from './not-found'; +import { DocLayout } from './doc-page'; + +export function Page() { + const { path } = useRoute(); + + if (!navRoutes[path]) { + return ; + } + + return ; +} diff --git a/src/components/controllers/page/index.js b/src/components/controllers/page/index.js deleted file mode 100644 index 9c81244aa..000000000 --- a/src/components/controllers/page/index.js +++ /dev/null @@ -1,175 +0,0 @@ -import { useEffect, useMemo } from 'preact/hooks'; -import cx from '../../../lib/cx'; -import ContentRegion from '../../content-region'; -import config from '../../../config.json'; -import style from './style.module.css'; -import Footer from '../../footer'; -import Sidebar from './sidebar'; -import Hydrator from '../../../lib/hydrator'; -import EditThisPage from '../../edit-button'; -import { InjectPrerenderData } from '../../../lib/prerender-data'; -import { isDocPage } from '../../../lib/docs'; -import { useStore } from '../../store-adapter'; -import { AVAILABLE_DOCS } from '../../doc-version'; -import { Time } from '../../time'; -import { usePage, getContentId } from '../utils'; - -export default function Page({ route, prev, next }, ctx) { - const store = useStore(['url', 'lang', 'docVersion']); - const { loading, meta, content, html, current, isFallback } = usePage( - route, - store.state.lang - ); - const urlState = store.state; - const url = useMemo(() => urlState.url, [current]); - - const docsUrl = useMemo( - () => url.replace(/(v\d{1,2})/, `v${AVAILABLE_DOCS[0]}`), - [url] - ); - - const layout = `${meta.layout || 'default'}Layout`; - const name = getContentId(route); - - const isReady = !loading; - - // workaround: we toc in the store in order for to pick it up. - if (meta.toc && ctx.store.getState().toc !== meta.toc) { - ctx.store.setState({ - toc: meta.toc - }); - } - - // Note: - // "name" is the exact page ID from the URL - // "current" is the currently *displayed* page ID. - - const canEdit = current !== 'index'; - const isBlogArticle = current.startsWith('/blog/'); - const hasSidebar = meta.toc !== false && isDocPage(url); - - useEffect(() => { - if (location.hash) { - const anchor = document.querySelector(location.hash); - if (anchor) { - // Do not use scrollIntoView as it will cause - // the heading to be covered by the header - scrollTo({ top: anchor.offsetTop }); - } - } - }, [html]); - - return ( -
- -
- -
- {isDocPage(url) && +store.state.docVersion !== AVAILABLE_DOCS[0] && ( -
- You are viewing the documentation for an older version of Preact.{' '} - Switch to the current version → -
- )} - - {isBlogArticle && } - -
-
-
- -
- ); -} - -function BlogMeta({ meta }) { - return ( -
- {meta.date &&
- ); -} diff --git a/src/components/controllers/page/sidebar.js b/src/components/controllers/page/sidebar.js deleted file mode 100644 index 41ed32fdb..000000000 --- a/src/components/controllers/page/sidebar.js +++ /dev/null @@ -1,68 +0,0 @@ -import style from './sidebar.module.css'; -import DocVersion from './../../doc-version'; -import SidebarNav from './sidebar-nav'; -import { useCallback } from 'preact/hooks'; -import config from '../../../config.json'; -import { useStore } from '../../store-adapter'; -import { useOverlayToggle } from '../../../lib/toggle-overlay'; -import { getRouteName } from '../../header'; - -export default function Sidebar() { - const [open, setOpen] = useOverlayToggle(false); - const toggle = useCallback(() => setOpen(!open), [open]); - const close = useCallback(() => setOpen(false), []); - const { docVersion, lang } = useStore(['docVersion', 'lang']).state; - - // Get menu items for the current version of the docs (guides) - // TODO: allow multiple sections - config[meta.section] - const docNav = config.docs - .filter(item => { - // We know that nested routes are part of the same - // doc version, so we just need to check the first - // route. - if (item.routes) { - item = item.routes[0]; - } - - return item.path.indexOf(`/v${docVersion}`) > -1; - }) - .reduce((acc, item) => { - if (item.routes) { - acc.push({ - text: getRouteName(item, lang), - level: 2, - href: null, - routes: item.routes.map(nested => ({ - text: getRouteName(nested, lang), - level: 3, - href: nested.path - })) - }); - } else { - acc.push({ - text: getRouteName(item, lang), - level: 2, - href: item.path - }); - } - return acc; - }, []); - - // TODO: use URL match instead of .content - const guide = config.nav.filter(item => item.content === 'guide')[0]; - const sectionName = getRouteName(guide, lang); - - return ( -
- - -
- ); -} diff --git a/src/components/controllers/page/style.module.css b/src/components/controllers/style.module.css similarity index 87% rename from src/components/controllers/page/style.module.css rename to src/components/controllers/style.module.css index 8c2491d19..dfc89912c 100644 --- a/src/components/controllers/page/style.module.css +++ b/src/components/controllers/style.module.css @@ -7,7 +7,7 @@ margin-bottom: 4rem; } - content-region:not([name='index']) :global(.markup) { + content-region:not([name='/']) :global(.markup) { margin-top: 2.5rem; @media (max-width: 600px) { @@ -45,14 +45,6 @@ text-align: center; } -.blogMeta { - /* Negative bottom margin negates content-region's top margin */ - margin: 2.5rem auto -3rem; - padding: 0.5em 1rem 0.25rem; - width: 100%; - max-width: var(--content-width); -} - content-region h1 { padding: 0.5rem 1rem 0.25rem !important; font-weight: 200; @@ -64,10 +56,6 @@ content-region h1 { } } -.authors { - display: inline-block; -} - .inner { display: flex; flex-direction: column; diff --git a/src/components/controllers/tutorial/index.js b/src/components/controllers/tutorial/index.js index 70f7731ab..120abb8f7 100644 --- a/src/components/controllers/tutorial/index.js +++ b/src/components/controllers/tutorial/index.js @@ -8,19 +8,42 @@ import { useMemo, useCallback } from 'preact/hooks'; +import { useLocation, useRoute } from 'preact-iso'; import linkState from 'linkstate'; import cx from '../../../lib/cx'; import style from './style.module.css'; import { ErrorOverlay } from '../repl/error-overlay'; import { parseStackTrace } from '../repl/errors'; -import ContentRegion from '../../content-region'; import widgets from '../../widgets'; -import { usePage } from '../utils'; -import { useStore, storeCtx } from '../../store-adapter'; import { InjectPrerenderData } from '../../../lib/prerender-data'; -import { getContent } from '../../../lib/content'; +import { useLanguage } from '../../../lib/i18n'; +import { useContent } from '../../../lib/use-resource'; +import { useTitle, useDescription } from '../utils'; import { Splitter } from '../../splitter'; import config from '../../../config.json'; +import { getContent } from '../../../lib/content'; +import { MarkdownRegion } from '../markdown-region.js'; + +/** + * @typedef SolutionContext + * @property {boolean} solved + * @property {(boolean) => void} setSolved + */ + +/** + * @type {import('preact').Context} + */ +const SolutionContext = createContext(/** @type {SolutionContext} */ ({})); + +export function SolutionProvider({ children }) { + const [solved, setSolved] = useState(false); + + return ( + + {children} + + ); +} const IS_PRERENDERING = typeof window === 'undefined'; @@ -43,7 +66,7 @@ export default class Tutorial extends Component { content = createRef(); runner = createRef(); - static contextType = storeCtx; + static contextType = SolutionContext; resultHandlers = new Set(); realmHandlers = new Set(); @@ -79,7 +102,7 @@ export default class Tutorial extends Component { 'repl-initial': '', 'repl-final': '' }); - this.context.setState({ solved: false }); + this.context.setSolved(false); } } @@ -138,9 +161,8 @@ export default class Tutorial extends Component { this.setState({ error: null }); }; - render({ route, step }, { loading, code, error }) { + render({ step }, { loading, code, error }) { const state = { - route, step, loading, code, @@ -158,7 +180,6 @@ export default class Tutorial extends Component { function TutorialView({ step, - route, loading, code, error, @@ -166,36 +187,43 @@ function TutorialView({ CodeEditor, clearError }) { - const content = useRef(); + const content = useRef(null); const tutorial = useContext(TutorialContext); const [showCodeOverride, toggleCode] = useReducer(s => !s, true); - const { lang, solved } = useStore(['lang', 'solved']).state; - const fullPath = route.path.replace(':step?', step || route.first); - const page = usePage({ path: fullPath }, lang); - const title = (page && page.meta.title) || route.title; - const solvable = page && page.meta.solvable === true; - const hasCode = page && page.meta.code !== false && step && step !== 'index'; + const { url } = useLocation(); + const { params } = useRoute(); + const [lang] = useLanguage(); + const { solved } = useContext(SolutionContext); + + const { html, meta } = useContent([ + lang, + !params.step ? 'tutorial/index' : url + ]); + useTitle(meta.title); + useDescription(meta.description); + + const solvable = meta.solvable === true; + const hasCode = meta.code !== false && step && step !== 'index'; const showCode = showCodeOverride && hasCode; - loading = - !page.html || (showCode && (!!page.loading || !Runner || !CodeEditor)); - const initialLoad = !page.html || !Runner || !CodeEditor; + loading = !html || (showCode && (!Runner || !CodeEditor)); + const initialLoad = !html || !Runner || !CodeEditor; // Scroll to the top after loading useEffect(() => { if (!loading && !initialLoad) { content.current.scrollTo(0, 0); } - }, [fullPath, loading, initialLoad]); + }, [url, loading, initialLoad]); // Preload the next chapter useEffect(() => { - if (page.meta && page.meta.next) { - getContent([lang, page.meta.next]); + if (meta && meta.next) { + getContent([lang, meta.next]); } - }, [page.meta && page.meta.next, fullPath]); + }, [meta && meta.next, url]); const reRun = useCallback(() => { let code = tutorial.state.code; @@ -207,7 +235,6 @@ function TutorialView({ return ( {!initialLoad && (
-

{title}

+

{meta.title}

- @@ -316,10 +340,10 @@ function TutorialView({ @@ -338,7 +362,6 @@ const REPL_CSS = ` function ReplWrapper({ loading, - subtleLoading, solvable, solved, initialLoad, @@ -347,7 +370,7 @@ function ReplWrapper({ }) { return (
- +
tutorial.runner.current.realm.globalThis._require(m); const fn = new Function( @@ -422,11 +445,10 @@ function TutorialSetupBlock({ code }) { 'useEffect', 'useRef', 'useMemo', - 'useStore', 'useResult', 'useRealm', 'useError', - 'store', + 'solutionCtx', 'realm', 'require', code @@ -440,11 +462,10 @@ function TutorialSetupBlock({ code }) { useEffect, useRef, useMemo, - useStore, tutorial.useResult, tutorial.useRealm, tutorial.useError, - store, + solutionCtx, tutorial.runner.current && tutorial.runner.current.realm, require ); @@ -457,8 +478,8 @@ function TutorialSetupBlock({ code }) { /** Shows a solution banner when the chapter is solved */ function Solution({ children }) { - const { solved } = useStore(['solved']).state; - const ref = useRef(); + const { solved } = useContext(SolutionContext); + const ref = useRef(null); useEffect(() => { if (solved) { diff --git a/src/components/controllers/tutorial/style.module.css b/src/components/controllers/tutorial/style.module.css index 0d5f1c35e..46e9e09b4 100644 --- a/src/components/controllers/tutorial/style.module.css +++ b/src/components/controllers/tutorial/style.module.css @@ -79,9 +79,9 @@ background: rgba(150, 150, 150, 0.6); } - & > h1 { + h1 { margin: 0; - padding: 0.25em 0; + padding: 0.25em 0 !important; font-size: 2.5rem; font-weight: 200; font-weight: bold; diff --git a/src/components/controllers/utils.js b/src/components/controllers/utils.js index 5eb2663e6..288451e16 100644 --- a/src/components/controllers/utils.js +++ b/src/components/controllers/utils.js @@ -1,7 +1,5 @@ -import { useEffect, useRef, useState } from 'preact/hooks'; +import { useEffect } from 'preact/hooks'; -import { getContentOnServer, getContent } from '../../lib/content'; -import { getPrerenderData } from '../../lib/prerender-data'; import { createTitle } from '../../lib/page-title'; /** @@ -28,86 +26,3 @@ export function useDescription(text) { } }, [text]); } - -export const getContentId = route => route.content || route.path; -export function usePage(route, lang) { - // on the server, pass data down through the tree to avoid repeated FS lookups - if (PRERENDER) { - const { content, html, meta } = getContentOnServer(route.path, lang); - return { - current: getContentId(route), - content, - html, - meta, - loading: true // this is important since the client will initialize in a loading state. - }; - } - - const [current, setCurrent] = useState(getContentId(route)); - - const bootData = getPrerenderData(current); - - const [hydrated, setHydrated] = useState(!!bootData); - const [content, setContent] = useState( - hydrated && bootData && bootData.content - ); - const [html, setHtml] = useState(hydrated && bootData && bootData.html); - - const [loading, setLoading] = useState(true); - const [isFallback, setFallback] = useState(false); - let [meta, setMeta] = useState(hydrated ? bootData.meta : undefined); - if (!meta) meta = (hydrated && bootData.meta) || {}; - - const lock = useRef(); - useEffect(() => { - if (!didLoad) { - setLoading(true); - } - const contentId = getContentId(route); - lock.current = contentId; - getContent([lang, contentId]).then(data => { - // Discard old load events - if (lock.current !== contentId) return; - onLoad(data); - }); - }, [getContentId(route), lang]); - - useTitle(meta.title); - useDescription(meta.description); - - let didLoad = false; - function onLoad(data) { - const { content, html, meta, fallback } = data; - didLoad = true; - - // Don't show loader forever in case of an error - if (!meta) return; - - setContent(content); - setMeta(meta); - setHtml(html); - setLoading(false); - setFallback(fallback); - const current = getContentId(route); - const bootData = getPrerenderData(current); - setHydrated(!!bootData); - setCurrent(current); - // content was loaded. if this was a forward route transition, animate back to top - if (window.nextStateToTop) { - window.nextStateToTop = false; - scrollTo({ - top: 0, - left: 0 - }); - } - } - - return { - current, - content, - html, - meta, - loading, - isFallback - }; -} diff --git a/src/components/doc-version/index.js b/src/components/doc-version/index.js index 0399e251a..cbb9804bf 100644 --- a/src/components/doc-version/index.js +++ b/src/components/doc-version/index.js @@ -1,11 +1,7 @@ +import { useCallback } from 'preact/hooks'; +import { useLocation, useRoute } from 'preact-iso'; +import config from '../../config.json'; import style from './style.module.css'; -import { getCurrentUrl, route } from 'preact-router'; -import { useStore } from '../store-adapter'; - -function onChange(e) { - const url = getCurrentUrl().replace(/(v\d{1,2})/, `v${e.target.value}`); - route(url); -} export const AVAILABLE_DOCS = [10, 8]; @@ -13,16 +9,28 @@ export const AVAILABLE_DOCS = [10, 8]; * Select box to switch the currently displayed docs version */ export default function DocVersion() { - const { docVersion } = useStore(['docVersion']).state; + const { path, route } = useLocation(); + const { version, name } = useRoute().params; + + const onChange = useCallback( + e => { + const version = e.currentTarget.value; + const url = config.docs[version]?.[name] + ? path.replace(/(v\d{1,2})/, version) + : `/guide/${version}/getting-started`; + route(url); + }, + [path, route] + ); return (