Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/generators/web/bundlers/vite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/generators/web/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion src/generators/web/ui/components/Banner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -29,4 +30,4 @@ const Banners = () => {
);
};

export default Banners;
export default withIsland(Banners, { name: 'Banner', on: { idle: true } });
11 changes: 10 additions & 1 deletion src/generators/web/ui/components/CodeBox.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import BaseCodeBox from '@node-core/ui-components/Common/BaseCodeBox';

import withIsland from '../islands/withIsland.jsx';

import { languageDisplayNameMap } from '#theme/config';

/**
Expand All @@ -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-(?<language>[a-zA-Z]+)/);

const language = matches?.groups?.language ?? '';
Expand All @@ -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' },
});
8 changes: 8 additions & 0 deletions src/generators/web/ui/components/CodeTabs.jsx
Original file line number Diff line number Diff line change
@@ -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' },
});
3 changes: 1 addition & 2 deletions src/generators/web/ui/components/Layout/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,7 +19,7 @@ import SideBar from '#theme/Sidebar';
*/
export default ({ metadata, headings, readingTime, children }) => (
<>
{!server && <Banner />}
<Banner />
<NavBar metadata={metadata} />
<Article>
<SideBar metadata={metadata} />
Expand Down
42 changes: 17 additions & 25 deletions src/generators/web/ui/components/NavBar.jsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,30 @@
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';

/**
* NavBar component that displays the headings, search, etc.
*/
export default ({ metadata }) => {
const [themePreference, setThemePreference] = useTheme();

return (
<NavBar
Logo={Logo}
sidebarItemTogglerAriaLabel="Toggle navigation menu"
navItems={[]}
export default ({ metadata }) => (
<NavBar
Logo={Logo}
sidebarItemTogglerAriaLabel="Toggle navigation menu"
navItems={[]}
>
{showSearchBox && <SearchBox pathname={metadata.path} />}
<ThemeToggle />
<a
href={`https://github.com/${repository}`}
aria-label={`View ${repository} on GitHub`}
className={styles.ghIconWrapper}
>
{showSearchBox && <SearchBox pathname={metadata.path} />}
<ThemeToggle
onChange={setThemePreference}
currentTheme={themePreference}
/>
<a
href={`https://github.com/${repository}`}
aria-label={`View ${repository} on GitHub`}
className={styles.ghIconWrapper}
>
<GitHubIcon />
</a>
</NavBar>
);
};
<GitHubIcon />
</a>
</NavBar>
);
6 changes: 5 additions & 1 deletion src/generators/web/ui/components/SearchBox/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -59,4 +60,7 @@ const SearchBox = ({ pathname }) => {
);
};

export default SearchBox;
export default withIsland(SearchBox, {
name: 'SearchBox',
on: { interaction: 'pointerover,focusin,touchstart' },
});
5 changes: 4 additions & 1 deletion src/generators/web/ui/components/SideBar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
);
Expand Down Expand Up @@ -65,3 +66,5 @@ export default ({ metadata }) => {
</SideBar>
);
};

export default withIsland(Sidebar, { name: 'SideBar', on: { idle: true } });
20 changes: 20 additions & 0 deletions src/generators/web/ui/components/ThemeToggle.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<ThemeToggle onChange={setThemePreference} currentTheme={themePreference} />
);
};

export default withIsland(Toggle, {
name: 'ThemeToggle',
on: { idle: true },
});
15 changes: 15 additions & 0 deletions src/generators/web/ui/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment thread
avivkeller marked this conversation as resolved.

main {
/* Code should inherit its font size */
code {
Expand Down
11 changes: 11 additions & 0 deletions src/generators/web/ui/islands/loaders.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable jsdoc/require-jsdoc -- each entry is a bare dynamic import */

/**
* @type {Record<string, () => Promise<{ default: import('preact').ComponentType }>>}
*/
export default {
Banner: () => import('../components/Banner'),
ThemeToggle: () => import('../components/ThemeToggle.jsx'),
SearchBox: () => import('../components/SearchBox'),
SideBar: () => import('#theme/Sidebar'),
};
63 changes: 63 additions & 0 deletions src/generators/web/ui/islands/runtime.mjs
Original file line number Diff line number Diff line change
@@ -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<string, () => 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);
}
});
58 changes: 58 additions & 0 deletions src/generators/web/ui/islands/withIsland.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { toChildArray } from 'preact';

import { server } from '#theme/config';

/**
* Serializes island props for the inline `application/json` script.
*
* @param {Record<string, unknown>} props
* @returns {string}
*/
const serializeProps = props =>
JSON.stringify(props).replaceAll('<', '\\u003c');

/**
* Marks a component as an island
*
* Children are server-rendered inside an `<island-slot>` 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<string, string|true>} 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 }) => (
<is-land {...conditions} type="preact" data-island-name={name}>
{Object.keys(props).length > 0 && (
<script
type="application/json"
data-island-props
dangerouslySetInnerHTML={{ __html: serializeProps(props) }}
/>
)}

<Component {...props}>
{toChildArray(children).map((child, index) => (
<island-slot defer-hydration key={index}>
{child}
</island-slot>
))}
</Component>
</is-land>
);
};
Loading
Loading