mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
ba70ad28b8
Arabic runs the app right to left, but margins, paddings, insets, text alignment, borders and corner radii were written for the left side, so icons sat against the wrong edge of their labels, indents and badges pointed the wrong way and amount columns aligned left. They now use the logical forms (ms-, ps-, start-, text-start, marginInlineStart, insetInlineEnd and their CSS equivalents), which are the very same sides in a left to right language. Geometry stays physical on purpose: maps, positions measured from the pointer or a bounding box, time axes, sliders, transforms and print output. Amounts drawn as several spans keep their order in a right to left row, the time picker keeps hours before minutes, and two switches whose thumb slid out of its track in Arabic now move the right way. lint:rtl counts what is still physical per file against a baseline that may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI runs it, so new code reaches for the logical forms.
167 lines
6.5 KiB
JavaScript
167 lines
6.5 KiB
JavaScript
#!/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`(?<![\w-])(?:[\w-]+:)*-?(?:` +
|
|
[
|
|
String.raw`(?:ml|mr|pl|pr|left|right|scroll-m[lr]|scroll-p[lr])-(?:\[[^\]\s]+\]|[\w./]+)`,
|
|
String.raw`text-(?:left|right)(?![\w-])`,
|
|
String.raw`border-[lr](?:-[\w[\]#./()-]+)?(?![\w-])`,
|
|
String.raw`rounded-(?:l|r|tl|tr|bl|br)(?:-[\w[\]./]+)?(?![\w-])`,
|
|
String.raw`(?:float|clear)-(?:left|right)(?![\w-])`,
|
|
String.raw`space-x-[\w.]+`,
|
|
].join('|') +
|
|
')',
|
|
'g',
|
|
)
|
|
// The same sides as React style keys, and as properties in a stylesheet.
|
|
const STYLE =
|
|
/\b(?:marginLeft|marginRight|paddingLeft|paddingRight|borderLeft\w*|borderRight\w*|border(?:Top|Bottom)(?:Left|Right)Radius|left|right)\s*:|\b(?:textAlign|float)\s*:\s*['"](?:left|right)['"]/g
|
|
const CSS =
|
|
/(?<![\w-])(?:margin-left|margin-right|padding-left|padding-right|border-left[\w-]*|border-right[\w-]*|border-(?:top|bottom)-(?:left|right)-radius|left|right)\s*:|(?<![\w-])(?:text-align|float)\s*:\s*(?:left|right)\b/g
|
|
|
|
const DISABLE = 'rtl-lint-disable'
|
|
|
|
function walk(dir, files = []) {
|
|
for (const name of readdirSync(dir)) {
|
|
const path = join(dir, name)
|
|
if (statSync(path).isDirectory()) walk(path, files)
|
|
else if (/\.(?:tsx?|css)$/.test(name) && !/\.(?:test|spec)\./.test(name)) files.push(path)
|
|
}
|
|
return files
|
|
}
|
|
|
|
/**
|
|
* The code of each line, without its comments: prose like "top right" or
|
|
* "left out" is no side. Good enough for this source, which keeps `//` and
|
|
* `/*` out of string literals except in URLs (`https://`).
|
|
*/
|
|
function codeLines(source) {
|
|
let inBlock = false
|
|
return source.split('\n').map((line) => {
|
|
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(/(?<!:)\/\//)
|
|
if (lineComment >= 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)
|
|
}
|