#!/usr/bin/env node /* * lint:rtl: keeps the layout following the reading direction. * * Arabic runs the app with dir="rtl". A physical class or style (ml-2, * text-left, paddingLeft, left: 0) stays on the same side in either direction, * so a gap, an indent or an alignment that follows the text in English lands * on the wrong side in Arabic. The logical forms (ms-2, text-start, * paddingInlineStart, insetInlineStart) follow the direction, and in a * left-to-right language they are exactly the physical ones. * * Some things are physical on purpose: a map, a chart axis, a drag handle, a * popover placed at measured coordinates. Those stay. Every file's physical * uses are counted against scripts/rtl-baseline.json, and the check fails when * a file holds more than its entry (a file without an entry holds none), so * new code reaches for the logical forms. A line that is physical on purpose * in new code carries `rtl-lint-disable` in a comment. * * npm run lint:rtl check against the baseline (CI) * npm run lint:rtl -- --list print every counted use, file by file * npm run lint:rtl -- --update lower the baseline to what the files hold now; * it never raises an entry */ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' const SRC = fileURLToPath(new URL('../src', import.meta.url)) const BASELINE = fileURLToPath(new URL('./rtl-baseline.json', import.meta.url)) // Tailwind utilities that name a physical side, with any variant prefix // (sm:, hover:, group-hover:) and the negative form (-ml-2). const CLASS = new RegExp( String.raw`(? { let code = '' let rest = line while (rest) { if (inBlock) { const end = rest.indexOf('*/') if (end < 0) return code rest = rest.slice(end + 2) inBlock = false continue } const block = rest.indexOf('/*') const lineComment = rest.search(/(?= 0 && (block < 0 || lineComment < block)) return code + rest.slice(0, lineComment) if (block < 0) return code + rest code += rest.slice(0, block) rest = rest.slice(block + 2) inBlock = true } return code }) } /** Every physical use in one file, as `line: match`. */ export function physicalUses(source, isCss) { const uses = [] const lines = source.split('\n') codeLines(source).forEach((code, i) => { if (lines[i].includes(DISABLE)) return const patterns = isCss ? [CSS] : [CLASS, STYLE] for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`) }) return uses } function scan() { const counts = {} const listed = {} for (const file of walk(SRC)) { const uses = physicalUses(readFileSync(file, 'utf8'), file.endsWith('.css')) if (!uses.length) continue const key = relative(SRC, file).replace(/\\/g, '/') counts[key] = uses.length listed[key] = uses } return { counts, listed } } function readBaseline() { try { return JSON.parse(readFileSync(BASELINE, 'utf8')) } catch { return {} } } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] if (isCli) { const { counts, listed } = scan() const baseline = readBaseline() if (process.argv.includes('--list')) { for (const [file, uses] of Object.entries(listed).sort()) { console.log(`${file} (${uses.length}, baseline ${baseline[file] ?? 0})`) for (const use of uses) console.log(` ${use}`) } } if (process.argv.includes('--update')) { const lowered = {} for (const [file, allowed] of Object.entries(baseline)) { const now = counts[file] ?? 0 if (now > 0) lowered[file] = Math.min(allowed, now) } const sorted = Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => a.localeCompare(b))) writeFileSync(BASELINE, JSON.stringify(sorted, null, 2) + '\n') } const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0)) const total = Object.values(counts).reduce((a, b) => a + b, 0) const allowed = Object.values(baseline).reduce((a, b) => a + b, 0) const lowerable = Object.entries(baseline).filter(([file, n]) => (counts[file] ?? 0) < n) for (const [file, n] of grown) { console.error( `FAIL ${file}: ${n} physical side(s), baseline ${baseline[file] ?? 0}. ` + 'Use ms-/me-/ps-/pe-/start-/end-/text-start/text-end/border-s/border-e/rounded-s/rounded-e ' + 'or marginInlineStart/paddingInlineEnd/insetInlineStart/textAlign: start. ' + `If the side is geometry, mark the line with ${DISABLE}.`, ) } if (lowerable.length && !process.argv.includes('--update')) { console.log(`${lowerable.length} file(s) now hold fewer physical sides than the baseline: run with --update to lower it.`) } console.log(`rtl: ${total} physical side(s) in ${Object.keys(counts).length} file(s), baseline allows ${allowed}`) if (grown.length) process.exit(1) }