// SPDX-License-Identifier: AGPL-3.0-or-later import {readdir, readFile} from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {DOCS_ROOT, readMarkdownPages} from './DocsSource.ts'; import { columnWidthPercents, extractTables, PX, TABLE_BUDGET_PX, TABLE_FLOOR_CAP_SHARE, TABLE_MAX_CELL_CHARS, TABLE_MAX_COLUMNS, TABLE_MAX_IDENT_CHARS, TABLE_WIDE_TIER_PX, } from './DocsTableWidth.ts'; const STYLES_ROOT = fileURLToPath(new URL('../src/styles/', import.meta.url)); const STARLIGHT_STYLES = fileURLToPath(new URL('../node_modules/@astrojs/starlight/dist/style/', import.meta.url)); const FORBIDDEN_MARKETING_WORDS = [ 'seamless', 'effortless', 'blazing', 'cutting-edge', 'game-changing', 'powerful and', 'just simply', 'simply use', 'simply call', 'simply add', 'simply set', ]; const FORBIDDEN_TOPICS = [ {pattern: /push subscription/iu, reason: 'push API does not exist in the live era'}, {pattern: /\/push\/events/u, reason: 'push events API does not exist in the live era'}, {pattern: /voice[- ]public[- ]key/iu, reason: 'voice connection API does not exist in the live era'}, {pattern: /match-public-key/u, reason: 'voice connection API does not exist in the live era'}, ]; const CANONICAL_HEADINGS = new Map([ ['### JSON params', '### JSON body'], ['### Query string parameters', '### Query parameters'], ['### Response Body', '### Response body'], ['### JSON Body', '### JSON body'], ['### Query Parameters', '### Query parameters'], ]); interface Finding { readonly file: string; readonly line: number; readonly rule: string; readonly detail: string; } function frontmatterOf(source: string): string | null { if (!source.startsWith('---\n')) { return null; } const end = source.indexOf('\n---', 4); if (end === -1) { return null; } return source.slice(4, end); } const pages = await readMarkdownPages(DOCS_ROOT); const findings: Array = []; for (const {file, relativePath: relative, source, lines} of pages) { const frontmatter = frontmatterOf(source); if (frontmatter == null) { findings.push({file: relative, line: 1, rule: 'frontmatter', detail: 'missing frontmatter block'}); } else { if (!/^# SPDX-License-Identifier: AGPL-3\.0-or-later$/mu.test(frontmatter)) { findings.push({file: relative, line: 2, rule: 'spdx', detail: 'missing SPDX comment in frontmatter'}); } if (!/^title:\s*\S/mu.test(frontmatter)) { findings.push({file: relative, line: 1, rule: 'frontmatter', detail: 'missing title'}); } if (!/^description:\s*\S/mu.test(frontmatter)) { findings.push({file: relative, line: 1, rule: 'frontmatter', detail: 'missing description'}); } } const usesRouteHeader = source.includes(' = []; for (let index = 0; index < lines.length; index += 1) { if (lines[index].startsWith('## ') && !lines[index].startsWith('### ')) { sectionStarts.push(index); } } sectionStarts.push(lines.length); for (let s = 0; s + 1 < sectionStarts.length; s += 1) { let responseBody = -1; let response = -1; for (let index = sectionStarts[s]; index < sectionStarts[s + 1]; index += 1) { const heading = lines[index].trim(); if (heading === '### Response body' && responseBody === -1) { responseBody = index; } if (heading === '### Response' && response === -1) { response = index; } } if (responseBody !== -1 && response !== -1 && responseBody > response) { findings.push({ file: relative, line: response + 1, rule: 'order', detail: '"### Response body" must precede "### Response"', }); } } } for (const {relativePath: relative, lines} of pages) { let block = new Map(); let sinceFootnote = 0; for (let index = 0; index < lines.length; index += 1) { const definition = lines[index].match(/^(\d+)<\/sup>\s/u); if (definition == null) { if (lines[index].trim().length > 0) { sinceFootnote += 1; } if (sinceFootnote > 1) { block = new Map(); } continue; } sinceFootnote = 0; const previous = block.get(definition[1]); if (previous != null) { findings.push({ file: relative, line: index + 1, rule: 'footnote', detail: `footnote ${definition[1]} is defined twice in the same block, first at line ${previous.toString()}`, }); continue; } block.set(definition[1], index + 1); } } const NOTATION_EXAMPLE = 'A superscript marker such as 1 refers to the numbered footnote'; for (const {relativePath: relative, source, lines} of pages) { const explainsNotation = source.includes(NOTATION_EXAMPLE); const bounds: Array = [0]; for (let index = 0; index < lines.length; index += 1) { if (lines[index].startsWith('## ') && !lines[index].startsWith('### ')) { bounds.push(index); } } bounds.push(lines.length); for (let b = 0; b + 1 < bounds.length; b += 1) { const definitions = new Map(); const references = new Set(); for (let index = bounds[b]; index < bounds[b + 1]; index += 1) { const definition = lines[index].match(/^(\d+)<\/sup>\s/u); if (definition != null) { if (!definitions.has(definition[1])) { definitions.set(definition[1], index + 1); } continue; } for (const marker of lines[index].matchAll(/(\d+)<\/sup>/gu)) { references.add(marker[1]); } } for (const [number, line] of definitions) { if (!references.has(number)) { findings.push({ file: relative, line, rule: 'footnote', detail: `footnote ${number} is defined but never referenced in its section`, }); } } for (const number of references) { if (definitions.has(number)) { continue; } if (explainsNotation) { continue; } findings.push({ file: relative, line: bounds[b] + 1, rule: 'footnote', detail: `footnote ${number} is referenced but never defined in its section`, }); } } } type TableRule = | 'table-columns' | 'table-fit' | 'table-cell' | 'table-identifier' | 'table-rows' | 'table-parallel' | 'table-wide-tight'; const TABLE_RULES: ReadonlyArray = [ 'table-columns', 'table-fit', 'table-cell', 'table-identifier', 'table-rows', 'table-parallel', 'table-wide-tight', ]; const TABLE_COUNT_FLOOR = 1800; const ACCEPTED_TABLE_FINDINGS = new Map>>>([ ['admin-api/applications.mdx', {'table-identifier': 1}], ['admin-api/discovery.mdx', {'table-identifier': 1}], ['admin-api/gateway.mdx', {'table-identifier': 2}], ['admin-api/guilds.mdx', {'table-identifier': 3}], ['admin-api/index.mdx', {'table-identifier': 3}], ['admin-api/instance.mdx', {'table-identifier': 8}], ['admin-api/messages.mdx', {'table-identifier': 1}], ['admin-api/reports.mdx', {'table-fit': 1, 'table-identifier': 2}], ['admin-api/users.mdx', {'table-identifier': 3}], ['admin-api/voice.mdx', {'table-identifier': 3}], ['gateway/event-filtering.md', {'table-cell': 2}], ['gateway/events.md', {'table-identifier': 1}], ['gateway/opcodes-and-close-codes.md', {'table-cell': 1}], ['gateway/overview.md', {'table-cell': 1}], ['http-api/authentication.mdx', {'table-identifier': 1}], ['http-api/billing.mdx', {'table-identifier': 4}], ['http-api/calls.mdx', {'table-fit': 1, 'table-cell': 3}], ['http-api/channels.mdx', {'table-cell': 6}], ['http-api/connections.mdx', {'table-cell': 2}], ['http-api/discovery.mdx', {'table-cell': 2}], ['http-api/donations.mdx', {'table-cell': 1}], ['http-api/entrance-sounds.mdx', {'table-cell': 3, 'table-parallel': 1}], ['http-api/gifs.mdx', {'table-cell': 5}], ['http-api/gifts.mdx', {'table-cell': 1}], ['http-api/guild-audit-logs.mdx', {'table-identifier': 3}], ['http-api/guild-channels.mdx', {'table-cell': 2}], ['http-api/guild-emojis.mdx', {'table-cell': 4}], ['http-api/guild-members.mdx', {'table-identifier': 2}], ['http-api/guild-moderation.mdx', {'table-cell': 1}], ['http-api/guild-stickers.mdx', {'table-cell': 3}], ['http-api/guilds.mdx', {'table-fit': 1, 'table-identifier': 4}], ['http-api/instance.mdx', {'table-identifier': 6}], ['http-api/invites.mdx', {'table-cell': 6}], ['http-api/messages.mdx', {'table-fit': 1, 'table-cell': 20, 'table-identifier': 1}], ['http-api/permissions.mdx', {'table-cell': 8}], ['http-api/premium.mdx', {'table-identifier': 5}], ['http-api/reports.mdx', {'table-cell': 5, 'table-identifier': 1}], ['http-api/search.mdx', {'table-fit': 1, 'table-cell': 2, 'table-identifier': 1}], ['http-api/streams.mdx', {'table-cell': 5}], ['http-api/unfurl.mdx', {'table-cell': 2, 'table-parallel': 1}], ['http-api/users.mdx', {'table-fit': 1, 'table-identifier': 3}], ['http-api/users/content.mdx', {'table-cell': 4}], ['http-api/users/current-user.mdx', {'table-identifier': 1}], ['http-api/users/data-harvest.mdx', {'table-cell': 2}], ['http-api/users/email-and-password.mdx', {'table-identifier': 1}], ['http-api/users/mfa.mdx', {'table-cell': 3, 'table-parallel': 1}], ['http-api/users/private-channels.mdx', {'table-cell': 3}], ['http-api/users/relationships.mdx', {'table-fit': 2, 'table-cell': 3, 'table-identifier': 2}], ['http-api/users/settings-protobuf.md', {'table-fit': 2, 'table-identifier': 7, 'table-parallel': 1}], ['http-api/users/settings.mdx', {'table-fit': 1, 'table-cell': 2, 'table-identifier': 1, 'table-parallel': 1}], ['http-api/webhooks.mdx', {'table-identifier': 1, 'table-parallel': 1}], ['media-proxy/responses-and-limits.md', {'table-cell': 2}], ['media-proxy/routes.mdx', {'table-cell': 1}], ['voice/index.md', {'table-parallel': 1}], ]); const tableFindingsByPage = new Map>>(); let tablesMeasured = 0; const overWideTier: Array = []; for (const {relativePath: relative, source} of pages) { for (const table of extractTables(source)) { tablesMeasured += 1; const raise = (rule: TableRule, detail: string): void => { const perRule = tableFindingsByPage.get(relative) ?? new Map>(); const list = perRule.get(rule) ?? []; list.push({file: relative, line: table.line, rule, detail}); perRule.set(rule, list); tableFindingsByPage.set(relative, perRule); }; const shape = table.header.join(' | '); if (table.columns > TABLE_MAX_COLUMNS) { raise( 'table-columns', `[${shape}] has ${table.columns.toString()} columns, at most ${TABLE_MAX_COLUMNS.toString()} are allowed`, ); } if (table.minDemandPx > TABLE_BUDGET_PX) { raise( 'table-fit', `[${shape}] demands ${table.minDemandPx.toString()}px against the ${TABLE_BUDGET_PX.toString()}px budget, columns ${table.columnMinPx.join('/')}px`, ); } if (table.minDemandPx > TABLE_WIDE_TIER_PX) { overWideTier.push({ file: relative, line: table.line, rule: 'table-wide-tier', detail: `[${shape}] demands ${table.minDemandPx.toString()}px, over the ${TABLE_WIDE_TIER_PX.toString()}px width where the wide CSS tier switches to content sizing, so this table overflows its column instead of wrapping`, }); } if (table.worstCellChars > TABLE_MAX_CELL_CHARS) { raise( 'table-cell', `[${shape}] column ${(table.worstCellColumn + 1).toString()} holds ${table.worstCellChars.toString()} rendered characters, over ${TABLE_MAX_CELL_CHARS.toString()}: "${table.worstCellText.slice(0, 60)}..."`, ); } if (table.col1IdentChars > TABLE_MAX_IDENT_CHARS) { raise( 'table-identifier', `[${shape}] first column holds "${table.col1IdentText}", ${table.col1IdentChars.toString()} characters, over ${TABLE_MAX_IDENT_CHARS.toString()}`, ); } if (table.rows < 2 && !table.canonical) { raise( 'table-rows', `[${shape}] has ${table.rows.toString()} body row(s) and is not a canonical schema shape, so it is a sentence`, ); } if (!table.parallel) { raise('table-parallel', `[${shape}] ${table.nonParallelReason}`); } const shares = columnWidthPercents( table.columnMinPx.map((minPx, index) => ({minPx, maxPx: table.columnMaxPx[index]})), TABLE_WIDE_TIER_PX, ); const floorCap = TABLE_WIDE_TIER_PX * TABLE_FLOOR_CAP_SHARE; for (const [index, share] of shares.entries()) { const allotted = (share / 100) * TABLE_WIDE_TIER_PX; const unbreakable = table.columnMinPx[index] + PX.cellPad; if (unbreakable <= floorCap && allotted + PX.proseFallback < unbreakable) { raise( 'table-wide-tight', `[${shape}] column ${(index + 1).toString()} is allotted ${Math.round(allotted).toString()}px but its longest unbreakable word needs ${Math.round(unbreakable).toString()}px, so that word breaks mid-word even at the widest tier`, ); } } } } if (tablesMeasured < TABLE_COUNT_FLOOR) { findings.push({ file: 'scripts/DocsTableWidth.ts', line: 1, rule: 'table-extractor', detail: `only ${tablesMeasured.toString()} tables were measured, floor is ${TABLE_COUNT_FLOOR.toString()}. The table rules have gone blind`, }); } findings.push(...overWideTier); const acceptedByRule = new Map(); for (const [relative, perRule] of tableFindingsByPage) { const allowance = ACCEPTED_TABLE_FINDINGS.get(relative) ?? {}; for (const [rule, list] of perRule) { const allowed = allowance[rule] ?? 0; acceptedByRule.set(rule, (acceptedByRule.get(rule) ?? 0) + Math.min(allowed, list.length)); for (const finding of list.slice(allowed)) { findings.push(finding); } } } for (const [relative, allowance] of ACCEPTED_TABLE_FINDINGS) { const perRule = tableFindingsByPage.get(relative); for (const rule of TABLE_RULES) { const allowed = allowance[rule] ?? 0; const actual = perRule?.get(rule)?.length ?? 0; if (allowed > actual) { findings.push({ file: relative, line: 1, rule: 'table-ledger', detail: `the ledger allows ${allowed.toString()} ${rule} findings but the page now produces ${actual.toString()}. Lower the entry in VerifyDocsStyle.ts`, }); } } } interface CssBlock { readonly selector: string; readonly body: string; readonly line: number; } function cssBlocks(source: string): Array { const blocks: Array = []; const pattern = /([^{}]+)\{([^{}]*)\}/gu; let match: RegExpExecArray | null = pattern.exec(source); while (match != null) { blocks.push({ selector: match[1] .replace(/\/\*[\s\S]*?\*\//gu, ' ') .replace(/\s+/gu, ' ') .trim(), body: match[2], line: source.slice(0, match.index).split('\n').length, }); match = pattern.exec(source); } return blocks; } const SIDEWAYS_SCROLL = /(?:^|;)\s*overflow(?:-x)?\s*:\s*(?:auto|scroll)\s*(?:;|$)/mu; const SCROLL_FORBIDDEN_SELECTOR = /\btable\b|\bthead\b|\btbody\b|\btr\b|\.sl-markdown-content\s*(?:,|$)|table-wrapper|overflow-wrapper/u; for (const entry of await readdir(STYLES_ROOT, {withFileTypes: true})) { if (!entry.name.endsWith('.css')) { continue; } const source = await readFile(path.join(STYLES_ROOT, entry.name), 'utf8'); for (const block of cssBlocks(source)) { if (!SCROLL_FORBIDDEN_SELECTOR.test(block.selector) || !SIDEWAYS_SCROLL.test(block.body)) { continue; } findings.push({ file: `src/styles/${entry.name}`, line: block.line, rule: 'table-scroll', detail: `"${block.selector}" makes a table or the markdown container a sideways scroller. No table may ever scroll horizontally`, }); } } { const starlightTableScrollers: Array = []; for (const entry of await readdir(STARLIGHT_STYLES, {withFileTypes: true})) { if (!entry.name.endsWith('.css')) { continue; } const source = await readFile(path.join(STARLIGHT_STYLES, entry.name), 'utf8'); for (const block of cssBlocks(source)) { if (block.selector.includes('table') && SIDEWAYS_SCROLL.test(block.body)) { starlightTableScrollers.push(`${entry.name}:${block.line.toString()} ${block.selector}`); } } } const content = await readFile(path.join(STYLES_ROOT, 'content.css'), 'utf8'); const neutralised = cssBlocks(content).some( (block) => /\.sl-markdown-content\s+table\b/u.test(block.selector) && /(?:^|;)\s*display\s*:\s*table\s*(?:;|$)/mu.test(block.body) && /(?:^|;)\s*overflow\s*:\s*visible\s*(?:;|$)/mu.test(block.body), ); if (starlightTableScrollers.length > 0 && !neutralised) { findings.push({ file: 'src/styles/content.css', line: 1, rule: 'table-scroll', detail: `Starlight still ships a scrolling table (${starlightTableScrollers.join(', ')}) and content.css no longer overrides it with "display: table" and "overflow: visible"`, }); } } const byRule = new Map(); for (const finding of findings) { byRule.set(finding.rule, (byRule.get(finding.rule) ?? 0) + 1); } console.log(`pages checked: ${pages.length.toString()}`); console.log(`tables measured: ${tablesMeasured.toString()}`); for (const rule of TABLE_RULES) { console.log( ` ${rule}: ${(byRule.get(rule) ?? 0).toString()} new, ${(acceptedByRule.get(rule) ?? 0).toString()} accepted`, ); } for (const [rule, count] of [...byRule.entries()].sort()) { if ((TABLE_RULES as ReadonlyArray).includes(rule)) { continue; } console.log(` ${rule}: ${count.toString()}`); } if (findings.length > 0) { console.log(''); for (const finding of findings.slice(0, 200)) { console.log(`${finding.file}:${finding.line.toString()} [${finding.rule}] ${finding.detail}`); } if (findings.length > 200) { console.log(`... and ${(findings.length - 200).toString()} more`); } console.error(`FAIL: ${findings.length.toString()} style problems`); process.exit(1); } console.log('OK: house style clean');