From 2fbe443e9ee520ccfd3fcb6eb00855f116666f68 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:07:07 +0100 Subject: [PATCH 1/7] refactor(web): read Markdown in one module, which now also takes tables, a code block's language and links within the text --- web/src/lib/markdown.test.ts | 131 +++++++++++++++ web/src/lib/markdown.ts | 255 ++++++++++++++++++++++++++++++ web/src/updates/Markdown.test.tsx | 69 ++------ web/src/updates/Markdown.tsx | 231 ++++----------------------- 4 files changed, 432 insertions(+), 254 deletions(-) create mode 100644 web/src/lib/markdown.test.ts create mode 100644 web/src/lib/markdown.ts diff --git a/web/src/lib/markdown.test.ts b/web/src/lib/markdown.test.ts new file mode 100644 index 00000000..65f8a218 --- /dev/null +++ b/web/src/lib/markdown.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import { parseBlocks, parseInline, plainText } from './markdown'; + +const NOTES = [ + 'Observer 0.2.0, built from 0123456789ab.', + '', + '- `observer-0.2.0-windows-x86_64.zip`: Windows 10 or Server 2016 and later, x64.', + '- `SHA256SUMS`: check the archives with `sha256sum -c SHA256SUMS`.', + '', + "## What's Changed", + '* Install updates from the panel by @someone in https://github.com/SensityTech/Observer/pull/45', + '', + '**Full Changelog**: https://github.com/SensityTech/Observer/compare/v0.1.0...v0.2.0', +].join('\r\n'); + +describe('parseBlocks', () => { + it('reads the paragraphs, lists and headings of release notes', () => { + expect(parseBlocks(NOTES).map((block) => block.kind)).toMatchObject([ + 'paragraph', + 'list', + 'heading', + 'list', + 'paragraph', + ]); + }); + + it('keeps a fenced block as it is, with the language it names, and joins the lines of a paragraph', () => { + const blocks = parseBlocks('one\ntwo\n\n```lua\n# not a heading\n```\n1. first\n2. second'); + + expect(blocks).toMatchObject([ + { kind: 'paragraph', text: 'one two' }, + { kind: 'code', text: '# not a heading', lang: 'lua' }, + { kind: 'list', ordered: true, items: [{ text: 'first' }, { text: 'second' }] }, + ]); + }); + + it('reads a table, a bar inside a code span staying in its cell', () => { + const blocks = parseBlocks( + [ + 'Before.', + '| Field | Type | Meaning |', + '|---|:--|---|', + '| `at` | integer | When it happens. |', + '| `kind` | string | `a|b`, or nothing |', + '| `short` |', + 'After.', + ].join('\n'), + ); + + expect(blocks).toMatchObject([ + { kind: 'paragraph', text: 'Before.' }, + { + kind: 'table', + head: [{ text: 'Field' }, { text: 'Type' }, { text: 'Meaning' }], + rows: [ + { cells: [{ text: '`at`' }, { text: 'integer' }, { text: 'When it happens.' }] }, + { cells: [{ text: '`kind`' }, { text: 'string' }, { text: '`a|b`, or nothing' }] }, + { cells: [{ text: '`short`' }, { text: '' }, { text: '' }] }, + ], + }, + { kind: 'paragraph', text: 'After.' }, + ]); + }); + + it('takes lines between bars for text when no rule follows them', () => { + expect(parseBlocks('| not | a table |\nnext')).toMatchObject([ + { kind: 'paragraph', text: '| not | a table | next' }, + ]); + }); +}); + +describe('parseInline', () => { + it('reads code, emphasis and links', () => { + expect(parseInline('**Full** `code` *mild* [docs](https://example.com/a)')).toMatchObject([ + { kind: 'strong', children: [{ kind: 'text', text: 'Full' }] }, + { kind: 'text', text: ' ' }, + { kind: 'code', text: 'code' }, + { kind: 'text', text: ' ' }, + { kind: 'em', children: [{ kind: 'text', text: 'mild' }] }, + { kind: 'text', text: ' ' }, + { + kind: 'link', + href: 'https://example.com/a', + children: [{ kind: 'text', text: 'docs' }], + }, + ]); + }); + + it('never makes a link of an address that is not http or https', () => { + expect(parseInline('[click](javascript:alert(1))')).toMatchObject([{ kind: 'text', text: 'click)' }]); + expect(parseInline('[file](file:///etc/passwd)')).toMatchObject([{ kind: 'text', text: 'file' }]); + expect(parseInline('[elsewhere](//example.com/a)')).toMatchObject([ + { kind: 'text', text: 'elsewhere' }, + ]); + }); + + it('tells a place in the same text and a path beside it from an address', () => { + expect(parseInline('See [Bans](#bans) and [`observer_example`](observer_example).')).toMatchObject([ + { kind: 'text', text: 'See ' }, + { kind: 'local', target: '#bans', children: [{ kind: 'text', text: 'Bans' }] }, + { kind: 'text', text: ' and ' }, + { + kind: 'local', + target: 'observer_example', + children: [{ kind: 'code', text: 'observer_example' }], + }, + { kind: 'text', text: '.' }, + ]); + }); + + it('leaves a snake_case name alone and a bare address without its last stop', () => { + expect(parseInline('observer_bridge and https://example.com/x.')).toMatchObject([ + { kind: 'text', text: 'observer_bridge and ' }, + { + kind: 'link', + href: 'https://example.com/x', + children: [{ kind: 'text', text: 'https://example.com/x' }], + }, + { kind: 'text', text: '.' }, + ]); + }); +}); + +describe('plainText', () => { + it('says a text without its markup', () => { + expect(plainText(parseInline('`observer:shuttingDown` is **raised** [here](#events)'))).toBe( + 'observer:shuttingDown is raised here', + ); + }); +}); diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts new file mode 100644 index 00000000..a76f284a --- /dev/null +++ b/web/src/lib/markdown.ts @@ -0,0 +1,255 @@ +// Markdown as the panel reads it, for release notes and the documentation: headings, lists, +// paragraphs, fenced code, tables, emphasis and links. What it yields is drawn as React elements, +// never as HTML. + +/** Each part carries its place in the text as its key: a text never moves once read. */ +export type Block = { key: string } & ( + | { kind: 'heading'; level: number; text: string } + | { kind: 'list'; ordered: boolean; items: { key: string; text: string }[] } + | { kind: 'code'; text: string; lang: string } + | { kind: 'table'; head: Cell[]; rows: { key: string; cells: Cell[] }[] } + | { kind: 'quote'; text: string } + | { kind: 'rule' } + | { kind: 'paragraph'; text: string } +); + +/** A cell of a table, in Markdown. */ +export type Cell = { key: string; text: string }; + +export type Inline = { key: string } & ( + | { kind: 'text'; text: string } + | { kind: 'code'; text: string } + | { kind: 'strong'; children: Inline[] } + | { kind: 'em'; children: Inline[] } + | { kind: 'link'; href: string; children: Inline[] } + /** A link inside the text's own place: `#section`, or a path beside it. Its reader decides where it leads. */ + | { kind: 'local'; target: string; children: Inline[] } +); + +type Unkeyed = T extends unknown ? Omit : never; + +const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/; +const BULLET = /^\s{0,3}[-*+]\s+(.*)$/; +const NUMBERED = /^\s{0,3}\d{1,9}[.)]\s+(.*)$/; +const FENCE = /^\s{0,3}(```|~~~)\s*([\w+-]*)/; +const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; +const QUOTE = /^\s{0,3}>\s?(.*)$/; +const TABLE_ROW = /^\s{0,3}\|.*\|\s*$/; +const TABLE_RULE = /^\s{0,3}\|(\s*:?-+:?\s*\|)+\s*$/; + +/** The cells of a table's line. A `|` inside a code span belongs to its cell. */ +const cellsOf = (line: string): string[] => { + const cells: string[] = []; + let cell = ''; + let code = false; + for (const char of line.trim().replace(/^\|/, '').replace(/\|$/, '')) { + if (char === '`') { + code = !code; + } + if (char === '|' && !code) { + cells.push(cell.trim()); + cell = ''; + } else { + cell += char; + } + } + cells.push(cell.trim()); + return cells; +}; + +export function parseBlocks(source: string): Block[] { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const blocks: Block[] = []; + const add = (block: Unkeyed) => blocks.push({ ...block, key: `b${blocks.length}` } as Block); + let paragraph: string[] = []; + const flush = () => { + if (paragraph.length > 0) { + add({ kind: 'paragraph', text: paragraph.join(' ') }); + paragraph = []; + } + }; + for (let at = 0; at < lines.length; at += 1) { + const line = lines[at] ?? ''; + if (line.trim() === '') { + flush(); + continue; + } + const fence = FENCE.exec(line); + if (fence !== null) { + flush(); + const marker = fence[1] ?? '```'; + const code: string[] = []; + at += 1; + while (at < lines.length && !(lines[at] ?? '').trimStart().startsWith(marker)) { + code.push(lines[at] ?? ''); + at += 1; + } + add({ kind: 'code', text: code.join('\n'), lang: fence[2] ?? '' }); + continue; + } + const heading = HEADING.exec(line); + if (heading !== null) { + flush(); + add({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' }); + continue; + } + if (TABLE_ROW.test(line) && TABLE_RULE.test(lines[at + 1] ?? '')) { + flush(); + const key = `b${blocks.length}`; + const head = cellsOf(line).map((text, index) => ({ key: `${key}.h${index}`, text })); + const rows: { key: string; cells: Cell[] }[] = []; + at += 2; + while (at < lines.length && TABLE_ROW.test(lines[at] ?? '')) { + const cells = cellsOf(lines[at] ?? ''); + const row = `${key}.${rows.length}`; + // As many cells as the head has, whatever the line holds. + rows.push({ + key: row, + cells: head.map((_, index) => ({ key: `${row}.${index}`, text: cells[index] ?? '' })), + }); + at += 1; + } + at -= 1; + add({ kind: 'table', head, rows }); + continue; + } + if (RULE.test(line)) { + flush(); + add({ kind: 'rule' }); + continue; + } + const item = BULLET.exec(line) ?? NUMBERED.exec(line); + if (item !== null) { + flush(); + const ordered = NUMBERED.test(line) && !BULLET.test(line); + const text = item[1] ?? ''; + const last = blocks.at(-1); + if (last?.kind === 'list' && last.ordered === ordered) { + last.items.push({ key: `${last.key}.${last.items.length}`, text }); + } else { + add({ kind: 'list', ordered, items: [{ key: `b${blocks.length}.0`, text }] }); + } + continue; + } + const quote = QUOTE.exec(line); + if (quote !== null) { + flush(); + const text = quote[1] ?? ''; + const last = blocks.at(-1); + if (last?.kind === 'quote') { + last.text = `${last.text} ${text}`; + } else { + add({ kind: 'quote', text }); + } + continue; + } + // A line that carries on the list item above it. + const last = blocks.at(-1); + if (paragraph.length === 0 && last?.kind === 'list' && /^\s{2,}\S/.test(line)) { + const carried = last.items.at(-1); + if (carried !== undefined) { + carried.text = `${carried.text} ${line.trim()}`; + } + continue; + } + paragraph.push(line.trim()); + } + flush(); + return blocks; +} + +const CODE = /^`([^`]+)`/; +const LINK = /^\[([^\]]+)\]\(([^)\s]+)\)/; +const ANGLED = /^<(https?:\/\/[^>\s]+)>/; +const BARE = /^https?:\/\/[^\s<>]+/; +const STRONG = /^(\*\*|__)(?=\S)(.+?)(?<=\S)\1/; +const EM = /^\*(?=[^\s*])(.+?)(?<=[^\s*])\*/; +// A place in the same text, or a path beside it: no scheme, so never an address of its own. +const LOCAL = /^(?:#[\w-]+|[\w-][\w./-]*(?:#[\w-]+)?)$/; + +export function parseInline(text: string): Inline[] { + const nodes: Inline[] = []; + const keyed = (node: Unkeyed) => nodes.push({ ...node, key: `i${nodes.length}` } as Inline); + let plain = ''; + const push = (node: Unkeyed) => { + if (plain !== '') { + keyed({ kind: 'text', text: plain }); + plain = ''; + } + keyed(node); + }; + let at = 0; + while (at < text.length) { + const rest = text.slice(at); + const char = text.charAt(at); + let match: RegExpExecArray | null = null; + if (char === '`' && (match = CODE.exec(rest)) !== null) { + push({ kind: 'code', text: match[1] ?? '' }); + } else if (char === '[' && (match = LINK.exec(rest)) !== null) { + const target = match[2] ?? ''; + const href = safeHref(target); + if (href !== null) { + push({ kind: 'link', href, children: parseInline(match[1] ?? '') }); + } else if (LOCAL.test(target)) { + push({ kind: 'local', target, children: parseInline(match[1] ?? '') }); + } else { + plain += match[1] ?? ''; + } + } else if (char === '<' && (match = ANGLED.exec(rest)) !== null) { + push(urlLink(match[1] ?? '')); + } else if ( + char === 'h' && + /\w/.test(text[at - 1] ?? ' ') === false && + (match = BARE.exec(rest)) !== null + ) { + // A sentence's last stop or a closing bracket is not part of the address. + const url = match[0].replace(/[.,;:!?'")\]]+$/, ''); + push(urlLink(url)); + at += url.length; + continue; + } else if ((char === '*' || char === '_') && (match = STRONG.exec(rest)) !== null) { + push({ kind: 'strong', children: parseInline(match[2] ?? '') }); + } else if (char === '*' && (match = EM.exec(rest)) !== null) { + push({ kind: 'em', children: parseInline(match[1] ?? '') }); + } + if (match === null) { + plain += char; + at += 1; + } else { + at += match[0].length; + } + } + if (plain !== '') { + keyed({ kind: 'text', text: plain }); + } + return nodes; +} + +/** What the nodes say, without their markup. */ +export const plainText = (nodes: Inline[]): string => + nodes.map((node) => ('text' in node ? node.text : plainText(node.children))).join(''); + +/** An http or https address, or null for anything else (`javascript:`, a relative path…). */ +const safeHref = (href: string): string | null => { + try { + const url = new URL(href); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null; + } catch { + return null; + } +}; + +const GITHUB_ITEM = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+)$/; +const GITHUB_COMPARE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/compare\/(\S+)$/; + +/** A pull request, an issue or a comparison on GitHub reads as GitHub shows it, `#45`. */ +const urlLink = (url: string): Unkeyed => { + const href = safeHref(url); + if (href === null) { + return { kind: 'text', text: url }; + } + const item = GITHUB_ITEM.exec(url); + const compare = GITHUB_COMPARE.exec(url); + const text = item !== null ? `#${item[1] ?? ''}` : compare !== null ? (compare[1] ?? url) : url; + return { kind: 'link', href, children: [{ key: 'i0', kind: 'text', text }] }; +}; diff --git a/web/src/updates/Markdown.test.tsx b/web/src/updates/Markdown.test.tsx index db9efd6f..ece4ac47 100644 --- a/web/src/updates/Markdown.test.tsx +++ b/web/src/updates/Markdown.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { Markdown, parseBlocks, parseInline } from './Markdown'; +import { Markdown } from './Markdown'; const NOTES = [ 'Observer 0.2.0, built from 0123456789ab.', @@ -15,63 +15,6 @@ const NOTES = [ '**Full Changelog**: https://github.com/SensityTech/Observer/compare/v0.1.0...v0.2.0', ].join('\r\n'); -describe('parseBlocks', () => { - it('reads the paragraphs, lists and headings of release notes', () => { - expect(parseBlocks(NOTES).map((block) => block.kind)).toMatchObject([ - 'paragraph', - 'list', - 'heading', - 'list', - 'paragraph', - ]); - }); - - it('keeps a fenced block as it is, and joins the lines of a paragraph', () => { - const blocks = parseBlocks('one\ntwo\n\n```\n# not a heading\n```\n1. first\n2. second'); - - expect(blocks).toMatchObject([ - { kind: 'paragraph', text: 'one two' }, - { kind: 'code', text: '# not a heading' }, - { kind: 'list', ordered: true, items: [{ text: 'first' }, { text: 'second' }] }, - ]); - }); -}); - -describe('parseInline', () => { - it('reads code, emphasis and links', () => { - expect(parseInline('**Full** `code` *mild* [docs](https://example.com/a)')).toMatchObject([ - { kind: 'strong', children: [{ kind: 'text', text: 'Full' }] }, - { kind: 'text', text: ' ' }, - { kind: 'code', text: 'code' }, - { kind: 'text', text: ' ' }, - { kind: 'em', children: [{ kind: 'text', text: 'mild' }] }, - { kind: 'text', text: ' ' }, - { - kind: 'link', - href: 'https://example.com/a', - children: [{ kind: 'text', text: 'docs' }], - }, - ]); - }); - - it('never makes a link of an address that is not http or https', () => { - expect(parseInline('[click](javascript:alert(1))')).toMatchObject([{ kind: 'text', text: 'click)' }]); - expect(parseInline('[file](file:///etc/passwd)')).toMatchObject([{ kind: 'text', text: 'file' }]); - }); - - it('leaves a snake_case name alone and a bare address without its last stop', () => { - expect(parseInline('observer_bridge and https://example.com/x.')).toMatchObject([ - { kind: 'text', text: 'observer_bridge and ' }, - { - kind: 'link', - href: 'https://example.com/x', - children: [{ kind: 'text', text: 'https://example.com/x' }], - }, - { kind: 'text', text: '.' }, - ]); - }); -}); - describe('Markdown', () => { it('draws a pull request and a comparison on GitHub as GitHub names them', () => { render(); @@ -91,4 +34,14 @@ describe('Markdown', () => { expect(container.querySelector('b')).toBeNull(); expect(container.textContent).toContain(''); }); + + it('draws a table, and a link to a place beside the notes as its words', () => { + const { container } = render( + , + ); + + expect(container.querySelector('th:last-child')?.textContent).toBe('For'); + expect(container.querySelector('td:last-child')?.textContent).toBe('Windows'); + expect(container.querySelector('a')).toBeNull(); + }); }); diff --git a/web/src/updates/Markdown.tsx b/web/src/updates/Markdown.tsx index 91fd8433..45d410f4 100644 --- a/web/src/updates/Markdown.tsx +++ b/web/src/updates/Markdown.tsx @@ -1,9 +1,12 @@ import { Fragment, type ReactNode } from 'react'; +import { table } from '../components/table'; +import { parseBlocks, parseInline, type Block, type Inline } from '../lib/markdown'; + /** * Release notes as GitHub writes them, drawn as React elements and never as HTML: headings, - * lists, paragraphs, code, emphasis and links. A link leads only to an http or https address; a - * pull request, an issue or a comparison on GitHub reads as GitHub shows it, `#45`. + * lists, paragraphs, code, tables, emphasis and links. A link leads only to an http or https + * address; a pull request, an issue or a comparison on GitHub reads as GitHub shows it, `#45`. */ export function Markdown({ source }: { source: string }) { return ( @@ -15,200 +18,6 @@ export function Markdown({ source }: { source: string }) { ); } -/** Each part carries its place in the notes as its key: the notes never move once read. */ -export type Block = { key: string } & ( - | { kind: 'heading'; level: number; text: string } - | { kind: 'list'; ordered: boolean; items: { key: string; text: string }[] } - | { kind: 'code'; text: string } - | { kind: 'quote'; text: string } - | { kind: 'rule' } - | { kind: 'paragraph'; text: string } -); - -export type Inline = { key: string } & ( - | { kind: 'text'; text: string } - | { kind: 'code'; text: string } - | { kind: 'strong'; children: Inline[] } - | { kind: 'em'; children: Inline[] } - | { kind: 'link'; href: string; children: Inline[] } -); - -type Unkeyed = T extends unknown ? Omit : never; - -const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/; -const BULLET = /^\s{0,3}[-*+]\s+(.*)$/; -const NUMBERED = /^\s{0,3}\d{1,9}[.)]\s+(.*)$/; -const FENCE = /^\s{0,3}(```|~~~)/; -const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; -const QUOTE = /^\s{0,3}>\s?(.*)$/; - -export function parseBlocks(source: string): Block[] { - const lines = source.replace(/\r\n?/g, '\n').split('\n'); - const blocks: Block[] = []; - const add = (block: Unkeyed) => blocks.push({ ...block, key: `b${blocks.length}` } as Block); - let paragraph: string[] = []; - const flush = () => { - if (paragraph.length > 0) { - add({ kind: 'paragraph', text: paragraph.join(' ') }); - paragraph = []; - } - }; - for (let at = 0; at < lines.length; at += 1) { - const line = lines[at] ?? ''; - if (line.trim() === '') { - flush(); - continue; - } - const fence = FENCE.exec(line); - if (fence !== null) { - flush(); - const marker = fence[1] ?? '```'; - const code: string[] = []; - at += 1; - while (at < lines.length && !(lines[at] ?? '').trimStart().startsWith(marker)) { - code.push(lines[at] ?? ''); - at += 1; - } - add({ kind: 'code', text: code.join('\n') }); - continue; - } - const heading = HEADING.exec(line); - if (heading !== null) { - flush(); - add({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' }); - continue; - } - if (RULE.test(line)) { - flush(); - add({ kind: 'rule' }); - continue; - } - const item = BULLET.exec(line) ?? NUMBERED.exec(line); - if (item !== null) { - flush(); - const ordered = NUMBERED.test(line) && !BULLET.test(line); - const text = item[1] ?? ''; - const last = blocks.at(-1); - if (last?.kind === 'list' && last.ordered === ordered) { - last.items.push({ key: `${last.key}.${last.items.length}`, text }); - } else { - add({ kind: 'list', ordered, items: [{ key: `b${blocks.length}.0`, text }] }); - } - continue; - } - const quote = QUOTE.exec(line); - if (quote !== null) { - flush(); - const text = quote[1] ?? ''; - const last = blocks.at(-1); - if (last?.kind === 'quote') { - last.text = `${last.text} ${text}`; - } else { - add({ kind: 'quote', text }); - } - continue; - } - // A line that carries on the list item above it. - const last = blocks.at(-1); - if (paragraph.length === 0 && last?.kind === 'list' && /^\s{2,}\S/.test(line)) { - const carried = last.items.at(-1); - if (carried !== undefined) { - carried.text = `${carried.text} ${line.trim()}`; - } - continue; - } - paragraph.push(line.trim()); - } - flush(); - return blocks; -} - -const CODE = /^`([^`]+)`/; -const LINK = /^\[([^\]]+)\]\(([^)\s]+)\)/; -const ANGLED = /^<(https?:\/\/[^>\s]+)>/; -const BARE = /^https?:\/\/[^\s<>]+/; -const STRONG = /^(\*\*|__)(?=\S)(.+?)(?<=\S)\1/; -const EM = /^\*(?=[^\s*])(.+?)(?<=[^\s*])\*/; - -export function parseInline(text: string): Inline[] { - const nodes: Inline[] = []; - const keyed = (node: Unkeyed) => nodes.push({ ...node, key: `i${nodes.length}` } as Inline); - let plain = ''; - const push = (node: Unkeyed) => { - if (plain !== '') { - keyed({ kind: 'text', text: plain }); - plain = ''; - } - keyed(node); - }; - let at = 0; - while (at < text.length) { - const rest = text.slice(at); - const char = text.charAt(at); - let match: RegExpExecArray | null = null; - if (char === '`' && (match = CODE.exec(rest)) !== null) { - push({ kind: 'code', text: match[1] ?? '' }); - } else if (char === '[' && (match = LINK.exec(rest)) !== null) { - const href = safeHref(match[2] ?? ''); - if (href === null) { - plain += match[1] ?? ''; - } else { - push({ kind: 'link', href, children: parseInline(match[1] ?? '') }); - } - } else if (char === '<' && (match = ANGLED.exec(rest)) !== null) { - push(urlLink(match[1] ?? '')); - } else if ( - char === 'h' && - /\w/.test(text[at - 1] ?? ' ') === false && - (match = BARE.exec(rest)) !== null - ) { - // A sentence's last stop or a closing bracket is not part of the address. - const url = match[0].replace(/[.,;:!?'")\]]+$/, ''); - push(urlLink(url)); - at += url.length; - continue; - } else if ((char === '*' || char === '_') && (match = STRONG.exec(rest)) !== null) { - push({ kind: 'strong', children: parseInline(match[2] ?? '') }); - } else if (char === '*' && (match = EM.exec(rest)) !== null) { - push({ kind: 'em', children: parseInline(match[1] ?? '') }); - } - if (match === null) { - plain += char; - at += 1; - } else { - at += match[0].length; - } - } - if (plain !== '') { - keyed({ kind: 'text', text: plain }); - } - return nodes; -} - -/** An http or https address, or null for anything else (`javascript:`, a relative path…). */ -const safeHref = (href: string): string | null => { - try { - const url = new URL(href); - return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null; - } catch { - return null; - } -}; - -const GITHUB_ITEM = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+)$/; -const GITHUB_COMPARE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/compare\/(\S+)$/; - -const urlLink = (url: string): Unkeyed => { - const href = safeHref(url); - if (href === null) { - return { kind: 'text', text: url }; - } - const item = GITHUB_ITEM.exec(url); - const compare = GITHUB_COMPARE.exec(url); - const text = item !== null ? `#${item[1] ?? ''}` : compare !== null ? (compare[1] ?? url) : url; - return { kind: 'link', href, children: [{ key: 'i0', kind: 'text', text }] }; -}; - function BlockView({ block }: { block: Block }) { switch (block.kind) { case 'heading': @@ -239,6 +48,33 @@ function BlockView({ block }: { block: Block }) { {block.text} ); + case 'table': + return ( +
+ + + + {block.head.map((cell) => ( + + ))} + + + + {block.rows.map((row) => ( + + {row.cells.map((cell) => ( + + ))} + + ))} + +
+ +
+ +
+
+ ); case 'quote': return (
@@ -282,6 +118,9 @@ const render = (nodes: Inline[]): ReactNode => ); case 'em': return {render(node.children)}; + // Release notes stand alone: a link to a place beside them leads nowhere here. + case 'local': + return {render(node.children)}; case 'link': return ( Date: Wed, 11 Nov 2026 05:07:07 +0100 Subject: [PATCH 2/7] refactor(web): give the copy buttons one hook, which copies and says when the browser refuses --- web/src/components/CopyField.tsx | 26 +++++++++++------------- web/src/components/CopyValue.tsx | 26 ++++-------------------- web/src/lib/useCopy.ts | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 36 deletions(-) create mode 100644 web/src/lib/useCopy.ts diff --git a/web/src/components/CopyField.tsx b/web/src/components/CopyField.tsx index 68ea07aa..9d457a4d 100644 --- a/web/src/components/CopyField.tsx +++ b/web/src/components/CopyField.tsx @@ -1,8 +1,9 @@ import { Check, Copy } from 'lucide-react'; -import { useId, useRef, useState } from 'react'; +import { useId, useRef } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { cn } from '../lib/cn'; +import { useCopy } from '../lib/useCopy'; import { Button } from './Button'; import { inputClass } from './Field'; @@ -11,18 +12,7 @@ export function CopyField({ label, value }: { label: string; value: string }) { const { t } = useI18n(); const id = useId(); const input = useRef(null); - const [state, setState] = useState<'idle' | 'copied' | 'blocked'>('idle'); - - const copy = async () => { - try { - await navigator.clipboard.writeText(value); - setState('copied'); - } catch { - // No clipboard outside a secure context (plain HTTP on a LAN address): select it instead. - input.current?.select(); - setState('blocked'); - } - }; + const { state, copy } = useCopy({ kept: true }); return (
@@ -39,7 +29,15 @@ export function CopyField({ label, value }: { label: string; value: string }) { className={cn(inputClass, 'min-w-0 font-mono text-ui [font-variant-ligatures:none]')} onFocus={(event) => event.currentTarget.select()} /> -
diff --git a/web/src/components/CopyValue.tsx b/web/src/components/CopyValue.tsx index 9b43e566..3a2c3e3a 100644 --- a/web/src/components/CopyValue.tsx +++ b/web/src/components/CopyValue.tsx @@ -1,11 +1,9 @@ import { Check, Copy } from 'lucide-react'; -import { useEffect, useId, useState } from 'react'; +import { useId } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { cn } from '../lib/cn'; - -/** How long "Copied" stays under the value. */ -const COPIED_FOR = 2_000; +import { useCopy } from '../lib/useCopy'; /** * A long value shown short, in mono, and copied whole in one click where the browser allows it. @@ -14,23 +12,7 @@ const COPIED_FOR = 2_000; export function CopyValue({ value, shown, label }: { value: string; shown: string; label: string }) { const { t } = useI18n(); const id = useId(); - const [state, setState] = useState<'idle' | 'copied' | 'blocked'>('idle'); - useEffect(() => { - if (state !== 'copied') { - return; - } - const timer = setTimeout(() => setState('idle'), COPIED_FOR); - return () => clearTimeout(timer); - }, [state]); - const copy = async () => { - try { - await navigator.clipboard.writeText(value); - setState('copied'); - } catch { - // No clipboard outside a secure context (plain HTTP on a LAN address). - setState('blocked'); - } - }; + const { state, copy } = useCopy(); return (
+ + + {state === 'copied' + ? t.docs.linkCopied + : state === 'blocked' + ? t.common.copyBlockedValue(address) + : ''} + + + ); +} + +/** The files a section's link leads to, each folded under its name. */ +function Files({ files }: { files: ExampleFile[] }) { + const folders = [...new Set(files.map(folderOf))]; + return folders.map((folder) => ( +
+ {files + .filter((file) => folderOf(file) === folder) + .map((file) => ( + + ))} +
+ )); +} + +/** A section of the contract: its text, the files it links to, and what the panel adds to it. */ +export function SectionCard({ + section, + model, + docs, +}: { + section: DocSection; + model: DocModel; + docs: IntegrationDocs; +}) { + const { t } = useI18n(); + const titleId = `${section.id}-title`; + const reading = useMemo( + () => ({ anchors: model.anchors, paths: new Set(docs.example.map(folderOf)), own: section.title }), + [model.anchors, docs.example, section.title], + ); + return ( + +
+ +

+ {section.title} +

+ + {(section.blocks.length > 0 || section.files.length > 0) && ( +
+ {section.exports.length > 0 && ( + // As the bridge's script declares them: the parameters are its own names. +

+ {t.docs.exports} + {section.exports.map(({ name, parameters }) => ( + + {name}({parameters.join(', ')}) + + ))} +

+ )} + + +
+ )} + +
+
+ ); +} + +/** + * An event, as a rule: when it comes, what it carries, what the contract adds, and a handler to + * copy. Another third-level heading of the contract is drawn the same way, without the handler. + */ +export function EntryCard({ + entry, + model, + docs, +}: { + entry: DocEntry; + model: DocModel; + docs: IntegrationDocs; +}) { + const { t } = useI18n(); + const titleId = `${entry.id}-title`; + const reading = useMemo( + () => ({ anchors: model.anchors, paths: new Set(docs.example.map(folderOf)), own: entry.title }), + [model.anchors, docs.example, entry.title], + ); + const handler = useMemo( + () => handlerOf(entry, docs.example.map((file) => file.text).join('\n')), + [entry, docs.example], + ); + // What comes before the fields introduces them; the rest follows them. + const at = entry.blocks.findIndex((block) => block.kind === 'table'); + const fields = entry.blocks[at]; + const intro = at < 0 ? [] : entry.blocks.slice(0, at); + const rest = at < 0 ? entry.blocks : entry.blocks.slice(at + 1); + return ( + +
+ +

+ {entry.title} +

+ {entry.event && {t.docs.event}} + + {(entry.when !== null || intro.length > 0) && ( +
+ {entry.when !== null && ( +

+ +

+ )} + +
+ )} + {fields?.kind === 'table' && } + {(rest.length > 0 || entry.event) && ( +
+ + {entry.event && ( + + )} +
+ )} + +
+
+ ); +} diff --git a/web/src/docs/CodeBlock.tsx b/web/src/docs/CodeBlock.tsx new file mode 100644 index 00000000..b2622cf6 --- /dev/null +++ b/web/src/docs/CodeBlock.tsx @@ -0,0 +1,127 @@ +import { Check, ChevronRight, Copy } from 'lucide-react'; +import { useId, useMemo, useRef, useState } from 'react'; + +import { Button } from '../components/Button'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { useCopy } from '../lib/useCopy'; +import { luaTokens, type LuaKind } from './lua'; + +const INK: Record = { + comment: 'text-term-dim', + string: 'text-term-green', + keyword: 'text-term-magenta', + number: 'text-term-yellow', + plain: '', +}; + +/** + * Code to copy, on the console's ground: a line never folds, the block scrolls. `name` says what the + * code is, for its copy button. A `folded` block is a file: it opens on its name. Lua is coloured + * unless `lua` says the code is something else. + */ +export function CodeBlock({ + code, + label, + name, + folded = false, + lua = true, +}: { + code: string; + label: string; + name: string; + folded?: boolean; + lua?: boolean; +}) { + const { t } = useI18n(); + const id = useId(); + const { state, copy } = useCopy(); + const [open, setOpen] = useState(!folded); + const pre = useRef(null); + const tokens = useMemo( + () => (lua ? luaTokens(code) : [{ at: 0, text: code, kind: 'plain' as const }]), + [code, lua], + ); + + const copyCode = async () => { + const text = pre.current; + // Where the browser refuses, the code is selected instead, so the keyboard can copy it. + if (!(await copy(code)) && text !== null) { + const range = document.createRange(); + range.selectNodeContents(text); + const selection = getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }; + + return ( +
+
+ {folded ? ( + + ) : ( + {label} + )} + +
+ + + {state === 'copied' ? t.common.copied : state === 'blocked' ? t.common.copyBlocked : ''} + +
+ ); +} diff --git a/web/src/docs/Notes.tsx b/web/src/docs/Notes.tsx new file mode 100644 index 00000000..4372a69a --- /dev/null +++ b/web/src/docs/Notes.tsx @@ -0,0 +1,116 @@ +import { Link } from '@tanstack/react-router'; +import { AppWindow, Server, type LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { IntegrationServer } from '../api/generated/IntegrationServer'; +import { Permission } from '../api/generated/Permission'; +import { allows, useMe } from '../auth/me'; +import { useI18n } from '../i18n/I18nProvider'; +import { anchorOf as settingAnchor } from '../settings/catalog'; + +/** + * The places of the contract the panel adds a word to, by their address on the page: where the + * effect of a resource's code shows in the panel, and what this server does. A test holds each + * address to the contract, so that a heading renamed there does not lose its note in silence. + */ +export const NOTED = ['player-labels', 'observerstartchanges', 'bans', 'txadmins-events'] as const; + +export const link = + 'font-medium text-ink-2 underline decoration-line-strong underline-offset-3 hover:text-ink'; + +/** The link to the launch setting that sends txAdmin's events, for the owner, who alone opens it. */ +export function LaunchSettingsLink() { + const { t } = useI18n(); + return useMe().isOwner ? ( + + {t.docs.server.launchSettings} + + ) : null; +} + +/** What the panel says under a section of the contract, if it has a word for it. */ +export function SectionNote({ id, server }: { id: string; server: IntegrationServer | null }) { + const { t } = useI18n(); + const me = useMe(); + const words = t.docs.notes; + // A page the account does not open is named, not linked. + const page = (to: '/players' | '/changes' | '/bans', name: string, permission: bigint) => + allows(me, permission) ? ( + + {name} + + ) : ( + name + ); + + let icon: LucideIcon = AppWindow; + let lead = words.panel; + let text: ReactNode; + switch (id as (typeof NOTED)[number]) { + case 'player-labels': + text = words.labels(page('/players', t.nav.players, Permission.PlayersView)); + break; + case 'observerstartchanges': + text = words.startChanges(page('/changes', t.nav.changes, Permission.ChangesView)); + break; + case 'bans': + text = words.bans( + page('/bans', t.nav.bans, Permission.BansView), + allows(me, Permission.SettingsView) ? ( + + {words.refusalMessage} + + ) : ( + words.refusalMessage + ), + ); + break; + case 'txadmins-events': { + // An account that is not told what the server does reads the contract alone. + if (server === null) { + return null; + } + const said = + server.txadminEvents !== null + ? server.txadminEvents + ? words.txadminOn + : words.txadminOff + : server.txadminEventsNext !== null + ? words.txadminStopped(server.txadminEventsNext) + : null; + if (said === null) { + return null; + } + icon = Server; + lead = words.server; + text = ( + <> + {said} + + ); + break; + } + default: + return null; + } + const Icon = icon; + return ( +
+
+ ); +} diff --git a/web/src/docs/Prose.tsx b/web/src/docs/Prose.tsx new file mode 100644 index 00000000..0b4ab9d9 --- /dev/null +++ b/web/src/docs/Prose.tsx @@ -0,0 +1,257 @@ +import { Fragment, createContext, use, type ReactNode } from 'react'; + +import { table } from '../components/table'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { parseInline, plainText, type Block, type Inline } from '../lib/markdown'; +import { Anchor } from './Anchor'; +import { CodeBlock } from './CodeBlock'; +import { filesAnchor } from './model'; + +/** What the text of the documentation is read against. */ +export type Reading = { + /** Where a name written in code leads on the page. */ + anchors: Map; + /** The paths whose files the page shows. */ + paths: Set; + /** The title of the section or entry the text belongs to: its own name is not a link to itself. */ + own: string; +}; + +export const ReadingContext = createContext({ anchors: new Map(), paths: new Set(), own: '' }); + +const link = + 'font-medium text-ink underline decoration-line-strong underline-offset-3 hover:decoration-ink-3'; + +/** A long name folds after a colon before it folds anywhere else. */ +const foldable = (text: string) => { + let at = 0; + return text.split(/(?<=:)(?=\w)/).map((piece) => { + const start = at; + at += piece.length; + return ( + + {start > 0 && } + {piece} + + ); + }); +}; + +function Nodes({ nodes, folding }: { nodes: Inline[]; folding: boolean }): ReactNode { + const { anchors, paths, own } = use(ReadingContext); + return nodes.map((node) => { + switch (node.kind) { + case 'text': + return {node.text}; + case 'code': { + const code = ( + + {folding ? foldable(node.text) : node.text} + + ); + const anchor = node.text === own ? undefined : anchors.get(node.text); + return anchor === undefined ? ( + {code} + ) : ( + + {code} + + ); + } + case 'strong': + return ( + + + + ); + case 'em': + return ( + + + + ); + case 'link': + return ( + + + + ); + case 'local': { + const hash = node.target.startsWith('#') + ? node.target.slice(1) + : paths.has(node.target) + ? filesAnchor(node.target) + : null; + // A path the page shows nothing of leads nowhere: its words stay. + return hash === null ? ( + + + + ) : ( + + + + ); + } + } + }); +} + +/** A line of the contract: its code, its emphasis and its links, drawn as elements, never as HTML. */ +export function Text({ text, folding = false }: { text: string; folding?: boolean }) { + return ; +} + +/** Longer than this, a table's second column no longer rides beside the first on a phone. */ +const BESIDE_CHARS = 24; +const short = (text: string) => plainText(parseInline(text)).length <= BESIDE_CHARS; + +/** + * A table of the contract, to the card's edges. On a phone each row is a block: its first cell is + * its title, a short second one rides beside it, and the cells after them are named by their column + * once there are several. + */ +export function DocTable({ block }: { block: Extract }) { + const beside = block.head.length >= 3 && block.rows.every((row) => short(row.cells[1]?.text ?? '')); + const named = block.head.length - (beside ? 2 : 1) > 1; + return ( +
+ + + + {block.head.map((cell) => ( + + ))} + + + + {block.rows.map((row) => ( + td]:border-b-0', + beside && + 'max-md:grid max-md:grid-cols-[auto_1fr] max-md:items-baseline max-md:gap-x-2.5', + )} + > + {row.cells.map((cell, index) => { + const key = index === 0 && /^`[^`]+`$/.test(cell.text); + const riding = beside && index === 1; + const label = + named && index >= (beside ? 2 : 1) ? block.head[index]?.text : undefined; + return ( + + ); + })} + + ))} + +
+ +
0 && !riding && 'max-md:mt-1', + beside && index >= 2 && 'max-md:col-span-2', + )} + > + {label !== undefined && cell.text !== '' && ( + + + + )} + +
+
+ ); +} + +/** + * Blocks of the contract inside a card's body: paragraphs, lists, code to copy and tables that run + * to the card's edges. `codeName` says what a block of code is, for its copy button. + */ +export function Prose({ blocks, codeName }: { blocks: Block[]; codeName: string }) { + const { t } = useI18n(); + return blocks.map((block) => { + switch (block.kind) { + case 'paragraph': + return ( +

+ +

+ ); + case 'list': { + const items = block.items.map((item) => ( +
  • + +
  • + )); + const className = 'flex max-w-[68ch] flex-col gap-1.5 pl-5 text-pretty marker:text-ink-3'; + return block.ordered ? ( +
      + {items} +
    + ) : ( +
      + {items} +
    + ); + } + case 'code': + return ( + + ); + case 'table': + return ( + // The last thing in a card runs to its foot. +
    + +
    + ); + case 'quote': + return ( +
    + +
    + ); + case 'heading': + return ( +

    + +

    + ); + case 'rule': + return
    ; + } + }); +} diff --git a/web/src/docs/ThisServer.tsx b/web/src/docs/ThisServer.tsx new file mode 100644 index 00000000..78e27bc1 --- /dev/null +++ b/web/src/docs/ThisServer.tsx @@ -0,0 +1,112 @@ +import { Server } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { IntegrationServer } from '../api/generated/IntegrationServer'; +import { Card } from '../components/Card'; +import { useI18n } from '../i18n/I18nProvider'; +import { useServerStatus } from '../server/context'; +import { ANCHORED } from './Cards'; +import { LaunchSettingsLink } from './Notes'; + +/** Where the card stands on the page, for the contents and for links. */ +export const THIS_SERVER = 'this-server'; + +function Fact({ term, value, children }: { term: string; value: ReactNode; children: ReactNode }) { + return ( +
    +
    {term}
    +
    + {value} + {children} +
    +
    + ); +} + +/** + * What the server Observer runs does of the contract, now: the version its bridge speaks, the name + * the bridge took for this start, and whether txAdmin's events are raised too. + */ +export function ThisServer({ + contractVersion, + server, +}: { + contractVersion: number; + server: IntegrationServer; +}) { + const { t, format } = useI18n(); + const words = t.docs.server; + const { status } = useServerStatus(); + const { bridge, starting, txadminEvents, txadminEventsNext: next } = server; + const startedAt = status?.startedAt ?? null; + const meta = + bridge === null + ? words.stopped + : startedAt === null + ? words.running + : words.startedAt(format.timestamp(startedAt, { seconds: false })); + + let txadmin: { value: string; about: string }; + if (txadminEvents !== null) { + const about = txadminEvents ? words.txadminOn : words.txadminOff; + txadmin = { + value: txadminEvents ? words.on : words.off, + // The setting was saved since this start: the next one goes by it. + about: next !== null && next !== txadminEvents ? `${about} ${words.txadminNext(next)}` : about, + }; + } else if (next !== null) { + txadmin = { value: next ? words.on : words.off, about: words.txadminFromNext }; + } else { + txadmin = { value: words.notSet, about: words.notSetAbout }; + } + + return ( +
    + +
    + + {words.versionAbout} + + {bridge === null ? ( + + + ) : ( + + + )} + + {txadmin.about} + +
    +
    +
    + ); +} diff --git a/web/src/docs/Toc.tsx b/web/src/docs/Toc.tsx new file mode 100644 index 00000000..7dd40888 --- /dev/null +++ b/web/src/docs/Toc.tsx @@ -0,0 +1,197 @@ +import { ChevronDown } from 'lucide-react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; + +import { buttonVariants } from '../components/Button'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { Anchor } from './Anchor'; + +export type TocItem = { + key: string; + /** Where it leads on the page. */ + id: string; + label: string; + /** An event or an export, under its section. */ + sub: boolean; +}; + +export type TocGroup = { label: string; items: TocItem[] }; + +/** How far under where a link brings a section its top may be for the section to count as reached. */ +const SLACK = 24; + +/** + * The place of the page that is under the reader's eyes: the last of `ids` whose top went past + * where a link to it would bring it, the last one once the page is at its end. + */ +export const useReached = (ids: string[]): string | null => { + const [reached, setReached] = useState(null); + useEffect(() => { + const read = () => { + let found = ids[0] ?? null; + for (const id of ids) { + const element = document.getElementById(id); + if (element === null) { + continue; + } + const line = (Number.parseFloat(getComputedStyle(element).scrollMarginTop) || 0) + SLACK; + if (element.getBoundingClientRect().top <= line) { + found = id; + } + } + const page = document.documentElement; + if ( + ids.length > 0 && + page.scrollTop > 0 && + innerHeight + page.scrollTop >= page.scrollHeight - 2 + ) { + found = ids.at(-1) ?? found; + } + setReached(found); + }; + read(); + addEventListener('scroll', read, { passive: true }); + addEventListener('resize', read); + return () => { + removeEventListener('scroll', read); + removeEventListener('resize', read); + }; + }, [ids]); + return reached; +}; + +const entry = + 'flex min-h-8 items-center rounded-[9px] px-3 text-nav font-medium text-ink-2 transition-colors hover:bg-hover hover:text-ink focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent aria-[current=location]:bg-active aria-[current=location]:text-ink max-md:min-h-11'; +const subEntry = + 'min-h-7 pl-6 font-mono text-meta font-normal text-ink-3 [font-variant-ligatures:none] aria-[current=location]:text-ink'; + +function Entries({ + groups, + reached, + onNavigate, +}: { + groups: TocGroup[]; + reached: string | null; + onNavigate?: () => void; +}) { + // Exports share their section's place: the section alone is marked. + const current = groups.flatMap((group) => group.items).find((item) => item.id === reached)?.key; + return groups.map((group) => ( +
    +

    + {group.label} +

    + {group.items.map((item) => ( + + {item.label} + + ))} +
    + )); +} + +/** + * The documentation's contents: beside the page on a wide screen, where it follows the reader, and + * under one button on a phone, which names the section being read. + */ +export function Toc({ groups }: { groups: TocGroup[] }) { + const { t } = useI18n(); + const listId = useId(); + const ids = useMemo( + () => [...new Set(groups.flatMap((group) => group.items.map((item) => item.id)))], + [groups], + ); + const reached = useReached(ids); + const [open, setOpen] = useState(false); + const side = useRef(null); + const phone = useRef(null); + const items = groups.flatMap((group) => group.items); + const reading = + items.find((item) => item.id === reached && !item.sub) ?? items.find((item) => item.id === reached); + + // A long contents list scrolls by itself: the entry being read stays in sight. + useEffect(() => { + const list = side.current; + const marked = reached === null ? null : list?.querySelector('[aria-current="location"]'); + if (list === null || marked === null || marked === undefined) { + return; + } + const [box, row] = [list.getBoundingClientRect(), marked.getBoundingClientRect()]; + if (row.top < box.top) { + list.scrollTop -= box.top - row.top + 8; + } else if (row.bottom > box.bottom) { + list.scrollTop += row.bottom - box.bottom + 8; + } + }, [reached]); + + useEffect(() => { + if (!open) { + return; + } + const close = (event: Event) => { + if ( + event instanceof KeyboardEvent + ? event.key === 'Escape' + : !phone.current?.contains(event.target as Node) + ) { + setOpen(false); + } + }; + addEventListener('keydown', close); + addEventListener('pointerdown', close); + return () => { + removeEventListener('keydown', close); + removeEventListener('pointerdown', close); + }; + }, [open]); + + return ( + <> + +
    + + +
    + + ); +} diff --git a/web/src/docs/fixtures.ts b/web/src/docs/fixtures.ts new file mode 100644 index 00000000..f484b644 --- /dev/null +++ b/web/src/docs/fixtures.ts @@ -0,0 +1,41 @@ +// The contract, the bridge and the example as the repository holds them, for the tests: what the +// API serves from the binary, read here from the same files. + +import bridge from '../../../resource/observer/server.lua?raw'; +import contract from '../../../resource/CONTRACT.md?raw'; +import exampleManifest from '../../../resource/observer_example/fxmanifest.lua?raw'; +import exampleScript from '../../../resource/observer_example/server.lua?raw'; +import type { IntegrationDocs } from '../api/generated/IntegrationDocs'; +import type { IntegrationServer } from '../api/generated/IntegrationServer'; + +const named = /local EVENTS = \{\n([\s\S]*?)\n\}/.exec(bridge)?.[1] ?? ''; + +/** + * What `GET /api/docs/integration` answers for a server that does not run, or for an account that + * is not told what the server does when `server` is null. + */ +export const integrationDocs = (server: Partial | null = {}): IntegrationDocs => ({ + contract, + contractVersion: Number(/local CONTRACT_VERSION = (\d+)/.exec(bridge)?.[1]), + events: [ + ...new Set([ + ...[...named.matchAll(/^\s*(\w+) = true,$/gm)].map((match) => `observer:${match[1] ?? ''}`), + ...[...bridge.matchAll(/TriggerEvent\('(observer:\w+)'/g)].map((match) => match[1] ?? ''), + ]), + ], + exports: [...bridge.matchAll(/^exports\('(\w+)', function\(([^)]*)\)/gm)].map((match) => ({ + name: match[1] ?? '', + parameters: (match[2] ?? '') + .split(',') + .map((parameter) => parameter.trim()) + .filter(Boolean), + })), + example: [ + { name: 'observer_example/fxmanifest.lua', text: exampleManifest }, + { name: 'observer_example/server.lua', text: exampleScript }, + ], + server: + server === null + ? null + : { bridge: null, starting: false, txadminEvents: null, txadminEventsNext: null, ...server }, +}); diff --git a/web/src/docs/lua.test.ts b/web/src/docs/lua.test.ts new file mode 100644 index 00000000..da7d4a5d --- /dev/null +++ b/web/src/docs/lua.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { integrationDocs } from './fixtures'; +import { luaTokens } from './lua'; + +describe('luaTokens', () => { + it('tells comments, strings, keywords and numbers from the rest', () => { + const code = "local bridge = GetConvar('observer_bridge', '') -- empty, or 4"; + + expect(luaTokens(code).map(({ text, kind }) => [text, kind])).toEqual([ + ['local', 'keyword'], + [' bridge = GetConvar(', 'plain'], + ["'observer_bridge'", 'string'], + [', ', 'plain'], + ["''", 'string'], + [') ', 'plain'], + ['-- empty, or 4', 'comment'], + ]); + }); + + it('takes a keyword inside a name or a string for what it is', () => { + expect( + luaTokens("endsAt = 'in the end' and 30 or nil").map(({ text, kind }) => [text, kind]), + ).toEqual([ + ['endsAt = ', 'plain'], + ["'in the end'", 'string'], + [' ', 'plain'], + ['and', 'keyword'], + [' ', 'plain'], + ['30', 'number'], + [' ', 'plain'], + ['or', 'keyword'], + [' ', 'plain'], + ['nil', 'keyword'], + ]); + }); + + it('gives back every character of the example resource, in order', () => { + for (const { text } of integrationDocs().example) { + const tokens = luaTokens(text); + + expect(tokens.map((token) => token.text).join('')).toBe(text); + expect(new Set(tokens.map((token) => token.at)).size).toBe(tokens.length); + } + }); +}); diff --git a/web/src/docs/lua.ts b/web/src/docs/lua.ts new file mode 100644 index 00000000..ba8c633c --- /dev/null +++ b/web/src/docs/lua.ts @@ -0,0 +1,34 @@ +export type LuaKind = 'comment' | 'string' | 'keyword' | 'number' | 'plain'; +/** A piece of Lua, keyed by where it starts in its text. */ +export type LuaToken = { at: number; text: string; kind: LuaKind }; + +// A comment to the end of its line, a quoted string, a keyword, a number. Long brackets are left +// plain: the contract's examples have none. +const PIECE = + /(--.*)|('(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*")|\b(and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b|\b(\d+(?:\.\d+)?)\b/g; + +/** Lua as the documentation colours it: comments, strings, keywords and numbers. */ +export const luaTokens = (code: string): LuaToken[] => { + const tokens: LuaToken[] = []; + let at = 0; + for (const match of code.matchAll(PIECE)) { + if (match.index > at) { + tokens.push({ at, text: code.slice(at, match.index), kind: 'plain' }); + } + const [text, comment, string, keyword] = match; + const kind = + comment !== undefined + ? 'comment' + : string !== undefined + ? 'string' + : keyword !== undefined + ? 'keyword' + : 'number'; + tokens.push({ at: match.index, text, kind }); + at = match.index + text.length; + } + if (at < code.length) { + tokens.push({ at, text: code.slice(at), kind: 'plain' }); + } + return tokens; +}; diff --git a/web/src/docs/model.test.ts b/web/src/docs/model.test.ts new file mode 100644 index 00000000..62b81066 --- /dev/null +++ b/web/src/docs/model.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { parseBlocks, parseInline, type Inline } from '../lib/markdown'; +import { integrationDocs } from './fixtures'; +import { anchorOf, buildDocs, docIndex, fieldsOf, filesAnchor, handlerOf } from './model'; + +const docs = integrationDocs(); +const model = buildDocs(docs); +const entries = model.sections.flatMap((section) => section.entries); +const script = docs.example.find((file) => file.name.endsWith('server.lua'))?.text ?? ''; + +describe('anchorOf', () => { + it('makes the address GitHub makes of a heading', () => { + expect(anchorOf('Player labels')).toBe('player-labels'); + expect(anchorOf("txAdmin's events")).toBe('txadmins-events'); + expect(anchorOf('`observer:restartScheduled`')).toBe('observerrestartscheduled'); + expect(anchorOf('Not in version 4')).toBe('not-in-version-4'); + }); +}); + +describe('buildDocs, on the contract as the repository holds it', () => { + it('gives every heading of the contract a place, each with its own address', () => { + const headings = parseBlocks(docs.contract).filter((block) => block.kind === 'heading'); + const ids = [...model.sections.map((section) => section.id), ...entries.map((entry) => entry.id)]; + + expect(model.sections).toHaveLength(headings.filter((heading) => heading.level === 2).length); + expect(entries).toHaveLength(headings.filter((heading) => heading.level === 3).length); + expect(new Set(ids).size).toBe(ids.length); + expect(model.lead).not.toHaveLength(0); + }); + + it('knows every event the bridge raises, when it comes and what it carries', () => { + expect(docs.events).not.toHaveLength(0); + for (const event of docs.events) { + const entry = entries.find((candidate) => candidate.title === event); + + expect(entry, event).toMatchObject({ event: true }); + expect(entry?.when, event).toBeTruthy(); + expect(entry === undefined ? [] : fieldsOf(entry), event).not.toHaveLength(0); + expect(model.anchors.get(event), event).toBe(entry?.id); + } + }); + + it('places every export of the bridge in the one section that shows it called', () => { + expect(docs.exports).not.toHaveLength(0); + for (const { name } of docs.exports) { + const holders = model.sections.filter((section) => + section.exports.some((placed) => placed.name === name), + ); + + expect(holders, name).toHaveLength(1); + } + }); + + it('leads every link of the contract somewhere on the page', () => { + const locals = (nodes: Inline[]): string[] => + nodes.flatMap((node) => + node.kind === 'local' ? [node.target] : 'children' in node ? locals(node.children) : [], + ); + const targets = parseBlocks(docs.contract).flatMap((block) => { + const texts = + block.kind === 'paragraph' + ? [block.text] + : block.kind === 'list' + ? block.items.map((item) => item.text) + : block.kind === 'table' + ? block.rows.flatMap((row) => row.cells.map((cell) => cell.text)) + : []; + return texts.flatMap((text) => locals(parseInline(text))); + }); + const ids = new Set([ + ...model.sections.map((section) => section.id), + ...entries.map((entry) => entry.id), + ]); + const shown = model.sections.flatMap((section) => section.files.map((file) => file.name)); + + expect(targets).not.toHaveLength(0); + for (const target of targets) { + if (target.startsWith('#')) { + expect(ids.has(target.slice(1)), target).toBe(true); + } else { + expect( + shown.some((name) => name.startsWith(`${target}/`)), + target, + ).toBe(true); + } + } + expect(filesAnchor('observer_example')).toBe('files-observer_example'); + }); +}); + +describe('handlerOf', () => { + it('gives each event a handler that names its fields and says what the example says', () => { + for (const entry of entries.filter((candidate) => candidate.event)) { + const handler = handlerOf(entry, script); + const lines = handler.split('\n'); + + expect(lines[0], entry.title).toBe(`AddEventHandler('${entry.title}', function(payload)`); + expect(lines.at(-1), entry.title).toBe('end)'); + for (const field of fieldsOf(entry)) { + expect(handler, entry.title).toContain(`payload.${field}`); + } + // The example's own comment for this event, folded. + expect(lines.length, entry.title).toBeGreaterThan(3); + for (const line of lines.slice(1, -1)) { + expect(line.startsWith(' -- '), line).toBe(true); + expect(line.length, line).toBeLessThanOrEqual(72); + } + } + }); + + it('stands without the example', () => { + const entry = entries.find((candidate) => candidate.title === 'observer:shuttingDown'); + + expect(entry && handlerOf(entry, '')).toBe( + [ + "AddEventHandler('observer:shuttingDown', function(payload)", + ' -- payload.delayMs, payload.reason, payload.by', + 'end)', + ].join('\n'), + ); + }); +}); + +describe('docIndex', () => { + it('lists the sections, the events, the exports and the names txAdmin gives its events', () => { + const index = docIndex(model); + const of = (kind: string) => index.filter((match) => match.kind === kind).map((match) => match.label); + + expect(of('section')).toContain('Player labels'); + expect(of('event')).toEqual(docs.events); + expect(of('export').toSorted()).toEqual(docs.exports.map((item) => item.name).toSorted()); + expect(of('txadmin')).toContain('txAdmin:events:serverShuttingDown'); + expect(index.find((match) => match.label === 'setPlayerLabel')?.id).toBe('player-labels'); + }); +}); diff --git a/web/src/docs/model.ts b/web/src/docs/model.ts new file mode 100644 index 00000000..e16a1e63 --- /dev/null +++ b/web/src/docs/model.ts @@ -0,0 +1,239 @@ +// How the Documentation page reads the integration contract: its sections, the events and exports +// among them, and where a name written in the text leads. The contract is the binary's own +// `resource/CONTRACT.md`; nothing of its text is written again here. + +import type { BridgeExport } from '../api/generated/BridgeExport'; +import type { ExampleFile } from '../api/generated/ExampleFile'; +import type { IntegrationDocs } from '../api/generated/IntegrationDocs'; +import { parseBlocks, parseInline, plainText, type Block, type Inline } from '../lib/markdown'; + +/** A third-level heading of the contract: an event, as a rule. */ +export type DocEntry = { + id: string; + title: string; + /** The bridge raises it. */ + event: boolean; + /** When it comes, in Markdown, as the first table that lists it says. */ + when: string | null; + blocks: Block[]; +}; + +/** A second-level heading of the contract and what it holds. */ +export type DocSection = { + id: string; + title: string; + blocks: Block[]; + entries: DocEntry[]; + /** The bridge's exports this section shows called. */ + exports: BridgeExport[]; + /** The files a link of this section leads to, such as the example resource's. */ + files: ExampleFile[]; +}; + +export type DocModel = { + /** What the contract says before its first section. */ + lead: Block[]; + sections: DocSection[]; + /** Where a name leads when the text writes it in code: a section's or an entry's title. */ + anchors: Map; +}; + +const text = (markdown: string) => plainText(parseInline(markdown)); + +/** + * The address of a heading as GitHub makes it, so that the contract's own links lead to the same + * places in the panel: `Player labels` is `player-labels`. + */ +export const anchorOf = (title: string) => + text(title) + .toLowerCase() + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .trim() + .replace(/\s/g, '-'); + +/** Where the files of a path the contract links to are shown. */ +export const filesAnchor = (path: string) => `files-${anchorOf(path)}`; + +const inlinesOf = (block: Block): string[] => { + switch (block.kind) { + case 'heading': + case 'paragraph': + case 'quote': + return [block.text]; + case 'list': + return block.items.map((item) => item.text); + case 'table': + return [...block.head, ...block.rows.flatMap((row) => row.cells)].map((cell) => cell.text); + case 'code': + case 'rule': + return []; + } +}; + +/** Everything a block says, its code included. */ +const said = (block: Block) => (block.kind === 'code' ? block.text : inlinesOf(block).join('\n')); + +const localTargets = (nodes: Inline[]): string[] => + nodes.flatMap((node) => + node.kind === 'local' + ? [node.target, ...localTargets(node.children)] + : 'children' in node + ? localTargets(node.children) + : [], + ); + +export function buildDocs({ + contract, + events, + exports, + example, +}: Pick): DocModel { + const blocks = parseBlocks(contract); + const lead: Block[] = []; + const sections: DocSection[] = []; + const taken = new Map(); + // A title met again takes a number, as on GitHub. + const unique = (id: string) => { + const seen = taken.get(id) ?? 0; + taken.set(id, seen + 1); + return seen === 0 ? id : `${id}-${seen}`; + }; + const when = new Map(); + for (const block of blocks) { + if (block.kind !== 'table') { + continue; + } + for (const { cells } of block.rows) { + const name = text(cells[0]?.text ?? ''); + if (events.includes(name) && !when.has(name)) { + when.set(name, cells[1]?.text ?? ''); + } + } + } + + for (const block of blocks) { + if (block.kind === 'heading' && block.level === 1) { + continue; + } + if (block.kind === 'heading' && block.level === 2) { + const title = text(block.text); + sections.push({ + id: unique(anchorOf(title)), + title, + blocks: [], + entries: [], + exports: [], + files: [], + }); + continue; + } + const section = sections.at(-1); + if (section === undefined) { + lead.push(block); + } else if (block.kind === 'heading' && block.level === 3) { + const title = text(block.text); + section.entries.push({ + id: unique(anchorOf(title)), + title, + event: events.includes(title), + when: when.get(title) ?? null, + blocks: [], + }); + } else { + (section.entries.at(-1) ?? section).blocks.push(block); + } + } + + const anchors = new Map(); + const placed = new Set(); + for (const section of sections) { + const all = [...section.blocks, ...section.entries.flatMap((entry) => entry.blocks)]; + const whole = all.map(said).join('\n'); + // An export belongs to the first section that shows it called. + section.exports = exports.filter(({ name }) => !placed.has(name) && whole.includes(`]:${name}(`)); + for (const { name } of section.exports) { + placed.add(name); + } + const linked = new Set(all.flatMap(inlinesOf).flatMap((inline) => localTargets(parseInline(inline)))); + section.files = example.filter((file) => + [...linked].some((path) => !path.startsWith('#') && file.name.startsWith(`${path}/`)), + ); + anchors.set(section.title, section.id); + for (const entry of section.entries) { + anchors.set(entry.title, entry.id); + } + } + return { lead, sections, anchors }; +} + +/** The fields of an event, as the first table of its section names them. */ +export const fieldsOf = (entry: DocEntry): string[] => { + const table = entry.blocks.find((block) => block.kind === 'table'); + return table?.kind === 'table' ? table.rows.map((row) => text(row.cells[0]?.text ?? '')) : []; +}; + +/** The widest a line of a handler runs, so that it fits its block without scrolling. */ +const HANDLER_WIDTH = 72; +const COMMENT = ' --'; + +const folded = (words: string[]): string[] => { + const lines: string[] = []; + let line = COMMENT; + for (const word of words) { + if (line !== COMMENT && `${line} ${word}`.length > HANDLER_WIDTH) { + lines.push(line); + line = COMMENT; + } + line += ` ${word}`; + } + return line === COMMENT ? lines : [...lines, line]; +}; + +/** + * A handler to copy for an event: its fields as its table names them, then what the example + * resource says goes there, in its own words. + */ +export const handlerOf = (entry: DocEntry, example: string): string => { + const fields = fieldsOf(entry).map((field) => `payload.${field}`); + const lines = folded(fields.map((field, index) => (index < fields.length - 1 ? `${field},` : field))); + const start = example.indexOf(`AddEventHandler('${entry.title}'`); + if (start >= 0) { + const end = example.indexOf('\nend)', start); + for (const row of example.slice(start, end < 0 ? undefined : end).split('\n')) { + const comment = /^\s*--\s*(.*)$/.exec(row); + if (comment !== null) { + lines.push(...folded((comment[1] ?? '').split(' ').filter(Boolean))); + } + } + } + return [`AddEventHandler('${entry.title}', function(payload)`, ...lines, 'end)'].join('\n'); +}; + +export type DocMatch = { + /** Where it is on the page. */ + id: string; + label: string; + kind: 'section' | 'event' | 'export' | 'txadmin'; +}; + +const TXADMIN_EVENT = /\btxAdmin:events:\w+/g; + +/** + * What the search finds in the documentation: its sections, each event, each export, and txAdmin's + * events by their own names. + */ +export const docIndex = (model: DocModel): DocMatch[] => + model.sections.flatMap((section) => { + const all = [...section.blocks, ...section.entries.flatMap((entry) => entry.blocks)]; + const txadmin = new Set(all.flatMap((block) => said(block).match(TXADMIN_EVENT) ?? [])); + return [ + { id: section.id, label: section.title, kind: 'section' as const }, + ...section.entries.map((entry) => ({ + id: entry.id, + label: entry.title, + kind: entry.event ? ('event' as const) : ('section' as const), + })), + ...section.exports.map(({ name }) => ({ id: section.id, label: name, kind: 'export' as const })), + ...[...txadmin].map((name) => ({ id: section.id, label: name, kind: 'txadmin' as const })), + ]; + }); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bf30a1bd..528fa2c7 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -101,6 +101,7 @@ import type { UpdateWhen } from '../api/generated/UpdateWhen'; import type { DestinationKind } from '../api/backups'; import { parsePermissions, permissionKeys, type PermissionKey } from '../auth/permissions'; import type { InvisibleKind } from '../changes/invisible'; +import type { DocMatch } from '../docs/model'; import type { LinkState } from '../live/realtime'; import type { Applies as SettingApplies, CategoryGroup, CategoryId, SettingId } from '../settings/catalog'; import type { LaunchStep, SetupStep } from '../setup/state'; @@ -471,6 +472,7 @@ export const en = { debugBags: 'State bags', debugHttp: 'HTTP requests', maintenance: 'Maintenance', + docs: 'Documentation', settings: 'Settings', schedule: 'Restarts', artifacts: 'FXServer builds', @@ -565,10 +567,17 @@ export const en = { }, palette: { label: 'Go to a page, a setting or a player', - placeholder: 'A page, a setting, a player online', + placeholder: 'A page, a setting, an event, a player online', results: 'Results', page: 'Page', tool: 'Tool', + doc: (kind: string) => `Documentation · ${kind}`, + docKinds: { + section: 'Section', + event: 'Event', + export: 'Export', + txadmin: "txAdmin's event", + } satisfies Record, setting: (category: string) => `Setting · ${category}`, player: (netId: number) => `Player online · #${netId}`, none: 'Nothing matches.', @@ -6082,6 +6091,83 @@ export const en = { missing: 'This build carries no licence list. cargo xtask web and cargo xtask dist write it.', loadFailed: 'The licence list could not be loaded.', }, + // The contract itself is not here: the page draws the text this build of Observer ships, as it + // draws a licence or a release's notes. These are the panel's own words around it. + docs: { + title: 'Documentation', + gloss: "for your server's resources", + find: 'Find an event or an export', + toc: 'On this page', + contract: 'The contract', + copyLink: (section: string) => `Copy the link to ${section}`, + linkCopied: 'Link copied', + event: 'Event', + exports: 'Exports', + lua: 'Lua', + copyNamed: (what: string) => `Copy ${what}`, + handlerOf: (event: string) => `the handler for ${event}`, + codeOf: (section: string) => `the code of ${section}`, + lines: (value: number) => count(value, { one: 'line', other: 'lines' }), + loading: 'Reading the documentation', + failed: 'The documentation could not be read.', + server: { + title: 'This server', + startedAt: (when: string) => `FXServer started ${when}`, + running: 'FXServer is running', + stopped: 'FXServer is stopped', + contract: 'Contract', + version: (version: number) => `Version ${formatNumber('en', version)}`, + versionAbout: "What this Observer's bridge speaks.", + bridge: 'Bridge', + bridgeRunning: 'Running', + bridgeStarting: 'Starting', + bridgeName: (name: ReactNode): ReactNode => [ + 'As ', + name, + ', the name drawn for this start. The next start draws another.', + ], + bridgeStopped: 'Not running', + bridgeStoppedAbout: 'It starts with FXServer, under a name drawn for that start.', + txadmin: "txAdmin's events", + on: 'On', + off: 'Off', + txadminOn: "Raised right after Observer's own events.", + txadminOff: "Only Observer's own events are raised.", + txadminNext: (on: boolean) => + on ? 'On from the next start of FXServer.' : 'Off from the next start of FXServer.', + txadminFromNext: 'From the next start of FXServer.', + notSet: 'Not set', + notSetAbout: 'The launch settings are not saved yet.', + launchSettings: 'Launch settings', + }, + notes: { + panel: 'In the panel.', + server: 'This server.', + labels: (players: ReactNode): ReactNode => [ + 'Online now shows a label in place of the FiveM name, with a tag. The ', + players, + ' page finds players by it, and each profile keeps it with the resource that gave it.', + ], + startChanges: (changes: ReactNode): ReactNode => [ + changes, + ' lists every resource this event names, and those it left out.', + ], + bans: (bans: ReactNode, message: ReactNode): ReactNode => [ + 'The staff bans from a profile or the ', + bans, + ' page. The ', + message, + ' banned players read is in the settings.', + ], + txadminOn: "txAdmin's events are on: the bridge raises them right after Observer's own.", + txadminOff: "txAdmin's events are off: the bridge raises Observer's events only.", + txadminStopped: (on: boolean) => + on + ? "txAdmin's events are on in the launch settings: the bridge raises them from the next start." + : "txAdmin's events are off in the launch settings: the next start raises Observer's events only.", + refusalMessage: 'refusal message', + }, + }, permissions, permissionAreas: { dashboard: 'Dashboard', diff --git a/web/src/pages/DocsPage.test.tsx b/web/src/pages/DocsPage.test.tsx new file mode 100644 index 00000000..09a82a02 --- /dev/null +++ b/web/src/pages/DocsPage.test.tsx @@ -0,0 +1,359 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; +import { act, cleanup, fireEvent, render, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiError } from '../api/client'; +import { integrationDocsQuery } from '../api/docs'; +import type { IntegrationDocs } from '../api/generated/IntegrationDocs'; +import type { Me } from '../api/generated/Me'; +import type { ServerStatus } from '../api/generated/ServerStatus'; +import { MeContext } from '../auth/me'; +import { integrationDocs } from '../docs/fixtures'; +import { buildDocs, fieldsOf, handlerOf } from '../docs/model'; +import { NOTED } from '../docs/Notes'; +import { en } from '../i18n/en'; +import { I18nProvider } from '../i18n/I18nProvider'; +import { PaletteContext } from '../shell/palette'; +import { DocsPage } from './DocsPage'; + +/** The status the shell would follow, set by each test. */ +const server = vi.hoisted(() => { + const listeners = new Set<() => void>(); + const store = { + status: null as ServerStatus | null, + set: (next: ServerStatus | null) => { + store.status = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return store; +}); +vi.mock('../server/context', async () => { + const { useSyncExternalStore } = await import('react'); + return { + useServerStatus: () => ({ + status: useSyncExternalStore(server.subscribe, () => server.status), + refused: false, + }), + }; +}); + +/** A status with what the page reads of it. */ +const status = (generation: number, state: ServerStatus['state'], startedAt: string | null = null) => + ({ generation, state, startedAt }) as ServerStatus; + +const OWNER: Me = { + id: 1, + username: 'ada', + isOwner: true, + permissions: 'fffffff', + mustChangePassword: false, +}; +/** Sees the dashboard and nothing else: is told what the server does, and opens none of the pages named. */ +const VIEWER: Me = { id: 2, username: 'grace', isOwner: false, permissions: '1', mustChangePassword: false }; +/** Holds no permission: reads the contract, and is not told what the server does. */ +const READER: Me = { id: 3, username: 'alan', isOwner: false, permissions: '0', mustChangePassword: false }; + +const words = en.docs; +const BRIDGE = 'sys_k3jx7qmw2pza'; +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); +const openPalette = vi.fn(); +const writeText = vi.fn(async (_text: string) => {}); + +const open = async ( + docs: IntegrationDocs | ApiError, + { me = OWNER, path = '/docs' }: { me?: Me; path?: string } = {}, +) => { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false, retryOnMount: false } }, + }); + if (docs instanceof ApiError) { + await client.prefetchQuery({ ...integrationDocsQuery, queryFn: () => Promise.reject(docs) }); + } else { + client.setQueryData(integrationDocsQuery.queryKey, docs); + } + const root = createRootRoute({ component: Outlet }); + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/docs', component: DocsPage }), + ]), + history: createMemoryHistory({ initialEntries: [path] }), + }); + const view = render( + + + + + + + + + , + ); + await settle(); + await settle(); + return { ...view, client, router }; +}; + +/** The card a section or an event has on the page. */ +const card = (id: string) => document.getElementById(id) as HTMLElement; +const facts = () => within(card('this-server')); + +describe('DocsPage', () => { + beforeEach(() => { + // jsdom lays nothing out, so it has no scrolling to the section a link leads to. + Element.prototype.scrollIntoView = vi.fn(); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(integrationDocs())), + ); + server.set(null); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('should draw every section of the contract in its order, under contents that list them', async () => { + const docs = integrationDocs(); + const model = buildDocs(docs); + const { getAllByRole, getByRole } = await open(docs); + + expect(getByRole('heading', { level: 1 }).textContent).toBe(`${words.title} ${words.gloss}`); + expect(getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent)).toEqual([ + words.server.title, + ...model.sections.map((section) => section.title), + ]); + const contents = within(getByRole('navigation', { name: words.toc })); + const listed = contents + .getAllByRole('link') + .map((link) => [link.textContent, link.getAttribute('href')]); + expect(listed[0]).toEqual([words.server.title, '#this-server']); + for (const section of model.sections) { + expect(listed).toContainEqual([section.title, `#${section.id}`]); + for (const { name } of section.exports) { + expect(listed).toContainEqual([name, `#${section.id}`]); + } + } + expect(listed).toContainEqual(['shuttingDown', '#observershuttingdown']); + // Each export shows where it is documented, with the parameters its script declares. + for (const section of model.sections) { + for (const { name, parameters } of section.exports) { + expect(within(card(section.id)).getByText(`${name}(${parameters.join(', ')})`)).toBeTruthy(); + } + } + expect(listed).toHaveLength( + 1 + + model.sections.length + + model.sections.flatMap((section) => [...section.entries, ...section.exports]).length, + ); + }); + + it('should give each event the bridge raises its card: when it comes, its fields and a handler to copy', async () => { + const docs = integrationDocs(); + const model = buildDocs(docs); + const script = docs.example.map((file) => file.text).join('\n'); + await open(docs); + + expect(docs.events).not.toHaveLength(0); + for (const event of docs.events) { + const entry = model.sections + .flatMap((section) => section.entries) + .find((found) => found.title === event); + const own = within(card(entry?.id ?? '')); + + expect(own.getByRole('heading', { level: 3 }).textContent, event).toBe(event); + expect(own.getByText(words.event), event).toBeTruthy(); + for (const field of entry === undefined ? [] : fieldsOf(entry)) { + expect(own.getAllByText(field).length, `${event} ${field}`).toBeGreaterThan(0); + } + const handler = entry === undefined ? '' : handlerOf(entry, script); + expect(card(entry?.id ?? '').querySelector('pre')?.textContent, event).toBe(handler); + fireEvent.click(own.getByRole('button', { name: words.copyNamed(words.handlerOf(event)) })); + expect(writeText, event).toHaveBeenLastCalledWith(handler); + } + await settle(); + }); + + it('should say what a server that does not run will do, and what one that runs does', async () => { + const stopped = await open(integrationDocs(), { me: VIEWER }); + expect(facts().getByText(words.server.version(integrationDocs().contractVersion))).toBeTruthy(); + expect(facts().getByText(words.server.stopped)).toBeTruthy(); + expect(facts().getByText(words.server.bridgeStopped)).toBeTruthy(); + expect(facts().getByText(words.server.notSet)).toBeTruthy(); + expect(stopped.queryByRole('link', { name: words.server.launchSettings })).toBeNull(); + cleanup(); + + await open(integrationDocs({ txadminEventsNext: true })); + expect(facts().getByText(words.server.on)).toBeTruthy(); + expect(facts().getByText(words.server.txadminFromNext, { exact: false })).toBeTruthy(); + expect(facts().getByRole('link', { name: words.server.launchSettings }).getAttribute('href')).toBe( + '/settings/launch#setting-txadminEvents', + ); + cleanup(); + + await open( + integrationDocs({ bridge: BRIDGE, starting: true, txadminEvents: true, txadminEventsNext: true }), + ); + expect(facts().getByText(words.server.running)).toBeTruthy(); + expect(facts().getByText(words.server.bridgeStarting)).toBeTruthy(); + expect(facts().getByText(BRIDGE)).toBeTruthy(); + cleanup(); + + server.set(status(3, 'online', '2026-11-11T13:02:00Z')); + await open(integrationDocs({ bridge: BRIDGE, txadminEvents: false, txadminEventsNext: true })); + expect(facts().getByText(words.server.bridgeRunning)).toBeTruthy(); + expect(facts().getByText(words.server.startedAt('').trim(), { exact: false })).toBeTruthy(); + expect(facts().getByText(words.server.off)).toBeTruthy(); + // The setting was saved since this start. + expect( + facts().getByText(`${words.server.txadminOff} ${words.server.txadminNext(true)}`, { + exact: false, + }), + ).toBeTruthy(); + }); + + it('should add a word of the panel under the sections it has one for, each held to the contract', async () => { + const docs = integrationDocs({ bridge: BRIDGE, txadminEvents: false, txadminEventsNext: false }); + const model = buildDocs(docs); + const ids = model.sections.flatMap((section) => [ + section.id, + ...section.entries.map((entry) => entry.id), + ]); + for (const noted of NOTED) { + expect(ids, `CONTRACT.md has no heading at #${noted} any more`).toContain(noted); + } + + await open(docs); + const labels = within(card('player-labels')); + expect(labels.getByText(words.notes.panel)).toBeTruthy(); + expect(labels.getByRole('link', { name: en.nav.players }).getAttribute('href')).toBe('/players'); + expect( + within(card('observerstartchanges')) + .getByRole('link', { name: en.nav.changes }) + .getAttribute('href'), + ).toBe('/changes'); + const bans = within(card('bans')); + expect(bans.getByRole('link', { name: en.nav.bans }).getAttribute('href')).toBe('/bans'); + expect(bans.getByRole('link', { name: words.notes.refusalMessage }).getAttribute('href')).toBe( + '/settings/bans#setting-refusalMessage', + ); + const txadmin = within(card('txadmins-events')); + expect(txadmin.getByText(words.notes.server)).toBeTruthy(); + expect(txadmin.getByText(words.notes.txadminOff, { exact: false })).toBeTruthy(); + cleanup(); + + // An account that opens none of those pages reads their names. + await open(docs, { me: VIEWER }); + for (const noted of NOTED) { + const footer = card(noted).querySelector('footer') as HTMLElement; + expect(footer, noted).not.toBeNull(); + expect(within(footer).queryByRole('link'), noted).toBeNull(); + } + expect(card('player-labels').querySelector('footer')?.textContent).toContain(en.nav.players); + }); + + it('should leave what the server does out for an account that is not told it', async () => { + const docs = integrationDocs(null); + const { getAllByRole, getByRole } = await open(docs, { me: READER }); + + expect(card('this-server')).toBeNull(); + expect(card('txadmins-events').querySelector('footer')).toBeNull(); + expect(card('bans').querySelector('footer')).not.toBeNull(); + const contents = within(getByRole('navigation', { name: words.toc })); + expect(contents.queryByRole('link', { name: words.server.title })).toBeNull(); + expect(getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent)).toEqual( + buildDocs(docs).sections.map((section) => section.title), + ); + }); + + it('should lead the links of the contract to their place on the page, and show the files it links to', async () => { + const { getByRole } = await open(integrationDocs()); + + const events = within(card('events')); + expect(events.getAllByRole('link', { name: 'Bans' })[0]?.getAttribute('href')).toBe('#bans'); + expect(events.getByRole('link', { name: 'observer:playerRefused' }).getAttribute('href')).toBe( + '#observerplayerrefused', + ); + // Its own name, in its own card, is not a link to itself. + expect( + within(card('observerplayerrefused')).queryByRole('link', { name: 'observer:playerRefused' }), + ).toBeNull(); + const example = within(card('example')); + expect(example.getByRole('link', { name: 'observer_example' }).getAttribute('href')).toBe( + '#files-observer_example', + ); + const file = getByRole('button', { name: /^observer_example\/server\.lua/ }); + const code = document.getElementById(file.getAttribute('aria-controls') ?? '') as HTMLElement; + expect(file.getAttribute('aria-expanded')).toBe('false'); + expect(code.hidden).toBe(true); + fireEvent.click(file); + expect(file.getAttribute('aria-expanded')).toBe('true'); + expect(code.hidden).toBe(false); + expect(code.textContent).toContain("AddEventHandler('observer:shuttingDown'"); + }); + + it('should open on the section its address names, and copy the address of a section', async () => { + const { getByRole } = await open(integrationDocs(), { path: '/docs#bans' }); + + expect(vi.mocked(Element.prototype.scrollIntoView).mock.contexts).toContain(card('bans')); + fireEvent.click(getByRole('button', { name: words.copyLink('Bans') })); + await settle(); + expect(writeText).toHaveBeenLastCalledWith(`${location.origin}/docs#bans`); + expect(within(card('bans')).getByText(words.linkCopied)).toBeTruthy(); + }); + + it('should give the link to read when the browser refuses to copy it', async () => { + writeText.mockRejectedValueOnce(new Error('not allowed')); + const { getByRole } = await open(integrationDocs()); + + fireEvent.click(getByRole('button', { name: words.copyLink('Security') })); + await settle(); + + expect( + within(card('security')).getByText( + en.common.copyBlockedValue(`${location.origin}/docs#security`), + ), + ).toBeTruthy(); + }); + + it('should open the search from its button, and say when the documentation cannot be read', async () => { + const read = await open(integrationDocs()); + fireEvent.click(read.getByRole('button', { name: new RegExp(words.find) })); + expect(openPalette).toHaveBeenCalledOnce(); + cleanup(); + + const failed = await open(new ApiError(500, 'internal', {})); + expect(failed.getByRole('alert').textContent).toContain(words.failed); + expect(failed.queryByRole('navigation', { name: words.toc })).toBeNull(); + }); + + it('should read what the server does again when it starts or stops, and not before', async () => { + server.set(status(3, 'stopped')); + const { client } = await open(integrationDocs()); + const invalidate = vi.spyOn(client, 'invalidateQueries'); + expect(invalidate).not.toHaveBeenCalled(); + + act(() => server.set(status(4, 'starting'))); + await settle(); + + expect(invalidate).toHaveBeenCalledExactlyOnceWith({ queryKey: integrationDocsQuery.queryKey }); + }); +}); diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx new file mode 100644 index 00000000..19b3b5ba --- /dev/null +++ b/web/src/pages/DocsPage.tsx @@ -0,0 +1,168 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useLocation } from '@tanstack/react-router'; +import { Search } from 'lucide-react'; +import { Fragment, useEffect, useMemo, useRef } from 'react'; + +import { integrationDocsQuery } from '../api/docs'; +import type { IntegrationDocs } from '../api/generated/IntegrationDocs'; +import { buttonVariants } from '../components/Button'; +import { Notice } from '../components/Notice'; +import { EntryCard, SectionCard, folderOf } from '../docs/Cards'; +import { buildDocs } from '../docs/model'; +import { Prose, ReadingContext } from '../docs/Prose'; +import { THIS_SERVER, ThisServer } from '../docs/ThisServer'; +import { Toc, type TocGroup } from '../docs/Toc'; +import { errorMessage } from '../i18n/errors'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { useServerStatus } from '../server/context'; +import { useOpenPalette } from '../shell/palette'; + +/** + * What Observer gives the server's resources, for whoever writes them: the integration contract as + * this build ships it, drawn section by section, with what this server does of it and where the + * panel shows its effect. The text is the binary's own: this page writes none of it. + */ +export function DocsPage() { + const { t } = useI18n(); + const docs = useQuery(integrationDocsQuery); + const openPalette = useOpenPalette(); + useServerSide(); + + return ( +
    +
    +

    + {t.docs.title} {t.docs.gloss} +

    + +
    + {/* What was read stays on the page when reading it again fails. */} + {docs.data !== undefined ? ( + + ) : docs.isError ? ( + + {t.docs.failed} {errorMessage(t, docs.error)} + + ) : ( +
    + {t.docs.loading} +
    +
    +
    +
    + )} +
    + ); +} + +/** The server's side of the documentation changes with each start and stop: it is read again then. */ +const useServerSide = () => { + const client = useQueryClient(); + const { status } = useServerStatus(); + const run = status === null ? null : `${status.generation} ${status.state}`; + const last = useRef(run); + useEffect(() => { + if (run === null) { + return; + } + if (last.current !== null && last.current !== run) { + void client.invalidateQueries({ queryKey: integrationDocsQuery.queryKey }); + } + last.current = run; + }, [client, run]); +}; + +/** `observer:shuttingDown` is `shuttingDown` under its section, where the prefix says nothing more. */ +const short = (event: string) => event.slice(event.indexOf(':') + 1); + +function Documentation({ docs }: { docs: IntegrationDocs }) { + const { t } = useI18n(); + const { contract, events, exports, example, server } = docs; + const situated = server !== null; + const model = useMemo( + () => buildDocs({ contract, events, exports, example }), + [contract, events, exports, example], + ); + const groups = useMemo( + () => [ + ...(situated + ? [ + { + label: t.docs.toc, + items: [ + { key: THIS_SERVER, id: THIS_SERVER, label: t.docs.server.title, sub: false }, + ], + }, + ] + : []), + { + label: t.docs.contract, + items: model.sections.flatMap((section) => [ + { key: section.id, id: section.id, label: section.title, sub: false }, + ...section.exports.map(({ name }) => ({ + key: `${section.id} ${name}`, + id: section.id, + label: name, + sub: true, + })), + ...section.entries.map((entry) => ({ + key: entry.id, + id: entry.id, + label: entry.event ? short(entry.title) : entry.title, + sub: true, + })), + ]), + }, + ], + [model, t, situated], + ); + const reading = useMemo( + () => ({ anchors: model.anchors, paths: new Set(example.map(folderOf)), own: '' }), + [model.anchors, example], + ); + + // A link to a section opens on it, once the page has drawn what it leads to. + const hash = useLocation({ select: (location) => location.hash }); + useEffect(() => { + if (hash !== '') { + document.getElementById(hash)?.scrollIntoView(); + } + }, [hash]); + + return ( + <> +
    + + + +
    +
    + +
    + {server !== null && } + {model.sections.map((section) => ( + + + {section.entries.map((entry) => ( + + ))} + + ))} +
    +
    + + ); +} diff --git a/web/src/router.test.tsx b/web/src/router.test.tsx index 55da8898..5201c16e 100644 --- a/web/src/router.test.tsx +++ b/web/src/router.test.tsx @@ -46,6 +46,7 @@ vi.mock('./shell/FocusShell', async () => { }); vi.mock('./pages/OverviewPage', () => ({ OverviewPage: () =>

    Overview

    })); vi.mock('./pages/ServerSetupPage', () => ({ ServerSetupPage: () =>

    Server setup

    })); +vi.mock('./pages/DocsPage', () => ({ DocsPage: () =>

    Documentation

    })); vi.mock('./pages/SettingsPage', async () => { const { Outlet } = await import('@tanstack/react-router'); return { SettingsPage: () => }; @@ -167,6 +168,14 @@ describe('the panel router', () => { expect(router.state.location.pathname).toBe(`/settings/${category}`); }); + it('should open the documentation for an account that holds no permission', async () => { + const { findByRole, router } = panel('/docs#bans', [[meQuery.queryKey, NOBODY]]); + + await findByRole('heading', { name: 'Documentation' }); + expect(router.state.location.pathname).toBe('/docs'); + expect(router.state.location.hash).toBe('bans'); + }); + it('should say a category of the settings that does not exist is not found', async () => { const { findByText, queryByRole } = panel('/settings/everything', [[meQuery.queryKey, OWNER]]); diff --git a/web/src/router.tsx b/web/src/router.tsx index 98bcc87b..0e040dbd 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -63,6 +63,7 @@ const BackupsPage = lazyCode(() => ); const AuditPage = lazyCode(() => import('./pages/AuditPage').then((page) => ({ default: page.AuditPage }))); const AboutPage = lazyCode(() => import('./pages/AboutPage').then((page) => ({ default: page.AboutPage }))); +const DocsPage = lazyCode(() => import('./pages/DocsPage').then((page) => ({ default: page.DocsPage }))); const DiagnosticsPage = lazyCode(() => import('./pages/DiagnosticsPage').then((page) => ({ default: page.DiagnosticsPage })), ); @@ -496,6 +497,14 @@ const aboutRoute = createRoute({ component: AboutPage, }); +// The contract Observer ships holds no secret: any signed-in account reads it, as the About page. +// What the server does of it comes only to those who see the dashboard: the API leaves it out. +const docsRoute = createRoute({ + getParentRoute: () => panelRoute, + path: '/docs', + component: DocsPage, +}); + const routeTree = rootRoute.addChildren([ setupRoute, loginRoute, @@ -530,6 +539,7 @@ const routeTree = rootRoute.addChildren([ settingsRoute.addChildren([settingsIndexRoute, settingsCategoryRoute]), accountRoute, aboutRoute, + docsRoute, ]), focusRoute.addChildren([serverSetupRoute]), ]); diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 2eb2291d..7c2c7b2a 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -182,7 +182,7 @@ describe('AppShell', () => { expect(without.queryByRole('button', { name: en.nav.groupDebug })).toBeNull(); }); - it('should keep the settings at the foot of the rail, for every account', async () => { + it('should keep the documentation and the settings at the foot of the rail, for every account', async () => { const { findByRole, getByRole } = shell({ ...OWNER, id: 2, @@ -195,6 +195,14 @@ describe('AppShell', () => { const rail = within(getByRole('complementary', { name: en.nav.label })); const settings = rail.getByRole('link', { name: en.nav.settings }); expect(settings.getAttribute('href')).toBe('/settings'); + const docs = rail.getByRole('link', { name: en.nav.docs }); + expect(docs.getAttribute('href')).toBe('/docs'); + expect(docs.compareDocumentPosition(settings) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + expect( + within(getByRole('navigation', { name: en.nav.sections })).queryByRole('link', { + name: en.nav.docs, + }), + ).toBeNull(); expect( within(getByRole('navigation', { name: en.nav.sections })).queryByRole('link', { name: en.nav.settings, diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx index b2581e16..0f308a63 100644 --- a/web/src/shell/AppShell.tsx +++ b/web/src/shell/AppShell.tsx @@ -23,6 +23,7 @@ import { StatusBar } from './StatusBar'; import { UpdateSignal } from '../updates/UpdateSignal'; import { CommandPalette } from './CommandPalette'; import { navSections, opensEntry, type NavEntry } from './nav'; +import { PaletteContext } from './palette'; import { UserMenu } from './UserMenu'; export function AppShell() { @@ -175,7 +176,9 @@ export function AppShell() {
    }> - + + +
    diff --git a/web/src/shell/CommandPalette.test.tsx b/web/src/shell/CommandPalette.test.tsx index edca62cb..a718f5f0 100644 --- a/web/src/shell/CommandPalette.test.tsx +++ b/web/src/shell/CommandPalette.test.tsx @@ -3,9 +3,11 @@ import { cleanup, fireEvent, render } from '@testing-library/react'; import { useState } from 'react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { integrationDocsQuery } from '../api/docs'; import type { Me } from '../api/generated/Me'; import type { OnlinePlayers } from '../api/generated/OnlinePlayers'; import { onlineQuery } from '../api/players'; +import { integrationDocs } from '../docs/fixtures'; import { en } from '../i18n/en'; import { I18nProvider } from '../i18n/I18nProvider'; import { CommandPalette } from './CommandPalette'; @@ -38,6 +40,7 @@ const listed = () => const palette = (me: Me = OWNER) => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); client.setQueryData(onlineQuery.queryKey, ONLINE); + client.setQueryData(integrationDocsQuery.queryKey, integrationDocs()); const view = render( @@ -75,7 +78,13 @@ describe('CommandPalette', () => { cleanup(); palette(VIEWER); - expect(listed()).toEqual([en.nav.overview, en.nav.schedule, en.nav.statistics, en.nav.settings]); + expect(listed()).toEqual([ + en.nav.overview, + en.nav.schedule, + en.nav.statistics, + en.nav.docs, + en.nav.settings, + ]); }); it('should go to the page the arrows are on when Enter is pressed', () => { @@ -105,6 +114,34 @@ describe('CommandPalette', () => { }); }); + it('should find an event of the documentation by any word of its name, and open the page on it', () => { + const { field, getByText } = palette(VIEWER); + + fireEvent.change(field(), { target: { value: 'rescheduled' } }); + + expect(listed()).toEqual(['observer:restartRescheduled']); + expect(getByText(en.palette.doc(en.palette.docKinds.event))).not.toBeNull(); + fireEvent.keyDown(field(), { key: 'Enter' }); + expect(navigate).toHaveBeenCalledWith({ to: '/docs', hash: 'observerrestartrescheduled' }); + }); + + it('should find a section, an export and an event txAdmin names, each where the page documents it', () => { + const { field, getByText } = palette(VIEWER); + + fireEvent.change(field(), { target: { value: 'label' } }); + expect(listed()).toEqual(['Player labels', 'setPlayerLabel', 'clearPlayerLabel']); + fireEvent.click(getByText('clearPlayerLabel')); + expect(navigate).toHaveBeenLastCalledWith({ to: '/docs', hash: 'player-labels' }); + cleanup(); + + const again = palette(VIEWER); + fireEvent.change(again.field(), { target: { value: 'serverShuttingDown' } }); + expect(listed()).toEqual(['txAdmin:events:serverShuttingDown']); + expect(again.getByText(en.palette.doc(en.palette.docKinds.txadmin))).not.toBeNull(); + fireEvent.keyDown(again.field(), { key: 'Enter' }); + expect(navigate).toHaveBeenLastCalledWith({ to: '/docs', hash: 'txadmins-events' }); + }); + it('should find a connected player whose profile is known, by name or by ID', () => { const { field, getByText } = palette(); diff --git a/web/src/shell/CommandPalette.tsx b/web/src/shell/CommandPalette.tsx index 23a2aa6d..1403423c 100644 --- a/web/src/shell/CommandPalette.tsx +++ b/web/src/shell/CommandPalette.tsx @@ -1,13 +1,16 @@ import { useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; -import { Contact, Search, SlidersHorizontal, Wrench, type LucideIcon } from 'lucide-react'; -import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { BookOpen, Contact, Search, SlidersHorizontal, Wrench, type LucideIcon } from 'lucide-react'; +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { integrationDocsQuery } from '../api/docs'; import type { Me } from '../api/generated/Me'; import { Permission } from '../api/generated/Permission'; import { onlineQuery } from '../api/players'; import { allows } from '../auth/me'; +import { buildDocs, docIndex } from '../docs/model'; import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; import { anchorOf, search as searchSettings } from '../settings/catalog'; import { navSections, opensEntry } from './nav'; @@ -20,6 +23,8 @@ type Result = { label: string; /** What kind of place it is, or where it lies. */ hint: string; + /** A name of the code, an event's or an export's, shown as code is. */ + code?: boolean; go: () => void; }; @@ -34,8 +39,9 @@ const asksForPalette = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k'; /** - * Goes anywhere in the panel from the keyboard: a page, a setting by its name, a player who is - * connected. Ctrl K opens it from any page; it lists only what the account may open. + * Goes anywhere in the panel from the keyboard: a page, a setting by its name, a section, an event + * or an export of the documentation, a player who is connected. Ctrl K opens it from any page; it + * lists only what the account may open. */ export function CommandPalette({ me, @@ -76,6 +82,12 @@ function Palette({ me, onClose }: { me: Me; onClose: () => void }) { const [active, setActive] = useState(0); const seesPlayers = allows(me, Permission.PlayersView); const online = useQuery({ ...onlineQuery, enabled: seesPlayers, refetchInterval: false }); + // The contract only changes with the build: its sections are read once. + const contract = useQuery({ ...integrationDocsQuery, staleTime: Infinity }).data; + const documented = useMemo( + () => (contract === undefined ? [] : docIndex(buildDocs(contract))), + [contract], + ); useLayoutEffect(() => { const element = dialog.current; @@ -149,6 +161,18 @@ function Palette({ me, onClose }: { me: Me; onClose: () => void }) { ? pages : [ ...pages.filter((page) => matches(page.label)), + // `restartScheduled` is also found by `scheduled`: a name of the code is read word by word. + ...documented + .filter(({ label }) => matches(`${label} ${label.replace(/([a-z])([A-Z])/g, '$1 $2')}`)) + .slice(0, MAX_OF_KIND) + .map((match) => ({ + key: `doc ${match.kind} ${match.label}`, + icon: BookOpen, + label: match.label, + hint: words.doc(words.docKinds[match.kind]), + code: match.kind !== 'section', + go: leave(() => void navigate({ to: '/docs', hash: match.id })), + })), // Those named by what was asked first, then those whose explanation says it. ...searchSettings(t, me, query) .map((match) => { @@ -263,7 +287,13 @@ function Palette({ me, onClose }: { me: Me; onClose: () => void }) { className="flex-none text-ink-3" aria-hidden="true" /> - + {result.label} {result.hint} diff --git a/web/src/shell/nav.ts b/web/src/shell/nav.ts index 3c60840d..20e827d0 100644 --- a/web/src/shell/nav.ts +++ b/web/src/shell/nav.ts @@ -1,5 +1,6 @@ import { Ban, + BookOpen, CalendarClock, ChartLine, Contact, @@ -42,6 +43,7 @@ export type NavEntry = { | '/bans' | '/changes' | '/drops' + | '/docs' | '/settings' | '/tools' | '/cfg' @@ -73,7 +75,7 @@ export const opensEntry = (me: Me, { access }: Pick) => /** * The panel's pages as the rail lists them, by what one comes for: a person, the server's setup, - * what the server did, the upkeep. The settings sit at the rail's foot. + * what the server did, the upkeep. The documentation and the settings sit at the rail's foot. */ export const navSections = (t: Messages) => ({ main: [ @@ -129,8 +131,10 @@ export const navSections = (t: Messages) => ({ { to: '/tools', label: t.nav.tools, icon: Wrench, access: 'owner', nested: true }, { to: '/audit', label: t.nav.audit, icon: ScrollText, access: Permission.AuditView }, ] satisfies NavEntry[], - // Every account opens its own settings, and the categories its permissions open. + // Every account reads the documentation, which holds no secret, and opens its own settings and + // the categories its permissions open. settings: [ + { to: '/docs', label: t.nav.docs, icon: BookOpen, access: 'everyone' }, { to: '/settings', label: t.nav.settings, icon: Settings, access: 'everyone', nested: true }, ] satisfies NavEntry[], }); diff --git a/web/src/shell/palette.ts b/web/src/shell/palette.ts new file mode 100644 index 00000000..dcb217ca --- /dev/null +++ b/web/src/shell/palette.ts @@ -0,0 +1,6 @@ +import { createContext, use } from 'react'; + +/** Opens the panel's search, for a page that offers it besides the rail's button and Ctrl K. */ +export const PaletteContext = createContext<() => void>(() => {}); + +export const useOpenPalette = () => use(PaletteContext); diff --git a/web/vite.config.ts b/web/vite.config.ts index de8164a2..483afedf 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -14,6 +14,8 @@ export default defineConfig({ proxy: { '/api': { target: observer, ws: true }, }, + // The documentation's tests read the contract as the binary embeds it, from `resource/`. + fs: { allow: ['.', '../resource'] }, }, build: { outDir: 'dist', From 2f41f6be86e27b3d43fce393f53ca39cc2e9a84e Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 15:43:46 +0100 Subject: [PATCH 7/7] test(web): keep the Documentation page's tests true for contracts 5 to 7, which order and fill their events otherwise --- web/src/docs/model.test.ts | 31 ++++++++++++++++++++----------- web/src/pages/DocsPage.test.tsx | 8 +++++--- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/web/src/docs/model.test.ts b/web/src/docs/model.test.ts index 62b81066..0e2a2bbb 100644 --- a/web/src/docs/model.test.ts +++ b/web/src/docs/model.test.ts @@ -109,16 +109,24 @@ describe('handlerOf', () => { } }); - it('stands without the example', () => { - const entry = entries.find((candidate) => candidate.title === 'observer:shuttingDown'); - - expect(entry && handlerOf(entry, '')).toBe( - [ - "AddEventHandler('observer:shuttingDown', function(payload)", - ' -- payload.delayMs, payload.reason, payload.by', - 'end)', - ].join('\n'), - ); + it('stands without the example: its fields alone', () => { + for (const entry of entries.filter((candidate) => candidate.event)) { + const lines = handlerOf(entry, '').split('\n'); + + expect(lines[0], entry.title).toBe(`AddEventHandler('${entry.title}', function(payload)`); + expect(lines.at(-1), entry.title).toBe('end)'); + expect( + lines + .slice(1, -1) + .map((line) => line.replace(/^ {4}-- /, '')) + .join(' '), + entry.title, + ).toBe( + fieldsOf(entry) + .map((field) => `payload.${field}`) + .join(', '), + ); + } }); }); @@ -128,7 +136,8 @@ describe('docIndex', () => { const of = (kind: string) => index.filter((match) => match.kind === kind).map((match) => match.label); expect(of('section')).toContain('Player labels'); - expect(of('event')).toEqual(docs.events); + // In the contract's order, which need not be the order the bridge raises them in. + expect(of('event').toSorted()).toEqual(docs.events.toSorted()); expect(of('export').toSorted()).toEqual(docs.exports.map((item) => item.name).toSorted()); expect(of('txadmin')).toContain('txAdmin:events:serverShuttingDown'); expect(index.find((match) => match.label === 'setPlayerLabel')?.id).toBe('player-labels'); diff --git a/web/src/pages/DocsPage.test.tsx b/web/src/pages/DocsPage.test.tsx index 09a82a02..a04f59ee 100644 --- a/web/src/pages/DocsPage.test.tsx +++ b/web/src/pages/DocsPage.test.tsx @@ -153,10 +153,12 @@ describe('DocsPage', () => { } } expect(listed).toContainEqual(['shuttingDown', '#observershuttingdown']); - // Each export shows where it is documented, with the parameters its script declares. - for (const section of model.sections) { + // Each export shows where it is documented, with the parameters its script declares, on the + // line that heads the section: the contract's own text may name it too. + for (const section of model.sections.filter((documented) => documented.exports.length > 0)) { + const heading = within(within(card(section.id)).getByText(words.exports)); for (const { name, parameters } of section.exports) { - expect(within(card(section.id)).getByText(`${name}(${parameters.join(', ')})`)).toBeTruthy(); + expect(heading.getByText(`${name}(${parameters.join(', ')})`)).toBeTruthy(); } } expect(listed).toHaveLength(