feat: round 4 components

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 19:23:29 +02:00
commit 370bee1470
15 files changed
+1062 -163

No files matched your search

+7 -1
View File
@@ -2,7 +2,9 @@
// Starlight's footer (edit link, previous and next), who keeps the project
// and where to report a problem, the release the packs come from, and the
// licence line. The release is read from release.json, written when the
// packs were published: the site may be newer than the packs.
// packs were published: the site may be newer than the packs. None of it
// is in the search index: "MIT for the tooling" was the excerpt of short
// pages, and "bios", "release" and "problem" matched every page.
import Default from '@astrojs/starlight/components/Footer.astro';
import release from '../../../release.json';
@@ -19,6 +21,7 @@ const tag: string = release.tag;
const date = releaseDate(tag);
---
<div class="rb-footer-root" data-pagefind-ignore>
<Default />
<div class="rb-footer">
<p>
@@ -33,8 +36,11 @@ const date = releaseDate(tag);
archival and interoperability.
</p>
</div>
</div>
<style>
/* The wrapper only keeps the footer out of the search index: it adds no box. */
.rb-footer-root { display: contents; }
.rb-footer {
margin-top: 1.5rem;
padding-top: 1rem;
@@ -51,4 +51,11 @@ const { searchWeight } = Astro.locals.starlightRoute.entry.data;
.sl-markdown-content .rb-file-pick-note { color: var(--sl-color-gray-3); font-size: var(--sl-text-sm); }
.sl-markdown-content .rb-file-pick-hashes code { overflow-wrap: anywhere; }
.sl-markdown-content .rb-file-pick-hashes[hidden] { display: none; }
/* On touch the copy button is always shown, and it covered the end of a
command's first line. A titled frame (Terminal, PowerShell) has a
header row with room on its right: the button sits there. */
@media (pointer: coarse) {
.expressive-code .frame.has-title .copy { inset-block-start: calc(var(--ec-brdWd) + 0.0625rem); }
.expressive-code .frame.has-title .copy button { width: 2rem; height: 2rem; }
}
</style>
+7 -3
View File
@@ -7,6 +7,10 @@
// a dark mark stays visible in the dark theme. A setup page with no logo file
// gets its initials in the same square, as its tile does (sitemarkup.py
// platform_tiles).
//
// The crumbs and the title stay out of the search index: every excerpt
// began with the title and the crumbs matched "bios" on every page. The
// title still names the result, as the page's title field.
import setups from '../../public/data/picker/index.json';
const PAGE_TITLE_ID = '_top';
@@ -46,7 +50,7 @@ const setup = !logoSrc && setups.find((entry) => id === `platforms/${entry.platf
{
crumbs.length > 0 && (
<nav class="rb-breadcrumbs" aria-label="Breadcrumb">
<nav class="rb-breadcrumbs" aria-label="Breadcrumb" data-pagefind-ignore>
<ol>
{crumbs.map((crumb) => (
<li>{crumb.href ? <a href={crumb.href}>{crumb.label}</a> : crumb.label}</li>
@@ -68,10 +72,10 @@ const setup = !logoSrc && setups.find((entry) => id === `platforms/${entry.platf
</text>
</svg>
)}
<h1 id={PAGE_TITLE_ID}>{title}</h1>
<h1 id={PAGE_TITLE_ID} data-pagefind-ignore data-pagefind-meta="title">{title}</h1>
</div>
) : (
<h1 id={PAGE_TITLE_ID}>{title}</h1>
<h1 id={PAGE_TITLE_ID} data-pagefind-ignore data-pagefind-meta="title">{title}</h1>
)
}
+498
View File
@@ -0,0 +1,498 @@
---
// Starlight's search (@astrojs/starlight 0.42.6, components/Search.astro),
// with one change: the Pagefind UI (ui-core.js, 26 KiB) loads when the
// reader reaches for search, a pointer on the button, focus on it, a tap,
// Ctrl+K, and not on idle on every page. It was a third of the home page's
// weight. The markup and the styles are Starlight's own.
import { Icon } from '@astrojs/starlight/components';
import project from 'virtual:starlight/project-context';
const pagefindTranslations = {
placeholder: Astro.locals.t('search.label'),
...Object.fromEntries(
Object.entries(Astro.locals.t.all())
.filter(([key]) => key.startsWith('pagefind.'))
.map(([key, value]) => [key.replace('pagefind.', ''), value])
),
};
const dataAttributes: DOMStringMap = { 'data-translations': JSON.stringify(pagefindTranslations) };
if (project.trailingSlash === 'never') dataAttributes['data-strip-trailing-slash'] = '';
---
<site-search class={Astro.props.class} {...dataAttributes}>
<button
data-open-modal
disabled
aria-label={Astro.locals.t('search.label')}
aria-keyshortcuts="Control+K"
>
<Icon name="magnifier" />
<span class="sl-hidden md:sl-block" aria-hidden="true">{Astro.locals.t('search.label')}</span>
<kbd class="sl-hidden md:sl-flex" style="display: none;">
<kbd>{Astro.locals.t('search.ctrlKey')}</kbd><kbd>K</kbd>
</kbd>
</button>
<dialog style="padding:0" aria-label={Astro.locals.t('search.label')}>
<div class="dialog-frame sl-flex">
<button data-close-modal class="sl-flex md:sl-hidden">
{Astro.locals.t('search.cancelLabel')}
</button>
{
import.meta.env.DEV ? (
<div style="margin: auto; text-align: center; white-space: pre-line;" dir="ltr">
<p>{Astro.locals.t('search.devWarning')}</p>
</div>
) : (
<div class="search-container">
<div id="starlight__search" />
</div>
)
}
</div>
</dialog>
</site-search>
{
/**
* This is intentionally inlined to avoid briefly showing an invalid shortcut.
* Purposely using the deprecated `navigator.platform` property to detect Apple devices, as the
* user agent is spoofed by some browsers when opening the devtools.
*/
}
<script is:inline>
(() => {
const openBtn = document.querySelector('button[data-open-modal]');
const shortcut = openBtn?.querySelector('kbd');
if (!openBtn || !(shortcut instanceof HTMLElement)) return;
const platformKey = shortcut.querySelector('kbd');
if (platformKey && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)) {
platformKey.textContent = '⌘';
openBtn.setAttribute('aria-keyshortcuts', 'Meta+K');
}
shortcut.style.display = '';
})();
</script>
<script>
import { pagefindUserConfig } from 'virtual:starlight/pagefind-config';
class SiteSearch extends HTMLElement {
constructor() {
super();
const openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')!;
const closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')!;
const dialog = this.querySelector('dialog')!;
const dialogFrame = this.querySelector('.dialog-frame')!;
/** Close the modal if a user clicks on a link or outside of the modal. */
const onClick = (event: MouseEvent) => {
const isLink = 'href' in (event.target || {});
if (
isLink ||
(document.body.contains(event.target as Node) &&
!dialogFrame.contains(event.target as Node))
) {
closeModal();
}
};
const openModal = (event?: MouseEvent) => {
dialog.showModal();
document.body.toggleAttribute('data-search-modal-open', true);
this.querySelector('input')?.focus();
event?.stopPropagation();
window.addEventListener('click', onClick);
// Opened before the UI was there: its input takes the focus once drawn.
loadSearch()?.then(() => {
if (dialog.open) this.querySelector('input')?.focus();
});
};
const closeModal = () => dialog.close();
openBtn.addEventListener('click', openModal);
openBtn.disabled = false;
closeBtn.addEventListener('click', closeModal);
dialog.addEventListener('close', () => {
document.body.toggleAttribute('data-search-modal-open', false);
window.removeEventListener('click', onClick);
});
// Listen for `ctrl + k` and `cmd + k` keyboard shortcuts.
window.addEventListener('keydown', (e) => {
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
dialog.open ? closeModal() : openModal();
e.preventDefault();
}
});
let translations = {};
try {
translations = JSON.parse(this.dataset.translations || '{}');
} catch {}
const shouldStrip = this.dataset.stripTrailingSlash !== undefined;
const stripTrailingSlash = (path: string) => path.replace(/(.)\/(#.*)?$/, '$1$2');
const formatURL = shouldStrip ? stripTrailingSlash : (path: string) => path;
let loading: Promise<void> | undefined;
const loadSearch = () => {
if (import.meta.env.DEV) return undefined;
loading ??= (async () => {
// @ts-expect-error: the @pagefind/default-ui package has no types.
const { PagefindUI } = await import('@pagefind/default-ui');
new PagefindUI({
...pagefindUserConfig,
element: '#starlight__search',
baseUrl: import.meta.env.BASE_URL,
bundlePath: import.meta.env.BASE_URL.replace(/\/$/, '') + '/pagefind/',
showImages: false,
translations,
showSubResults: true,
processResult: (result: { url: string; sub_results: Array<{ url: string }> }) => {
result.url = formatURL(result.url);
result.sub_results = result.sub_results.map((sub_result) => {
sub_result.url = formatURL(sub_result.url);
return sub_result;
});
},
});
})();
return loading;
};
for (const type of ['pointerenter', 'pointerdown', 'focus']) {
openBtn.addEventListener(type, () => loadSearch(), { once: true, passive: true });
}
}
}
customElements.define('site-search', SiteSearch);
</script>
<style>
@layer starlight.core {
site-search {
display: contents;
}
button[data-open-modal] {
display: flex;
align-items: center;
gap: 0.5rem;
border: 0;
background-color: transparent;
color: var(--sl-color-gray-1);
cursor: pointer;
height: 2.5rem;
font-size: var(--sl-text-xl);
}
@media (min-width: 50rem) {
button[data-open-modal] {
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.5rem;
padding-inline-start: 0.75rem;
padding-inline-end: 0.5rem;
background-color: var(--sl-color-black);
color: var(--sl-color-gray-2);
font-size: var(--sl-text-sm);
width: 100%;
max-width: 22rem;
}
button[data-open-modal]:hover {
border-color: var(--sl-color-gray-2);
color: var(--sl-color-white);
}
button[data-open-modal] > :last-child {
margin-inline-start: auto;
}
}
button > kbd {
border-radius: 0.25rem;
font-size: var(--sl-text-2xs);
gap: 0.25em;
padding-inline: 0.375rem;
background-color: var(--sl-color-gray-6);
}
kbd {
font-family: var(--__sl-font);
}
dialog {
margin: 0;
background-color: var(--sl-color-gray-6);
border: 1px solid var(--sl-color-gray-5);
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
box-shadow: var(--sl-shadow-lg);
}
dialog[open] {
display: flex;
}
dialog::backdrop {
background-color: var(--sl-color-backdrop-overlay);
-webkit-backdrop-filter: blur(0.25rem);
backdrop-filter: blur(0.25rem);
}
.dialog-frame {
position: relative;
overflow: auto;
flex-direction: column;
flex-grow: 1;
gap: 1rem;
padding: 1rem;
}
button[data-close-modal] {
position: absolute;
z-index: 1;
align-items: center;
align-self: flex-end;
height: calc(64px * var(--pagefind-ui-scale));
padding: 0.25rem;
border: 0;
background: transparent;
cursor: pointer;
color: var(--sl-color-text-accent);
}
#starlight__search {
--pagefind-ui-primary: var(--sl-color-text);
--pagefind-ui-text: var(--sl-color-gray-2);
--pagefind-ui-font: var(--__sl-font);
--pagefind-ui-background: var(--sl-color-black);
--pagefind-ui-border: var(--sl-color-gray-5);
--pagefind-ui-border-width: 1px;
--pagefind-ui-tag: var(--sl-color-gray-5);
--sl-search-cancel-space: 5rem;
}
:root[data-theme='light'] #starlight__search {
--pagefind-ui-tag: var(--sl-color-gray-6);
}
@media (min-width: 50rem) {
#starlight__search {
--sl-search-cancel-space: 0px;
}
dialog {
margin: 4rem auto auto;
border-radius: 0.5rem;
width: 90%;
max-width: 40rem;
height: max-content;
min-height: 15rem;
max-height: calc(100% - 8rem);
}
.dialog-frame {
padding: 1.5rem;
}
}
}
</style>
<style is:global>
@import url('@pagefind/default-ui/css/ui.css') layer(starlight.core);
@layer starlight.core {
[data-search-modal-open] {
overflow: hidden;
}
#starlight__search {
--sl-search-result-spacing: calc(1.25rem * var(--pagefind-ui-scale));
--sl-search-result-pad-inline-start: calc(3.75rem * var(--pagefind-ui-scale));
--sl-search-result-pad-inline-end: calc(1.25rem * var(--pagefind-ui-scale));
--sl-search-result-pad-block: calc(0.9375rem * var(--pagefind-ui-scale));
--sl-search-result-nested-pad-block: calc(0.625rem * var(--pagefind-ui-scale));
--sl-search-corners: calc(0.3125rem * var(--pagefind-ui-scale));
--sl-search-page-icon-size: calc(1.875rem * var(--pagefind-ui-scale));
--sl-search-page-icon-inline-start: calc(
(var(--sl-search-result-pad-inline-start) - var(--sl-search-page-icon-size)) / 2
);
--sl-search-tree-diagram-size: calc(2.5rem * var(--pagefind-ui-scale));
--sl-search-tree-diagram-inline-start: calc(
(var(--sl-search-result-pad-inline-start) - var(--sl-search-tree-diagram-size)) / 2
);
}
#starlight__search .pagefind-ui__form::before {
--pagefind-ui-text: var(--sl-color-gray-1);
opacity: 1;
}
#starlight__search .pagefind-ui__search-input {
color: var(--sl-color-white);
font-weight: 400;
width: calc(100% - var(--sl-search-cancel-space));
}
#starlight__search input:focus {
--pagefind-ui-border: var(--sl-color-accent);
}
#starlight__search .pagefind-ui__search-clear {
inset-inline-end: var(--sl-search-cancel-space);
width: calc(60px * var(--pagefind-ui-scale));
padding: 0;
background-color: transparent;
overflow: hidden;
}
#starlight__search .pagefind-ui__search-clear:focus {
outline: 1px solid var(--sl-color-accent);
}
#starlight__search .pagefind-ui__search-clear::before {
content: '';
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
center / 50% no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
center / 50% no-repeat;
background-color: var(--sl-color-text-accent);
display: block;
width: 100%;
height: 100%;
}
#starlight__search .pagefind-ui__results > * + * {
margin-top: var(--sl-search-result-spacing);
}
#starlight__search .pagefind-ui__result {
border: 0;
padding: 0;
}
#starlight__search .pagefind-ui__result-nested {
position: relative;
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
padding-inline-start: var(--sl-search-result-pad-inline-start);
}
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)),
#starlight__search .pagefind-ui__result-nested {
position: relative;
background-color: var(--sl-color-black);
}
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):hover,
#starlight__search
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
#starlight__search .pagefind-ui__result-nested:hover,
#starlight__search .pagefind-ui__result-nested:focus-within {
outline: 1px solid var(--sl-color-accent-high);
}
#starlight__search
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
#starlight__search .pagefind-ui__result-nested:focus-within {
background-color: var(--sl-color-accent-low);
}
#starlight__search .pagefind-ui__result-thumb,
#starlight__search .pagefind-ui__result-inner {
margin-top: 0;
}
#starlight__search .pagefind-ui__result-inner > :first-child {
border-radius: var(--sl-search-corners) var(--sl-search-corners) 0 0;
}
#starlight__search .pagefind-ui__result-inner > :last-child {
border-radius: 0 0 var(--sl-search-corners) var(--sl-search-corners);
}
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title {
padding: var(--sl-search-result-pad-block) var(--sl-search-result-pad-inline-end);
padding-inline-start: var(--sl-search-result-pad-inline-start);
}
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: var(--sl-search-page-icon-inline-start);
width: var(--sl-search-page-icon-size);
background: var(--sl-color-gray-3);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
center no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
center no-repeat;
}
#starlight__search .pagefind-ui__result-inner {
align-items: stretch;
gap: 1px;
}
#starlight__search .pagefind-ui__result-link {
position: unset;
--pagefind-ui-text: var(--sl-color-white);
font-weight: 600;
}
#starlight__search .pagefind-ui__result-link:hover {
text-decoration: none;
}
#starlight__search .pagefind-ui__result-nested .pagefind-ui__result-link::before {
content: unset;
}
#starlight__search .pagefind-ui__result-nested::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: var(--sl-search-tree-diagram-inline-start);
width: var(--sl-search-tree-diagram-size);
background: var(--sl-color-gray-4);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
0% 0% / 100% no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
0% 0% / 100% no-repeat;
}
#starlight__search .pagefind-ui__result-nested:last-of-type::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
}
/* Flip page and tree icons around the vertical axis when in an RTL layout. */
[dir='rtl'] .pagefind-ui__result-title::before,
[dir='rtl'] .pagefind-ui__result-nested::before {
transform: matrix(-1, 0, 0, 1, 0, 0);
}
#starlight__search .pagefind-ui__result-link::after {
content: '';
position: absolute;
inset: 0;
}
#starlight__search .pagefind-ui__result-excerpt {
font-size: calc(1rem * var(--pagefind-ui-scale));
overflow-wrap: anywhere;
}
#starlight__search mark {
color: var(--sl-color-gray-2);
background-color: transparent;
font-weight: 600;
}
#starlight__search .pagefind-ui__filter-value::before {
border-color: var(--sl-color-text-invert);
}
#starlight__search .pagefind-ui__result-tags {
background-color: var(--sl-color-black);
margin-top: 0;
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
}
}
</style>
+94
View File
@@ -0,0 +1,94 @@
// Code that must read in one piece, marked when the page is built (the CSS
// is retrobios.css, code.rb-short and table.rb-options).
//
// - Inline code of SHORT_CODE characters or fewer gets `rb-short` and never
// breaks: `--restore` split into `--` and `restore` on the home page at
// 1440 px, and `--list-` / `systems` in the installer's options table.
// Longer inline code (a path, a full command) still wraps.
// - A table whose first column is "Option" gets `rb-options`, so its flags
// keep their column.
//
// Satteri runs hast plugins (the rehype stage of its pipeline): this one is
// registered in astro.config.mjs through satteri({ hastPlugins }).
export const SHORT_CODE = 30;
const OPTION_HEADER = /^options?$/i;
const classes = (node) => {
const value = node.properties?.className;
if (Array.isArray(value)) return value.map(String);
return value ? String(value).split(/\s+/) : [];
};
const addClass = (ctx, node, name) => {
const present = classes(node);
if (!present.includes(name)) ctx.setProperty(node, 'className', [...present, name]);
};
const firstHeader = (ctx, table) => {
for (const part of table.children ?? []) {
if (part.type !== 'element' || part.tagName !== 'thead') continue;
for (const row of part.children ?? []) {
if (row.type !== 'element' || row.tagName !== 'tr') continue;
const cell = (row.children ?? []).find((child) => child.type === 'element' && child.tagName === 'th');
return cell ? ctx.textContent(cell).trim() : '';
}
}
return '';
};
// A code element is inline unless a `pre` holds it.
export const isShort = (text, inPre) => !inPre && text.length > 0 && text.length <= SHORT_CODE;
// The cards and tables the generator writes as HTML reach this step as raw
// text: their code elements are marked by rewriting the tag. A block that
// holds a `pre` is left as written.
const RAW_CODE = /<code(\s[^>]*)?>([^<]*)<\/code>/g;
const ENTITY = /&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi;
export const markRawCode = (html) => {
if (html.includes('<pre')) return html;
return html.replace(RAW_CODE, (whole, attributes = '', text) => {
if (/\bclass=/.test(attributes) || !isShort(text.replace(ENTITY, '_'), false)) return whole;
return `<code class="rb-short"${attributes}>${text}</code>`;
});
};
export default function shortCode() {
return {
name: 'rb-short-code',
raw(node, ctx) {
// Inline HTML in Markdown (a hash in a table cell) arrives in three
// nodes: the opening tag, the text, the closing tag.
const open = /^<code(\s[^>]*)?>$/.exec(node.value ?? '');
if (open) {
const siblings = ctx.parent(node)?.children ?? [];
const index = ctx.indexOf(node) ?? -1;
const [text, close] = [siblings[index + 1], siblings[index + 2]];
if (text?.type === 'text' && close?.type === 'raw' && close.value === '</code>'
&& !/\bclass=/.test(open[1] ?? '') && isShort(text.value, false)) {
ctx.replaceNode(node, { type: 'raw', value: `<code class="rb-short"${open[1] ?? ''}>` });
}
return;
}
const value = markRawCode(node.value ?? '');
if (value !== node.value) ctx.replaceNode(node, { type: 'raw', value });
},
element: [
{
filter: ['code'],
visit(code, ctx) {
const parent = ctx.parent(code);
const inPre = parent?.type === 'element' && parent.tagName === 'pre';
if (isShort(ctx.textContent(code), inPre)) addClass(ctx, code, 'rb-short');
},
},
{
filter: ['table'],
visit(table, ctx) {
if (OPTION_HEADER.test(firstHeader(ctx, table))) addClass(ctx, table, 'rb-options');
},
},
],
};
}
+115 -44
View File
@@ -1,7 +1,8 @@
// Progressive enhancement of the generated pages: table captions, filters
// and keyboard scrolling, the hash lookup on files/ (a pasted hash or a file
// hashed on this device), moved cards, a hash typed in the search, glossary
// terms on a tap, the OS tabs kept in view, and the 404 page's hints.
// terms on a tap, the OS tab of the visitor's system, the current page in
// the sidebar, and the 404 page's hints.
// Which tables stack on a phone is decided when the page is built
// (src/plugins/rehype-stack-tables.mjs), so the layout is right on first paint.
@@ -262,7 +263,11 @@ const lookupValues = async (values: string[]): Promise<Item[]> => {
return shards.flat().filter((item) => wanted.has(item.value));
};
type Held = { name: string; path: string; sha1: string; size: number };
type Held = { name: string; path: string; sha1: string; size: number; md5?: string; crc32?: string; sha256?: string };
// Every hash of a collection file, lower case.
const heldHashes = (file: Held) =>
[file.sha1, file.md5, file.crc32, file.sha256].filter((value): value is string => !!value).map((value) => value.toLowerCase());
type Declared = { name?: string; destination?: string; required?: boolean; [hash: string]: unknown };
type ProfileFile = { name?: string; region?: string | string[]; category?: string; [field: string]: unknown };
type Reader = { name: string; files: ProfileFile[] };
@@ -334,15 +339,21 @@ const heldFile = async (item: Item): Promise<Held | undefined> => {
return undefined;
};
// Whether an entry of a list declares one of *values*, under any of its
// hash fields. Recalbox writes several MD5 in one field, separated by commas.
const HASH_FIELDS = ['sha1', 'md5', 'crc32', 'sha256'];
const declares = (entry: Record<string, unknown>, values: Set<string>) =>
HASH_FIELDS.some((field) =>
String(entry[field] ?? '').toLowerCase().split(',').some((hash) => values.has(hash.trim())));
// A setup's own entry for the value, from its contract in api/v1/platforms/:
// where the file goes and whether the setup requires it. Recalbox writes
// several MD5 in one field, separated by commas.
const declaredEntry = async (item: Item): Promise<Declared | undefined> => {
// where the file goes and whether the setup requires it. *values* are the
// hashes of the file the value names.
const declaredEntry = async (item: Item, values: Set<string>): Promise<Declared | undefined> => {
const system = decodeURIComponent(new URL(item.url, siteRoot()).hash.slice(1));
const data = await json(`api/v1/platforms/${item.owner}.json`);
const files: Declared[] = data.items?.[0]?.contract?.systems?.[system]?.files ?? [];
const holds = (entry: Declared) =>
String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(item.value);
const holds = (entry: Declared) => declares(entry, values);
return files.find((entry) => holds(entry) && entry.name === item.name) ?? files.find(holds);
};
@@ -355,9 +366,8 @@ const readerProfile = async (owner: string): Promise<Reader | undefined> => {
};
// The entry of a profile that reads the value: by name and hash, else by hash.
const readerEntry = (reader: Reader | undefined, item: Item): ProfileFile | undefined => {
const holds = (entry: ProfileFile) =>
String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(item.value);
const readerEntry = (reader: Reader | undefined, item: Item, values: Set<string>): ProfileFile | undefined => {
const holds = (entry: ProfileFile) => declares(entry, values);
return reader?.files.find((entry) => holds(entry) && entry.name === item.name) ?? reader?.files.find(holds);
};
@@ -414,21 +424,38 @@ const distinct = (items: Item[], key: (item: Item) => string) => {
// how many setups list it under which names. Then where the collection
// holds it, the setups that list it (where it goes, whether it is
// required), and the emulators that read it, by name.
const renderMatches = async (status: HTMLElement, result: HTMLElement, items: Item[]) => {
//
// A setup or a profile declares one hash of a file, the one its list
// writes. When the value matches a file of the collection, the lists are
// read under every hash of that file, so the MD5, the SHA1, the CRC32 and
// the SHA256 of one dump give one answer. Setups and emulators are counted
// per file, by its SHA1.
const renderMatches = async (status: HTMLElement, result: HTMLElement, found: Item[]) => {
const held = distinct(found.filter((item) => item.layer === 'collection'), (item) => item.sha1 ?? item.url);
const files = await Promise.all(held.map((item) => heldFile(item).catch(() => undefined)));
const looked = new Set(found.map((item) => item.value));
const others = [...new Set(files.flatMap((file) => (file ? heldHashes(file) : [])))].filter((value) => !looked.has(value));
const items = others.length ? [...found, ...(await lookupValues(others).catch(() => [] as Item[]))] : found;
// The file a declaration names: the SHA1 the record carries, else the
// collection file one of whose hashes it declares.
const fileOf = (item: Item) =>
item.sha1 ?? files.find((file) => file && heldHashes(file).includes(item.value))?.sha1;
const valuesOf = (item: Item) => {
const file = files.find((each) => each && each.sha1 === fileOf(item));
return new Set([item.value, ...(file ? heldHashes(file) : [])]);
};
const byLayer = (layer: string) => items.filter((item) => item.layer === layer);
const held = distinct(byLayer('collection'), (item) => item.sha1 ?? item.url);
const platforms = distinct(byLayer('platform'), (item) => `${item.owner}|${item.name}|${item.url}`);
const emulators = distinct(byLayer('emulator'), (item) => `${item.owner}|${item.name}|${item.url}`);
const owners = [...new Set(emulators.map((item) => item.owner ?? ''))];
const [files, entries, names, readers] = await Promise.all([
Promise.all(held.map((item) => heldFile(item).catch(() => undefined))),
Promise.all(platforms.map((item) => declaredEntry(item).catch(() => undefined))),
const [entries, names, readers] = await Promise.all([
Promise.all(platforms.map((item) => declaredEntry(item, valuesOf(item)).catch(() => undefined))),
json('data/picker/index.json').catch(() => []) as Promise<{ platform: string; name: string }[]>,
Promise.all(owners.map((owner) => readerProfile(owner))),
]);
const setupName = new Map(names.map((entry) => [entry.platform, entry.name]));
const reader = new Map(owners.map((owner, index) => [owner, readers[index]]));
const readEntries = emulators.map((item) => readerEntry(reader.get(item.owner ?? ''), item));
const readEntries = emulators.map((item) => readerEntry(reader.get(item.owner ?? ''), item, valuesOf(item)));
const setups = new Set(platforms.map((item) => item.owner));
const counted = [
@@ -452,8 +479,11 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, items: It
const what: (Node | string)[] = system
? [', ', link(item.url, system), category ? ` ${category}` : ' file']
: category ? [`, a ${category} file`] : [];
// The setups that list this file, whatever hash their list writes.
const listing = platforms.filter((declared) => (fileOf(declared) ?? item.sha1) === item.sha1);
const listers = new Set(listing.map((declared) => declared.owner));
const others = new Map<string, Set<string>>();
for (const declared of platforms) {
for (const declared of listing) {
const other = declared.name ?? '';
if (anyName(other) || other === name) continue;
if (!others.has(other)) others.set(other, new Set());
@@ -466,17 +496,17 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, items: It
if (position) named.push(position === otherNames.length - 1 ? ' and ' : ', ');
named.push(code(other));
});
const listing: (Node | string)[] = setups.size
const verdict: (Node | string)[] = listers.size
? [
` ${count(setups.size, 'setup lists', 'setups list')} it`,
` ${count(listers.size, 'setup lists', 'setups list')} it`,
...(renamed.size
? [`, ${renamed.size.toLocaleString('en')} of them under the ${otherNames.length === 1 ? 'name' : 'names'} `, ...named]
? [`, ${renamed.size.toLocaleString('en')} of them under ${otherNames.length === 1 ? 'another name' : 'other names'}: `, ...named]
: []),
'.',
]
: [];
blocks.push(
paragraph('This is ', code(name), ...what, ...(regions ? [` for ${regions}`] : []), '.', ...listing),
paragraph('This is ', code(name), ...what, ...(regions ? [` for ${regions}`] : []), '.', ...verdict),
paragraph(
...(file ? ['Held in the collection at ', code(file.path), '. SHA1 ', code(file.sha1), `, ${formatSize(file.size)}. `] : []),
link(item.url, 'Open its card'),
@@ -697,11 +727,11 @@ const hashLookupRow = (drawer: HTMLElement, value: string) => {
// Pagefind lists a page's title twice: once for the page and once for the
// anchor of its h1 (#_top). The second title is hidden, its excerpt kept.
// The `files`, `aliases` and `devices` fields weigh in the ranking and are
// never shown: a result is not a list of 120 names. Pagefind reuses
// The `files`, `aliases`, `devices`, `consoles` and `terms` fields weigh in
// the ranking and are never shown: a result is not a list of 120 names. Pagefind reuses
// its nodes from one query to the next, so both are worked out again on
// every change.
const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices']);
const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'terms']);
const tidyResults = (root: Element) => {
for (const title of root.querySelectorAll<HTMLElement>('.pagefind-ui__result-nested > .pagefind-ui__result-title')) {
@@ -751,29 +781,69 @@ const hashSearch = () => {
);
};
// The OS tabs scroll sideways on a phone instead of wrapping to a second
// row (retrobios.css): the chosen one is kept in view, on load and on a
// change, by scrolling the row and never the page.
const keepTabInView = (tab: Element) => {
const list = tab.closest('[role="tablist"]');
if (!list) return;
const row = list.getBoundingClientRect();
const box = tab.getBoundingClientRect();
if (box.left < row.left) list.scrollLeft -= row.left - box.left;
else if (box.right > row.right) list.scrollLeft += box.right - row.right;
// The OS tabs open on the visitor's own system: the tab its label names, on
// every tab set of the page that shares the "os" sync key. A tab the
// visitor chose before (Starlight keeps it under the sync key and restores
// it as the page draws) wins over the guess, and the guess is not saved as
// a choice. No tab is for iOS or ChromeOS: those keep the first tab.
const OS_SYNC_KEY = 'os';
const OS_TAB_RULES: [RegExp, string][] = [
[/SteamOS|Valve Steam/i, 'Steam Deck'],
[/Android/i, 'Android (Termux)'],
[/iPhone|iPad|iPod|CrOS|Chrome OS/i, ''],
[/Mac/i, 'macOS'],
[/Win/i, 'Windows'],
[/Linux|X11/i, 'Linux'],
];
const osTabLabel = (userAgent: string, platform: string): string =>
OS_TAB_RULES.find(([pattern]) => pattern.test(userAgent) || pattern.test(platform))?.[1] ?? '';
// End of the OS tab rule.
type SyncedTabs = HTMLElement & { tabs: HTMLElement[]; switchTab(tab: HTMLElement, index: number, sync?: boolean): void };
const savedTab = (key: string): string | null => {
try {
return localStorage.getItem(`starlight-synced-tabs__${key}`);
} catch {
// Storage refused (a private window, blocked site data): no saved tab.
return null;
}
};
const osTabs = () => {
const selected = () => document.querySelectorAll('starlight-tabs [role="tab"][aria-selected="true"]').forEach(keepTabInView);
if (!document.querySelector('starlight-tabs')) return;
const later = () => requestAnimationFrame(selected);
for (const type of ['click', 'keyup']) {
document.addEventListener(type, (event) => {
if ((event.target as Element | null)?.closest?.('starlight-tabs [role="tab"]')) later();
});
}
if (document.readyState === 'complete') later();
else addEventListener('load', later, { once: true });
const sets = [...document.querySelectorAll<SyncedTabs>(`starlight-tabs[data-sync-key="${OS_SYNC_KEY}"]`)];
if (!sets.length || savedTab(OS_SYNC_KEY)) return;
const agent = navigator as Navigator & { userAgentData?: { platform?: string } };
const label = osTabLabel(navigator.userAgent, agent.userAgentData?.platform ?? '');
if (!label) return;
customElements.whenDefined('starlight-tabs').then(() => {
for (const set of sets) {
const index = set.tabs.findIndex((tab) => tab.textContent?.trim() === label);
if (index > 0 && set.tabs[index].getAttribute('aria-selected') !== 'true') set.switchTab(set.tabs[index], index, false);
}
});
};
// The sidebar lists 91 manufacturers: the current page's link sat below the
// pane, out of sight. On load, and when the phone menu opens, the pane
// scrolls so the link sits a third of the way down. Only the pane scrolls,
// never the page.
const showCurrentPage = () => {
const pane = document.getElementById('starlight__sidebar');
if (!pane) return;
const reveal = () => {
const current = pane.querySelector<HTMLElement>('[aria-current="page"]');
if (!current) return;
const box = pane.getBoundingClientRect();
const link = current.getBoundingClientRect();
if (link.top >= box.top && link.bottom <= box.bottom) return;
pane.scrollTop += link.top - box.top - pane.clientHeight / 3;
};
reveal();
pane.addEventListener('toggle', (event) => {
if ((event as ToggleEvent).newState === 'open') requestAnimationFrame(reveal);
});
};
// A glossary term shows its definition on hover; a tap or Enter shows it
@@ -825,4 +895,5 @@ enhanceTables();
hashSearch();
glossaryTerms();
osTabs();
showCurrentPage();
notFound();