diff --git a/.vscode/settings.json b/.vscode/settings.json
index 00fe0693..6da0d31a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -15,6 +15,7 @@
"effectful",
"effectfully",
"execroot",
+ "expando",
"genfiles",
"hydroactive",
"inlines",
diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel
index 66570d43..183fe393 100644
--- a/docs/BUILD.bazel
+++ b/docs/BUILD.bazel
@@ -1,3 +1,4 @@
+load("@aspect_bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin")
load(
"//:index.bzl",
"css_library",
@@ -6,6 +7,12 @@ load(
)
load("//tools/typescript:defs.bzl", "ts_project")
+copy_to_bin(
+ name = "package",
+ srcs = ["package.json"],
+ visibility = [":__subpackages__"],
+)
+
prerender_pages(
name = "site",
entry_point = "./site.js",
@@ -16,12 +23,21 @@ prerender_pages(
ts_project(
name = "prerender",
srcs = ["site.tsx"],
+ # Need `"type": "module"` to load `*.js` files output by `*.tsx` compilation.
+ data = [":package"],
deps = [
+ ":route",
"//docs/components/layout:layout_prerender",
"//:node_modules/@rules_prerender/preact",
],
)
+ts_project(
+ name = "route",
+ srcs = ["route.mts"],
+ visibility = [":__subpackages__"],
+)
+
css_library(
name = "styles",
srcs = ["site.css"],
diff --git a/docs/components/layout/BUILD.bazel b/docs/components/layout/BUILD.bazel
index a6cce71f..c26847b4 100644
--- a/docs/components/layout/BUILD.bazel
+++ b/docs/components/layout/BUILD.bazel
@@ -13,8 +13,10 @@ ts_project(
name = "prerender",
srcs = ["layout.tsx"],
deps = [
+ "//docs:route",
"//docs/components/footer:footer_prerender",
"//docs/components/header:header_prerender",
+ "//docs/components/nav_pane:nav_pane_prerender",
"//:node_modules/@rules_prerender/preact",
"//:node_modules/preact",
],
diff --git a/docs/components/layout/layout.css b/docs/components/layout/layout.css
index 990a347e..c522a2b6 100644
--- a/docs/components/layout/layout.css
+++ b/docs/components/layout/layout.css
@@ -11,6 +11,17 @@ body {
min-height: 100vh;
}
-body > main {
+body > .layout-middle {
+ flex-grow: 1;
+
+ display: flex;
+}
+
+body > .layout-middle > rp-nav-pane {
+ max-width: 300px;
+ box-shadow: 2px 0 5px 0 gray;
+}
+
+body > .layout-middle > main {
flex-grow: 1;
}
diff --git a/docs/components/layout/layout.tsx b/docs/components/layout/layout.tsx
index 2e34d8b4..590bceec 100644
--- a/docs/components/layout/layout.tsx
+++ b/docs/components/layout/layout.tsx
@@ -1,7 +1,9 @@
import { inlineStyle } from '@rules_prerender/preact';
import { VNode, ComponentChildren } from 'preact';
-import { Footer } from '../../components/footer/footer.js';
-import { Header } from '../../components/header/header.js';
+import { Footer } from '../footer/footer.js';
+import { Header } from '../header/header.js';
+import { NavPane } from '../nav_pane/nav_pane.js';
+import { Route } from '../../route.mjs';
/**
* Renders the base layout for documentation. Most pages should use this.
@@ -13,12 +15,20 @@ import { Header } from '../../components/header/header.js';
* @param headChildren Children to render under the `
` element. Callers
* should *not* render `` or ``, `Layout` will
* do that automatically.
+ * @param routes List of routes to render in the navigation pane.
*/
-export function Layout({ pageTitle, headerTitle, children, headChildren }: {
+export function Layout({
+ pageTitle,
+ headerTitle,
+ children,
+ headChildren,
+ routes = [],
+}: {
pageTitle: string,
headerTitle?: string,
children: ComponentChildren,
headChildren?: ComponentChildren,
+ routes?: readonly Route[],
}): VNode {
return
@@ -29,7 +39,13 @@ export function Layout({ pageTitle, headerTitle, children, headChildren }: {
- {children}
+
+ {routes.length
+ ?
+ : undefined
+ }
+ {children}
+
;
diff --git a/docs/components/layout/layout_test.tsx b/docs/components/layout/layout_test.tsx
index 431276f2..cf246a2b 100644
--- a/docs/components/layout/layout_test.tsx
+++ b/docs/components/layout/layout_test.tsx
@@ -1,6 +1,7 @@
import { render } from 'preact-render-to-string';
import { HTMLElement, parse } from 'node-html-parser';
import { Layout } from './layout.js';
+import { Route } from 'docs/route.mjs';
describe('layout', () => {
describe('Layout()', () => {
@@ -35,7 +36,7 @@ describe('layout', () => {
expect(footer).not.toBeNull();
// Renders main content.
- const main = html.querySelector('body > main');
+ const main = html.querySelector('body main');
expect(main).not.toBeNull();
const content = main!.firstChild as HTMLElement;
expect(content).not.toBeNull();
@@ -63,10 +64,49 @@ describe('layout', () => {
}>
));
- expect(document).toBeDefined();
+ expect(document).not.toBeNull();
const meta = document.querySelector('head > meta[content="test"]');
expect(meta).not.toBeNull();
});
+
+ it('renders provided routes', () => {
+ const routes = [
+ { label: 'Home', content: '/' },
+ ] satisfies Route[];
+
+ const document = parse(render(
+
+
+ ));
+ expect(document).not.toBeNull();
+
+ const navPane = document.querySelector('rp-nav-pane');
+ expect(navPane).not.toBeNull();
+
+ expect(navPane!.textContent).toContain('Home');
+ });
+
+ it('does *not* render navigation pane when there are no routes', () => {
+ const document = parse(render(
+
+
+ ));
+ expect(document).not.toBeNull();
+
+ const navPane = document.querySelector('rp-nav-pane');
+ expect(navPane).toBeNull();
+ });
+
+ it('does *not* render navigation pane when routes are empty', () => {
+ const document = parse(render(
+
+
+ ));
+ expect(document).not.toBeNull();
+
+ const navPane = document.querySelector('rp-nav-pane');
+ expect(navPane).toBeNull();
+ });
});
});
diff --git a/docs/components/nav_pane/BUILD.bazel b/docs/components/nav_pane/BUILD.bazel
new file mode 100644
index 00000000..632691c7
--- /dev/null
+++ b/docs/components/nav_pane/BUILD.bazel
@@ -0,0 +1,101 @@
+load(
+ "//:index.bzl",
+ "css_library",
+ "prerender_component",
+ "prerender_pages",
+ "web_resources_devserver",
+)
+load("//tools/jasmine:defs.bzl", "jasmine_node_test", "jasmine_web_test_suite")
+load("//tools/typescript:defs.bzl", "ts_project")
+
+prerender_component(
+ name = "nav_pane",
+ prerender = ":prerender",
+ scripts = ":scripts",
+ styles = ":styles",
+ visibility = ["//docs:__subpackages__"],
+)
+
+ts_project(
+ name = "prerender",
+ srcs = ["nav_pane.tsx"],
+ deps = [
+ "//docs:route",
+ "//:node_modules/@rules_prerender/preact",
+ "//:node_modules/preact",
+ "//:prerender_components/@rules_prerender/declarative_shadow_dom_prerender",
+ ],
+)
+
+ts_project(
+ name = "prerender_test_lib",
+ srcs = ["nav_pane_test.tsx"],
+ testonly = True,
+ deps = [
+ ":prerender",
+ "//:node_modules/@types/jasmine",
+ "//:node_modules/node-html-parser",
+ "//:node_modules/preact",
+ "//:node_modules/preact-render-to-string",
+ ],
+)
+
+jasmine_node_test(
+ name = "prerender_test",
+ deps = [":prerender_test_lib"],
+)
+
+ts_project(
+ name = "scripts",
+ srcs = ["nav_pane_script.mts"],
+ tsconfig = "//:tsconfig_client",
+ deps = ["//:node_modules/hydroactive"],
+)
+
+ts_project(
+ name = "scripts_test_cases_lib",
+ srcs = ["nav_pane_script_test_cases.tsx"],
+ data = ["//docs:package"],
+ testonly = True,
+ deps = [
+ ":nav_pane_prerender",
+ "//:node_modules/@rules_prerender/preact",
+ "//:node_modules/preact",
+ ],
+)
+
+prerender_pages(
+ name = "scripts_test_cases",
+ entry_point = "./nav_pane_script_test_cases.js",
+ prerender = ":scripts_test_cases_lib",
+ testonly = True,
+)
+
+web_resources_devserver(
+ name = "scripts_test_cases_devserver",
+ resources = ":scripts_test_cases",
+ testonly = True,
+)
+
+ts_project(
+ name = "scripts_test_lib",
+ srcs = ["nav_pane_script_test.mts"],
+ testonly = True,
+ data = [":scripts_test_cases_devserver"],
+ deps = [
+ "//common/testing:devserver",
+ "//common/testing:webdriver",
+ "//:node_modules/@types/jasmine",
+ ],
+)
+
+jasmine_web_test_suite(
+ name = "scripts_test",
+ deps = [":scripts_test_lib"],
+)
+
+css_library(
+ name = "styles",
+ srcs = ["nav_pane.css"],
+ deps = ["//docs:theme"],
+)
diff --git a/docs/components/nav_pane/nav_pane.css b/docs/components/nav_pane/nav_pane.css
new file mode 100644
index 00000000..97f82db2
--- /dev/null
+++ b/docs/components/nav_pane/nav_pane.css
@@ -0,0 +1,101 @@
+@import '../../theme.css';
+
+:host {
+ font-size: 1.5rem;
+ background-color: var(--light-gray);
+
+ --rp-nav-pane--indentation: 1.5rem;
+}
+
+/* Root `` element. */
+nav > ul {
+ padding: 0.5rem 0;
+}
+
+/* Remove user agent styles from all `` elements. */
+ul {
+ list-style: none;
+ margin-block: 0;
+ margin-inline: 0;
+ padding-inline: 0;
+}
+
+li ul {
+ /*
+ * Hide sublists by default. Cannot use `display: none;` for two reasons:
+ * 1. Changing to `display: block;` triggers nested animations to play
+ * immediately, causing sublist `>` characters to rotate when revealed.
+ * 2. `display: none;` implicitly sets `width: 0; height: 0;`. We actually
+ * want to preserve `width: auto;` so the width of the navigation pane
+ * is dictated by the longest text in it, even when that text is hidden.
+ */
+ height: 0;
+ overflow: hidden;
+}
+
+/* Show sublists when the `expanded` class is applied. */
+li.expanded > ul {
+ height: auto;
+}
+
+/* Apply `>` to any expandable lists. */
+li.sublist > button::after {
+ content: '>';
+ float: right;
+ font-family: monospace;
+ will-change: animation-name transform;
+
+ animation-duration: 0.25s;
+ animation-timing-function: ease-in-out;
+ animation-fill-mode: forwards;
+}
+
+/* Rotate `>` when list is expanded. */
+li.sublist.expanded > button::after {
+ animation-name: rotateExpando;
+}
+
+@keyframes rotateExpando {
+ to { transform: rotate(90deg); }
+}
+
+/* Un-rotate `>` when list is collapsed. */
+li.sublist.collapsed > button::after {
+ /* It should probably be possible to use `rotateExpando` backwards instead
+ of defining a second animation, but attempting to do so breaks both
+ animations for some unknown reason. */
+ animation-name: unrotateExpando;
+}
+
+@keyframes unrotateExpando {
+ from { transform: rotate(90deg); }
+}
+
+.list-el {
+ display: block;
+ padding: 0.25rem 0.75rem;
+}
+
+.list-el:hover {
+ background-color: lightgray;
+}
+
+/* Indent each level of the nested lists. */
+.list-el.depth-1 { padding-left: var(--rp-nav-pane--indentation); }
+.list-el.depth-2 { padding-left: calc(2 * var(--rp-nav-pane--indentation)); }
+
+/* Clear built-in button styles to align with anchor tags. */
+button {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ width: 100%;
+ text-align: left;
+ font: inherit;
+}
+
+/* Clear built-in anchor styles to align with button tags. */
+a {
+ color: inherit;
+ text-decoration: none;
+}
diff --git a/docs/components/nav_pane/nav_pane.tsx b/docs/components/nav_pane/nav_pane.tsx
new file mode 100644
index 00000000..925205f4
--- /dev/null
+++ b/docs/components/nav_pane/nav_pane.tsx
@@ -0,0 +1,59 @@
+import { polyfillDeclarativeShadowDom } from '@rules_prerender/declarative_shadow_dom/preact.mjs';
+import { Template, inlineStyle, includeScript } from '@rules_prerender/preact';
+import { VNode } from 'preact';
+import { Route } from '../../route.mjs';
+
+/** Renders a navigation pane with the given routes. */
+export function NavPane({ routes }: { routes: readonly Route[] }): VNode {
+ return
+
+
+
+ {polyfillDeclarativeShadowDom()}
+ {includeScript('./nav_pane_script.mjs', import.meta)}
+ {inlineStyle('./nav_pane.css', import.meta)}
+
+ ;
+}
+
+declare module 'preact' {
+ namespace JSX {
+ interface IntrinsicElements {
+ 'rp-nav-pane': JSX.HTMLAttributes;
+ }
+ }
+}
+
+/** Recursively renders all the given routes to nested lists. */
+function RouteList({ routes, depth = 0 }: {
+ routes: readonly Route[],
+ depth?: number,
+}): VNode {
+ if (routes.length === 0) {
+ throw new Error('Must provide at least one route to render.');
+ }
+
+ return
+ {routes.map(({ label, content }) => {
+ const hasSublist = typeof content !== 'string';
+ const depthClass = `depth-${depth}`;
+ return -
+ {hasSublist
+ ? <>
+ {/* Expands and collapses the associated sublist. */}
+
+
+ >
+ :
+ {label}
+
+ }
+
;
+ })}
+
;
+}
diff --git a/docs/components/nav_pane/nav_pane_script.mts b/docs/components/nav_pane/nav_pane_script.mts
new file mode 100644
index 00000000..112b8ce4
--- /dev/null
+++ b/docs/components/nav_pane/nav_pane_script.mts
@@ -0,0 +1,43 @@
+import { component } from 'hydroactive';
+
+/** Controls the navigation pane by expanding and collapsing list items. */
+export const NavPane = component('rp-nav-pane', ($) => {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ $.listen($.host.shadowRoot!, 'click', toggleNavItem);
+});
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'rp-nav-page': InstanceType;
+ }
+}
+
+/** Toggles the expanded state of the clicked navigation list item. */
+function toggleNavItem(evt: Event): void {
+ // Ignore clicks not coming from toggle buttons.
+ const target = (evt.target as HTMLElement);
+ if (!target.hasAttribute('data-list-toggle')) {
+ return;
+ }
+
+ // Find the `- ` tag containing the clicked button.
+ const parentListItem = target.closest('li');
+ if (!parentListItem) {
+ throw new Error('Could not find list item for button');
+ }
+
+ // Toggle the sublist's visibility.
+ parentListItem.classList.toggle('expanded');
+
+ // Updated the `collapsed` class to be `!expanded`. This _feels_ unnecessary
+ // but actually isn't. On page load, no items have any classes. After
+ // expanding and collapsing an item, it has the `collapsed` class. This
+ // distinguishes "an element which has been closed" from "an element which
+ // has not been interacted with". This distinction is important for
+ // animations, which should only play in the former case.
+ if (parentListItem.classList.contains('expanded')) {
+ parentListItem.classList.remove('collapsed');
+ } else {
+ parentListItem.classList.add('collapsed');
+ }
+}
diff --git a/docs/components/nav_pane/nav_pane_script_test.mts b/docs/components/nav_pane/nav_pane_script_test.mts
new file mode 100644
index 00000000..03f87a0e
--- /dev/null
+++ b/docs/components/nav_pane/nav_pane_script_test.mts
@@ -0,0 +1,55 @@
+import { useDevserver } from '../../../common/testing/devserver.mjs';
+import { useWebDriver, webDriverTestTimeout } from '../../../common/testing/webdriver.mjs';
+
+describe('NavPane', () => {
+ const devserver = useDevserver(
+ 'docs/components/nav_pane/scripts_test_cases_devserver.sh');
+ const wd = useWebDriver(devserver);
+
+ it('does nothing for a flat navigation', async () => {
+ const browser = wd.get();
+ await browser.url('/flat.html');
+
+ const navPane = await browser.$('rp-nav-pane');
+ const navItems = await navPane.shadow$$('.list-el');
+ const labels = await Promise.all(navItems.map((el) => el.getText()));
+
+ expect(labels).toEqual([ 'First', 'Second', 'Third' ]);
+ }, webDriverTestTimeout);
+
+ it('expands a nested route', async () => {
+ const browser = wd.get();
+ await browser.url('/nested.html');
+
+ const navPane = await browser.$('rp-nav-pane');
+
+ // Sublist should be hidden (height: 0) by default.
+ const subList = await navPane.shadow$('li ul');
+ const initialSubListHeight = await browser.execute(
+ (el) => getComputedStyle(el).height,
+ subList as unknown as HTMLElement,
+ );
+ expect(initialSubListHeight).toBe('0px');
+
+ // Click the root button, should expand children.
+ const rootItemBtn = await navPane.shadow$('[data-list-toggle]');
+ await rootItemBtn.click();
+
+ // Check the height of the sublist again, it should be expanded now.
+ const expandedSubListHeight = await browser.execute(
+ (el) => getComputedStyle(el).height,
+ subList as unknown as HTMLElement,
+ );
+ expect(expandedSubListHeight).not.toBe('0px');
+
+ // Click the root button again, should collapse children.
+ await rootItemBtn.click();
+
+ // Check the height of the sublist one more time, should be collapsed.
+ const collapsedSubListHeight = await browser.execute(
+ (el) => getComputedStyle(el).height,
+ subList as unknown as HTMLElement,
+ );
+ expect(collapsedSubListHeight).toBe('0px');
+ }, webDriverTestTimeout);
+});
diff --git a/docs/components/nav_pane/nav_pane_script_test_cases.tsx b/docs/components/nav_pane/nav_pane_script_test_cases.tsx
new file mode 100644
index 00000000..45e62220
--- /dev/null
+++ b/docs/components/nav_pane/nav_pane_script_test_cases.tsx
@@ -0,0 +1,42 @@
+import { PrerenderResource, renderToHtml } from '@rules_prerender/preact';
+import { NavPane } from './nav_pane.js';
+
+/** Generates prerendered test cases of the navigation pane. */
+export default function* (): Generator {
+ // Navigation pane with flat hierarchy.
+ yield PrerenderResource.fromHtml('/flat.html', renderToHtml(
+
+
+ Flat
+
+
+
+
+
+ ));
+
+ // Navigation pane with a nested hierarchy.
+ yield PrerenderResource.fromHtml('/nested.html', renderToHtml(
+
+
+ Nested
+
+
+
+
+
+ ));
+}
diff --git a/docs/components/nav_pane/nav_pane_test.tsx b/docs/components/nav_pane/nav_pane_test.tsx
new file mode 100644
index 00000000..e2b6aee2
--- /dev/null
+++ b/docs/components/nav_pane/nav_pane_test.tsx
@@ -0,0 +1,64 @@
+import { render } from 'preact-render-to-string';
+import { HTMLElement, parse } from 'node-html-parser';
+import { NavPane } from './nav_pane.js';
+import { Route } from '../../route.mjs';
+
+describe('nav_pane', () => {
+ describe('NavPane()', () => {
+ it('renders a flat navigation hierarchy', () => {
+ const routes = [
+ { label: 'Home', content: '/' },
+ { label: 'About', content: '/about/' },
+ { label: 'Contact', content: '/contact/' },
+ ] satisfies Route[];
+
+ const fragment = parse(render());
+
+ // Renders the custom element.
+ const root = fragment.firstChild as HTMLElement;
+ expect(root).not.toBeNull();
+ expect(root.tagName).toBe('RP-NAV-PANE');
+
+ // Renders the list of routes in order.
+ const navItems = root.querySelectorAll('a');
+ expect(navItems.map((el) => el.textContent))
+ .toEqual([ 'Home', 'About', 'Contact' ]);
+ expect(navItems.map((el) => el.getAttribute('href')))
+ .toEqual([ '/', '/about/', '/contact/' ]);
+ });
+
+ it('renders a nested hierarchy', () => {
+ const routes = [
+ {
+ label: 'Root',
+ content: [
+ { label: 'First', content: '/first/' },
+ { label: 'Second', content: '/second/' },
+ ],
+ },
+ ] satisfies Route[];
+
+ const fragment = parse(render());
+ expect(fragment).not.toBeNull();
+
+ // Renders the root element at depth 0.
+ const rootItems = fragment.querySelectorAll('li:has(.depth-0)');
+ expect(rootItems.length).toBe(1);
+ const root = rootItems[0]!;
+ expect(root.querySelector('.list-el')!.textContent).toBe('Root');
+
+ // Renders first and second at depth 1.
+ const rootChildren = root.querySelectorAll('li:has(.depth-1)');
+ expect(rootChildren.length).toBe(2);
+ const first = rootChildren[0]!;
+ expect(first.textContent).toBe('First');
+ const second = rootChildren[1]!;
+ expect(second.textContent).toBe('Second');
+ });
+
+ it('throws an error when given no routes', () => {
+ expect(() => render())
+ .toThrowError(/Must provide at least one route/);
+ });
+ });
+});
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 00000000..3dbc1ca5
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/docs/route.mts b/docs/route.mts
new file mode 100644
index 00000000..c63f46a4
--- /dev/null
+++ b/docs/route.mts
@@ -0,0 +1,11 @@
+/**
+ * Recursive data type of route labels which map to either a URL to navigate to
+ * or a list of child routes to expand.
+ */
+export interface Route {
+ /** The text to display to the user. */
+ readonly label: string;
+
+ /** Either a link to a page, or a list of nested routes to expand. */
+ readonly content: string | Route[];
+}
diff --git a/docs/site.tsx b/docs/site.tsx
index 21e9c5b3..99941e7f 100644
--- a/docs/site.tsx
+++ b/docs/site.tsx
@@ -1,5 +1,44 @@
-import { Layout } from './components/layout/layout.js';
import { PrerenderResource, inlineStyle, renderToHtml } from '@rules_prerender/preact';
+import { Layout } from './components/layout/layout.js';
+import { Route } from './route.mjs';
+
+/** Docs site routes. */
+export const routes: readonly Route[] = [
+ {
+ label: 'Home',
+ content: '/',
+ },
+ {
+ label: 'Tutorials',
+ content: [
+ {
+ label: 'Getting Started',
+ content: '/tutorials/getting-started/',
+ },
+ {
+ label: 'Rendering Markdown',
+ content: '/tutorials/rendering-markdown/',
+ },
+ ],
+ },
+ {
+ label: 'Concepts',
+ content: [
+ {
+ label: 'Components',
+ content: '/concepts/components/',
+ },
+ {
+ label: 'Bundling',
+ content: '/concepts/bundling/',
+ },
+ ],
+ },
+ {
+ label: 'API Reference',
+ content: '/reference/',
+ },
+];
export default function*(): Generator {
yield PrerenderResource.fromHtml('/index.html', renderToHtml(
@@ -7,6 +46,7 @@ export default function*(): Generator {
pageTitle="Documentation Home"
headerTitle="rules_prerender"
headChildren={inlineStyle('./site.css', import.meta)}
+ routes={routes}
>
Hello World!
diff --git a/docs/theme.css b/docs/theme.css
index 244c9168..f194e527 100644
--- a/docs/theme.css
+++ b/docs/theme.css
@@ -1,5 +1,6 @@
:host {
--bazel-green: #0c713a;
+ --light-gray: #e8eaed;
--light-text: white;
--dark-text: black;