From 2c71f0d3dadb5fd779bc816c5a85a4f81d4e6a9e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:27:09 +0200 Subject: [PATCH] feat: stack tables at build, add section tabs --- website/astro.config.mjs | 34 +++++++- website/src/components/Header.astro | 88 +++++++++++++++++++++ website/src/plugins/rehype-stack-tables.mjs | 73 +++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 website/src/components/Header.astro create mode 100644 website/src/plugins/rehype-stack-tables.mjs diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 03b72b92..cb2996f8 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -1,6 +1,7 @@ // The RetroBIOS site. Pages, navigation and data files are written by // scripts/generate_site.py; this folder holds the engine's configuration, // the components and the styles. +import { readFileSync } from 'node:fs'; import { copyFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'astro/config'; @@ -11,10 +12,27 @@ import starlightSidebarTopics from 'starlight-sidebar-topics'; import starlightLinksValidator from 'starlight-links-validator'; import sidebar from './src/generated/sidebar.json' with { type: 'json' }; import lastmod from './src/generated/lastmod.json' with { type: 'json' }; +import stackTables from './src/plugins/rehype-stack-tables.mjs'; const SITE = 'https://abdess.github.io'; const BASE = '/retrobios/'; +// Old addresses that lead on to a page: {"/from/": "/to/"}, site paths +// written by the generator. A checkout that has not run it builds with none. +// Astro puts the base before the old address but not before the new one. +const readRedirects = () => { + let redirects; + try { + redirects = JSON.parse(readFileSync(new URL('./src/generated/redirects.json', import.meta.url), 'utf-8')); + } catch (error) { + if (error.code === 'ENOENT') return {}; + throw error; + } + const withBase = (to) => (/^https?:/.test(to) || to.startsWith(BASE) ? to : BASE + to.replace(/^\//, '')); + const withoutBase = (from) => (from.startsWith(BASE) ? `/${from.slice(BASE.length)}` : from); + return Object.fromEntries(Object.entries(redirects).map(([from, to]) => [withoutBase(from), withBase(to)])); +}; + // The sitemap keeps the address MkDocs gave it: sitemap.xml, beside the index. const sitemapAlias = { name: 'retrobios-sitemap-alias', @@ -30,8 +48,10 @@ export default defineConfig({ site: SITE, base: BASE, trailingSlash: 'always', + redirects: readRedirects(), markdown: { processor: satteri({ + hastPlugins: [stackTables()], features: { headingAttributes: true, definitionList: true, @@ -59,11 +79,21 @@ export default defineConfig({ PageTitle: './src/components/PageTitle.astro', MarkdownContent: './src/components/MarkdownContent.astro', Footer: './src/components/Footer.astro', + Header: './src/components/Header.astro', }, routeMiddleware: ['./src/routeData.ts'], tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 }, - // Commands wrap instead of scrolling, so the end of a one-liner stays visible. - expressiveCode: { defaultProps: { wrap: true } }, + // Commands wrap instead of scrolling, so the end of a one-liner stays + // visible. A command block is titled (Terminal, PowerShell) and drawn + // without the three window dots. Starlight writes the dots into each + // theme after the global style overrides, so they are set here. + expressiveCode: { + defaultProps: { wrap: true }, + customizeTheme(theme) { + theme.styleOverrides.frames = { ...theme.styleOverrides.frames, terminalTitlebarDotsOpacity: '0' }; + return theme; + }, + }, lastUpdated: false, pagination: true, // Measured on the queries of tools/search_gate.py, at the centre of a diff --git a/website/src/components/Header.astro b/website/src/components/Header.astro new file mode 100644 index 00000000..271f7904 --- /dev/null +++ b/website/src/components/Header.astro @@ -0,0 +1,88 @@ +--- +// Starlight's header bar, and under it the five sections of the site as +// tabs: the funnel (Start here to Contribute) is the site's structure, so it +// sits where every docs site puts its sections. On a phone the tabs stay the +// first row of the menu, where the sidebar lists them. +import Default from '@astrojs/starlight/components/Header.astro'; + +const topics = Astro.locals.starlightSidebarTopics?.topics ?? []; +--- + +
+{ + topics.length > 0 && ( + + ) +} + + + diff --git a/website/src/plugins/rehype-stack-tables.mjs b/website/src/plugins/rehype-stack-tables.mjs new file mode 100644 index 00000000..29b9235a --- /dev/null +++ b/website/src/plugins/rehype-stack-tables.mjs @@ -0,0 +1,73 @@ +// Tables a phone reads one row at a time, marked when the page is built so +// the stacked layout is there on first paint (the CSS is retrobios.css, +// table[data-rb-stack]). The rule reads the table, never the page that +// wrote it: +// - a row that ends in an action (a release download, a button, the link to +// a setup's install command) or a header that names a ZIP column: one +// block per row, each cell under its column name; +// - two columns: a list of label and value, data-rb-stack="kv"; +// - on a level 0 or 1 page (`level: start` or `setup`), a table short enough +// to read as records (under 20 rows, the size from which site.ts adds a +// filter): one block per row. A longer one is a list scanned by column and +// keeps its scroll. +// Satteri runs hast plugins (the rehype stage of its pipeline): this one is +// registered in astro.config.mjs through satteri({ hastPlugins }). + +const ACTION_HREF = /\/releases\/(download\/|latest\/?$)|#install$/; +const ACTION_CLASS = /\bclass=["'][^"']*\brb-button\b/; +const DOWNLOAD_HEADER = /^(zip|download)$/i; +const READER_LEVELS = new Set(['start', 'setup']); +export const RECORD_ROWS = 20; + +const elements = (node, tag, found = []) => { + for (const child of node.children ?? []) { + if (child.type !== 'element') continue; + if (child.tagName === tag) found.push(child); + // A table inside a cell is its own table. + if (child.tagName !== 'table') elements(child, tag, found); + } + return found; +}; + +const holdsAction = (node) => + (node.children ?? []).some( + (child) => + (child.type === 'element' && + ((child.tagName === 'a' && ACTION_HREF.test(String(child.properties?.href ?? ''))) || + String(child.properties?.className ?? '').split(/[\s,]+/).includes('rb-button') || + holdsAction(child))) || + (child.type === 'raw' && ACTION_CLASS.test(child.value ?? '')), + ); + +export const stackKind = ({ labels, rows, action, level }) => { + if (labels.length === 2) return 'kv'; + if (action || DOWNLOAD_HEADER.test(labels.at(-1) ?? '')) return 'true'; + if (READER_LEVELS.has(level) && labels.length > 2 && rows < RECORD_ROWS) return 'true'; + return null; +}; + +export default function stackTables() { + return { + name: 'rb-stack-tables', + element: { + filter: ['table'], + visit(table, ctx) { + const head = elements(table, 'thead')[0]; + const body = elements(table, 'tbody')[0]; + if (!head || !body) return; + const labels = elements(head, 'th').map((cell) => ctx.textContent(cell).trim()); + const rows = elements(body, 'tr'); + const level = ctx.data.astro?.frontmatter?.level; + const kind = stackKind({ labels, rows: rows.length, action: holdsAction(body), level }); + if (!kind) return; + ctx.setProperty(table, 'dataRbStack', kind); + if (kind === 'kv') return; + for (const row of rows) { + elements(row, 'td').forEach((cell, column) => { + if (labels[column] && cell.properties?.dataLabel === undefined) ctx.setProperty(cell, 'dataLabel', labels[column]); + }); + } + }, + }, + }; +}