diff --git a/.changeset/fix-init-template-emoji-placeholder.md b/.changeset/fix-init-template-emoji-placeholder.md new file mode 100644 index 0000000..b829776 --- /dev/null +++ b/.changeset/fix-init-template-emoji-placeholder.md @@ -0,0 +1,11 @@ +--- +"@labcatr/labcommitr": patch +--- + +fix: include emoji placeholder in generated config template + +- Add {emoji} placeholder to template in buildConfig function +- Generated configs now include {emoji} in format.template field +- Fixes issue where emojis didn't appear in commits even when enabled +- Template now matches default config structure with emoji support +- Ensures formatCommitMessage can properly replace emoji placeholder diff --git a/.changeset/fix-preview-emoji-display.md b/.changeset/fix-preview-emoji-display.md new file mode 100644 index 0000000..3b03053 --- /dev/null +++ b/.changeset/fix-preview-emoji-display.md @@ -0,0 +1,11 @@ +--- +"@labcatr/labcommitr": patch +--- + +fix: show actual commit message with emojis in preview + +- Preview now displays the exact commit message as it will be stored in Git +- Removed emoji stripping from preview display logic +- Users can see emojis even if terminal doesn't support emoji display +- Ensures preview accurately reflects what will be committed to Git/GitHub +- Fixes issue where emojis were hidden in preview on non-emoji terminals diff --git a/.changeset/implement-emoji-detection-and-display.md b/.changeset/implement-emoji-detection-and-display.md new file mode 100644 index 0000000..4323432 --- /dev/null +++ b/.changeset/implement-emoji-detection-and-display.md @@ -0,0 +1,12 @@ +--- +"@labcatr/labcommitr": minor +--- + +feat: implement terminal emoji detection and display adaptation + +- Add emoji detection utility with industry-standard heuristics (CI, TERM, NO_COLOR, Windows Terminal) +- Implement automatic emoji stripping for non-emoji terminals in Labcommitr UI +- Always store Unicode emojis in Git commits regardless of terminal support +- Update commit, preview, and revert commands to adapt display based on terminal capabilities +- Ensure GitHub and emoji-capable terminals always show emojis correctly +- Improve user experience by cleaning up broken emoji symbols on non-emoji terminals diff --git a/.npmignore b/.npmignore index 5a01cf7..4d95af8 100644 --- a/.npmignore +++ b/.npmignore @@ -24,6 +24,8 @@ docs/ # Exclude build artifacts *.map +**/*.map +dist/**/*.map tsconfig.json .prettierrc* diff --git a/CHANGELOG.md b/CHANGELOG.md index ae42c81..61528a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # @labcatr/labcommitr -## 0.2.0 +## 0.3.0 ### Minor Changes diff --git a/README.md b/README.md index e9572a1..4ec55e1 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,8 @@ See [`docs/CONFIG_SCHEMA.md`](docs/CONFIG_SCHEMA.md) for complete configuration **Configuration discovery:** - Searches from current directory up to project root -- Falls back to global configuration if available - Uses sensible defaults if no configuration found +- Global configuration support is planned for future releases (see [Planned Features](#planned-features)) --- @@ -404,10 +404,6 @@ Before implementing any changes, please follow this process: - Follow the project's development guidelines - Ensure your commits follow the project's commit message format (you can set up using `lab init`) -### Development Guidelines - -For detailed development guidelines, coding standards, and architecture information, see [`docs/DEVELOPMENT_GUIDELINES.md`](docs/DEVELOPMENT_GUIDELINES.md). - ### Questions? If you have questions or need clarification, feel free to open a discussion or issue. @@ -416,4 +412,27 @@ If you have questions or need clarification, feel free to open a discussion or i ## Planned Features -_No planned features at this time. Check back later or open an issue to suggest new features!_ +### Global Configuration + +Support for user-level global configuration files to enable consistent commit conventions across multiple projects. This will allow you to: + +- Set default commit types and preferences in a single location +- Apply your preferred commit conventions to all projects automatically +- Override global settings on a per-project basis when needed + +**Use cases:** + +- Developers working across multiple repositories who want consistent commit message formats +- Teams that want to standardize commit conventions organization-wide +- Personal projects where you want the same commit types everywhere + +The global configuration will be stored in OS-specific locations: + +- **macOS/Linux**: `~/.labcommitr.config.yaml` or XDG config directory +- **Windows**: `%USERPROFILE%\.labcommitr.config.yaml` or AppData directory + +Project-specific configurations will always take precedence over global settings. + +--- + +_Have a feature idea? Open an issue to suggest new features!_ diff --git a/package.json b/package.json index d393821..12a6d42 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,19 @@ { "name": "@labcatr/labcommitr", - "version": "0.2.0", + "version": "0.3.0", "description": "Labcommitr is a solution for building standardized git commits, hassle-free!", "main": "dist/index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "npx tsc", + "build:prod": "npx tsc && pnpm run clean:maps", + "clean:maps": "node scripts/clean-maps.js", "format": "pnpm run format:code", "format:ci": "pnpm run format:code", "format:code": "prettier -w \"**/*\" --ignore-unknown --cache", "version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run format", - "dev:cli": "node dist/index-dev.js" + "dev:cli": "node dist/index-dev.js", + "prepublishOnly": "pnpm run build:prod" }, "type": "module", "bin": { @@ -29,6 +32,9 @@ "homepage": "https://github.com/labcatr/labcommitr#readme", "author": "Trevor Fox", "license": "ISC", + "engines": { + "node": ">=18.0.0" + }, "dependencies": { "@changesets/cli": "^2.29.7", "@clack/prompts": "^0.11.0", @@ -56,7 +62,10 @@ "dist/cli/commands/revert", "dist/cli/commands/shared", "dist/cli/utils", - "dist/lib" + "dist/lib", + "README.md", + "CHANGELOG.md", + "TESTING.md" ], "publishConfig": { "access": "public" diff --git a/scripts/clean-maps.js b/scripts/clean-maps.js new file mode 100644 index 0000000..3c6bf08 --- /dev/null +++ b/scripts/clean-maps.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +/** + * Remove all .map files from dist directory + * Cross-platform solution for cleaning source maps before publishing + */ + +import { readdir, stat, unlink } from "fs/promises"; +import { join } from "path"; + +async function removeMaps(dir) { + try { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + await removeMaps(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".map")) { + await unlink(fullPath); + console.log(`Removed: ${fullPath}`); + } + } + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } +} + +const distDir = join(process.cwd(), "dist"); +removeMaps(distDir).catch((error) => { + console.error("Error removing source maps:", error); + process.exit(1); +}); diff --git a/src/cli/commands/commit/editor.ts b/src/cli/commands/commit/editor.ts index 37a4995..c75127c 100644 --- a/src/cli/commands/commit/editor.ts +++ b/src/cli/commands/commit/editor.ts @@ -11,32 +11,75 @@ import { unlinkSync, mkdtempSync, rmdirSync, + accessSync, + constants, } from "fs"; import { join, dirname } from "path"; -import { tmpdir } from "os"; +import { tmpdir, platform } from "os"; import { Logger } from "../../../lib/logger.js"; +/** + * Cross-platform command resolver + * On Windows: uses 'where' command + * On Unix: uses 'which' command + * + * @param command - Command name to find + * @returns Full path to command or null if not found + */ +function findCommand(command: string): string | null { + const isWindows = platform() === "win32"; + const findCommand = isWindows ? "where" : "which"; + + try { + const result = spawnSync(findCommand, [command], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + + if (result.status === 0 && result.stdout) { + // On Windows, 'where' may return multiple paths, take the first one + const path = result.stdout.trim().split("\n")[0].trim(); + return path || null; + } + } catch { + // Command not found or error occurred + } + + return null; +} + /** * Detect available editor in priority order: nvim → vim → vi * Also checks $EDITOR and $VISUAL environment variables + * Cross-platform: works on Windows, macOS, and Linux */ export function detectEditor(): string | null { // Check environment variables first (user preference) const envEditor = process.env.EDITOR || process.env.VISUAL; if (envEditor) { - // Verify the editor exists - const check = spawnSync("which", [envEditor], { encoding: "utf-8" }); - if (check.status === 0) { - return envEditor.trim(); + // If it's already a full path, verify it exists + if (envEditor.includes("/") || envEditor.includes("\\")) { + try { + accessSync(envEditor, constants.F_OK); + return envEditor.trim(); + } catch { + // Path doesn't exist, try to find it as a command + } + } + + // Try to find it as a command in PATH + const found = findCommand(envEditor); + if (found) { + return found; } } // Try nvim, vim, vi in order const editors = ["nvim", "vim", "vi"]; for (const editor of editors) { - const check = spawnSync("which", [editor], { encoding: "utf-8" }); - if (check.status === 0) { - return editor; + const found = findCommand(editor); + if (found) { + return found; } } @@ -58,9 +101,11 @@ export function editInEditor( if (!editorCommand) { Logger.error("No editor found"); + const isWindows = platform() === "win32"; + const envVar = isWindows ? "%EDITOR%" : "$EDITOR"; console.error("\n No editor available (nvim, vim, or vi)"); console.error( - " Set $EDITOR environment variable to your preferred editor\n", + ` Set ${envVar} environment variable to your preferred editor\n`, ); return null; } diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts index 47697b0..9c6a12e 100644 --- a/src/cli/commands/commit/index.ts +++ b/src/cli/commands/commit/index.ts @@ -14,6 +14,7 @@ import { loadConfig, ConfigError } from "../../../lib/config/index.js"; import type { LabcommitrConfig } from "../../../lib/config/types.js"; import { Logger } from "../../../lib/logger.js"; +import { formatForDisplay } from "../../../lib/util/emoji.js"; import { isGitRepository } from "./git.js"; import { stageAllTrackedFiles, @@ -193,6 +194,7 @@ export async function commitAction(options: { } const config = configResult.config; + const emojiModeActive = configResult.emojiModeActive; // Step 2: Verify git repository if (!isGitRepository()) { @@ -353,7 +355,11 @@ export async function commitAction(options: { ); console.log(`${success("✓")} Commit created successfully!`); - console.log(` ${commitHash} ${formattedMessage}`); + const displayMessage = formatForDisplay( + formattedMessage, + emojiModeActive, + ); + console.log(` ${commitHash} ${displayMessage}`); } catch (error: unknown) { // Cleanup on failure await cleanup({ @@ -441,7 +447,12 @@ export async function commitAction(options: { ); // Show preview and get user action - action = await displayPreview(formattedMessage, body, config); + action = await displayPreview( + formattedMessage, + body, + config, + emojiModeActive, + ); // Handle edit actions if (action === "edit-type") { @@ -508,7 +519,11 @@ export async function commitAction(options: { ); console.log(`${success("✓")} Commit created successfully!`); - console.log(` ${commitHash} ${formattedMessage}`); + const displayMessage = formatForDisplay( + formattedMessage, + emojiModeActive, + ); + console.log(` ${commitHash} ${displayMessage}`); } catch (error: unknown) { // Cleanup on failure await cleanup({ diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts index 18de034..68e08b5 100644 --- a/src/cli/commands/commit/prompts.ts +++ b/src/cli/commands/commit/prompts.ts @@ -1076,6 +1076,7 @@ export async function displayPreview( formattedMessage: string, body: string | undefined, config?: LabcommitrConfig, + emojiModeActive: boolean = true, ): Promise< | "commit" | "edit-type" @@ -1084,6 +1085,12 @@ export async function displayPreview( | "edit-body" | "cancel" > { + // Preview shows the actual commit message as it will be stored in Git + // We don't strip emojis here because the user needs to see what will be committed + // even if their terminal doesn't support emoji display + const displayMessage = formattedMessage; + const displayBody = body; + // Start connector line using @clack/prompts log.info( `${label("preview", "green")} ${textColors.pureWhite("Commit message preview:")}`, @@ -1092,11 +1099,11 @@ export async function displayPreview( // Render content with connector lines // Empty line after header console.log(renderWithConnector("")); - console.log(renderWithConnector(textColors.brightCyan(formattedMessage))); + console.log(renderWithConnector(textColors.brightCyan(displayMessage))); - if (body) { + if (displayBody) { console.log(renderWithConnector("")); - const bodyLines = body.split("\n"); + const bodyLines = displayBody.split("\n"); for (const line of bodyLines) { console.log(renderWithConnector(textColors.white(line))); } diff --git a/src/cli/commands/preview/index.ts b/src/cli/commands/preview/index.ts index d1d0339..a4db6d0 100644 --- a/src/cli/commands/preview/index.ts +++ b/src/cli/commands/preview/index.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { Logger } from "../../../lib/logger.js"; +import { detectEmojiSupport } from "../../../lib/util/emoji.js"; import { isGitRepository, getCurrentBranch, @@ -93,6 +94,9 @@ async function previewAction(options: { process.exit(0); } + // Detect emoji support for display + const emojiModeActive = detectEmojiSupport(); + // Main loop let exit = false; let viewingDetails = false; @@ -105,7 +109,12 @@ async function previewAction(options: { if (viewingDetails && currentDetailCommit) { // Detail view - displayCommitDetails(currentDetailCommit, showBody, showFiles); + displayCommitDetails( + currentDetailCommit, + showBody, + showFiles, + emojiModeActive, + ); console.log( ` ${textColors.white("Press")} ${textColors.brightYellow("b")} ${textColors.white("to toggle body,")} ${textColors.brightYellow("f")} ${textColors.white("to toggle files,")} ${textColors.brightYellow("d")} ${textColors.white("for diff,")} ${textColors.brightYellow("r")} ${textColors.white("to revert,")} ${textColors.brightYellow("←")} ${textColors.white("to go back")}`, ); @@ -188,6 +197,7 @@ async function previewAction(options: { hasMore, hasPreviousPage, hasMorePages, + emojiModeActive, ); const action = await waitForListAction( diff --git a/src/cli/commands/preview/prompts.ts b/src/cli/commands/preview/prompts.ts index 63d0e50..cd6180e 100644 --- a/src/cli/commands/preview/prompts.ts +++ b/src/cli/commands/preview/prompts.ts @@ -6,6 +6,7 @@ import { select, isCancel } from "@clack/prompts"; import { labelColors, textColors } from "../init/colors.js"; +import { formatForDisplay } from "../../../lib/util/emoji.js"; import type { CommitInfo } from "../shared/types.js"; import { getCommitDetails, getCommitDiff } from "../shared/git-operations.js"; import readline from "readline"; @@ -46,6 +47,7 @@ export function displayCommitList( hasMore: boolean, hasPreviousPage: boolean = false, hasMorePages: boolean = false, + emojiModeActive: boolean = true, ): void { console.log(); console.log( @@ -64,10 +66,11 @@ export function displayCommitList( const commit = commits[i]; const number = i.toString(); const mergeIndicator = commit.isMerge ? " [Merge]" : ""; + const displaySubject = formatForDisplay(commit.subject, emojiModeActive); const truncatedSubject = - commit.subject.length > 50 - ? commit.subject.substring(0, 47) + "..." - : commit.subject; + displaySubject.length > 50 + ? displaySubject.substring(0, 47) + "..." + : displaySubject; console.log( ` ${textColors.brightCyan(`[${number}]`)} ${textColors.brightWhite(commit.shortHash)} ${truncatedSubject}${mergeIndicator}`, @@ -124,6 +127,7 @@ export function displayCommitDetails( commit: CommitInfo, showBody: boolean = true, showFiles: boolean = true, + emojiModeActive: boolean = true, ): void { console.log(); console.log( @@ -131,7 +135,8 @@ export function displayCommitDetails( ); console.log(); console.log(` ${textColors.brightWhite("Hash:")} ${commit.hash}`); - console.log(` ${textColors.brightWhite("Subject:")} ${commit.subject}`); + const displaySubject = formatForDisplay(commit.subject, emojiModeActive); + console.log(` ${textColors.brightWhite("Subject:")} ${displaySubject}`); console.log(); console.log( ` ${textColors.brightWhite("Author:")} ${commit.author.name} <${commit.author.email}>`, @@ -174,7 +179,8 @@ export function displayCommitDetails( if (showBody) { if (commit.body) { console.log(` ${textColors.brightWhite("Body:")}`); - const bodyLines = commit.body.split("\n"); + const displayBody = formatForDisplay(commit.body, emojiModeActive); + const bodyLines = displayBody.split("\n"); bodyLines.forEach((line) => { console.log(` ${line}`); }); diff --git a/src/cli/commands/revert/index.ts b/src/cli/commands/revert/index.ts index f3a127f..78976ed 100644 --- a/src/cli/commands/revert/index.ts +++ b/src/cli/commands/revert/index.ts @@ -7,6 +7,10 @@ import { Command } from "commander"; import { Logger } from "../../../lib/logger.js"; import { loadConfig } from "../../../lib/config/index.js"; +import { + detectEmojiSupport, + formatForDisplay, +} from "../../../lib/util/emoji.js"; import { isGitRepository, getCurrentBranch, @@ -133,6 +137,7 @@ export async function revertCommit( } const config = configResult.config; + const emojiModeActive = configResult.emojiModeActive; // Get commit details const commit = getCommitDetails(commitHash); @@ -151,7 +156,7 @@ export async function revertCommit( // Show confirmation clearTerminal(); - displayRevertConfirmation(commit); + displayRevertConfirmation(commit, emojiModeActive); let useWorkflow = !options?.noEdit; if (useWorkflow) { @@ -241,7 +246,12 @@ export async function revertCommit( subject, ); - action = await displayPreview(formattedMessage, body, config); + action = await displayPreview( + formattedMessage, + body, + config, + emojiModeActive, + ); if (action === "edit-type") { const typeResult = await promptType(config, undefined, type); @@ -305,7 +315,11 @@ export async function revertCommit( hashResult.stdout?.toString().trim().substring(0, 7) || "unknown"; console.log(`${success("✓")} Revert commit created successfully!`); - console.log(` ${revertHash} ${formattedMessage}`); + const displayMessage = formatForDisplay( + formattedMessage, + emojiModeActive, + ); + console.log(` ${revertHash} ${displayMessage}`); } catch (error: unknown) { // Check if it's a conflict if (error instanceof Error && error.message.includes("conflict")) { @@ -394,6 +408,9 @@ async function revertAction(options: { process.exit(1); } + // Detect emoji support for display (always detect, even if no config) + const emojiModeActive = detectEmojiSupport(); + // Check for config if --no-edit is not used (config needed for commit workflow) if (!options.noEdit) { const configResult = await loadConfig(); @@ -483,6 +500,7 @@ async function revertAction(options: { hasMore, hasPreviousPage, hasMorePages, + emojiModeActive, ); // Build navigation hints diff --git a/src/cli/commands/revert/prompts.ts b/src/cli/commands/revert/prompts.ts index 40e9b07..7683c0f 100644 --- a/src/cli/commands/revert/prompts.ts +++ b/src/cli/commands/revert/prompts.ts @@ -6,6 +6,7 @@ import { select, confirm, isCancel } from "@clack/prompts"; import { labelColors, textColors, success, attention } from "../init/colors.js"; +import { formatForDisplay } from "../../../lib/util/emoji.js"; import type { CommitInfo, MergeParent } from "../shared/types.js"; /** @@ -54,6 +55,7 @@ export function displayRevertCommitList( hasMore: boolean, hasPreviousPage: boolean = false, hasMorePages: boolean = false, + emojiModeActive: boolean = true, ): void { console.log(); console.log( @@ -71,10 +73,11 @@ export function displayRevertCommitList( const commit = commits[i]; const number = i.toString(); const mergeIndicator = commit.isMerge ? " [Merge]" : ""; + const displaySubject = formatForDisplay(commit.subject, emojiModeActive); const truncatedSubject = - commit.subject.length > 50 - ? commit.subject.substring(0, 47) + "..." - : commit.subject; + displaySubject.length > 50 + ? displaySubject.substring(0, 47) + "..." + : displaySubject; console.log( ` ${textColors.brightCyan(`[${number}]`)} ${textColors.brightWhite(commit.shortHash)} ${truncatedSubject}${mergeIndicator}`, @@ -124,7 +127,10 @@ export async function promptMergeParent( /** * Display revert confirmation */ -export function displayRevertConfirmation(commit: CommitInfo): void { +export function displayRevertConfirmation( + commit: CommitInfo, + emojiModeActive: boolean = true, +): void { console.log(); console.log( `${label("confirm", "green")} ${textColors.pureWhite("Revert Confirmation")}`, @@ -133,6 +139,8 @@ export function displayRevertConfirmation(commit: CommitInfo): void { console.log( ` ${textColors.brightWhite("Reverting commit:")} ${commit.shortHash}`, ); + const displaySubject = formatForDisplay(commit.subject, emojiModeActive); + console.log(` ${textColors.brightWhite("Original:")} ${displaySubject}`); console.log(` ${textColors.brightWhite("Original:")} ${commit.subject}`); console.log(); console.log( diff --git a/src/lib/config/loader.ts b/src/lib/config/loader.ts index bd9cac4..38e74ab 100644 --- a/src/lib/config/loader.ts +++ b/src/lib/config/loader.ts @@ -20,6 +20,7 @@ import type { import { ConfigError } from "./types.js"; import { mergeWithDefaults, createFallbackConfig } from "./defaults.js"; import { ConfigValidator } from "./validator.js"; +import { detectEmojiSupport } from "../util/emoji.js"; /** * Configuration file names to search for (in priority order) @@ -231,7 +232,7 @@ export class ConfigLoader { config: fallbackConfig, source: "defaults", loadedAt: Date.now(), - emojiModeActive: this.detectEmojiSupport(), // TODO: Implement emoji detection + emojiModeActive: this.detectEmojiSupport(fallbackConfig), }; } @@ -309,7 +310,7 @@ export class ConfigLoader { source: "project", path: configPath, loadedAt: Date.now(), - emojiModeActive: this.detectEmojiSupport(), // TODO: Implement emoji detection + emojiModeActive: this.detectEmojiSupport(processedConfig), }; } @@ -346,14 +347,23 @@ export class ConfigLoader { /** * Detects whether the current terminal supports emoji display * - * TODO: Implement proper emoji detection logic - * For now, returns true as a placeholder + * Combines user preference (force_emoji_detection) with terminal capability detection. + * User preference takes precedence over automatic detection. * - * @returns Whether emojis should be displayed + * @param config - The loaded configuration (may contain force_emoji_detection override) + * @returns Whether emojis should be displayed in the terminal */ - private detectEmojiSupport(): boolean { - // Placeholder implementation - will be enhanced later - return true; + private detectEmojiSupport(config?: LabcommitrConfig): boolean { + // User override takes highest priority + if ( + config?.config.force_emoji_detection !== null && + config?.config.force_emoji_detection !== undefined + ) { + return config.config.force_emoji_detection; + } + + // Automatic terminal detection + return detectEmojiSupport(); } /** @@ -444,7 +454,7 @@ export class ConfigLoader { } catch (error: any) { if (error.code === "ENOENT") { // File not found - this is handled upstream, but provide clear error if called directly - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Configuration file not found: ${filePath}`, "The file does not exist", ["Run 'lab init' to create a configuration file"], @@ -452,7 +462,7 @@ export class ConfigLoader { ); } else if (error.code === "EACCES") { // Permission denied - provide actionable solutions - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Cannot read configuration file: ${filePath}`, "Permission denied - insufficient file permissions", [ @@ -464,7 +474,7 @@ export class ConfigLoader { ); } else if (error.code === "ENOTDIR") { // Path component is not a directory - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Invalid path to configuration file: ${filePath}`, "A component in the path is not a directory", [ @@ -476,7 +486,7 @@ export class ConfigLoader { } // Re-throw unexpected errors with additional context - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Failed to access configuration file: ${filePath}`, `System error: ${error.message}`, [ @@ -507,7 +517,7 @@ export class ConfigLoader { // Check for empty file (common user error) if (!fileContent.trim()) { - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Configuration file is empty: ${filePath}`, "The file contains no content or only whitespace", [ @@ -529,7 +539,7 @@ export class ConfigLoader { // Validate that result is an object (not null, string, array, etc.) if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { const actualType = Array.isArray(parsed) ? "array" : typeof parsed; - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Invalid configuration structure in ${filePath}`, `Configuration must be a YAML object, but got ${actualType}`, [ @@ -544,7 +554,7 @@ export class ConfigLoader { // Basic structure validation - ensure required 'types' field exists const config = parsed as any; if (!Array.isArray(config.types)) { - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Missing required 'types' field in ${filePath}`, "Configuration must include a 'types' array defining commit types", [ @@ -565,7 +575,7 @@ export class ConfigLoader { // Extract line and column information if available if (mark) { const lineInfo = `line ${mark.line + 1}, column ${mark.column + 1}`; - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Invalid YAML syntax in ${filePath} at ${lineInfo}`, `Parsing error: ${message}`, [ @@ -578,7 +588,7 @@ export class ConfigLoader { ); } else { // YAML error without specific location - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Invalid YAML syntax in ${filePath}`, `Parsing error: ${message}`, [ @@ -599,7 +609,7 @@ export class ConfigLoader { // Handle file system errors that might occur during reading if (error.code === "EISDIR") { - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Cannot read configuration: ${filePath} is a directory`, "Expected a file but found a directory", [ @@ -611,7 +621,7 @@ export class ConfigLoader { } // Generic error fallback with context - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Failed to parse configuration file: ${filePath}`, `Unexpected error: ${error.message}`, [ @@ -643,7 +653,7 @@ export class ConfigLoader { // Handle common file system errors with specific guidance if (error.code === "ENOENT") { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `No configuration found starting from ${context}`, "Could not locate a labcommitr configuration file in the project", [ @@ -655,7 +665,7 @@ export class ConfigLoader { } if (error.code === "EACCES") { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Permission denied while searching for configuration`, `Cannot access directory or file: ${error.path || context}`, [ @@ -667,7 +677,7 @@ export class ConfigLoader { } if (error.code === "ENOTDIR") { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Invalid directory structure encountered`, `Expected directory but found file: ${error.path || context}`, [ @@ -679,7 +689,7 @@ export class ConfigLoader { // Handle YAML-related errors (these should typically be caught upstream) if (error instanceof yaml.YAMLException) { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Configuration file contains invalid YAML syntax`, `YAML parsing error: ${error.message}`, [ @@ -692,7 +702,7 @@ export class ConfigLoader { // Handle timeout errors (e.g., from slow file systems) if (error.code === "ETIMEDOUT") { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Timeout while accessing configuration files`, "File system operation took too long to complete", [ @@ -709,7 +719,7 @@ export class ConfigLoader { ? `\n\nTechnical details:\n${error.stack}` : ""; - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Configuration loading failed`, `${errorMessage}${errorContext}`, [ diff --git a/src/lib/presets/index.ts b/src/lib/presets/index.ts index 6978664..d4432d0 100644 --- a/src/lib/presets/index.ts +++ b/src/lib/presets/index.ts @@ -141,8 +141,8 @@ export function buildConfig( force_emoji_detection: null, }, format: { - // Template is determined by style; emoji is handled at render time - template: "{type}({scope}): {subject}", + // Template includes {emoji} placeholder - will be replaced with emoji or empty string + template: "{emoji}{type}({scope}): {subject}", subject_max_length: 50, // Body configuration (respects user choice, defaults to optional) body: { diff --git a/src/lib/util/emoji.ts b/src/lib/util/emoji.ts new file mode 100644 index 0000000..46c4e63 --- /dev/null +++ b/src/lib/util/emoji.ts @@ -0,0 +1,138 @@ +/** + * Emoji Detection and Display Utilities + * + * Provides terminal emoji support detection and emoji stripping + * functionality for clean display on non-emoji terminals. + * + * Industry Standard Approach: + * - Always store Unicode emojis in Git commits + * - Strip emojis from Labcommitr's UI display when terminal doesn't support them + * - This ensures GitHub and emoji-capable terminals show emojis correctly + */ + +import { platform } from "os"; + +/** + * Detects whether the current terminal supports emoji display + * + * Uses industry-standard heuristics: + * - Disable in CI environments (CI=true) + * - Disable for dumb terminals (TERM=dumb) + * - Disable on older Windows terminals + * - Check for NO_COLOR environment variable + * - Allow user override via FORCE_EMOJI_DETECTION + * + * @returns Whether emojis should be displayed in the terminal + */ +export function detectEmojiSupport(): boolean { + // User override (highest priority) + const forceDetection = process.env.FORCE_EMOJI_DETECTION; + if (forceDetection !== undefined) { + return forceDetection.toLowerCase() === "true" || forceDetection === "1"; + } + + // NO_COLOR standard (https://no-color.org/) + if (process.env.NO_COLOR) { + return false; + } + + // CI environments typically don't support emojis well + if (process.env.CI === "true" || process.env.CI === "1") { + return false; + } + + // Dumb terminals don't support emojis + const term = process.env.TERM; + if (term === "dumb" || term === "unknown") { + return false; + } + + // Windows terminal detection + const isWindows = platform() === "win32"; + if (isWindows) { + // Modern Windows Terminal (10+) supports emojis + // Older cmd.exe and PowerShell may not + // Check for Windows Terminal specific environment variables + const wtSession = process.env.WT_SESSION; + if (wtSession) { + // Windows Terminal detected - supports emojis + return true; + } + + // Check for ConEmu or other modern terminals + const conEmu = process.env.CONEMUANSI; + if (conEmu === "ON") { + return true; + } + + // For older Windows terminals, be conservative + // Check if we're in a TTY (interactive terminal) + if (!process.stdout.isTTY) { + return false; + } + + // Default to false for older Windows (can be overridden by FORCE_EMOJI_DETECTION) + return false; + } + + // Unix-like systems: check TERM variable + // Most modern terminals support emojis + if (term) { + // Known non-emoji terminals + const nonEmojiTerms = ["linux", "vt100", "vt220", "xterm-mono"]; + if (nonEmojiTerms.includes(term.toLowerCase())) { + return false; + } + + // Modern terminals typically support emojis + // xterm-256color, screen-256color, tmux-256color, etc. + return true; + } + + // Default: assume emoji support if we have a TTY + return process.stdout.isTTY === true; +} + +/** + * Strips Unicode emojis from a string for display on non-emoji terminals + * + * Uses Unicode emoji pattern matching to remove emoji characters + * while preserving the rest of the text. + * + * @param text - Text that may contain emojis + * @returns Text with emojis removed + */ +export function stripEmojis(text: string): string { + // Unicode emoji pattern matching + // Matches emoji characters including: + // - Emoticons (😀-🙏) + // - Symbols & Pictographs (🌀-🗿) + // - Transport & Map Symbols (🚀-🛿) + // - Flags (country flags) + // - Regional indicators + // - Variation selectors + const emojiPattern = + /[\p{Emoji}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Emoji_Modifier}\p{Emoji_Component}]/gu; + + return text.replace(emojiPattern, "").trim(); +} + +/** + * Conditionally strips emojis from text based on terminal support + * + * If terminal supports emojis, returns original text. + * If terminal doesn't support emojis, returns text with emojis stripped. + * + * @param text - Text that may contain emojis + * @param terminalSupportsEmojis - Whether terminal supports emoji display + * @returns Text with emojis conditionally stripped + */ +export function formatForDisplay( + text: string, + terminalSupportsEmojis: boolean, +): string { + if (terminalSupportsEmojis) { + return text; + } + return stripEmojis(text); +}