Files
CherryHQ-cherry-studio/scripts/fix-tailwind-canonical-classes.ts
fullex 53a3577389 refactor(renderer): flatten src/renderer/src to src/renderer
Move all renderer source from src/renderer/src/* up one level to
src/renderer/*, removing the redundant nested src directory.

- Update path aliases (@renderer, @types, @logger, @data) and TanStack
  Router paths in electron.vite.config.ts; update tsconfig.{json,web,node}
  path mappings and include globs.
- Fix Vite root-relative script paths in the 8 renderer HTML entries.
- Update cross-process relative imports in main/preload (language,
  apiServer models, preload index) to drop the /src segment.
- Switch renderer test imports of the logger mock to the @test-mocks alias.
- Update hardcoded renderer paths in scripts and their fixtures, lint
  configs (eslint/oxlint/biome), CODEOWNERS, docs, and the data-classify tool.
- Convert deep (../../+) relative imports within the renderer to the
  @renderer alias (69 files, 108 imports); keep single-level relatives.
- Fix doc links broken by the move and correct one pre-existing broken
  link in naming-conventions.md.
2026-05-28 21:40:20 -07:00

393 lines
13 KiB
TypeScript

import fs from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { __unstable__loadDesignSystem } from 'tailwindcss'
import ts from 'typescript'
type DesignSystem = Awaited<ReturnType<typeof __unstable__loadDesignSystem>>
type Replacement = {
start: number
end: number
text: string
count: number
}
type ProcessSummary = {
scannedFiles: number
changedFiles: number
replacements: number
}
export type TailwindCanonicalClassFinding = {
line: number
original: string
canonical: string
lineText: string
}
type RunCliOptions = {
cwd?: string
stdout?: Pick<typeof process.stdout, 'write'>
stderr?: Pick<typeof process.stderr, 'write'>
}
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
const IGNORED_DIRS = new Set(['.git', 'node_modules', 'out', 'dist', 'build', 'v2-refactor-temp', 'resources'])
const CLASS_ATTRIBUTES = new Set(['class', 'className'])
const CANONICALIZE_OPTIONS = { rem: 16, collapse: false, logicalToPhysical: false }
const requireFromCwd = createRequire(`${process.cwd()}${path.sep}`)
function getScriptKind(filePath: string): ts.ScriptKind {
const ext = path.extname(filePath)
if (ext === '.tsx') return ts.ScriptKind.TSX
if (ext === '.jsx') return ts.ScriptKind.JSX
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') return ts.ScriptKind.JS
return ts.ScriptKind.TS
}
function isCnCall(node: ts.CallExpression): boolean {
return ts.isIdentifier(node.expression) && node.expression.text === 'cn'
}
function getJsxAttributeName(name: ts.JsxAttributeName): string | null {
if (ts.isIdentifier(name)) return name.text
if (ts.isJsxNamespacedName(name)) return `${name.namespace.text}:${name.name.text}`
return null
}
function escapeTextForQuote(text: string, quote: string): string {
return text.replace(/\\/g, '\\\\').replaceAll(quote, `\\${quote}`)
}
function makeStringReplacement(
source: string,
node: ts.StringLiteralLike,
nextText: string,
count: number
): Replacement {
const start = node.getStart()
const end = node.getEnd()
const quote = source[start]
return {
start: start + 1,
end: end - 1,
text: quote === '`' ? nextText.replace(/`/g, '\\`').replace(/\$\{/g, '\\${') : escapeTextForQuote(nextText, quote),
count
}
}
function canonicalizeClassText(designSystem: DesignSystem, value: string): { text: string; count: number } {
const tokens = value.trim().split(/\s+/).filter(Boolean)
if (tokens.length === 0) {
return { text: value, count: 0 }
}
const canonicalTokens = designSystem
.canonicalizeCandidates(tokens, CANONICALIZE_OPTIONS)
.map((token, index) => canonicalizeCssVariableToken(tokens[index], token))
const count = tokens.reduce((total, token, index) => total + (token === canonicalTokens[index] ? 0 : 1), 0)
return { text: canonicalTokens.join(' '), count }
}
function canonicalizeCssVariableToken(originalToken: string, canonicalToken: string): string {
const match = originalToken.match(/^(?<prefix>.*-\[)var\((?<variable>--[A-Za-z0-9_-]+)\)\]!?$/)
if (!match?.groups) return canonicalToken
const prefix = match.groups.prefix.slice(0, -1)
const important = originalToken.endsWith('!') ? '!' : ''
return `${prefix}(${match.groups.variable})${important}`
}
function addLiteralReplacement(
replacements: Replacement[],
source: string,
designSystem: DesignSystem,
node: ts.StringLiteralLike
): void {
const result = canonicalizeClassText(designSystem, node.text)
if (result.count === 0) return
replacements.push(makeStringReplacement(source, node, result.text, result.count))
}
function getLineNumber(sourceFile: ts.SourceFile, position: number): number {
return sourceFile.getLineAndCharacterOfPosition(position).line + 1
}
function collectCanonicalClassFindings(
findings: TailwindCanonicalClassFinding[],
source: string,
sourceFile: ts.SourceFile,
designSystem: DesignSystem,
node: ts.StringLiteralLike
): void {
const tokens = node.text.trim().split(/\s+/).filter(Boolean)
if (tokens.length === 0) return
const canonicalTokens = designSystem
.canonicalizeCandidates(tokens, CANONICALIZE_OPTIONS)
.map((token, index) => canonicalizeCssVariableToken(tokens[index], token))
const line = getLineNumber(sourceFile, node.getStart())
const lineText = source.split(/\r?\n/)[line - 1]?.trim() ?? ''
for (let index = 0; index < tokens.length; index++) {
if (tokens[index] === canonicalTokens[index]) continue
findings.push({
line,
original: tokens[index],
canonical: canonicalTokens[index],
lineText
})
}
}
function collectReplacements(source: string, filePath: string, designSystem: DesignSystem): Replacement[] {
const replacements: Replacement[] = []
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, getScriptKind(filePath))
function visit(node: ts.Node): void {
if (ts.isJsxAttribute(node) && node.initializer) {
const attributeName = getJsxAttributeName(node.name)
if (attributeName && CLASS_ATTRIBUTES.has(attributeName)) {
if (ts.isStringLiteral(node.initializer)) {
addLiteralReplacement(replacements, source, designSystem, node.initializer)
} else if (
ts.isJsxExpression(node.initializer) &&
node.initializer.expression &&
(ts.isStringLiteral(node.initializer.expression) ||
ts.isNoSubstitutionTemplateLiteral(node.initializer.expression))
) {
addLiteralReplacement(replacements, source, designSystem, node.initializer.expression)
}
}
}
if (ts.isCallExpression(node) && isCnCall(node)) {
for (const argument of node.arguments) {
if (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument)) {
addLiteralReplacement(replacements, source, designSystem, argument)
} else if (ts.isObjectLiteralExpression(argument)) {
for (const property of argument.properties) {
if (
ts.isPropertyAssignment(property) &&
property.name &&
(ts.isStringLiteral(property.name) || ts.isNoSubstitutionTemplateLiteral(property.name))
) {
addLiteralReplacement(replacements, source, designSystem, property.name)
}
}
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return replacements
}
export function findTailwindCanonicalClassFindingsInContent(
content: string,
filePath: string,
designSystem: DesignSystem
): TailwindCanonicalClassFinding[] {
const findings: TailwindCanonicalClassFinding[] = []
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, getScriptKind(filePath))
function visit(node: ts.Node): void {
if (ts.isJsxAttribute(node) && node.initializer) {
const attributeName = getJsxAttributeName(node.name)
if (attributeName && CLASS_ATTRIBUTES.has(attributeName)) {
if (ts.isStringLiteral(node.initializer)) {
collectCanonicalClassFindings(findings, content, sourceFile, designSystem, node.initializer)
} else if (
ts.isJsxExpression(node.initializer) &&
node.initializer.expression &&
(ts.isStringLiteral(node.initializer.expression) ||
ts.isNoSubstitutionTemplateLiteral(node.initializer.expression))
) {
collectCanonicalClassFindings(findings, content, sourceFile, designSystem, node.initializer.expression)
}
}
}
if (ts.isCallExpression(node) && isCnCall(node)) {
for (const argument of node.arguments) {
if (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument)) {
collectCanonicalClassFindings(findings, content, sourceFile, designSystem, argument)
} else if (ts.isObjectLiteralExpression(argument)) {
for (const property of argument.properties) {
if (
ts.isPropertyAssignment(property) &&
property.name &&
(ts.isStringLiteral(property.name) || ts.isNoSubstitutionTemplateLiteral(property.name))
) {
collectCanonicalClassFindings(findings, content, sourceFile, designSystem, property.name)
}
}
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return findings
}
function applyReplacements(source: string, replacements: Replacement[]): string {
return replacements
.toSorted((a, b) => b.start - a.start)
.reduce((nextSource, replacement) => {
return `${nextSource.slice(0, replacement.start)}${replacement.text}${nextSource.slice(replacement.end)}`
}, source)
}
async function resolveStylesheet(id: string, base: string, cwd: string): Promise<string> {
if (id === 'tailwindcss') {
return requireFromCwd.resolve('tailwindcss/index.css')
}
if (id === 'tw-animate-css') {
return path.join(cwd, 'node_modules/tw-animate-css/dist/tw-animate.css')
}
if (id === '@cherrystudio/ui/styles/theme.css') {
const sourcePath = path.join(cwd, 'packages/ui/src/styles/theme.css')
try {
await fs.access(sourcePath)
return sourcePath
} catch {
return requireFromCwd.resolve(id)
}
}
if (id.startsWith('.')) {
return path.resolve(base, id)
}
return requireFromCwd.resolve(id)
}
export async function loadTailwindDesignSystem(cwd = process.cwd()): Promise<DesignSystem> {
const entryPath = path.join(cwd, 'src/renderer/assets/styles/tailwind.css')
const css = await fs.readFile(entryPath, 'utf8')
return __unstable__loadDesignSystem(css, {
base: path.dirname(entryPath),
from: entryPath,
loadStylesheet: async (id, base) => {
const stylesheetPath = await resolveStylesheet(id, base, cwd)
return {
path: stylesheetPath,
base: path.dirname(stylesheetPath),
content: await fs.readFile(stylesheetPath, 'utf8')
}
}
})
}
async function listSourceFiles(targetPath: string): Promise<string[]> {
const stat = await fs.stat(targetPath)
if (stat.isFile()) {
return SOURCE_EXTENSIONS.has(path.extname(targetPath)) ? [targetPath] : []
}
if (!stat.isDirectory()) {
return []
}
const entries = await fs.readdir(targetPath, { withFileTypes: true })
const files = await Promise.all(
entries
.toSorted((a, b) => a.name.localeCompare(b.name))
.map(async (entry) => {
const entryPath = path.join(targetPath, entry.name)
if (entry.isDirectory()) {
return IGNORED_DIRS.has(entry.name) ? [] : listSourceFiles(entryPath)
}
if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
return [entryPath]
}
return []
})
)
return files.flat()
}
export async function processPath(targetPath: string, designSystem: DesignSystem): Promise<ProcessSummary> {
const files = await listSourceFiles(targetPath)
const summary: ProcessSummary = {
scannedFiles: files.length,
changedFiles: 0,
replacements: 0
}
for (const filePath of files) {
const source = await fs.readFile(filePath, 'utf8')
const replacements = collectReplacements(source, filePath, designSystem)
if (replacements.length === 0) continue
await fs.writeFile(filePath, applyReplacements(source, replacements))
summary.changedFiles += 1
summary.replacements += replacements.reduce((total, replacement) => total + replacement.count, 0)
}
return summary
}
function printUsage(stderr: Pick<typeof process.stderr, 'write'>): void {
stderr.write('Usage: pnpm styles:canonical <path>\n')
}
export async function runCli(argv = process.argv.slice(2), options: RunCliOptions = {}): Promise<number> {
const cwd = options.cwd ?? process.cwd()
const stdout = options.stdout ?? process.stdout
const stderr = options.stderr ?? process.stderr
if (argv.length !== 1) {
printUsage(stderr)
return 1
}
const targetPath = path.resolve(cwd, argv[0])
try {
await fs.stat(targetPath)
} catch {
stderr.write(`Path does not exist: ${argv[0]}\n`)
return 1
}
const designSystem = await loadTailwindDesignSystem(cwd)
const summary = await processPath(targetPath, designSystem)
stdout.write(
`Tailwind canonical classes: scanned ${summary.scannedFiles} files, changed ${summary.changedFiles} files, fixed ${summary.replacements} classes.\n`
)
return 0
}
const currentFile = fileURLToPath(import.meta.url)
if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
void runCli()
.then((exitCode) => {
process.exitCode = exitCode
})
.catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
})
}