diff --git a/.changeset/legal-hands-switch.md b/.changeset/legal-hands-switch.md new file mode 100644 index 00000000000..6212e88548d --- /dev/null +++ b/.changeset/legal-hands-switch.md @@ -0,0 +1,5 @@ +--- +"@navikt/aksel": patch +--- + +CLI: Improvements to v8-tokens codemod. diff --git a/@navikt/aksel/package.json b/@navikt/aksel/package.json index d135470e7b9..ac99eb885b2 100644 --- a/@navikt/aksel/package.json +++ b/@navikt/aksel/package.json @@ -37,6 +37,7 @@ "axios": "1.13.2", "chalk": "4.1.0", "cli-progress": "^3.12.0", + "clipboardy": "^2.3.0", "commander": "10.0.1", "enquirer": "^2.3.6", "fast-glob": "3.2.11", diff --git a/@navikt/aksel/src/codemod/codeshift.utils.ts b/@navikt/aksel/src/codemod/codeshift.utils.ts index 0f87b40f266..cfbaaf5e06d 100644 --- a/@navikt/aksel/src/codemod/codeshift.utils.ts +++ b/@navikt/aksel/src/codemod/codeshift.utils.ts @@ -21,8 +21,17 @@ type SupportedCodemodExtensions = */ function getDefaultGlob(ext: string): string { const defaultExt = "js,ts,jsx,tsx,css,scss,less"; + const extensions = cleanExtensions(ext ?? defaultExt); - return `**/*.{${cleanExtensions(ext ?? defaultExt).join(",")}}`; + /** + * Single-item braces are treated as a literal string by some globbing libraries, + * so we only use them when there are multiple extensions + */ + if (extensions.length > 1) { + return `**/*.{${extensions.join(",")}}`; + } + + return `**/*.${extensions[0]}`; } /** diff --git a/@navikt/aksel/src/codemod/run-codeshift.ts b/@navikt/aksel/src/codemod/run-codeshift.ts index 9fedaf7aed4..96861924126 100644 --- a/@navikt/aksel/src/codemod/run-codeshift.ts +++ b/@navikt/aksel/src/codemod/run-codeshift.ts @@ -28,15 +28,31 @@ export async function runCodeshift( `./transforms/${getMigrationPath(input)}.js`, ); - const filepaths = fg.sync([options.glob ?? getDefaultGlob(options?.ext)], { + console.info(chalk.greenBright.bold("\nWelcome to Aksel codemods!")); + console.info("\nRunning migration:", chalk.green(input)); + + const globList = options.glob ?? getDefaultGlob(options?.ext); + + console.info( + chalk.gray( + `Using glob pattern(s): ${globList}\nWorking directory: ${process.cwd()}\n`, + ), + ); + + const filepaths = fg.sync(globList, { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS, + /** + * When globbing, do not follow symlinks to avoid processing files outside the directory. + * This is most likely to happen in monorepos where node_modules may contain symlinks to packages + * in other parts of the repo. + * + * While node_modules is already ignored via GLOB_IGNORE_PATTERNS, if user globs upwards (e.g., using '../src/**'), + * that ignore-pattern may be ignored, leading to unintended file processing. + */ + followSymbolicLinks: false, }); - console.info("\nRunning migration:", chalk.green("input")); - - options?.glob && console.info(`Using glob: ${chalk.green(options.glob)}\n`); - const warning = getWarning(input); const unsafeExtensions = getIgnoredFileExtensions(input); diff --git a/@navikt/aksel/src/darkside/index.ts b/@navikt/aksel/src/darkside/index.ts index 1a210923122..6b925391268 100644 --- a/@navikt/aksel/src/darkside/index.ts +++ b/@navikt/aksel/src/darkside/index.ts @@ -1,31 +1,27 @@ import chalk from "chalk"; import { Command } from "commander"; -import { validateGit } from "../codemod/validation.js"; -// import figlet from "figlet"; -// import { getMigrationString } from "./migrations.js"; import { runTooling } from "./run-tooling.js"; const program = new Command(); export function darksideCommand() { - program.name(`${chalk.blueBright(`npx @navikt/aksel`)}`); + program.name(`${chalk.blueBright(`npx @navikt/aksel v8-tokens`)}`); program .option( "-g, --glob [glob]", "Globbing pattern, overrides --ext! Run with 'noglob' if using zsh-terminal. ", ) + .option( + "-e, --ext [ext]", + "File extensions to include, defaults to 'js,ts,jsx,tsx,css,scss,less'", + ) .option("-d, --dry-run", "Dry run, no changes will be made") .option("-f, --force", "Forcibly run updates without checking git-changes") - .description("Update tool for darkside token updates"); + .description("Update tool for v8 token updates"); program.parse(); const options = program.opts(); - /* Makes sure that you don't migrate lots of files while having other uncommitted changes */ - if (!options.force) { - validateGit(options, program); - } - runTooling(options as Parameters["0"], program); } diff --git a/@navikt/aksel/src/darkside/run-tooling.ts b/@navikt/aksel/src/darkside/run-tooling.ts index 22db3ef6dac..aed816210b2 100644 --- a/@navikt/aksel/src/darkside/run-tooling.ts +++ b/@navikt/aksel/src/darkside/run-tooling.ts @@ -10,6 +10,7 @@ import { getDefaultGlob, } from "../codemod/codeshift.utils"; import { validateGit } from "../codemod/validation"; +import { TokenStatus } from "./config/TokenStatus"; import { printRemaining } from "./tasks/print-remaining"; import { getStatus } from "./tasks/status"; @@ -28,7 +29,6 @@ type TaskName = type ToolingOptions = { force: boolean; dryRun: boolean; - print: boolean; glob: string; ext: string; }; @@ -47,24 +47,6 @@ const TRANSFORMS: Record = { "tailwind-tokens": "./transforms/darkside-tokens-tailwind", }; -const TASK_MENU = { - type: "select", - name: "task", - message: "Task", - initial: "status", - choices: [ - { message: "Check status", name: "status" }, - { message: "Print remaining tokens", name: "print-remaining-tokens" }, - { message: "Migrate CSS tokens", name: "css-tokens" }, - { message: "Migrate Scss tokens", name: "scss-tokens" }, - { message: "Migrate Less tokens", name: "less-tokens" }, - { message: "Migrate JS tokens", name: "js-tokens" }, - { message: "Migrate tailwind tokens", name: "tailwind-tokens" }, - { message: "Run all migrations", name: "run-all-migrations" }, - { message: "Exit", name: "exit" }, - ] as { message: string; name: TaskName }[], -}; - /** * Main entry point for the tooling system */ @@ -72,10 +54,31 @@ export async function runTooling( options: ToolingOptions, program: Command, ): Promise { + console.info( + chalk.greenBright.bold("\nWelcome to the Aksel v8 token migration tool!"), + ); + + const globList = options.glob ?? getDefaultGlob(options?.ext); + + console.info( + chalk.gray( + `Using glob pattern(s): ${globList}\nWorking directory: ${process.cwd()}\n`, + ), + ); + // Find matching files based on glob pattern - const filepaths = fg.sync([options.glob ?? getDefaultGlob(options?.ext)], { + const filepaths = await fg(globList, { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS, + /** + * When globbing, do not follow symlinks to avoid processing files outside the directory. + * This is most likely to happen in monorepos where node_modules may contain symlinks to packages + * in other parts of the repo. + * + * While node_modules is already ignored via GLOB_IGNORE_PATTERNS, if user globs upwards (e.g., using '../src/**'), + * that ignore-pattern may be ignored, leading to unintended file processing. + */ + followSymbolicLinks: false, }); if (options.dryRun) { @@ -85,20 +88,29 @@ export async function runTooling( } // Show initial status - getStatus(filepaths); + const initialStatus = getStatus(filepaths); // Task execution loop - let task: TaskName = await getNextTask(); + let task: TaskName = await getNextTask(initialStatus.status); + let currentStatus = initialStatus; while (task !== "exit") { console.info("\n\n"); try { - await executeTask(task, filepaths, options, program); + currentStatus = await executeTask( + task, + filepaths, + options, + program, + currentStatus, + () => getStatus(filepaths, "no-print"), + ); } catch (error) { program.error(chalk.red("Error:", error.message)); } - task = await getNextTask(); + + task = await getNextTask(currentStatus.status); } process.exit(0); @@ -112,29 +124,46 @@ async function executeTask( filepaths: string[], options: ToolingOptions, program: Command, -): Promise { + statusStore: TokenStatus, + updateStatus: () => TokenStatus, +): Promise { switch (task) { case "status": - getStatus(filepaths); - break; + return updateStatus(); - case "print-remaining-tokens": - printRemaining(filepaths); - break; + case "print-remaining-tokens": { + const newStatus = updateStatus(); + await printRemaining(filepaths, newStatus.status); + return newStatus; + } case "css-tokens": case "scss-tokens": case "less-tokens": case "js-tokens": case "tailwind-tokens": { - const updatedStatus = getStatus(filepaths, "no-print").status; - const scopedFiles = getScopedFilesForTask(task, filepaths, updatedStatus); + if (!options.force) { + validateGit(options, program); + } + const scopedFiles = getScopedFilesForTask( + task, + filepaths, + statusStore.status, + ); + + const tokensBefore = getTokenCount(statusStore.status, task); - await runCodeshift(task, scopedFiles, { + const stats = await runCodeshift(task, scopedFiles, { dryRun: options.dryRun, force: options.force, }); - break; + + const newStatus = updateStatus(); + const tokensAfter = getTokenCount(newStatus.status, task); + + printSummary(task, stats, tokensBefore, tokensAfter); + + return newStatus; } case "run-all-migrations": { const tasks = [ @@ -144,22 +173,65 @@ async function executeTask( "js-tokens", "tailwind-tokens", ] as const; - for (const migrationTask of tasks) { - if (!options.force) { - validateGit(options, program); - } + if (!options.force) { + validateGit(options, program); + } + + let currentStatus = statusStore; + const summaryData: { + task: string; + stats: { ok: number }; + tokensBefore: number; + tokensAfter: number; + }[] = []; + + for (const migrationTask of tasks) { console.info(`\nRunning ${migrationTask}...`); - await runCodeshift(migrationTask, filepaths, { + const scopedFiles = getScopedFilesForTask( + migrationTask, + filepaths, + currentStatus.status, + ); + + const tokensBefore = getTokenCount(currentStatus.status, migrationTask); + + const stats = await runCodeshift(migrationTask, scopedFiles, { dryRun: options.dryRun, force: true, }); + + currentStatus = updateStatus(); + const tokensAfter = getTokenCount(currentStatus.status, migrationTask); + + summaryData.push({ + task: migrationTask, + stats, + tokensBefore, + tokensAfter, + }); } - break; + + console.info(chalk.bold(`\nMigration Summary:`)); + console.info("-".repeat(60)); + + for (const data of summaryData) { + const replaced = data.tokensBefore - data.tokensAfter; + const remaining = data.tokensAfter; + const icon = remaining === 0 ? "✨" : "⚠️"; + console.info(`${chalk.bold(data.task)}:`); + console.info(` Files changed: ${data.stats.ok}`); + console.info(` Tokens replaced: ${replaced}`); + console.info(` ${icon} Remaining: ${remaining}`); + console.info(""); + } + + return currentStatus; } default: program.error(chalk.red(`Unknown task: ${task}`)); + return statusStore; } } @@ -211,14 +283,19 @@ async function runCodeshift( task: TaskName, filepaths: string[], options: CodeshiftOptions, -): Promise { +): Promise<{ + error: number; + ok: number; + nochange: number; + skip: number; +}> { if (!TRANSFORMS[task]) { throw new Error(`No transform found for task: ${task}`); } const codemodPath = path.join(__dirname, `${TRANSFORMS[task]}.js`); - await jscodeshift.run(codemodPath, filepaths, { + return await jscodeshift.run(codemodPath, filepaths, { babel: true, ignorePattern: GLOB_IGNORE_PATTERNS, parser: "tsx", @@ -235,15 +312,54 @@ async function runCodeshift( /** * Prompts the user for the next task to run */ -async function getNextTask(): Promise { +async function getNextTask(status?: any): Promise { + const getMessage = (base: string, tokens: any[]) => { + if (!status) return base; + const fileCount = new Set(tokens.map((t) => t.fileName)).size; + if (fileCount === 0) return `${base} (Done)`; + return `${base} (${fileCount} files)`; + }; + + const choices = [ + { message: "Check status", name: "status" }, + { message: "Print status", name: "print-remaining-tokens" }, + { + message: getMessage("Migrate CSS tokens", status?.css?.legacy ?? []), + name: "css-tokens", + }, + { + message: getMessage("Migrate Scss tokens", status?.scss?.legacy ?? []), + name: "scss-tokens", + }, + { + message: getMessage("Migrate Less tokens", status?.less?.legacy ?? []), + name: "less-tokens", + }, + { + message: getMessage("Migrate JS tokens", status?.js?.legacy ?? []), + name: "js-tokens", + }, + { + message: getMessage( + "Migrate tailwind tokens", + status?.tailwind?.legacy ?? [], + ), + name: "tailwind-tokens", + }, + { message: "Run all migrations", name: "run-all-migrations" }, + { message: "Exit", name: "exit" }, + ] as { message: string; name: TaskName }[]; + try { - const response = await Enquirer.prompt([ - { - ...TASK_MENU, - onCancel: () => process.exit(1), - }, - ]); - return (response as { task: TaskName }).task; + const response = await Enquirer.prompt<{ task: TaskName }>({ + type: "select", + name: "task", + message: "Task", + initial: "status", + choices, + onCancel: () => process.exit(1), + } as any); + return response.task; } catch (error) { if (error.isTtyError) { console.info( @@ -256,3 +372,41 @@ async function getNextTask(): Promise { process.exit(1); } } + +function getTokenCount(status: TokenStatus["status"], task: string): number { + switch (task) { + case "css-tokens": + return status.css.legacy.length; + case "scss-tokens": + return status.scss.legacy.length; + case "less-tokens": + return status.less.legacy.length; + case "js-tokens": + return status.js.legacy.length; + case "tailwind-tokens": + return status.tailwind.legacy.length; + default: + return 0; + } +} + +function printSummary( + task: string, + stats: { ok: number }, + tokensBefore: number, + tokensAfter: number, +) { + console.info(chalk.bold(`\nMigration Summary for ${task}:`)); + console.info("-".repeat(40)); + console.info(`✅ Files changed: ${stats.ok}`); + console.info(`✅ Tokens replaced: ${tokensBefore - tokensAfter}`); + if (tokensAfter > 0) { + console.info( + chalk.yellow( + `⚠️ Tokens remaining: ${tokensAfter} (manual intervention needed)`, + ), + ); + } else { + console.info(chalk.green(`✨ Tokens remaining: ${tokensAfter}`)); + } +} diff --git a/@navikt/aksel/src/darkside/tasks/print-remaining.ts b/@navikt/aksel/src/darkside/tasks/print-remaining.ts index d15ae00f95d..d17c18491c7 100644 --- a/@navikt/aksel/src/darkside/tasks/print-remaining.ts +++ b/@navikt/aksel/src/darkside/tasks/print-remaining.ts @@ -1,64 +1,198 @@ +import clipboardy from "clipboardy"; +import Enquirer from "enquirer"; import path from "node:path"; +import { TokenStatus } from "../config/TokenStatus"; import { getStatus } from "./status"; -function printRemaining(files: string[]) { +async function printRemaining(files: string[], status?: TokenStatus["status"]) { process.stdout.write("\nAnalyzing..."); - const statusObj = getStatus(files, "no-print").status; - - Object.entries(statusObj).forEach(([tokenType, data]) => { - console.group(`\n${tokenType.toUpperCase()}:`); - const fileLinks: string[] = []; - - data.legacy.forEach((tokenData) => { - fileLinks.push( - `${tokenData.name.replace(":", "")}:${tokenData.fileName}:${ - tokenData.lineNumber - }:${tokenData.columnNumber}`, - ); - }); - if (fileLinks.length === 0) { - console.info("Nothing to update."); - console.groupEnd(); - } + /** + * Skip re-calculating status if already provided + */ + const statusObj = status ?? getStatus(files, "no-print").status; - // Ensure every string is unique - const uniqueFileLinks = Array.from(new Set(fileLinks)); + /* Flatten all legacy tokens */ + const allTokens = Object.values(statusObj).flatMap((data) => data.legacy); - // Sort the unique fileLinks based on fileName first, lineNumber second, and columnNumber third - uniqueFileLinks.sort((a, b) => { - const [fileA, lineA, columnA] = a.split(":"); - const [fileB, lineB, columnB] = b.split(":"); + if (allTokens.length === 0) { + console.info("\nNo legacy tokens found!"); + return; + } - if (fileA !== fileB) { - return fileA.localeCompare(fileB); - } - if (Number(lineA) !== Number(lineB)) { - return Number(lineA) - Number(lineB); + const response = await Enquirer.prompt<{ + groupBy: "file" | "token"; + copy: boolean; + }>([ + { + type: "select", + name: "groupBy", + message: "How would you like to group the remaining tokens?", + choices: [ + { message: "By File", name: "file" }, + { message: "By Token", name: "token" }, + ], + }, + { + type: "confirm", + name: "copy", + message: "Copy report to clipboard?", + initial: true, + }, + ]); + + const { groupBy, copy } = response; + + console.info("\n"); + + const log = (str: string, indent = 0) => { + const prefix = " ".repeat(indent); + console.info(prefix + str); + }; + + let jsonOutput: unknown; + + /** + * Group by filename + */ + if (groupBy === "file") { + const byFile = new Map(); + + allTokens.forEach((token) => { + if (!byFile.has(token.fileName)) { + byFile.set(token.fileName, []); } - return Number(columnA) - Number(columnB); + byFile.get(token.fileName)!.push(token); }); - // Print the unique and sorted fileLinks as clickable links with full path - uniqueFileLinks.forEach((link) => { - const [tokenName, fileName, lineNumber, columnNumber] = link.split(":"); + /* Sort files by number of tokens (descending) */ + const sortedFiles = Array.from(byFile.entries()).sort( + (a, b) => b[1].length - a[1].length, + ); + + const fileOutput: { + file: string; + fullPath: string; + count: number; + tokens: { + name: string; + line: number; + column: number; + comment?: string; + link: string; + }[]; + }[] = []; + + sortedFiles.forEach(([fileName, tokens]) => { const fullPath = path.resolve(process.cwd(), fileName); + log(`${fileName} (${tokens.length} tokens)`); + + /* Sort tokens in file by line number */ + tokens.sort((a, b) => a.lineNumber - b.lineNumber); + + const fileEntry = { + file: fileName, + fullPath, + count: tokens.length, + tokens: [] as (typeof fileOutput)[0]["tokens"], + }; - const withComment = data.legacy.find((token) => { - return token.name === tokenName && token.comment; + tokens.forEach((token) => { + if (token.comment) { + log(`/* ${token.comment} */`, 1); + } + log( + `${token.name}: ${fullPath}:${token.lineNumber}:${token.columnNumber}`, + 1, + ); + fileEntry.tokens.push({ + name: token.name, + line: token.lineNumber, + column: token.columnNumber, + comment: token.comment, + link: `file://${fullPath}`, + }); }); + /* Empty line */ + log(""); + fileOutput.push(fileEntry); + }); + jsonOutput = fileOutput; + } else { + /* Group by token name */ + const byToken = new Map(); - if (withComment) { - console.info(`\n/* ${withComment.comment} */`); + allTokens.forEach((token) => { + if (!byToken.has(token.name)) { + byToken.set(token.name, []); } - console.info( - `${tokenName}: file://${fullPath}:${lineNumber}:${columnNumber}`, - ); + byToken.get(token.name)!.push(token); }); - console.groupEnd(); - }); - console.info("\n"); + /* Sort tokens by frequency (descending) */ + const sortedTokens = Array.from(byToken.entries()).sort( + (a, b) => b[1].length - a[1].length, + ); + + const tokenOutput: { + token: string; + count: number; + occurrences: { + file: string; + fullPath: string; + line: number; + column: number; + comment?: string; + link: string; + }[]; + }[] = []; + + sortedTokens.forEach(([tokenName, tokens]) => { + log(`${tokenName} (${tokens.length} occurrences)`); + /** + * We can assume all comments are the same for a "tokenName" + */ + const foundComment = tokens.find((t) => t.comment)?.comment; + if (foundComment) { + log(`/* ${foundComment} */`, 1); + } + + const tokenEntry = { + token: tokenName, + count: tokens.length, + occurrences: [] as (typeof tokenOutput)[0]["occurrences"], + }; + + tokens.forEach((token) => { + const fullPath = path.resolve(process.cwd(), token.fileName); + + log(`${fullPath}:${token.lineNumber}:${token.columnNumber}`, 1); + + tokenEntry.occurrences.push({ + file: token.fileName, + fullPath, + line: token.lineNumber, + column: token.columnNumber, + comment: token.comment, + link: `file://${fullPath}`, + }); + }); + + /* Empty line */ + log(""); + tokenOutput.push(tokenEntry); + }); + jsonOutput = tokenOutput; + } + + if (copy) { + try { + clipboardy.writeSync(JSON.stringify(jsonOutput, null, 2)); + console.info("✅ Report (JSON) copied to clipboard!"); + } catch (error) { + console.error("❌ Failed to copy to clipboard:", error.message); + } + } } export { printRemaining }; diff --git a/@navikt/aksel/src/darkside/tasks/status.ts b/@navikt/aksel/src/darkside/tasks/status.ts index de72d50b7ca..dd18d61049e 100644 --- a/@navikt/aksel/src/darkside/tasks/status.ts +++ b/@navikt/aksel/src/darkside/tasks/status.ts @@ -1,10 +1,10 @@ import ProgressBar from "cli-progress"; import fs from "node:fs"; +import { translateToken } from "../../codemod/utils/translate-token"; import { TokenStatus } from "../config/TokenStatus"; import { darksideTokenConfig } from "../config/darkside.tokens"; import { legacyComponentTokenList } from "../config/legacy-component.tokens"; import { legacyTokenConfig } from "../config/legacy.tokens"; -import { getTokenRegex } from "../config/token-regex"; const StatusStore = new TokenStatus(); @@ -30,14 +30,68 @@ function getStatus( StatusStore.initStatus(); + /** + * Prepare search terms for legacy and darkside tokens. + * By pre-computing these sets, we save re-calculating them for each file, + * improving performance when processing large numbers of files. + */ + const legacySearchTerms = getLegacySearchTerms(); + const darksideSearchTerms = getDarksideSearchTerms(); + + const legacyComponentTokensSet = new Set(legacyComponentTokenList); + + /** + * Pre-computed regex for legacy component tokens + */ + const legacyRegex = new RegExp( + `(${legacyComponentTokenList.map((t) => `${t}:`).join("|")})`, + "gm", + ); + + /** + * Process each file to find and record token usages + */ files.forEach((fileName, index) => { const fileSrc = fs.readFileSync(fileName, "utf8"); + /** + * Create a set of all words in the file to quickly check for potential matches + */ + const fileWords = new Set(fileSrc.match(/[a-zA-Z0-9_@$-]+/g) || []); + + let lineStarts: number[] | undefined; + + /** + * Gets line-start positions for the file, caching the result. + * We only calculate this if we actually find a token match, saving processing time. + */ + const getLineStartsLazy = () => { + if (!lineStarts) { + lineStarts = getLineStarts(fileSrc); + } + return lineStarts; + }; + /** * We first parse trough all legacy tokens (--a-) prefixed tokens */ for (const [legacyToken, config] of Object.entries(legacyTokenConfig)) { - if (!getTokenRegex(legacyToken, "css").test(fileSrc)) { + const terms = legacySearchTerms.get(legacyToken); + + /** + * Optimization: Check if any of the search terms exist in the file words set + * before running expensive regex operations. + */ + let found = false; + if (terms) { + for (const term of terms) { + if (fileWords.has(term)) { + found = true; + break; + } + } + } + if (!found) { continue; } @@ -49,7 +103,10 @@ function getStatus( let match: RegExpExecArray | null = regex.exec(fileSrc); while (match) { - const { row, column } = getWordPositionInFile(fileSrc, match.index); + const { row, column } = getCharacterPositionInFile( + match.index, + getLineStartsLazy(), + ); StatusStore.add({ isLegacy: true, @@ -70,31 +127,52 @@ function getStatus( } } - const legacyRegex = new RegExp( - `(${legacyComponentTokenList.map((t) => `${t}:`).join("|")})`, - "gm", - ); - - let legacyMatch: RegExpExecArray | null = legacyRegex.exec(fileSrc); - - while (legacyMatch !== null) { - const { row, column } = getWordPositionInFile(fileSrc, legacyMatch.index); - - StatusStore.add({ - isLegacy: true, - type: "component", - columnNumber: column, - lineNumber: row, - canAutoMigrate: false, - fileName, - name: legacyMatch[0], - }); + let hasLegacyComponentToken = false; + for (const token of legacyComponentTokensSet) { + if (fileWords.has(token)) { + hasLegacyComponentToken = true; + break; + } + } - legacyMatch = legacyRegex.exec(fileSrc); + if (hasLegacyComponentToken) { + legacyRegex.lastIndex = 0; + let legacyMatch: RegExpExecArray | null = legacyRegex.exec(fileSrc); + + while (legacyMatch !== null) { + const { row, column } = getCharacterPositionInFile( + legacyMatch.index, + getLineStartsLazy(), + ); + + StatusStore.add({ + isLegacy: true, + type: "component", + columnNumber: column, + lineNumber: row, + canAutoMigrate: false, + fileName, + name: legacyMatch[0], + }); + + legacyMatch = legacyRegex.exec(fileSrc); + } } for (const [newTokenName, config] of Object.entries(darksideTokenConfig)) { - if (!getTokenRegex(newTokenName, "css").test(fileSrc)) { + const terms = darksideSearchTerms.get(newTokenName); + + /* Optimization: Check if any of the search terms exist in the file words set */ + let found = false; + if (terms) { + for (const term of terms) { + if (fileWords.has(term)) { + found = true; + break; + } + } + } + if (!found) { continue; } @@ -105,7 +183,10 @@ function getStatus( let match: RegExpExecArray | null = regex.exec(fileSrc); while (match) { - const { row, column } = getWordPositionInFile(fileSrc, match.index); + const { row, column } = getCharacterPositionInFile( + match.index, + getLineStartsLazy(), + ); StatusStore.add({ isLegacy: false, @@ -140,26 +221,79 @@ function getStatus( return StatusStore; } -function getWordPositionInFile( - fileContent: string, - index: number, -): { row: number; column: number } { - const lines = fileContent.split("\n"); - let lineNumber = 1; - let charCount = 0; - - for (let i = 0; i < lines.length; i++) { - const lineLength = lines[i].length + 1; // +1 to account for the newline character that was removed by split +function getLegacySearchTerms() { + const legacySearchTerms = new Map>(); + for (const [legacyToken, config] of Object.entries(legacyTokenConfig)) { + const terms = new Set(); + const tokenName = `--a-${legacyToken}`; + terms.add(tokenName); + terms.add(translateToken(tokenName, "scss")); + terms.add(translateToken(tokenName, "less")); + terms.add(translateToken(tokenName, "js")); + + if (config.twOld) { + config.twOld.split(",").forEach((t) => terms.add(t.trim())); + } + legacySearchTerms.set(legacyToken, terms); + } + return legacySearchTerms; +} - if (charCount + lineLength > index) { - return { row: lineNumber, column: index - charCount + 1 }; +function getDarksideSearchTerms() { + const darksideSearchTerms = new Map>(); + for (const [newTokenName, config] of Object.entries(darksideTokenConfig)) { + const terms = new Set(); + const tokenName = `--ax-${newTokenName}`; + terms.add(tokenName); + terms.add(translateToken(tokenName, "scss")); + terms.add(translateToken(tokenName, "less")); + terms.add(translateToken(newTokenName, "js")); + terms.add(newTokenName); + + if (config.tw) { + config.tw.split(",").forEach((t) => terms.add(t.trim())); } + darksideSearchTerms.set(newTokenName, terms); + } + return darksideSearchTerms; +} - charCount += lineLength; - lineNumber++; +/** + * Given the content of a file, returns an array of line start positions. + */ +function getLineStarts(content: string): number[] { + const starts = [0]; + let lineIndex = content.indexOf("\n", 0); + while (lineIndex !== -1) { + starts.push(lineIndex + 1); + lineIndex = content.indexOf("\n", lineIndex + 1); + } + return starts; +} + +/** + * Given a character index and an array of line start positions, + * returns the corresponding row and column numbers. + */ +function getCharacterPositionInFile( + index: number, + lineStarts: number[], +): { row: number; column: number } { + let low = 0; + let high = lineStarts.length - 1; + let lineIndex = 0; + + while (low <= high) { + const mid = (low + high) >>> 1; + if (lineStarts[mid] <= index) { + lineIndex = mid; + low = mid + 1; + } else { + high = mid - 1; + } } - return { row: lineNumber, column: 0 }; // Should not reach here if the index is within the file content range + return { row: lineIndex + 1, column: index - lineStarts[lineIndex] + 1 }; } -export { getStatus }; +export { getStatus, getCharacterPositionInFile, getLineStarts }; diff --git a/@navikt/aksel/src/darkside/tasks/tests/status.test.ts b/@navikt/aksel/src/darkside/tasks/tests/status.test.ts new file mode 100644 index 00000000000..fe9232e8717 --- /dev/null +++ b/@navikt/aksel/src/darkside/tasks/tests/status.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "vitest"; +import { getCharacterPositionInFile, getLineStarts } from "../status"; + +describe("getCharacterPositionInFile", () => { + test("should return row 1, column 1 for the start of the file", () => { + const content = "const foo = 'bar';"; + const lineStarts = getLineStarts(content); + expect(getCharacterPositionInFile(0, lineStarts)).toEqual({ + row: 1, + column: 1, + }); + }); + + test("should return correct column for position within the first line", () => { + const content = "const foo = 'bar';"; + const lineStarts = getLineStarts(content); + // Index 6 is 'f' in 'foo' + expect(getCharacterPositionInFile(6, lineStarts)).toEqual({ + row: 1, + column: 7, + }); + }); + + test("should return correct row and column for start of second line", () => { + const content = "line1\nline2"; + const lineStarts = getLineStarts(content); + // Index 6 is 'l' in 'line2' (5 chars + 1 newline) + expect(getCharacterPositionInFile(6, lineStarts)).toEqual({ + row: 2, + column: 1, + }); + }); + + test("should return correct row and column for position within second line", () => { + const content = "line1\nline2"; + const lineStarts = getLineStarts(content); + // Index 10 is '2' in 'line2' + expect(getCharacterPositionInFile(10, lineStarts)).toEqual({ + row: 2, + column: 5, + }); + }); + + test("should handle multiple consecutive newlines", () => { + const content = "a\n\nb"; + const lineStarts = getLineStarts(content); + // Index 3 is 'b' (a=0, \n=1, \n=2, b=3) + expect(getCharacterPositionInFile(3, lineStarts)).toEqual({ + row: 3, + column: 1, + }); + }); + + test("should handle index pointing to a newline character itself", () => { + const content = "a\nb"; + const lineStarts = getLineStarts(content); + // Index 1 is the newline character + expect(getCharacterPositionInFile(1, lineStarts)).toEqual({ + row: 1, + column: 2, + }); + }); + + test("should handle empty string input", () => { + const content = ""; + const lineStarts = getLineStarts(content); + expect(getCharacterPositionInFile(0, lineStarts)).toEqual({ + row: 1, + column: 1, + }); + }); +}); + +describe("getLineStarts", () => { + test("should return [0] for an empty string", () => { + expect(getLineStarts("")).toEqual([0]); + }); + + test("should return [0] for a string with no newlines", () => { + const content = "const foo = 'bar';"; + expect(getLineStarts(content)).toEqual([0]); + }); + + test("should identify start indices for multiple lines", () => { + const content = "line1\nline2\nline3"; + // line1 starts at 0 + // line2 starts at 6 (5 chars + \n) + // line3 starts at 12 (5 chars + \n) + expect(getLineStarts(content)).toEqual([0, 6, 12]); + }); + + test("should handle leading newline", () => { + const content = "\nline2"; + // line1 (empty) starts at 0 + // line2 starts at 1 + expect(getLineStarts(content)).toEqual([0, 1]); + }); + + test("should handle trailing newline", () => { + const content = "line1\n"; + // line1 starts at 0 + // line2 (empty) starts at 6 + expect(getLineStarts(content)).toEqual([0, 6]); + }); + + test("should handle consecutive newlines", () => { + const content = "a\n\nb"; + // line1 starts at 0 + // line2 (empty) starts at 2 + // line3 starts at 3 + expect(getLineStarts(content)).toEqual([0, 2, 3]); + }); +}); diff --git a/@navikt/aksel/src/darkside/transforms/darkside-tokens-css.ts b/@navikt/aksel/src/darkside/transforms/darkside-tokens-css.ts index a94cee89bd2..f5def3aa61d 100644 --- a/@navikt/aksel/src/darkside/transforms/darkside-tokens-css.ts +++ b/@navikt/aksel/src/darkside/transforms/darkside-tokens-css.ts @@ -4,21 +4,35 @@ import { legacyTokenConfig } from "../config/legacy.tokens"; export default function transformer(file: FileInfo) { let src = file.source; - for (const [oldToken, config] of Object.entries(legacyTokenConfig)) { - const oldCSSVar = `--a-${oldToken}`; + /* + 1. Replace definitions: --a-token: -> --aksel-legacy__a-token: + Matches "--a-token" followed by optional whitespace and a colon. + Uses negative lookbehind to ensure we don't match "--not-a-token". + */ + src = src.replace( + /(? { + const key = tokenName.replace("--a-", ""); + if (legacyTokenConfig[key]) { + return `--aksel-legacy${tokenName.replace("--", "__")}${suffix}`; + } + return match; + }, + ); - /* We update all re-definitions of a token to a "legacy" version */ - const replaceRegex = new RegExp("(" + `${oldCSSVar}:` + ")", "gm"); + /* + 2. Replace usages: --a-token -> --ax-replacement + Matches "--a-token" with word boundaries. + */ + src = src.replace(/(? { + const key = tokenName.replace("--a-", ""); + const config = legacyTokenConfig[key]; - src = src.replace( - replaceRegex, - `--aksel-legacy${oldCSSVar.replace("--", "__")}:`, - ); - - if (config.replacement.length > 0) { - src = src.replace(config.regexes.css, `--ax-${config.replacement}`); + if (config?.replacement) { + return `--ax-${config.replacement}`; } - } + return match; + }); return src; } diff --git a/@navikt/aksel/src/darkside/transforms/darkside-tokens-tailwind.ts b/@navikt/aksel/src/darkside/transforms/darkside-tokens-tailwind.ts index 5320f44ec26..cfd07430dab 100644 --- a/@navikt/aksel/src/darkside/transforms/darkside-tokens-tailwind.ts +++ b/@navikt/aksel/src/darkside/transforms/darkside-tokens-tailwind.ts @@ -1,12 +1,11 @@ import type { FileInfo } from "jscodeshift"; import { legacyTokenConfig } from "../config/legacy.tokens"; -import { createSingleTwRegex } from "../config/token-regex"; export default function transformer(file: FileInfo) { let src = file.source; for (const [name, config] of Object.entries(legacyTokenConfig)) { - if (!config.twOld || !config.twNew) { + if (!config.twOld || !config.twNew || !config.regexes.tailwind) { continue; } @@ -20,25 +19,21 @@ export default function transformer(file: FileInfo) { const beforeSplit = config.twOld.split(","); const afterSplit = config.twNew.split(","); - const matches = src.match(config.regexes.tailwind) || []; - - for (const match of matches) { - const index = beforeSplit.indexOf(match.trim().replace(":", "")); + src = src.replace(config.regexes.tailwind, (match) => { + const trimmed = match.trim(); + const cleanToken = trimmed.replace(":", ""); + const index = beforeSplit.indexOf(cleanToken); if (index >= 0) { - const withPrefix = match.trim().startsWith(":"); - + const withPrefix = trimmed.startsWith(":"); const addSpace = match.startsWith(" "); - const replacementToken = afterSplit[index]; - src = src.replace( - createSingleTwRegex(match), - withPrefix - ? `:${replacementToken}` - : `${addSpace ? " " : ""}${replacementToken}`, - ); + + return `${addSpace ? " " : ""}${withPrefix ? ":" : ""}${replacementToken}`; } - } + + return match; + }); } return src; diff --git a/@navikt/aksel/src/darkside/transforms/tests/css-edge-cases.input.css b/@navikt/aksel/src/darkside/transforms/tests/css-edge-cases.input.css new file mode 100644 index 00000000000..c18e87cff29 --- /dev/null +++ b/@navikt/aksel/src/darkside/transforms/tests/css-edge-cases.input.css @@ -0,0 +1,19 @@ +.edge-cases { + /* Partial matches - should NOT change */ + --not-a-spacing-4: 10px; + color: var(--not-a-surface-action); + margin: var(--a-spacing-4-extra); + + /* Whitespace in definitions - SHOULD change */ + --a-surface-action : blue; + --a-surface-action-selected : red; + + /* Multiple tokens on one line - SHOULD change */ + padding: var(--a-spacing-4) var(--a-spacing-4); + + /* Inside functions - SHOULD change */ + width: calc(var(--a-spacing-4) * 2); + + /* Comments - SHOULD change (regex based) */ + /* var(--a-spacing-4) */ +} diff --git a/@navikt/aksel/src/darkside/transforms/tests/css-edge-cases.output.css b/@navikt/aksel/src/darkside/transforms/tests/css-edge-cases.output.css new file mode 100644 index 00000000000..c57b96fc83a --- /dev/null +++ b/@navikt/aksel/src/darkside/transforms/tests/css-edge-cases.output.css @@ -0,0 +1,19 @@ +.edge-cases { + /* Partial matches - should NOT change */ + --not-a-spacing-4: 10px; + color: var(--not-a-surface-action); + margin: var(--a-spacing-4-extra); + + /* Whitespace in definitions - SHOULD change */ + --aksel-legacy__a-surface-action : blue; + --aksel-legacy__a-surface-action-selected : red; + + /* Multiple tokens on one line - SHOULD change */ + padding: var(--ax-space-16) var(--ax-space-16); + + /* Inside functions - SHOULD change */ + width: calc(var(--ax-space-16) * 2); + + /* Comments - SHOULD change (regex based) */ + /* var(--ax-space-16) */ +} diff --git a/@navikt/aksel/src/darkside/transforms/tests/darkside-tokens.test.ts b/@navikt/aksel/src/darkside/transforms/tests/darkside-tokens.test.ts index cf1008a5604..f5d9e58956f 100644 --- a/@navikt/aksel/src/darkside/transforms/tests/darkside-tokens.test.ts +++ b/@navikt/aksel/src/darkside/transforms/tests/darkside-tokens.test.ts @@ -42,6 +42,14 @@ for (const fixture of ["css-complete"]) { }); } +for (const fixture of ["css-edge-cases"]) { + check(__dirname, { + fixture, + migration: "darkside-tokens-css", + extension: "css", + }); +} + /* Tailwind transforms */ for (const fixture of ["tw-complete"]) { check(__dirname, { diff --git a/@navikt/aksel/src/darkside/transforms/tests/tw-complete.input.css b/@navikt/aksel/src/darkside/transforms/tests/tw-complete.input.css index 3d3ab0a4e94..55a7b5488d3 100644 --- a/@navikt/aksel/src/darkside/transforms/tests/tw-complete.input.css +++ b/@navikt/aksel/src/darkside/transforms/tests/tw-complete.input.css @@ -5,4 +5,6 @@ @apply md:bg-surface-subtle text-red-500 lg:via-surface-transparent z-tooltip; @apply md:bg-surface-subtle! text-red-500! lg:via-surface-transparent z-tooltip!; @apply group:bg-red-500; + @apply !bg-surface-subtle hover:focus:bg-surface-subtle; + @apply data-[state=open]:bg-surface-subtle aria-expanded:bg-surface-subtle; } diff --git a/@navikt/aksel/src/darkside/transforms/tests/tw-complete.output.css b/@navikt/aksel/src/darkside/transforms/tests/tw-complete.output.css index 1561b15b869..fbbfaefa6e5 100644 --- a/@navikt/aksel/src/darkside/transforms/tests/tw-complete.output.css +++ b/@navikt/aksel/src/darkside/transforms/tests/tw-complete.output.css @@ -5,4 +5,6 @@ @apply ax-md:bg-ax-bg-neutral-soft text-ax-danger-600 ax-lg:via-surface-transparent z-[3000]; @apply ax-md:bg-ax-bg-neutral-soft! text-ax-danger-600! ax-lg:via-surface-transparent z-[3000]!; @apply group:bg-ax-danger-600; + @apply !bg-ax-bg-neutral-soft hover:focus:bg-ax-bg-neutral-soft; + @apply data-[state=open]:bg-ax-bg-neutral-soft aria-expanded:bg-ax-bg-neutral-soft; } diff --git a/yarn.lock b/yarn.lock index 60e1a4313c6..190044f1ccc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5084,6 +5084,7 @@ __metadata: axios: "npm:1.13.2" chalk: "npm:4.1.0" cli-progress: "npm:^3.12.0" + clipboardy: "npm:^2.3.0" commander: "npm:10.0.1" enquirer: "npm:^2.3.6" fast-glob: "npm:3.2.11" @@ -9748,6 +9749,13 @@ __metadata: languageName: node linkType: hard +"arch@npm:^2.1.1": + version: 2.2.0 + resolution: "arch@npm:2.2.0" + checksum: 10/e35dbc6d362297000ab90930069576ba165fe63cd52383efcce14bd66c1b16a91ce849e1fd239964ed029d5e0bdfc32f68e9c7331b7df6c84ddebebfdbf242f7 + languageName: node + linkType: hard + "archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2": version: 5.0.2 resolution: "archiver-utils@npm:5.0.2" @@ -11250,6 +11258,17 @@ __metadata: languageName: node linkType: hard +"clipboardy@npm:^2.3.0": + version: 2.3.0 + resolution: "clipboardy@npm:2.3.0" + dependencies: + arch: "npm:^2.1.1" + execa: "npm:^1.0.0" + is-wsl: "npm:^2.1.1" + checksum: 10/a112920915d841d158adf33f47bd8c23179340c9b8f2bd4b484cd151d5e3b8437b2080b72d5bcd7492f76eef8699177bd867373715c27465062e8f3b57f795d0 + languageName: node + linkType: hard + "cliui@npm:^7.0.2": version: 7.0.4 resolution: "cliui@npm:7.0.4" @@ -11969,6 +11988,19 @@ __metadata: languageName: node linkType: hard +"cross-spawn@npm:^6.0.0": + version: 6.0.6 + resolution: "cross-spawn@npm:6.0.6" + dependencies: + nice-try: "npm:^1.0.4" + path-key: "npm:^2.0.1" + semver: "npm:^5.5.0" + shebang-command: "npm:^1.2.0" + which: "npm:^1.2.9" + checksum: 10/7abf6137b23293103a22bfeaf320f2d63faae70d97ddb4b58597237501d2efdd84cdc69a30246977e0c5f68216593894d41a7f122915dd4edf448db14c74171b + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -14203,6 +14235,21 @@ __metadata: languageName: node linkType: hard +"execa@npm:^1.0.0": + version: 1.0.0 + resolution: "execa@npm:1.0.0" + dependencies: + cross-spawn: "npm:^6.0.0" + get-stream: "npm:^4.0.0" + is-stream: "npm:^1.1.0" + npm-run-path: "npm:^2.0.0" + p-finally: "npm:^1.0.0" + signal-exit: "npm:^3.0.0" + strip-eof: "npm:^1.0.0" + checksum: 10/9b7a0077ba9d0ecdd41bf2d8644f83abf736e37622e3d1af39dec9d5f2cfa6bf8263301d0df489688dda3873d877f4168c01172cbafed5fffd12c808983515b0 + languageName: node + linkType: hard + "execa@npm:^2.1.0": version: 2.1.0 resolution: "execa@npm:2.1.0" @@ -15162,6 +15209,15 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^4.0.0": + version: 4.1.0 + resolution: "get-stream@npm:4.1.0" + dependencies: + pump: "npm:^3.0.0" + checksum: 10/12673e8aebc79767d187b203e5bfabb8266304037815d3bcc63b6f8c67c6d4ad0d98d4d4528bcdc1cbea68f1dd91bcbd87827aa3cdcfa9c5fa4a4644716d72c2 + languageName: node + linkType: hard + "get-stream@npm:^5.0.0": version: 5.2.0 resolution: "get-stream@npm:5.2.0" @@ -19491,6 +19547,13 @@ __metadata: languageName: node linkType: hard +"nice-try@npm:^1.0.4": + version: 1.0.5 + resolution: "nice-try@npm:1.0.5" + checksum: 10/0b4af3b5bb5d86c289f7a026303d192a7eb4417231fe47245c460baeabae7277bcd8fd9c728fb6bd62c30b3e15cd6620373e2cf33353b095d8b403d3e8a15aff + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" @@ -19705,6 +19768,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^2.0.0": + version: 2.0.2 + resolution: "npm-run-path@npm:2.0.2" + dependencies: + path-key: "npm:^2.0.0" + checksum: 10/acd5ad81648ba4588ba5a8effb1d98d2b339d31be16826a118d50f182a134ac523172101b82eab1d01cb4c2ba358e857d54cfafd8163a1ffe7bd52100b741125 + languageName: node + linkType: hard + "npm-run-path@npm:^3.0.0": version: 3.1.0 resolution: "npm-run-path@npm:3.1.0" @@ -20437,6 +20509,13 @@ __metadata: languageName: node linkType: hard +"path-key@npm:^2.0.0, path-key@npm:^2.0.1": + version: 2.0.1 + resolution: "path-key@npm:2.0.1" + checksum: 10/6e654864e34386a2a8e6bf72cf664dcabb76574dd54013add770b374384d438aca95f4357bb26935b514a4e4c2c9b19e191f2200b282422a76ee038b9258c5e7 + languageName: node + linkType: hard + "path-key@npm:^3.0.0, path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -23622,7 +23701,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.6.0": +"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.5.0, semver@npm:^5.6.0": version: 5.7.2 resolution: "semver@npm:5.7.2" bin: @@ -23986,6 +24065,15 @@ __metadata: languageName: node linkType: hard +"shebang-command@npm:^1.2.0": + version: 1.2.0 + resolution: "shebang-command@npm:1.2.0" + dependencies: + shebang-regex: "npm:^1.0.0" + checksum: 10/9eed1750301e622961ba5d588af2212505e96770ec376a37ab678f965795e995ade7ed44910f5d3d3cb5e10165a1847f52d3348c64e146b8be922f7707958908 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -23995,6 +24083,13 @@ __metadata: languageName: node linkType: hard +"shebang-regex@npm:^1.0.0": + version: 1.0.0 + resolution: "shebang-regex@npm:1.0.0" + checksum: 10/404c5a752cd40f94591dfd9346da40a735a05139dac890ffc229afba610854d8799aaa52f87f7e0c94c5007f2c6af55bdcaeb584b56691926c5eaf41dc8f1372 + languageName: node + linkType: hard + "shebang-regex@npm:^3.0.0": version: 3.0.0 resolution: "shebang-regex@npm:3.0.0" @@ -24082,7 +24177,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2": +"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -27507,7 +27602,7 @@ __metadata: languageName: node linkType: hard -"which@npm:^1.2.8, which@npm:^1.3.1": +"which@npm:^1.2.8, which@npm:^1.2.9, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: