diff --git a/.changeset/gentle-pans-smile.md b/.changeset/gentle-pans-smile.md new file mode 100644 index 000000000..b56f7faad --- /dev/null +++ b/.changeset/gentle-pans-smile.md @@ -0,0 +1,11 @@ +--- +"@hyperbook/markdown": patch +"hyperbook": patch +"hyperbook-studio": patch +--- + +Inline the script that loads the light and dark stylesheets. It was a file, and +nothing can paint before the stylesheets it writes are there, so every first +paint waited for a round trip to fetch 1.5 kB and then another for the +stylesheets themselves. It is part of the page now, which measured about 140 ms +off the first contentful paint of the documentation on a throttled connection. diff --git a/.changeset/khaki-moles-tap.md b/.changeset/khaki-moles-tap.md new file mode 100644 index 000000000..a758c9061 --- /dev/null +++ b/.changeset/khaki-moles-tap.md @@ -0,0 +1,20 @@ +--- +"@hyperbook/types": minor +"@hyperbook/fs": minor +"@hyperbook/markdown": minor +"hyperbook": minor +"hyperbook-studio": minor +--- + +Order pages and sections together by `index`. Pages were always rendered before +the sections of the same level, so a page could not sit after or between them. +The `index` of a page and the `index` of a section are now one order, and a +section that ends up between two pages is rendered between them. + +The reading order follows the navigation, so the previous and next buttons, the +breadcrumb and `::pagelist` agree with the sidebar. + +A page or a section without an `index` keeps its old place: pages come before +sections, and a page wins a tie against a section. A book that gives its +sections an `index` but leaves it off a page will see that page move behind +those sections. Give the page an `index` to place it. diff --git a/.changeset/olive-moons-shake.md b/.changeset/olive-moons-shake.md new file mode 100644 index 000000000..b12d62fac --- /dev/null +++ b/.changeset/olive-moons-shake.md @@ -0,0 +1,10 @@ +--- +"@hyperbook/markdown": patch +"hyperbook": patch +"hyperbook-studio": patch +--- + +Fix the drawers flashing over the page while it loads. A custom element renders +its children until it is upgraded, and a side drawer is only hidden by its +shadow root, so the search drawer and the table of contents drawer painted over +the header and the article until `side-drawer.js` had run. diff --git a/.changeset/quiet-jokes-melt.md b/.changeset/quiet-jokes-melt.md new file mode 100644 index 000000000..0f771e5fb --- /dev/null +++ b/.changeset/quiet-jokes-melt.md @@ -0,0 +1,10 @@ +--- +"@hyperbook/markdown": patch +"hyperbook": patch +"hyperbook-studio": patch +--- + +Order the pages and the subsections of a section together too. Only the top +level of the navigation shared one order, so inside a section a subsection was +still rendered after every page of that section, and the sidebar disagreed with +the previous and next buttons, which already followed the index. diff --git a/.changeset/spotty-jars-smash.md b/.changeset/spotty-jars-smash.md new file mode 100644 index 000000000..15fe8c97b --- /dev/null +++ b/.changeset/spotty-jars-smash.md @@ -0,0 +1,11 @@ +--- +"@hyperbook/markdown": patch +"hyperbook": patch +"hyperbook-studio": patch +--- + +Indent the pages of a section in the navigation. They sat one pixel to the +right of a page of the level above, so only a hairline told the two levels +apart. That was harmless while every page came before every section, but a page +can sit after a section now, and it has to be readable as a page of the level +above. diff --git a/packages/fs/src/hyperbook.ts b/packages/fs/src/hyperbook.ts index 782949ac9..850f1a2f3 100644 --- a/packages/fs/src/hyperbook.ts +++ b/packages/fs/src/hyperbook.ts @@ -6,6 +6,7 @@ import { HyperbookSection, Navigation, PageNavigation, + sortNavigation, } from "@hyperbook/types"; import { findUp } from "find-up"; import { vfile } from "."; @@ -254,17 +255,25 @@ export const getPageList = ( sections: HyperbookSection[], pages: HyperbookPage[], ): HyperbookPage[] => { - let pageList = [...pages]; + let pageList: HyperbookPage[] = []; - for (const section of sections) { + // Reading order follows the navigation, so the previous and next buttons + // agree with the sidebar when a section sits between two pages. + for (const entry of sortNavigation(pages, sections)) { + if (entry.type === "page") { + pageList.push(entry.page); + continue; + } + + const section = entry.section; // Check if section is virtual or not const isVirtual = section.virtual || section.navigation === "virtual"; - + if (!isVirtual && section.href) { // For non-virtual sections with an href, add the index page first - const indexPage = section.pages.find(p => p.href === section.href); - const otherPages = section.pages.filter(p => p.href !== section.href); - + const indexPage = section.pages.find((p) => p.href === section.href); + const otherPages = section.pages.filter((p) => p.href !== section.href); + if (indexPage) { pageList.push(indexPage); } diff --git a/packages/markdown/assets/shell.css b/packages/markdown/assets/shell.css index 5d39024d9..2ed647c6a 100644 --- a/packages/markdown/assets/shell.css +++ b/packages/markdown/assets/shell.css @@ -41,6 +41,13 @@ side-drawer { display: none; } +/* A custom element renders its children until it is upgraded. The drawers are + only hidden by their shadow root, so without this their content paints over + the page until side-drawer.js has run. */ +side-drawer:not(:defined) { + display: none; +} + /* Hide mobile nav toggle when JavaScript is disabled */ .no-js .mobile-nav .toggle { display: none; @@ -662,6 +669,7 @@ nav li + li { .section { margin-top: 4px; + margin-bottom: 4px; } .section summary.name { @@ -733,6 +741,9 @@ nav li + li { .section > .links { border-left-width: 1px; border-left-style: solid; + /* The pages of a section have to read as belonging to it, now that a page of + the level above can follow them. */ + padding-left: 0.75rem; } .pages { diff --git a/packages/markdown/assets/dark-mode-toggle-stylesheets-loader.js b/packages/markdown/src/darkModeStylesheetsLoader.ts similarity index 50% rename from packages/markdown/assets/dark-mode-toggle-stylesheets-loader.js rename to packages/markdown/src/darkModeStylesheetsLoader.ts index 9e888b15e..5f08e2224 100644 --- a/packages/markdown/assets/dark-mode-toggle-stylesheets-loader.js +++ b/packages/markdown/src/darkModeStylesheetsLoader.ts @@ -1,19 +1,17 @@ /** - * Copyright 2024 Google LLC + * Writes the light and dark stylesheets into the page, honoring a mode the + * reader picked before. The stylesheets sit in a noscript element, so they are + * only text until this runs. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * This is inlined into the head instead of being loaded as a file. Nothing can + * paint before the stylesheets are there, so an external script would put a + * round trip in front of every first paint, and a second one in front of the + * stylesheets it writes. * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * © 2024 Google LLC. Licensed under the Apache License, Version 2.0. + * https://www.apache.org/licenses/LICENSE-2.0 */ - +export const darkModeStylesheetsLoader = ` // @license © 2024 Google LLC. Licensed under the Apache License, Version 2.0. (() => { const ELEMENT_ID = 'dark-mode-toggle-stylesheets'; @@ -28,8 +26,8 @@ mode = localStorage.getItem(STORAGE_NAME); } catch (e) {} - const lightCSSMediaRegex = /\(\s*prefers-color-scheme\s*:\s*light\s*\)/gi; - const darkCSSMediaRegex = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/gi; + const lightCSSMediaRegex = /\\(\\s*prefers-color-scheme\\s*:\\s*light\\s*\\)/gi; + const darkCSSMediaRegex = /\\(\\s*prefers-color-scheme\\s*:\\s*dark\\s*\\)/gi; switch (mode) { case LIGHT: @@ -47,3 +45,4 @@ document.write(stylesheets); })(); +// @license-end`; diff --git a/packages/markdown/src/rehypeHtmlStructure.ts b/packages/markdown/src/rehypeHtmlStructure.ts index 5254a7f9b..bdc7331ed 100644 --- a/packages/markdown/src/rehypeHtmlStructure.ts +++ b/packages/markdown/src/rehypeHtmlStructure.ts @@ -4,6 +4,7 @@ import { HyperbookContext } from "@hyperbook/types"; import { ElementContent, Root } from "hast"; import { VFile } from "vfile"; +import { darkModeStylesheetsLoader } from "./darkModeStylesheetsLoader"; function parseFont(font: string): [string, string] { const parts = font.split(":"); @@ -379,13 +380,13 @@ window.hyperbook.emoji = { style: "twemoji", base: "${makeUrl(["emoji"], "assets { type: "element", tagName: "script", - properties: { - src: makeUrl( - ["dark-mode-toggle-stylesheets-loader.js"], - "assets", - ), - }, - children: [], + properties: {}, + children: [ + { + type: "raw", + value: darkModeStylesheetsLoader, + }, + ], }, { type: "element", diff --git a/packages/markdown/src/rehypeShell.ts b/packages/markdown/src/rehypeShell.ts index 1f993320f..6ed68179c 100644 --- a/packages/markdown/src/rehypeShell.ts +++ b/packages/markdown/src/rehypeShell.ts @@ -6,6 +6,7 @@ import { HyperbookContext, HyperbookPage, HyperbookSection, + sortNavigation, } from "@hyperbook/types"; import { ElementContent, Root } from "hast"; import { VFile } from "vfile"; @@ -321,6 +322,46 @@ const makeNavigationSectionAsPageElement = ( }; }; +/** + * Renders one level of the navigation. Pages and sections share one order, so + * a section can sit between two pages. A run of pages becomes one list, and a + * section breaks the run. + */ +const makeNavigationLevel = ( + ctx: HyperbookContext, + pages: HyperbookPage[], + sections: HyperbookSection[], + listClass?: string, +): ElementContent[] => { + const elements: ElementContent[] = []; + let list: ElementContent[] | null = null; + const addToList = (element: ElementContent) => { + if (!list) { + list = []; + elements.push({ + type: "element", + tagName: "ul", + properties: listClass ? { class: listClass } : {}, + children: list, + }); + } + list.push(element); + }; + + for (const entry of sortNavigation(pages, sections)) { + if (entry.type === "page") { + addToList(makeNavigationPageElement(ctx, entry.page)); + } else if (entry.section.navigation === "page") { + addToList(makeNavigationSectionAsPageElement(ctx, entry.section)); + } else { + elements.push(makeNavigationSectionElement(ctx, entry.section)); + list = null; + } + } + + return elements; +}; + const makeNavigationSectionElement = ( ctx: HyperbookContext, section: HyperbookSection, @@ -332,68 +373,20 @@ const makeNavigationSectionElement = ( const isExpanded = navigation === "expanded" || (navigation === undefined && expanded) || ctx.navigation.current?.href?.startsWith(href || ""); - const pagesElements: ElementContent[] = pages - .filter((page) => !page.hide && page.navigation !== "hidden" && page.href !== href) - .map((page) => makeNavigationPageElement(ctx, page)); - - const linksElements: ElementContent[] = []; - if (pagesElements.length > 0) { - linksElements.push({ - type: "element", - tagName: "ul", - properties: { - class: "pages", - }, - children: pagesElements, - }); - } - - // Handle page-mode sections - they should be rendered as pages in the pages list - const pageModeChildSections = sections - .filter((s) => !s.hide && s.navigation !== "hidden" && s.navigation === "page" && !s.isEmpty); - - // Merge pages and page-mode sections, sort by index - if (pageModeChildSections.length > 0) { - // We need to combine the existing pages with page-mode sections - const combinedItems: { index?: number; name: string; element: ElementContent }[] = [ - ...pages - .filter((page) => !page.hide && page.navigation !== "hidden" && page.href !== href) - .map((page) => ({ - index: page.index, - name: page.name, - element: makeNavigationPageElement(ctx, page) - })), - ...pageModeChildSections.map((s) => ({ - index: s.index, - name: s.name, - element: makeNavigationSectionAsPageElement(ctx, s) - })), - ].sort((a, b) => { - const aIndex = a.index !== undefined ? a.index : 9999; - const bIndex = b.index !== undefined ? b.index : 9999; - if (aIndex !== bIndex) return aIndex - bIndex; - return a.name > b.name ? 1 : -1; - }); - - // Replace pagesElements with combined sorted elements - linksElements.length = 0; // Clear existing - if (combinedItems.length > 0) { - linksElements.push({ - type: "element", - tagName: "ul", - properties: { - class: "pages", - }, - children: combinedItems.map((item) => item.element), - }); - } - } - - // Regular sections (not page-mode) - const sectionElements: ElementContent[] = sections - .filter((s) => !s.hide && s.navigation !== "hidden" && s.navigation !== "page") - .map((s) => makeNavigationSectionElement(ctx, s)); - linksElements.push(...sectionElements); + const linksElements = makeNavigationLevel( + ctx, + pages.filter( + (page) => + !page.hide && page.navigation !== "hidden" && page.href !== href, + ), + sections.filter( + (s) => + !s.hide && + s.navigation !== "hidden" && + (s.navigation !== "page" || !s.isEmpty), + ), + "pages", + ); // For virtual sections, just render the links without a container if (isVirtual) { @@ -483,56 +476,24 @@ const makeNavigationSectionElement = ( }; }; -// Helper type for navigation items that can be sorted together -type NavigationItem = - | { type: "page"; item: HyperbookPage } - | { type: "section"; item: HyperbookSection }; - -const makeNavigationElements = (ctx: HyperbookContext): ElementContent[] => { - // Collect all navigation items (pages and sections in "page" mode go to pages list) - const pageItems: NavigationItem[] = ctx.navigation.pages - .filter((p) => !p.hide && p.navigation !== "hidden") - .map((p) => ({ type: "page" as const, item: p })); - - const pageModeSecions: NavigationItem[] = ctx.navigation.sections - .filter((s) => !s.hide && s.navigation !== "hidden" && s.navigation === "page" && !s.isEmpty) - .map((s) => ({ type: "section" as const, item: s })); - - const regularSections = ctx.navigation.sections - .filter((s) => !s.hide && s.navigation !== "hidden" && s.navigation !== "page"); - - // Merge pages and page-mode sections, then sort by index - const combinedItems = [...pageItems, ...pageModeSecions].sort((a, b) => { - const aIndex = a.item.index !== undefined ? a.item.index : 9999; - const bIndex = b.item.index !== undefined ? b.item.index : 9999; - if (aIndex !== bIndex) return aIndex - bIndex; - return a.item.name > b.item.name ? 1 : -1; - }); - - return [ - { - type: "element", - tagName: "nav", - properties: {}, - children: [ - { - type: "element", - tagName: "ul", - properties: {}, - children: combinedItems.map((navItem) => { - if (navItem.type === "page") { - return makeNavigationPageElement(ctx, navItem.item); - } else { - // Render section in page mode as a page element - return makeNavigationSectionAsPageElement(ctx, navItem.item); - } - }), - }, - ...regularSections.map((s) => makeNavigationSectionElement(ctx, s)), - ], - }, - ]; -}; +const makeNavigationElements = (ctx: HyperbookContext): ElementContent[] => [ + { + type: "element", + tagName: "nav", + properties: {}, + children: makeNavigationLevel( + ctx, + ctx.navigation.pages.filter((p) => !p.hide && p.navigation !== "hidden"), + ctx.navigation.sections.filter( + (s) => + !s.hide && + s.navigation !== "hidden" && + // A section shown as a page needs a page to link to. + (s.navigation !== "page" || !s.isEmpty), + ), + ), + }, +]; const makeMetaElements = (ctx: HyperbookContext): ElementContent[] => { const elements: ElementContent[] = []; diff --git a/packages/markdown/src/remarkDirectivePagelist.ts b/packages/markdown/src/remarkDirectivePagelist.ts index e9a7e527e..8ef96ab5b 100644 --- a/packages/markdown/src/remarkDirectivePagelist.ts +++ b/packages/markdown/src/remarkDirectivePagelist.ts @@ -5,6 +5,7 @@ import { HyperbookContext, HyperbookPage, HyperbookSection, + sortNavigation, } from "@hyperbook/types"; import { registerBasicHelpers } from "@hyperbook/fs"; import handlebars from "handlebars"; @@ -25,13 +26,17 @@ const getPageList = ( sections: HyperbookSection[], pages: HyperbookPage[], ): HyperbookPage[] => { - let pageList = [...pages]; - - for (const section of sections) { - pageList = [ - ...pageList, - ...getPageList(section.sections, section.pages), - ]; + let pageList: HyperbookPage[] = []; + + for (const entry of sortNavigation(pages, sections)) { + if (entry.type === "page") { + pageList.push(entry.page); + } else { + pageList = [ + ...pageList, + ...getPageList(entry.section.sections, entry.section.pages), + ]; + } } return pageList; diff --git a/packages/markdown/tests/__snapshots__/process.test.ts.snap b/packages/markdown/tests/__snapshots__/process.test.ts.snap index d811d1025..495f626d3 100644 --- a/packages/markdown/tests/__snapshots__/process.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/process.test.ts.snap @@ -41,7 +41,41 @@ body { }
My Hyperbook
+
My Hyperbook
@@ -178,12 +246,46 @@ body { }
My Hyperbook
+
My Hyperbook
@@ -241,12 +343,46 @@ body { }
My Hyperbook
+
My Hyperbook
@@ -302,12 +438,46 @@ body { }
My Hyperbook
+
My Hyperbook
@@ -363,12 +533,46 @@ body { }
My Hyperbook
+
My Hyperbook
@@ -426,12 +630,46 @@ body { }
My Hyperbook
+
My Hyperbook
@@ -494,12 +732,46 @@ body { }
My Hyperbook
+
My Hyperbook
@@ -587,7 +859,41 @@ body { }