diff --git a/package-lock.json b/package-lock.json index 7257be16..d94f2a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "@node-core/doc-kit", "version": "1.4.3", "dependencies": { + "@11ty/is-land": "^5.0.1", "@actions/core": "^3.0.0", "@fontsource-variable/open-sans": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0", @@ -79,6 +80,16 @@ "prettier": "3.8.5" } }, + "node_modules/@11ty/is-land": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@11ty/is-land/-/is-land-5.0.1.tgz", + "integrity": "sha512-Rh/sLhE4vrc2JaSjeY385v2UxnDY9BhnQtitETb3SKyr0A48Q5Vn06q2AvDBHObtk9+dcFWsoZX4jhT+O9g+xQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, "node_modules/@actions/core": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", diff --git a/package.json b/package.json index c0572be6..b124cc23 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "prettier": "3.8.5" }, "dependencies": { + "@11ty/is-land": "^5.0.1", "@actions/core": "^3.0.0", "@fontsource-variable/open-sans": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0", diff --git a/src/generators/web/bundlers/vite.mjs b/src/generators/web/bundlers/vite.mjs index ceeab15a..f6edcf23 100644 --- a/src/generators/web/bundlers/vite.mjs +++ b/src/generators/web/bundlers/vite.mjs @@ -236,6 +236,12 @@ export const createViteConfig = ({ ...(server ? { manifest: false } : {}), ssr: server, + // Islands make split CSS wrong: a component's stylesheet would arrive + // with the chunk that hydrates it, long after the server-rendered markup + // it styles is on screen. One stylesheet keeps the page styled from the + // first paint, whenever — or whether — its islands load. + ...(server ? {} : { cssCodeSplit: false }), + // Browser output follows the generator's minification setting. Temporary // server output stays readable and disappears immediately after render. minify: server ? false : (vite.build?.minify ?? webConfig.minify), diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index b78fd82c..3864313f 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -24,7 +24,7 @@ export const JSX_IMPORTS = { }, CodeTabs: { name: 'CodeTabs', - source: '@node-core/ui-components/MDX/CodeTabs', + source: resolve(ROOT, './ui/components/CodeTabs'), }, MDXTooltip: { name: 'MDXTooltip', diff --git a/src/generators/web/ui/components/Banner.jsx b/src/generators/web/ui/components/Banner.jsx index 1c5bb915..5b3cf801 100644 --- a/src/generators/web/ui/components/Banner.jsx +++ b/src/generators/web/ui/components/Banner.jsx @@ -2,6 +2,7 @@ import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; import Banner from '@node-core/ui-components/Common/Banner'; import useBanners from '../hooks/useBanners.mjs'; +import withIsland from '../islands/withIsland.jsx'; import { remoteConfigUrl, version } from '#theme/config'; @@ -29,4 +30,4 @@ const Banners = () => { ); }; -export default Banners; +export default withIsland(Banners, { name: 'Banner', on: { idle: true } }); diff --git a/src/generators/web/ui/components/CodeBox.jsx b/src/generators/web/ui/components/CodeBox.jsx index 15f46ffb..4e615a97 100644 --- a/src/generators/web/ui/components/CodeBox.jsx +++ b/src/generators/web/ui/components/CodeBox.jsx @@ -1,5 +1,7 @@ import BaseCodeBox from '@node-core/ui-components/Common/BaseCodeBox'; +import withIsland from '../islands/withIsland.jsx'; + import { languageDisplayNameMap } from '#theme/config'; /** @@ -15,7 +17,7 @@ export const getLanguageDisplayName = language => { }; /** @param {import('react').PropsWithChildren<{ className: string }>} props */ -export default ({ className, ...props }) => { +const CodeBox = ({ className, ...props }) => { const matches = className?.match(/language-(?[a-zA-Z]+)/); const language = matches?.groups?.language ?? ''; @@ -31,3 +33,10 @@ export default ({ className, ...props }) => { /> ); }; + +// The only interactive part is the copy button; the highlighted code itself is +// static markup, so it stays in the server output and is never re-rendered. +export default withIsland(CodeBox, { + name: 'CodeBox', + on: { interaction: 'pointerover,focusin,touchstart' }, +}); diff --git a/src/generators/web/ui/components/CodeTabs.jsx b/src/generators/web/ui/components/CodeTabs.jsx new file mode 100644 index 00000000..cfa80d4b --- /dev/null +++ b/src/generators/web/ui/components/CodeTabs.jsx @@ -0,0 +1,8 @@ +import CodeTabs from '@node-core/ui-components/MDX/CodeTabs'; + +import withIsland from '../islands/withIsland.jsx'; + +export default withIsland(CodeTabs, { + name: 'CodeTabs', + on: { interaction: 'pointerover,focusin,touchstart' }, +}); diff --git a/src/generators/web/ui/components/Layout/index.jsx b/src/generators/web/ui/components/Layout/index.jsx index 6ef28424..7b0c243c 100644 --- a/src/generators/web/ui/components/Layout/index.jsx +++ b/src/generators/web/ui/components/Layout/index.jsx @@ -3,7 +3,6 @@ import Article from '@node-core/ui-components/Containers/Article'; import Banner from '../Banner'; -import { server } from '#theme/config'; import Footer from '#theme/Footer'; import MetaBar from '#theme/Metabar'; import NavBar from '#theme/Navigation'; @@ -20,7 +19,7 @@ import SideBar from '#theme/Sidebar'; */ export default ({ metadata, headings, readingTime, children }) => ( <> - {!server && } +
diff --git a/src/generators/web/ui/components/NavBar.jsx b/src/generators/web/ui/components/NavBar.jsx index c0484e80..e2cc63f8 100644 --- a/src/generators/web/ui/components/NavBar.jsx +++ b/src/generators/web/ui/components/NavBar.jsx @@ -1,10 +1,9 @@ -import ThemeToggle from '@node-core/ui-components/Common/ThemeToggle'; import NavBar from '@node-core/ui-components/Containers/NavBar'; import styles from '@node-core/ui-components/Containers/NavBar/index.module.css'; import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub'; import SearchBox from './SearchBox'; -import { useTheme } from '../hooks/useTheme.mjs'; +import ThemeToggle from './ThemeToggle.jsx'; import { repository, showSearchBox } from '#theme/config'; import Logo from '#theme/Logo'; @@ -12,27 +11,20 @@ import Logo from '#theme/Logo'; /** * NavBar component that displays the headings, search, etc. */ -export default ({ metadata }) => { - const [themePreference, setThemePreference] = useTheme(); - - return ( - ( + + {showSearchBox && } + + - {showSearchBox && } - - - - - - ); -}; + + + +); diff --git a/src/generators/web/ui/components/SearchBox/index.jsx b/src/generators/web/ui/components/SearchBox/index.jsx index 6a24e7c8..49dba7e5 100644 --- a/src/generators/web/ui/components/SearchBox/index.jsx +++ b/src/generators/web/ui/components/SearchBox/index.jsx @@ -9,6 +9,7 @@ import SearchHit from '@node-core/ui-components/Common/Search/Results/Hit'; import styles from './index.module.css'; import useOrama from '../../hooks/useOrama.mjs'; +import withIsland from '../../islands/withIsland.jsx'; import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs'; const SearchBox = ({ pathname }) => { @@ -59,4 +60,7 @@ const SearchBox = ({ pathname }) => { ); }; -export default SearchBox; +export default withIsland(SearchBox, { + name: 'SearchBox', + on: { interaction: 'pointerover,focusin,touchstart' }, +}); diff --git a/src/generators/web/ui/components/SideBar/index.jsx b/src/generators/web/ui/components/SideBar/index.jsx index 2df4e200..19816208 100644 --- a/src/generators/web/ui/components/SideBar/index.jsx +++ b/src/generators/web/ui/components/SideBar/index.jsx @@ -2,6 +2,7 @@ import Select from '@node-core/ui-components/Common/Select'; import SideBar from '@node-core/ui-components/Containers/Sidebar'; import styles from './index.module.css'; +import withIsland from '../../islands/withIsland.jsx'; import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs'; import { project, version, versions, pages } from '#theme/config'; @@ -23,7 +24,7 @@ const redirect = url => (window.location.href = url); * Sidebar component for MDX documentation with version selection and page navigation * @param {{ metadata: import('../../types').SerializedMetadata }} props */ -export default ({ metadata }) => { +const Sidebar = ({ metadata }) => { const introducedMajor = getMajorVersion( metadata.added ?? metadata.introduced_in ); @@ -65,3 +66,5 @@ export default ({ metadata }) => { ); }; + +export default withIsland(Sidebar, { name: 'SideBar', on: { idle: true } }); diff --git a/src/generators/web/ui/components/ThemeToggle.jsx b/src/generators/web/ui/components/ThemeToggle.jsx new file mode 100644 index 00000000..2e973755 --- /dev/null +++ b/src/generators/web/ui/components/ThemeToggle.jsx @@ -0,0 +1,20 @@ +import ThemeToggle from '@node-core/ui-components/Common/ThemeToggle'; + +import { useTheme } from '../hooks/useTheme.mjs'; +import withIsland from '../islands/withIsland.jsx'; + +/** + * Theme switcher. + */ +const Toggle = () => { + const [themePreference, setThemePreference] = useTheme(); + + return ( + + ); +}; + +export default withIsland(Toggle, { + name: 'ThemeToggle', + on: { idle: true }, +}); diff --git a/src/generators/web/ui/index.css b/src/generators/web/ui/index.css index 1b375458..dc98fd83 100644 --- a/src/generators/web/ui/index.css +++ b/src/generators/web/ui/index.css @@ -10,6 +10,21 @@ --font-ibm-plex-mono: 'IBM Plex Mono', monospace; } +is-land, +island-slot { + display: contents; +} + +/* TODO(@avivkeller): make this component's CSS more agnostic */ +is-land[data-component='SideBar'] { + display: flex; + flex-direction: column; + + > aside { + flex: 1 0 auto; + } +} + main { /* Code should inherit its font size */ code { diff --git a/src/generators/web/ui/islands/loaders.mjs b/src/generators/web/ui/islands/loaders.mjs new file mode 100644 index 00000000..b5661a4a --- /dev/null +++ b/src/generators/web/ui/islands/loaders.mjs @@ -0,0 +1,11 @@ +/* eslint-disable jsdoc/require-jsdoc -- each entry is a bare dynamic import */ + +/** + * @type {Record Promise<{ default: import('preact').ComponentType }>>} + */ +export default { + Banner: () => import('../components/Banner'), + ThemeToggle: () => import('../components/ThemeToggle.jsx'), + SearchBox: () => import('../components/SearchBox'), + SideBar: () => import('#theme/Sidebar'), +}; diff --git a/src/generators/web/ui/islands/runtime.mjs b/src/generators/web/ui/islands/runtime.mjs new file mode 100644 index 00000000..a90aea5f --- /dev/null +++ b/src/generators/web/ui/islands/runtime.mjs @@ -0,0 +1,63 @@ +import { Island } from '@11ty/is-land'; +import { h, hydrate } from 'preact'; + +import loaders from './loaders.mjs'; + +/** + * Adds the generator's component map to the registry. + * + * @param {Record Promise<{ default: import('preact').ComponentType }>>} components + */ +export const registerIslands = components => Object.assign(loaders, components); + +/** + * Re-renders an island's server-rendered children as the markup they already + * are. Preact keeps the existing DOM because the HTML is identical, so nothing + * static ever has to be shipped as JavaScript. + * + * @param {{ html: string }} props + */ +const Slot = ({ html }) => + h('island-slot', { dangerouslySetInnerHTML: { __html: html } }); + +// Registered before any island can reach `beforeReady`: importing is-land above +// upgrades the elements already in the document, but `Island#hydrate` awaits its +// loading conditions first, and that await cannot resolve until this module body +// has run to completion. +Island.addInitType('preact', async island => { + const name = island.getAttribute('data-island-name'); + const loader = loaders[name]; + + if (!loader) { + console.error(`[is-land] no component registered for "${name}"`); + return; + } + + const script = [...island.children].find(child => + child.matches('script[data-island-props]') + ); + + const props = script ? JSON.parse(script.textContent) : {}; + + // Preact hydrates by walking the container's children in order, so the props + // script has to go before the tree it describes is diffed against them. + script?.remove(); + + const slots = [...island.querySelectorAll('island-slot')].filter( + slot => slot.closest('is-land') === island + ); + + if (slots.length) { + props.children = slots.map(slot => h(Slot, { html: slot.innerHTML })); + } + + try { + const { default: Component } = await loader(); + + hydrate(h(Component, props), island); + } catch (error) { + // is-land awaits this callback, so a rejection would leave the island + // silently stuck: never marked ready, and never reported anywhere. + console.error(`[is-land] "${name}" failed to hydrate`, error); + } +}); diff --git a/src/generators/web/ui/islands/withIsland.jsx b/src/generators/web/ui/islands/withIsland.jsx new file mode 100644 index 00000000..6c4bde22 --- /dev/null +++ b/src/generators/web/ui/islands/withIsland.jsx @@ -0,0 +1,58 @@ +import { toChildArray } from 'preact'; + +import { server } from '#theme/config'; + +/** + * Serializes island props for the inline `application/json` script. + * + * @param {Record} props + * @returns {string} + */ +const serializeProps = props => + JSON.stringify(props).replaceAll('<', '\\u003c'); + +/** + * Marks a component as an island + * + * Children are server-rendered inside an `` and adopted as-is on + * hydration, so static markup is never shipped as JavaScript to be rebuilt. + * + * @param {import('preact').ComponentType} Component + * @param {object} options + * @param {string} options.name - Key into `loaders.mjs`. + * @param {Record} options.on - is-land loading conditions + * without the `on:` prefix, e.g. `{ idle: true }`. + * @see https://is-land.11ty.dev/ + */ +export default (Component, { name, on }) => { + if (!server) { + return Component; + } + + const conditions = Object.fromEntries( + Object.entries(on).map(([condition, value]) => [ + `on:${condition}`, + value === true ? '' : value, + ]) + ); + + return ({ children, ...props }) => ( + + {Object.keys(props).length > 0 && ( +