diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 25fc9738..00000000 --- a/TODO.md +++ /dev/null @@ -1,8 +0,0 @@ -# TODO - -## Default theme - -- [x] Replace sidebar logo placeholder (`CubeIcon`) with configurable logo from `config.logo` (light/dark) -- [x] Add optional `icon` field per content dir and API in `chronicle.yaml` (accepts URL or inline SVG string) -- [x] "Open in AI" dropdown (Copy/View MD, Open in ChatGPT/Claude) in subNav -- [x] Sidebar nesting supports 2 levels; 3rd+ level items are ignored diff --git a/packages/chronicle/src/components/mdx/table.tsx b/packages/chronicle/src/components/mdx/table.tsx index 3313848a..4eea522b 100644 --- a/packages/chronicle/src/components/mdx/table.tsx +++ b/packages/chronicle/src/components/mdx/table.tsx @@ -1,7 +1,26 @@ 'use client' import { Table } from '@raystack/apsara' -import type { ComponentProps, ReactNode } from 'react' +import { Children, isValidElement, type ComponentProps, type ReactElement, type ReactNode } from 'react' +import { usePageContext } from '@/lib/page-context' +import { FanfoldExpandableRow } from '@/themes/fanfold/ExpandableRow' + +/** A cell as `rehype-table-columns` leaves it: a size class, or none if prose. */ +type CellElement = ReactElement<{ 'data-col'?: string; children?: ReactNode }> + +/** + * Separates a row's label cells from its one prose cell, or returns null when + * the row is not one `rehype-table-columns` marked up — every label cell carries + * a size class and the prose cell carries none, so the row can tell on its own + * without being told which table it belongs to. + */ +function splitRow(children: ReactNode) { + const cells = Children.toArray(children).filter(isValidElement) as CellElement[] + const labels = cells.filter(cell => cell.props['data-col'] != null) + const prose = cells.filter(cell => cell.props['data-col'] == null) + if (labels.length < 2 || prose.length !== 1) return null + return { cells, labels, prose: prose[0] } +} type TableProps = ComponentProps<'table'> @@ -24,6 +43,29 @@ export function MdxTbody({ children, ...props }: TbodyProps) { type TrProps = ComponentProps<'tr'> export function MdxTr({ children, ...props }: TrProps) { + const { config } = usePageContext() + + // Only fanfold folds the paragraph away. The other themes give a table the + // full width of the page, so their prose column has room to stay in the row. + if (config.theme?.name === 'fanfold') { + const split = splitRow(children) + + if (split?.cells.every(cell => cell.type === MdxTh)) { + // The prose column's heading moves over the toggle, so the reader can see + // what is folded away rather than a bare chevron. + return ( + + {split.labels} + {split.prose.props.children} + + ) + } + + if (split?.cells.every(cell => cell.type === MdxTd)) { + return + } + } + return {children} } diff --git a/packages/chronicle/src/lib/rehype-table-columns.test.ts b/packages/chronicle/src/lib/rehype-table-columns.test.ts new file mode 100644 index 00000000..3fa12ef5 --- /dev/null +++ b/packages/chronicle/src/lib/rehype-table-columns.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, test } from 'bun:test'; +import type { Element, ElementContent, Root } from 'hast'; +import rehypeTableColumns from './rehype-table-columns'; + +function cell(tagName: 'th' | 'td', value: string): Element { + return { type: 'element', tagName, properties: {}, children: [{ type: 'text', value }] }; +} + +function row(tagName: 'th' | 'td', values: string[]): Element { + return { + type: 'element', + tagName: 'tr', + properties: {}, + children: values.map(value => cell(tagName, value)), + }; +} + +/** A markdown table as rehype sees it: one `thead` row, then the body. */ +function table(headers: string[], body: string[][]): Element { + return { + type: 'element', + tagName: 'table', + properties: {}, + children: [ + { type: 'element', tagName: 'thead', properties: {}, children: [row('th', headers)] }, + { + type: 'element', + tagName: 'tbody', + properties: {}, + children: body.map(values => row('td', values)), + }, + ], + }; +} + +function run(node: Element): Element { + const tree: Root = { type: 'root', children: [node] }; + const transform = rehypeTableColumns.call({ use: () => undefined } as never) as ( + tree: Root + ) => void; + transform(tree); + return tree.children[0] as Element; +} + +/** The size class of each column, reading the header row. `null` is the prose column. */ +function classes(node: Element): Array { + const thead = node.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'thead' + ); + const headerRow = thead?.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'tr' + ); + return (headerRow?.children ?? []) + .filter((child): child is Element => child.type === 'element') + .map(th => (th.properties['data-col'] as string) ?? null); +} + +/** Which column carries `data-grow`, reading the header row. */ +function growColumn(node: Element): number { + const thead = node.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'thead' + ); + const headerRow = thead?.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'tr' + ); + return (headerRow?.children ?? []) + .filter((child): child is Element => child.type === 'element') + .findIndex(th => th.properties['data-grow'] != null); +} + +/** The cells of the first body row, as text with `|` marking each break opportunity. */ +function bodyParts(node: Element): string[] { + const tbody = node.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'tbody' + ); + const firstRow = tbody?.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'tr' + ); + return (firstRow?.children ?? []) + .filter((child): child is Element => child.type === 'element') + .map(flatten); +} + +/** Text with `|` for each break opportunity, however deeply the markup nests. */ +function flatten(node: ElementContent): string { + if (node.type === 'text') return node.value; + if (node.type !== 'element') return ''; + if (node.tagName === 'wbr') return '|'; + return node.children.map(flatten).join(''); +} + +const prose = (length: number) => 'x'.repeat(length); + +describe('rehypeTableColumns', () => { + test('states widths for a conformance table: one prose column, the rest labels', () => { + const result = run( + table(['Item', 'Description', 'Reference', 'Status', 'Support', 'Notes'], [ + ['SPP-14', 'Space Packet', '4.1', 'M', 'Yes', prose(400)], + ['SPP-15', 'Packet Primary Header', '4.1.3', 'M', 'Yes', prose(350)], + ]) + ); + + expect(result.properties['data-fit']).toBe('stated'); + // Sized by the longest run rather than the longest cell: "Packet Primary + // Header" is 21 characters but wraps at its spaces, so the column only has + // to fit "Description" in the heading above it. + expect(classes(result)).toEqual(['xs', 's', 's', 'xs', 'xs', null]); + }); + + test('leaves a field-and-value pair alone: one label column needs no stated width', () => { + const result = run( + table(['Field', 'Value'], [ + ['Implementation Name', 'astro/pkg/spp'], + ['Other Information', prose(200)], + ]) + ); + + expect(result.properties['data-fit']).toBeUndefined(); + expect(classes(result)).toEqual([null, null]); + }); + + test('leaves a table with no prose column alone', () => { + const result = run( + table(['Category', 'Total', 'Supported'], [ + ['Mandatory', '31', '31'], + ['Optional', '12', '9'], + ]) + ); + + expect(result.properties['data-fit']).toBeUndefined(); + }); + + test('leaves a table with a second prose column alone', () => { + const result = run( + table(['Requirement', 'Reference', 'Why it is not checked'], [ + [prose(200), '4.1.4', prose(300)], + [prose(180), '4.1.5', prose(250)], + ]) + ); + + expect(result.properties['data-fit']).toBeUndefined(); + }); + + test('a long label column is still a label column', () => { + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['TM-1', prose(88), prose(333)], + ['TM-2', prose(60), prose(300)], + ]) + ); + + expect(result.properties['data-fit']).toBe('stated'); + expect(classes(result)).toEqual(['xs', 'xl', null]); + }); + + test('a runaway cell in a label column makes it a second prose column', () => { + // One 156-character entry is enough: the column could need a paragraph's + // room, so the table is left to size itself rather than have that room + // taken from the Notes column beside it. + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['TM-1', prose(156), prose(130)], + ['TM-2', 'short', prose(125)], + ]) + ); + + expect(result.properties['data-fit']).toBeUndefined(); + }); + + test('the prose column can sit in the middle of a table', () => { + // The last column here is a flag; the paragraphs are in column four. + const result = run( + table(['Item', 'Description', 'Reference', 'Notes', 'Status'], [ + ['TM-1', prose(90), '4.1.4', prose(349), 'Yes'], + ['TM-2', prose(60), '4.1.5', prose(200), 'No'], + ]) + ); + + expect(result.properties['data-fit']).toBe('stated'); + expect(classes(result)).toEqual(['xs', 'xl', 's', null, 'xs']); + }); + + test('a header counts towards the width a column must fit', () => { + // Every value is one character, but "Support" is seven, so the column is + // sized for the heading rather than collapsing under it. + const result = run( + table(['Item', 'Support', 'Notes'], [ + ['A', 'M', prose(200)], + ['B', 'C', prose(180)], + ]) + ); + + expect(classes(result)).toEqual(['xs', 'xs', null]); + }); + + test('sizes a column to fit its longest word, so no word has to break', () => { + // "Unsupported" is eleven characters with nothing to break at. Sizing this + // column by its longest cell put it in a class that fits nine, and the word + // came out as "Unsupporte" above a stray "d". + const result = run( + table(['Item', 'Status', 'Notes'], [ + ['XT-1', 'Unsupported', prose(300)], + ['XT-2', 'Ignored', prose(280)], + ]) + ); + + expect(classes(result)[1]).toBe('m'); + }); + + test('a hyphen already breaks, so it does not widen a column', () => { + // "SPP-14" is six characters but CSS may break after the hyphen, so the + // column only has to fit "SPP-". + const result = run( + table(['Item', 'Ref', 'Notes'], [ + ['SPP-14', '4.1', prose(300)], + ['SPP-15', '4.2', prose(280)], + ]) + ); + + expect(classes(result)[0]).toBe('xs'); + }); + + test('a run too long for any class takes the widest rather than giving up', () => { + // Falling back to automatic layout here would hide the end of every line + // off the sheet, which is worse than one broken word. + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['A', 'Averyveryverylongunbrokenidentifier', prose(300)], + ['B', 'short', prose(280)], + ]) + ); + + expect(result.properties['data-fit']).toBe('stated'); + expect(classes(result)[1]).toBe('xl'); + }); + + test('breaks a long label at its punctuation, not mid-word', () => { + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['SPP-10', 'Octet_String.indication', prose(300)], + ['SPP-11', 'Packet.request', prose(280)], + ]) + ); + + expect(bodyParts(result)[1]).toBe('Octet_|String.|indication'); + }); + + test('leaves short labels whole, so a clause reference keeps its dots', () => { + const result = run( + table(['Item', 'Reference', 'Support', 'Notes'], [ + ['SPP-10', '4.1.3.3.3.4', 'Yes', prose(300)], + ['SPP-11', '4.1.4', 'Yes', prose(280)], + ]) + ); + + expect(bodyParts(result)[1]).toBe('4.1.3.3.3.4'); + }); + + test('seams the prose column too, since its width is only the remainder', () => { + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['SPP-10', 'short', `${prose(300)} Service.ReceivePacketIndication()`], + ['SPP-11', 'short', prose(280)], + ]) + ); + + expect(bodyParts(result)[2]).toContain('Service.|Receive|Packet|Indication()'); + }); + + test('breaks an identifier at its capitals when it has no punctuation', () => { + // `TransmissionConstraintList` has nothing else to break at. Left whole it + // overflowed its cell, and the cells are clipped with an ellipsis, so the + // end of the name simply disappeared. + const result = run( + table(['Element', 'Status', 'Notes'], [ + ['TransmissionConstraintList', 'Unsupported', prose(300)], + ['VerifierSet', 'Unsupported', prose(280)], + ]) + ); + + expect(bodyParts(result)[0]).toBe('Transmission|Constraint|List'); + // Sized for its longest part now, not the whole name. + expect(classes(result)[0]).toBe('m'); + }); + + test('adds nothing to a table it does not mark up', () => { + const result = run( + table(['Field', 'Value'], [['Implementation.Name.Long', prose(200)]]) + ); + + expect(bodyParts(result)[0]).toBe('Implementation.Name.Long'); + }); + + test('breaks a long label inside markup, such as an inline code span', () => { + const node = table(['Item', 'Description', 'Notes'], [ + ['SPP-10', 'placeholder', prose(300)], + ]); + const tbody = node.children[1] as Element; + const row = (tbody.children[0] as Element).children as Element[]; + row[1].children = [ + { type: 'element', tagName: 'code', properties: {}, children: [ + { type: 'text', value: 'Service.SendPacket' }, + ] }, + ]; + + expect(bodyParts(run(node))[1]).toBe('Service.|Send|Packet'); + }); + + test('marks the label column holding the most text as the one to grow', () => { + const result = run( + table(['Item', 'Description', 'Reference', 'Status', 'Notes'], [ + ['SPP-1', 'Space Packet Service Data Unit', '3.2.2', 'M', prose(300)], + ['SPP-2', 'Octet String SDU', '3.2.3', 'M', prose(280)], + ]) + ); + + // Description, not the item code beside it nor the clause reference. + expect(growColumn(result)).toBe(1); + }); + + test('never marks the prose column to grow', () => { + const result = run( + table(['Item', 'Description', 'Notes'], [ + ['A', 'short', prose(600)], + ['B', 'short', prose(500)], + ]) + ); + + const grow = growColumn(result); + expect(grow).not.toBe(2); + expect(classes(result)[grow]).not.toBeNull(); + }); + + test('marks exactly one column to grow', () => { + const result = run( + table(['Item', 'Description', 'Reference', 'Notes'], [ + ['A', 'Something long here', '3.2.2', prose(300)], + ['B', 'Another long one', '3.2.3', prose(280)], + ]) + ); + + const marked = classes(result).filter((_, i) => growColumn(result) === i); + expect(marked).toHaveLength(1); + }); + + test('ignores a table with a spanning cell', () => { + const node = table(['Item', 'Description', 'Notes'], [['A', 'b', prose(200)]]); + const tbody = node.children[1] as Element; + const firstRow = tbody.children[0] as Element; + (firstRow.children[0] as Element).properties.colSpan = 2; + + expect(run(node).properties['data-fit']).toBeUndefined(); + }); + + test('ignores a table with no body rows', () => { + expect(run(table(['Item', 'Notes'], [])).properties['data-fit']).toBeUndefined(); + }); +}); diff --git a/packages/chronicle/src/lib/rehype-table-columns.ts b/packages/chronicle/src/lib/rehype-table-columns.ts new file mode 100644 index 00000000..30e515c7 --- /dev/null +++ b/packages/chronicle/src/lib/rehype-table-columns.ts @@ -0,0 +1,341 @@ +import type { Element, ElementContent, Root } from 'hast' +import type { Plugin } from 'unified' +import { visit } from 'unist-util-visit' + +/** + * How long a single cell has to be before its column counts as able to hold + * prose. Measured against the corpus this was built for: across 482 tables the + * columns holding sentences top out well past this, while the ones holding + * names, clause references and flags sit under 100 characters. Anything below + * the line is a column of labels, however wide the widest label happens to be. + */ +const PROSE_MIN_CHARS = 120 + +/** + * Size classes for the columns that are not the prose column, by the longest run + * of text in the column that cannot be broken. The theme turns these into + * widths; the classes exist so that the decision about how much room a column of + * clause references deserves lives in the theme, next to the type it is sizing, + * rather than here. + * + * The run is what matters rather than the longest cell, because a stated width + * is narrower than the longest cell by design — the cell is expected to wrap. It + * is only the unbreakable run that has to fit on one line, and a class narrower + * than its own run is what produced `Unsupporte` above a stray `d`. So each + * class here is a promise the theme keeps: a column of this class fits this many + * characters without breaking a word. + * + * The last class is a catch-all. A run too long even for that is set narrow and + * breaks, which is the lesser fault: the alternative is handing the table back + * to automatic layout, and that hides the end of every line off the sheet. + */ +const SIZE_CLASSES = [ + { name: 'xs', maxRun: 6 }, + { name: 's', maxRun: 10 }, + { name: 'm', maxRun: 13 }, + { name: 'l', maxRun: 17 }, +] as const + +const WIDEST_CLASS = 'xl' + +function sizeClass(run: number): string { + for (const size of SIZE_CLASSES) { + if (run <= size.maxRun) return size.name + } + return WIDEST_CLASS +} + +/** + * Header text is set smaller than the cells below it, so a heading of a given + * length needs less room than the same length of body copy. Without this a + * column of one-character flags would be sized for the whole of "SUPPORT" as + * though it were body text, and four such columns would take 176px from the one + * column holding paragraphs. + */ +const HEADER_CHAR_RATIO = 0.85 + +/** + * How many columns of labels a table needs before stating their widths is worth + * it. One label beside a paragraph is a field-and-value pair: automatic layout + * already fits it, and stating a width there would only make one table look + * different from the untagged ones beside it. + */ +const MIN_LABEL_COLUMNS = 2 + +/** + * Punctuation a long label may break after. These are the seams of an + * identifier — `Octet_String.indication` reads as three parts, and breaking it + * at one of them is the difference between a wrapped name and a typo. + * + * A hyphen is absent because CSS already offers a break after one, so an item + * code like "SPP-14" can break there without any help; that is what the 8ch + * floor further up is for. + */ +const BREAK_AFTER = new Set(['.', '_', '/', ':']) + +/** + * Words at or below this length are left alone. The narrowest label column + * holds about eleven characters, so anything shorter than this cannot be forced + * to break and gains nothing from the extra markup — a clause reference like + * "4.1.3.3.3.4" keeps every dot unbroken. + */ +const MIN_BREAKABLE_WORD = 12 + +/** + * Splits a string where a long word could sensibly break, returning the parts + * that a break opportunity should sit between. + * + * Two kinds of seam. After the punctuation of an identifier, and before a + * capital that starts a new word inside one — `TransmissionConstraintList` has + * no punctuation at all, and the humps are the only thing it can be broken at + * without cutting a word in half. + * + * Neither is taken within two characters of either end of the word, where a + * break would stand one or two letters on a line by themselves. + */ +function breakParts(value: string): string[] { + const parts: string[] = [] + let start = 0 + + for (const match of value.matchAll(/\S+/g)) { + const word = match[0] + if (word.length <= MIN_BREAKABLE_WORD || match.index === undefined) continue + + for (let index = 2; index <= word.length - 2; index++) { + const afterPunctuation = BREAK_AFTER.has(word[index - 1]) + const camelHump = /[A-Z]/.test(word[index]) && /[a-z0-9]/.test(word[index - 1]) + if (!afterPunctuation && !camelHump) continue + + const cut = match.index + index + parts.push(value.slice(start, cut)) + start = cut + } + } + + parts.push(value.slice(start)) + return parts +} + +/** + * The longest run of characters in a string with no way to break inside it — + * what the column has to fit on one line. Whitespace, the seams that + * `breakParts` finds, and hyphens all end a run, the last because CSS already + * offers a break after one. + */ +function longestRun(value: string): number { + let longest = 0 + + for (const part of breakParts(value)) { + for (const word of part.split(/\s+/)) { + for (const run of word.split(/(?<=-)/)) { + longest = Math.max(longest, run.length) + } + } + } + + return longest +} + +/** + * Offers the line breaker somewhere sensible to break the long labels in a cell. + * + * A stated width is narrower than the longest label it has to hold, so those + * labels wrap — and `overflow-wrap: break-word` wraps a word with no break + * opportunity in it by cutting anywhere, which turned `Packet.request` into + * `Packet.requ` and a stray `est`. Widening the column instead would work, but + * a 23-character identifier needs 209px of it, taken from the one column that + * is holding paragraphs. + * + * `` costs nothing and reads as nothing: it is a break opportunity rather + * than a character, so it leaves the text it sits in untouched when copied. + * `break-word` stays on underneath as the fallback for a word with no seam in + * it at all, so that nothing can ever grow past its cell — the cells here are + * `overflow: hidden` with an ellipsis, and a word that refuses to break is a + * word whose end is quietly cut off. + */ +function addBreakOpportunities(parent: Element): void { + const children: ElementContent[] = [] + let inserted = false + + for (const child of parent.children) { + if (child.type === 'element') { + addBreakOpportunities(child) + children.push(child) + continue + } + + if (child.type !== 'text') { + children.push(child) + continue + } + + const parts = breakParts(child.value) + if (parts.length === 1) { + children.push(child) + continue + } + + inserted = true + parts.forEach((part, index) => { + if (index > 0) { + children.push({ type: 'element', tagName: 'wbr', properties: {}, children: [] }) + } + children.push({ type: 'text', value: part }) + }) + } + + if (inserted) parent.children = children +} + +/** Concatenates every text descendant, so a cell's markup does not hide its length. */ +function toText(node: unknown): string { + if (!node || typeof node !== 'object') return '' + const n = node as { children?: unknown[]; value?: unknown } + if (Array.isArray(n.children)) return n.children.map(toText).join('') + return typeof n.value === 'string' ? n.value : '' +} + +function isCell(node: Element): boolean { + return node.tagName === 'th' || node.tagName === 'td' +} + +/** The cells of one row, or null if any of them spans more than one column. */ +function cellsOf(row: Element): Element[] | null { + const cells = row.children.filter( + (child): child is Element => child.type === 'element' && isCell(child) + ) + for (const cell of cells) { + if (cell.properties.colSpan != null || cell.properties.rowSpan != null) return null + } + return cells +} + +interface ColumnStats { + /** Longest cell in the column, header included — what makes a column prose. */ + longest: number + /** Longest unbreakable run, in body characters — what the column must fit. */ + run: number +} + +/** + * Marks up what each column of a table holds, so a theme can lay one out + * without guessing from the column count. + * + * The problem it solves: a conformance table puts an item code, a name, a clause + * reference and two flags beside a column of full paragraphs. Automatic table + * layout gives the paragraph column whatever it asks for, which runs the table + * past its container and hides the end of every line, and it sizes each table + * from that table's own content, so no two on a page line up. Stating the widths + * fixes both, but only a theme that knows which column is which can state them, + * and CSS cannot ask how much text is in a column. + * + * So a table whose shape a theme can state widths for is given `data-fit`, and + * each of its label cells a `data-col` size class sized to the longest run of + * text in that column that cannot be broken. The label column holding the most + * text also gets `data-grow`, marking it as the one to give any spare width to. The prose column is left + * without one: in a fixed layout it takes whatever the stated columns do not, + * which is exactly what it should have. Absence of a class is what identifies + * it, so no second attribute has to agree with the first. + * + * A table that is any other shape gets no attributes at all. Marking one up and + * leaving the theme to opt out in CSS was worse: a width meant for a stated + * layout leaks into automatic layout as a preference, and a field-and-value pair + * came out with a narrower label column than the untagged tables beside it. + * + * One threshold does all the deciding. A column with a cell of at least + * PROSE_MIN_CHARS can hold prose; every other column is a column of labels, + * whatever the length of its longest label. A table qualifies when exactly one + * of its columns clears that line and at least two do not. + */ +const rehypeTableColumns: Plugin<[], Root> = () => { + return tree => { + visit(tree, 'element', (table: Element) => { + if (table.tagName !== 'table') return + + const rows: Element[] = [] + visit(table, 'element', (node: Element) => { + if (node.tagName === 'tr') rows.push(node) + }) + if (rows.length < 2) return + + const grid: Element[][] = [] + for (const row of rows) { + const cells = cellsOf(row) + // A spanning cell means the columns below it are not a straight grid, + // and every measurement here assumes one cell per column per row. + if (!cells) return + grid.push(cells) + } + + const columns = grid[0].length + if (columns < 2) return + if (grid.some(row => row.length !== columns)) return + + // The first row is the header — its text counts towards what a column has + // to fit, because a one-word heading over a column of flags is usually the + // widest thing in it, but not towards how much the column carries. + const [header, ...body] = grid + if (body.length === 0) return + + const stats: ColumnStats[] = [] + for (let column = 0; column < columns; column++) { + const texts = body.map(row => toText(row[column]).trim()) + const headerText = toText(header[column]).trim() + stats.push({ + longest: Math.max(headerText.length, ...texts.map(text => text.length)), + run: Math.max( + Math.ceil(longestRun(headerText) * HEADER_CHAR_RATIO), + ...texts.map(longestRun) + ), + }) + } + + // Exactly one column may hold prose. Two would each need a paragraph's room + // and there is only one remainder to give away, so such a table is left to + // size itself and scroll — as is one with no prose column at all, which + // automatic layout already serves. + const proseColumns = stats.filter(column => column.longest >= PROSE_MIN_CHARS) + if (proseColumns.length !== 1) return + const proseColumn = stats.indexOf(proseColumns[0]) + + // Every other column is a column of labels, and its class is the width its + // longest line needs. Nothing is marked up unless the whole table is a + // shape a theme can state widths for, so automatic layout is left + // completely alone everywhere else. + if (columns - 1 < MIN_LABEL_COLUMNS) return + const classes = stats.map((column, index) => + index === proseColumn ? null : sizeClass(column.run) + ) + + // One label column is marked to take whatever the others leave. A theme + // that folds the prose column away frees most of the table's width, and + // without somewhere to send it that room pools in whichever column the + // theme left unsized — on a three-column table that was 77% of the sheet + // standing empty beside a name wrapped over five lines. The column + // carrying the most text is the one that can use it. + let growColumn = -1 + for (let column = 0; column < columns; column++) { + if (classes[column] === null) continue + // `>=` so that a later column wins a tie, where the labels tend to run + // from short codes towards longer names. + if (growColumn === -1 || stats[column].longest >= stats[growColumn].longest) { + growColumn = column + } + } + + table.properties['data-fit'] = 'stated' + for (const row of grid) { + for (let column = 0; column < columns; column++) { + const name = classes[column] + if (name) row[column].properties['data-col'] = name + if (name && column === growColumn) row[column].properties['data-grow'] = 'true' + // The prose column gets seams too: its width is the remainder, which + // on a crowded table is narrow enough to break an identifier. + addBreakOpportunities(row[column]) + } + } + }) + } +} + +export default rehypeTableColumns diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 17cf761e..c377bd9a 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -320,6 +320,18 @@ export async function getPageNav(slug: string[]): Promise { return navMap.get(url) ?? { prev: null, next: null }; } +/** + * A frontmatter list of plain strings, or undefined. Anything that is not a + * string is dropped rather than rendered: this comes straight from a content + * file, where a single mistyped entry should not reach a theme as `[object + * Object]`. + */ +function normalizeStringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const items = value.filter((item): item is string => typeof item === 'string' && item.trim() !== '') + return items.length > 0 ? items : undefined +} + export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter { const d = page.data as Record; return { @@ -328,6 +340,7 @@ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: stri order: d.order as number | undefined, icon: d.icon as string | undefined, lastModified: d.lastModified as string | undefined, + identifiers: normalizeStringList(d.identifiers), authors: normalizeAuthorList(d.authors), draft: d.draft as boolean | undefined, _readingTime: d._readingTime as number | undefined, diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 1583c7df..5da531c7 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -14,6 +14,7 @@ import remarkReadingTime from 'remark-reading-time'; import remarkUnusedDirectives from '../lib/remark-unused-directives'; import type { Pluggable } from 'unified'; import remarkValidateMdx from '../lib/remark-validate-mdx'; +import rehypeTableColumns from '../lib/rehype-table-columns'; import rehypeTocText from '../lib/rehype-toc-text'; // Literal names rather than `string`: Nitro types the connector as db0's @@ -214,6 +215,12 @@ export async function createViteConfig( rehypeCodeOptions: { ...rehypeCodeDefaultOptions, fallbackLanguage: 'text', + // Puts `language-` on the `code` element, which is the only + // thing in the rendered markup that says what a block is. A theme + // needs it to tell a listing from a drawing: a fence with no + // language highlights as `plaintext`, and fanfold hangs line + // numbers off everything except that. + addLanguageClass: true, }, // Swap fumadocs' rehypeToc for a text-only toc: it exports heading // content as JSX evaluated at module scope, so any component in a @@ -222,6 +229,7 @@ export async function createViteConfig( rehypePlugins: (plugins: Pluggable[]) => [ ...plugins.filter(plugin => !isRehypeToc(plugin)), rehypeTocText, + rehypeTableColumns, ], remarkPlugins: [ remarkDirective, diff --git a/packages/chronicle/src/themes/fanfold/ExpandableRow.tsx b/packages/chronicle/src/themes/fanfold/ExpandableRow.tsx new file mode 100644 index 00000000..8ce84d9e --- /dev/null +++ b/packages/chronicle/src/themes/fanfold/ExpandableRow.tsx @@ -0,0 +1,86 @@ +'use client' + +import { type ReactNode, useEffect, useId, useRef, useState } from 'react' + +interface ExpandableRowProps { + /** The row's label cells, already rendered, in source order. */ + labels: ReactNode[] + /** Contents of the prose cell, shown only while the row is open. */ + notes: ReactNode +} + +/** + * One row of a conformance table, with its paragraph folded away. + * + * A stated-width table gives its prose column whatever the label columns leave, + * which on a seven-column table is under 200px — a measure of about 23 + * characters, and rows tall enough that the reader loses the row they are on + * partway down it. Folding the paragraph under the row gives it the width of the + * whole sheet when it is wanted and takes it out of the way when it is not, and + * the labels above keep the geometry they already had. + * + * The row stays a real row of a real table: the paragraph is a second `tr` + * spanning every column rather than a panel beside the table, so the columns go + * on lining up and a screen reader still reads the thing as a table. + */ +export function FanfoldExpandableRow({ labels, notes }: ExpandableRowProps) { + const [open, setOpen] = useState(false) + const notesId = useId() + const notesRow = useRef(null) + + // `hidden="until-found"` lets the browser open a folded row when the reader + // searches the page for something inside it. It has to be set here rather than + // in the markup below because React holds `hidden` as a boolean attribute and + // writes any value of it out as a bare `hidden`, losing the keyword. + // + // Rewriting it after every render is safe: React only touches an attribute + // when its prop changes, and `hidden={!open}` does not change while the row + // stays shut, so nothing here is undone until the row opens. + useEffect(() => { + const row = notesRow.current + if (!row || open) return + row.setAttribute('hidden', 'until-found') + }, [open]) + + // The browser drops the attribute itself when a search matches inside the row, + // so React has to be told, or its next render would hide the match again. + useEffect(() => { + const row = notesRow.current + if (!row) return + const reveal = () => setOpen(true) + row.addEventListener('beforematch', reveal) + return () => row.removeEventListener('beforematch', reveal) + }, []) + + return ( + <> + + {labels} + + + + + + {/* One more than the labels, for the column the toggle sits in. */} + +
{notes}
+ + + + ) +} diff --git a/packages/chronicle/src/themes/fanfold/Layout.module.css b/packages/chronicle/src/themes/fanfold/Layout.module.css index 87dc7968..384c377e 100644 --- a/packages/chronicle/src/themes/fanfold/Layout.module.css +++ b/packages/chronicle/src/themes/fanfold/Layout.module.css @@ -42,15 +42,25 @@ --fan-display: "Doto", "Geist Mono", ui-monospace, monospace; --fan-head: "Departure Mono", "Geist Mono", ui-monospace, monospace; - /* 1600 = two 44px feed strips, two 240px rails, a 904px column of type and a - 64px gutter either side of it. Past that the paper stops growing, so the - rails never drift away from what they describe. */ - max-width: 1600px; - /* The gap sits above the paper, not inside it, so the sheet — bands, feed - strips and all — starts a little down the window and reads as a page laid - on a surface rather than one running off the top of the screen. */ - margin: var(--fan-page-top) auto 0; - min-height: calc(100vh - var(--fan-page-top)); + /* 1416 = two 44px feed strips, two 240px rails, the 720px sheet and a 64px + gutter either side of it. Past that the paper stops growing, so the rails + never drift away from what they describe. + + Keep this in step with the sheet's own cap in `Page.module.css`: the sheet + is centred in what the rails leave, so a figure too large here does not + widen anything, it just parks the rails further from the type — at 1600, + which is what this was while the sheet was 904px, the gutters came out at + 155px. */ + max-width: 1416px; + /* The gap sits around the paper, not inside it, so the sheet — bands, feed + strips and all — reads as a page laid on a surface rather than one running + off the edge of the screen. The same gap below as above, so scrolling to + the end of a long page reaches the foot of the paper rather than a cut. + + Everything that subtracts this has to subtract it twice, or the two margins + push a short page past the viewport and raise a scrollbar over nothing. */ + margin: var(--fan-page-top) auto; + min-height: calc(100vh - var(--fan-page-top) * 2); color: var(--fan-ink); font-family: var(--fan-mono); -webkit-font-smoothing: antialiased; @@ -138,11 +148,20 @@ centred, and a `background-attachment: fixed` gradient tiles from the window edge, not the paper's, so the perforations would fall outside the sheet. Sticky keeps them still while they stay in flow beside it. */ +/* Stretched to the paper rather than held a viewport tall and stuck. Sticky at + `height: 100vh` covered the window while scrolling but could not know where + the paper ended: on a page shorter than the window the strip's own box ran + past the foot of the sheet and put a scrollbar over nothing, and at the end of + a long page it carried the feed holes down into the gap below the paper. A + `margin-bottom` of minus one page gap used to hide the first of those, and it + never quite did. + + Stretching solves both by construction. The strip is exactly as tall as the + sheet beside it, so the holes run the full length of the paper and stop with + it, and a strip that always matches its container has nothing to stick to. */ .strip { - position: sticky; - top: 0; + align-self: stretch; width: var(--fan-strip); - height: 100vh; flex-shrink: 0; background-image: radial-gradient( circle at 22px 20px, @@ -151,10 +170,6 @@ ); background-size: var(--fan-strip) 40px; background-repeat: repeat-y; - /* The strip is a viewport tall so it still fills the window once stuck. The - negative margin stops that height from adding the page's top margin back on - as overflow at the bottom of a short page. */ - margin-bottom: calc(var(--fan-page-top) * -1); } /* The zebra sits between the two feed strips and scrolls with the page. */ @@ -171,7 +186,7 @@ transparent var(--fan-bar-height), transparent calc(var(--fan-bar-height) * 2) ); - min-height: calc(100vh - var(--fan-page-top)); + min-height: calc(100vh - var(--fan-page-top) * 2); } .rail { @@ -458,21 +473,45 @@ a.navSubLabel:hover { gap: var(--rs-space-2); } -/* SidebarLinks brings its own list markup; this only has to line it up with the - rest of the footer column. */ -.railFooterLinks { +/* SidebarLinks renders Apsara's own sidebar items, which arrive at 16px with + 8px of indent — sized for a nav rail rather than for this footer, so they sat + larger than and offset from the links above them. + + The label inside each one has to be restyled as well as the anchor around it. + Apsara nests the text in its own span, and that span declares its own colour + and a 500 weight, so anything set on the anchor alone left the label reading + darker and heavier than every other link in the column. + + Restyled by descendant rather than by class: Apsara's class names are hashed, + so a theme cannot name them. The values match `.railFooterLink` below, which + is what these sit beside. */ +.railFooterLinks, +.railFooterLinks > div { display: flex; flex-direction: column; + /* Apsara's own wrapper sits between this and the links, so the footer's gap + never reached them and the two rows bunched up under the ones above. */ + gap: var(--rs-space-2); } -.railFooterToggle { - justify-content: flex-start; +.railFooterLinks a, +.railFooterLinks a * { + min-height: 0; + height: auto; + padding: 0; + border-radius: 0; + background: none; font-size: 12px; + font-weight: 400; line-height: 20px; + letter-spacing: 0; color: var(--fan-ink-3); + text-decoration: none; } -.railFooterToggle:hover { +.railFooterLinks a:hover, +.railFooterLinks a:hover * { + background: none; color: var(--fan-ink); } @@ -531,6 +570,26 @@ a.navSubLabel:hover { color: var(--fan-ink); } +/* Declared after `.iconButton` on purpose. The toggle carries both classes, and + `.iconButton` styles a real icon button — centred, and a step darker than the + links this one sits beside. At equal specificity the later rule wins, so while + this block sat above it every override here was dead: the label came out + centred in the rail and in the wrong ink. + + `align-self` also does a job `justify-content` could not. The rail is a flex + column, so the button was being stretched to its full 200px; shrinking it to + its label is what actually puts the text under the links. */ +.railFooterToggle { + align-self: flex-start; + font-size: 12px; + line-height: 20px; + color: var(--fan-ink-3); +} + +.railFooterToggle:hover { + color: var(--fan-ink); +} + .mobileMenu { display: none; } diff --git a/packages/chronicle/src/themes/fanfold/Layout.tsx b/packages/chronicle/src/themes/fanfold/Layout.tsx index 02fac2c4..efe3e1d9 100644 --- a/packages/chronicle/src/themes/fanfold/Layout.tsx +++ b/packages/chronicle/src/themes/fanfold/Layout.tsx @@ -44,6 +44,14 @@ const WEB_FONTS = * `navigation.social` often points at the same repository as a * `navigation.links` entry, so the list is deduplicated by destination — the * first spelling of a URL wins. + * + * `config.links` is deduplicated against as well, even though it is rendered + * elsewhere. A site that names its repository under both keys — a `social` + * entry for the icon and a footer link for the label — was getting it twice in + * one column, once as "github" and once as "GitHub". Neither component could + * see that on its own, since each is handed only its own list. The rail is the + * one that yields: `SidebarLinks` adds UTM parameters and routes relative hrefs + * through the router, so its copy is the one worth keeping. */ function useRailLinks() { const { config } = usePageContext(); @@ -54,9 +62,10 @@ function useRailLinks() { href: s.href })) ]; - const seen = new Set(); + const canonical = (href: string) => href.replace(/\/+$/, ''); + const seen = new Set((config.links ?? []).map(link => canonical(link.href))); return all.filter(link => { - const key = link.href.replace(/\/+$/, ''); + const key = canonical(link.href); if (seen.has(key)) return false; seen.add(key); return true; diff --git a/packages/chronicle/src/themes/fanfold/Page.module.css b/packages/chronicle/src/themes/fanfold/Page.module.css index 09d9ed29..68573a49 100644 --- a/packages/chronicle/src/themes/fanfold/Page.module.css +++ b/packages/chronicle/src/themes/fanfold/Page.module.css @@ -3,15 +3,33 @@ align-items: flex-start; } -/* The rails are pinned to the window edges, so the column of type is capped and +/* 720px is 672px of content between the two 24px gutters, and 672px is exactly + 80 characters of body type — a monospace advance is a flat 0.6em, so 80 x 14 x + 0.6. Eighty columns is the carriage this whole theme is an impression of, and + the paper being that wide is what lets one measure serve everything on it. + + The rails are pinned to the window edges, so the column of type is capped and centred in what is left between them. Auto inline margins on a flex item soak up the leftover space, which keeps the type centred on screen because the two rails are the same width. */ .sheet { flex: 1; min-width: 0; - max-width: 904px; + max-width: 720px; margin-inline: auto; + + /* Every run of prose, every table and every code block is held to this, so + they share one right edge with the star rule, the meta lines and the title + above them. A line printer had no way to set a paragraph narrower than a + table, and a theme drawn as printer output reads wrong when it does: the + paper looks wider than the report on it. + + `100%` is the width of whichever band the content sits in, which is the + 672px above. Both the alternatives were tried: holding prose to 600px left + the furniture running 120px past every paragraph, and widening the paper + instead of narrowing it put 86 characters on a line. Change the two numbers + together — this and the cap above — and everything follows. */ + --fan-measure: 100%; } /* ---- header band: the strip a printer lays down before the report ---- */ @@ -75,7 +93,7 @@ /* The lede stays a step above body copy. */ .subtitle { margin: 18px 0 0; - max-width: 620px; + max-width: var(--fan-measure); font-size: 15px; line-height: 26px; color: var(--fan-ink-2); @@ -116,13 +134,11 @@ letter-spacing: 0; } -/* 600px is 71 characters at 14px, since a monospace advance is a flat 0.6em. - Holding the old 680px would have run the line to 81. */ .article p, .article ul, .article ol, .article dl { - max-width: 600px; + max-width: var(--fan-measure); margin: 0 0 var(--rs-space-6); } @@ -211,7 +227,7 @@ .article blockquote { margin: 0 0 var(--rs-space-6); - max-width: 600px; + max-width: var(--fan-measure); padding-left: var(--rs-space-6); border-left: 1px solid var(--fan-rule-strong); color: var(--fan-ink-2); @@ -229,7 +245,7 @@ inner elements share the same class prefix, hence the descendant reset. */ .article div[aria-live] { - max-width: 600px; + max-width: var(--fan-measure); margin: 0 0 var(--rs-space-6); padding: var(--rs-space-4) 0 var(--rs-space-4) 18px; border: 0; @@ -333,6 +349,7 @@ .article pre { margin: 0; + max-width: var(--fan-measure); padding: var(--rs-space-4) 0; background: none; border: 0; @@ -371,6 +388,21 @@ min-height: 22px; } +/* A fence with no language is a drawing, not a listing — an ASCII diagram, a + tree, a captured terminal — and nobody cites a line of it by number. The + numbers also push the whole thing in by 3ch plus a gutter, which throws a + diagram's own alignment out and makes it read as source. + + `plaintext` is the signal, and it is a narrower one than it looks: fumadocs + highlights a bare fence with its `defaultLanguage`, which is `plaintext`, + while a fence naming a language nothing can highlight falls back to `text`. + So an author who wrote nothing gets no numbers, and one who wrote + ```logql keeps them. The counter still counts; only the digits are dropped, + so nothing downstream has to know about this. */ +.article pre code:global(.language-plaintext) :global(.line)::before { + display: none; +} + /* Right-aligned so the digits stay in a column once a listing passes line 99. */ .article pre code :global(.line)::before { content: counter(fan-line, decimal-leading-zero); @@ -402,7 +434,7 @@ .article table { width: 100%; - max-width: 100%; + max-width: var(--fan-measure); margin: 26px 0; border-collapse: collapse; background: none; @@ -469,13 +501,206 @@ color: var(--fan-ink); } -/* The field-name column is the spine of a field map. Auto table layout shares - width by content, so a long notes column would otherwise break short names - like "Packet Version Number" across three lines. A plain length is used - because Chrome ignores a table cell's min-width once a percentage is in it, - which is why the narrow-screen value is set in the media query below. */ +/* The field-name column is the spine of a field map, so it asks for the room a + name like "Packet Version Number" needs rather than letting a long notes + column squeeze it to a word per line. + + `width` rather than `min-width`: in auto table layout a width is the column's + preference, not a floor, so the column yields it back when the row cannot + afford it. A floor could not be overruled, and a conformance table whose first + column holds an item code like "SPP-14" was handing six characters 202px and + pushing the table 370px past the sheet. The same declaration now caps the + column too — auto layout will not grow a column past a width it was given + unless the content leaves it no choice. + + The 8ch floor is there because auto layout, given the chance, shrinks the + column to its longest unbreakable run — and an item code like "SPP-14" has a + break opportunity at the hyphen, so it was collapsing to the width of "SPP-" + and setting the code on two lines. + + A plain length is used because Chrome ignores a table cell's width once a + percentage is in it, which is why the narrow-screen value is set in the media + query below. */ .article :is(th, td):first-child { - min-width: 24ch; + min-width: 8ch; + width: 24ch; +} + +/* A table with a column of paragraphs is a different animal from a field map, + and `rehype-table-columns` is what tells them apart: it measures every column + at build time and marks the table with the index of the one holding prose, + giving each other cell a size class from its own longest line. CSS cannot ask + how much text is in a column, so without that the only thing to key off was + the column count — which said nothing about the table's shape. + + Automatic layout hands the paragraph column whatever it asks for: 616px in one + conformance table, which ran the table 238px past the sheet and hid the end of + every line in it, with nothing on screen to say so. It also sizes each table + from that table's own content, so no two on a page line up. + + Stating the widths fixes both. The short columns are told what they need, the + prose column is deliberately left without a width so that it takes whatever + they do not, and `width: 100%` means the table can no longer be wider than the + sheet, so nothing is hidden. + + The plugin marks a table only when its whole shape is one that can be stated + this way — a single column of prose beside two or more of labels — so the + attribute is the entire condition here, and every other table is left to + automatic layout untouched. */ +.article table[data-fit="stated"] { + table-layout: fixed; + width: 100%; +} + +/* The size classes, in the type they are sizing. A class is the longest line the + column has to hold, so these are the widths that line needs — the flags at the + bottom must not wrap at all, while a column of names may take two lines and is + given less than its longest entry would need on one. + + The widths are px rather than ch because fixed layout reads them off the header + row, where `ch` resolves against the header's 10.5px type instead of the 14px + of the cells below — 16ch there measured 101px, not the 134px meant. + + They are also tighter than each class could use, because what the label + columns leave over is the prose column's whole measure. On a seven-column + table the first values tried here left it 156px; these leave it 230px and take + 4% off the height of the page, at the cost of a second line in some of the + longer names. Nothing is broken mid-word to get there: the clause references + still set on one line, and every label that wraps does so at a space. */ +.article table[data-fit="stated"] :is(th, td)[data-col="xl"] { + width: 202px; +} + +.article table[data-fit="stated"] :is(th, td)[data-col="l"] { + width: 160px; +} + +.article table[data-fit="stated"] :is(th, td)[data-col="m"] { + width: 126px; +} + +.article table[data-fit="stated"] :is(th, td)[data-col="s"] { + width: 102px; +} + +/* Wider than the values need, and with the padding trimmed, because the header + words are what has to fit: at the standard 16px of cell padding these columns + were breaking "STATUS" and "SUPPORT" mid-word into STAT/US and SUPP/ORT. The + values below them run to a character or three, so the padding was not earning + its keep there anyway. */ +.article table[data-fit="stated"] :is(th, td)[data-col="xs"] { + width: 64px; + padding-right: 4px; +} + +/* One label column takes whatever the others leave, marked at build time as the + one holding the most text. + + Without it the spare width pooled wherever a column had no stated width, which + here is the toggle's: on a three-column table 554px of the 720px sheet stood + empty beside a name wrapping over five lines. The class widths above are the + minimum each column needs to hold its longest word, which was the right + measure while the paragraph column was competing for the same pixels and is + simply too tight now that it is folded away. + + This rule has to follow the class widths it overrides — the two selectors have + the same specificity, so their order is what settles it. */ +.article table[data-fit="stated"] :is(th, td)[data-grow] { + width: auto; +} + +/* ---- folded notes ---- */ + +/* The paragraph column of a stated table is folded under its row and opened on + demand (`ExpandableRow.tsx`). Data attributes rather than classes because CSS + modules rewrite class names but leave attributes alone, so a component in the + theme can hand its markup over to be styled from here. + + Styling has to live in this file rather than beside the component: the rules + above are scoped `.article td`, which outranks a bare class from another + module, so a class there could not set so much as the cell's padding. */ + +/* No stated width, so this column takes whatever the labels leave and the + toggle sits against the right edge of the sheet. */ +/* Stated, so this column no longer soaks up every spare pixel. What it does take + comes off the paragraph column, the one column of a stated table with nothing + to spare, so it is only as wide as the word beside the chevron needs. 96px is + affordable at 672px of paper: it leaves the narrowest paragraph column in the + corpus 156px, still wider than that column's own longest word. On a narrower + sheet the word would have to go and the chevron carry the column alone. */ +.article table[data-fit="stated"] :is(th, td)[data-fanfold-toggle] { + width: 96px; + text-align: right; + white-space: nowrap; +} + +.article [data-fanfold-toggle] button { + display: inline-flex; + align-items: baseline; + gap: 7px; + margin: 0; + padding: 0; + border: 0; + background: none; + font-family: var(--fan-mono); + font-size: 10.5px; + line-height: 18px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fan-ink-3); + cursor: pointer; +} + +.article [data-fanfold-toggle] button:hover { + color: var(--fan-ink); +} + +/* A dashed ring matches the rules the rest of the sheet is ruled with. */ +.article [data-fanfold-toggle] button:focus-visible { + outline: 1px dashed var(--fan-rule-strong); + outline-offset: 3px; +} + +.article [data-fanfold-chevron] { + display: inline-block; + transition: transform 120ms ease; +} + +/* Reduced motion turns the sweep off; the rotation still happens, so the state + of the row is never carried by the animation alone. */ +@media (prefers-reduced-motion: reduce) { + .article [data-fanfold-chevron] { + transition: none; + } +} + +.article [data-fanfold-summary][data-open] [data-fanfold-chevron] { + transform: rotate(90deg); +} + +/* An open row is marked at both ends: the labels take the bar tint, and the + paragraph below carries a rule down its left the way a quote does. */ +.article [data-fanfold-summary][data-open] > :is(th, td) { + background: var(--fan-bar); +} + +.article [data-fanfold-notes] > td { + padding: 0 0 20px; + border: 0; + background: var(--fan-bar); +} + +/* The same measure body copy uses, so an opened note reads at the line length of + the prose around the table. */ +.article [data-fanfold-notes] > td > div { + max-width: var(--fan-measure); + margin-left: var(--rs-space-5); + padding: 2px 0 0 var(--rs-space-5); + border-left: 1px dashed var(--fan-rule-strong); + font-size: 14px; + line-height: 24px; + letter-spacing: 0; + color: var(--fan-ink); } /* ---- footer band ---- */ @@ -640,7 +865,14 @@ } .article :is(th, td):first-child { - min-width: 10ch; + width: 10ch; + } + + /* Narrower than the short columns add up to, so the stated widths are dropped + and a wide table goes back to sizing itself and scrolling inside the + article, which is steadier than six columns all at their minimum. */ + .article table[data-fit="stated"] { + table-layout: auto; } .display[data-size="code"] { diff --git a/packages/chronicle/src/themes/fanfold/Page.tsx b/packages/chronicle/src/themes/fanfold/Page.tsx index c69ac1f0..9ea0535a 100644 --- a/packages/chronicle/src/themes/fanfold/Page.tsx +++ b/packages/chronicle/src/themes/fanfold/Page.tsx @@ -2,11 +2,13 @@ import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb'; import { flattenTree } from 'fumadocs-core/page-tree'; +import type { Node } from 'fumadocs-core/page-tree'; import { useMemo } from 'react'; import { Link as RouterLink, useLocation } from 'react-router'; import { AuthorByline } from '@/components/common/author-byline'; import { getActiveContentDir } from '@/lib/navigation'; import { usePageContext } from '@/lib/page-context'; +import { NodeType, shortName } from '@/lib/tree-utils'; import { filterPageTreeByContentDir, filterPageTreeByVersion @@ -21,6 +23,36 @@ export const STARS = '*'.repeat(400); const pad = (n: number) => String(n).padStart(2, '0'); +/** + * Every `short` in a tree, by the URL that carries it. + * + * The printed trail wants the codes a reader already uses — "TRANSPORT / SPP" — + * where `getBreadcrumbItems` hands back full names, because it is fumadocs' own + * and knows nothing about `short`. The tree does: `attachShortNames` puts one on + * every page node and on each folder's index. Collecting them here rather than + * teaching the shared breadcrumb helper keeps the other two themes, which render + * their breadcrumbs from it and never use `short`, exactly as they were. + */ +function collectShortNames(nodes: Node[], into = new Map()) { + for (const node of nodes) { + if (node.type === NodeType.Folder) { + // A folder's own name comes from `meta.json`; its short, if any, is on the + // index page the crumb actually links to. + if (node.index) { + const short = shortName(node.index); + if (short) into.set(node.index.url, short); + } + collectShortNames(node.children, into); + continue; + } + if (node.type !== NodeType.Page) continue; + const short = shortName(node); + if (short) into.set(node.url, short); + } + return into; +} + + /** * A title this short is a code or a command — `SPP`, `XTCE`, `astro spp` — and * gets the masthead. Tune this and nothing else: it is the only place the @@ -64,12 +96,17 @@ export function Page({ page, config, tree }: ThemePageProps) { return holdsThisPage ? scoped : versioned; }, [tree, version, config, contentDir, pathname]); + const shorts = useMemo( + () => collectShortNames(sectionTree.children), + [sectionTree] + ); + const crumbs = useMemo( () => getBreadcrumbItems(pathname, sectionTree, { includePage: true }).map( - item => item.name + item => (item.url ? shorts.get(item.url) : undefined) ?? item.name ), - [pathname, sectionTree] + [pathname, sectionTree, shorts] ); // "PAGE 03 / 22" — where this page falls in the section being read. @@ -83,6 +120,15 @@ export function Page({ page, config, tree }: ThemePageProps) { const title = page.frontmatter.title ?? ''; const trail = [section, ...crumbs].filter(Boolean).join(' / '); + + /** + * The lines under the trail. A page that states its own identifiers — the + * standard it implements, the package, the command — has more to print here + * than the theme can work out for itself, so those win. Without them the + * header still fills: what site this is, what section, and where the page + * sits, which is all a theme can know on its own. + */ + const identifiers = page.frontmatter.identifiers ?? []; const counter = index > 0 ? `PAGE ${pad(index)} / ${pad(total)}` : null; return ( @@ -96,11 +142,21 @@ export function Page({ page, config, tree }: ThemePageProps) { ** {trail} {counter ? ` * ${counter}` : ''} -
- ** {config.site.title} - {section ? ` * ${section}` : ''} -
-
** {pathname}
+ {identifiers.length ? ( + identifiers.map(line => ( +
+ ** {line} +
+ )) + ) : ( + <> +
+ ** {config.site.title} + {section ? ` * ${section}` : ''} +
+
** {pathname}
+ + )}
diff --git a/packages/chronicle/src/types/content.ts b/packages/chronicle/src/types/content.ts index 621b2956..6b910f3e 100644 --- a/packages/chronicle/src/types/content.ts +++ b/packages/chronicle/src/types/content.ts @@ -27,6 +27,15 @@ export interface Frontmatter { */ short?: string description?: string + /** + * Extra identifying lines for this page — a standard's number, a package + * path, a command. Printed verbatim by a theme with somewhere to put them, + * and ignored by the ones without, so nothing here is required. + * + * Named `identifiers` rather than `meta` because `meta.json` already means a + * directory's metadata, and the two would read as the same thing. + */ + identifiers?: string[] order?: number icon?: string lastModified?: string