mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-04 05:00:00 +08:00
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.
393 lines
13 KiB
TypeScript
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
|
|
})
|
|
}
|