From 15192b4a1f82509bda6a2fecbdcc887dc25cb438 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 22 Mar 2026 15:56:49 +0000 Subject: [PATCH 1/2] Add uninstall helpers for installed Google Font files systemFont.uninstall removes TTF/WOFF2 files from the same per-user directories used by install. googleFont.uninstallAsync resolves variants via the GWFH file map and deletes matching basenames. Extend tests and types accordingly. Co-authored-by: Tin Sever --- lib/google-font.js | 30 ++++++++++++++++++ lib/system-font.js | 68 ++++++++++++++++++++++++++++++++++++++++ lib/types.d.ts | 1 + test/google-font.test.js | 19 ++++++++++- test/system-font.test.js | 21 +++++++++++++ 5 files changed, 138 insertions(+), 1 deletion(-) diff --git a/lib/google-font.js b/lib/google-font.js index 34fb6b6..d82b997 100644 --- a/lib/google-font.js +++ b/lib/google-font.js @@ -187,6 +187,36 @@ googleFont.prototype.installAsync = async function(variants) { return resultList; }; +/** + * Remove installed font variants from the system font folder + * @param {string[] | false} [variants] - Variants to remove, or false for all + * @returns {Promise} Results of removed fonts (one entry per variant) + */ +googleFont.prototype.uninstallAsync = async function(variants) { + const fileList = await this._getFileMapAsync(DEFAULT_FORMAT); + const requested = (variants && variants.length) ? variants : Object.keys(fileList); + const resultList = []; + const fileName = await toPascalCase(this.getFamily()); + + for (const v of requested) { + const norm = this._normalizeVariant(v); + const url = fileList[norm]; + + if (url) { + try { + const paths = await systemFont.uninstall(fileName + '-' + norm); + for (const p of paths) { + resultList.push({ family: this.getFamily(), variant: norm, path: p }); + } + } catch (err) { + throw err; + } + } + } + + return resultList; +}; + /** * Save font variants to a specified folder * @param {string[] | false} [variants] - Variants to download, or false for all diff --git a/lib/system-font.js b/lib/system-font.js index 3420f78..a6f13fe 100644 --- a/lib/system-font.js +++ b/lib/system-font.js @@ -240,6 +240,74 @@ SystemFont.prototype.install = async function(remoteFile, fileName) { } }; +/** + * Candidate paths for an installed font file (TTF and WOFF2) for the current platform. + * @param {string} fileBase - Base filename without extension (e.g. OpenSans-regular) + * @returns {string[]} + */ +SystemFont.prototype._uninstallCandidatePaths = function(fileBase) { + /** @type {string[]} */ + const dirs = []; + switch (platform) { + case 'linux': { + const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + dirs.push(path.join(xdgDataHome, 'fonts')); + break; + } + case 'darwin': + dirs.push(path.join(os.homedir(), 'Library', 'Fonts')); + break; + case 'win32': { + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + dirs.push(path.join(localAppData, 'Microsoft', 'Windows', 'Fonts')); + } + break; + } + default: + throw new Error('Platform not supported.'); + } + /** @type {string[]} */ + const out = []; + for (const d of dirs) { + out.push(path.join(d, fileBase + '.ttf')); + out.push(path.join(d, fileBase + '.woff2')); + } + return out; +}; + +/** + * Remove installed font files matching the same basename used by install (TTF or WOFF2). + * @param {string} fileBase - Base filename without extension + * @returns {Promise} Paths that were removed + */ +SystemFont.prototype.uninstall = async function(fileBase) { + const candidates = this._uninstallCandidatePaths(fileBase); + /** @type {string[]} */ + const removed = []; + for (const p of candidates) { + try { + await fs.unlink(p); + removed.push(p); + } catch (err) { + if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') { + throw err; + } + } + } + if (removed.length === 0) { + throw new Error('Font file not found for: ' + fileBase); + } + if (platform === 'linux') { + try { + await util.promisify(exec)('fc-cache -f'); + } catch (err) { + // fc-cache might not be available + } + } + return removed; +}; + /** * Get a supported extension from the remote URL. * @param {string} remoteFile diff --git a/lib/types.d.ts b/lib/types.d.ts index 9389309..73786d5 100644 --- a/lib/types.d.ts +++ b/lib/types.d.ts @@ -131,6 +131,7 @@ export interface GoogleFontInstance extends FontData { _getFileMapAsync(format?: FontFormat): Promise>; install(variants?: string[] | false, callback?: FontResultCallback): void; installAsync(variants?: string[] | false): Promise; + uninstallAsync(variants?: string[] | false): Promise; saveAt(variants?: string[] | false, destFolder?: string, format?: FontFormat | FontResultCallback, callback?: FontResultCallback): void; saveAtAsync(variants?: string[] | false, destFolder?: string, format?: FontFormat): Promise; _normalizeVariant(variant: string): string; diff --git a/test/google-font.test.js b/test/google-font.test.js index d9bc2f1..f052d07 100644 --- a/test/google-font.test.js +++ b/test/google-font.test.js @@ -9,7 +9,8 @@ jest.mock('../lib/case', () => ({ jest.mock('../lib/request'); jest.mock('../lib/system-font', () => ({ install: jest.fn(), - saveAt: jest.fn() + saveAt: jest.fn(), + uninstall: jest.fn() })); const GoogleFont = require('../lib/google-font'); @@ -165,6 +166,22 @@ describe('GoogleFont', () => { expect(systemFont.saveAt).toHaveBeenCalledWith('https://cdn.example.com/noto-sans-jp-regular.ttf', '/fonts', 'NotoSansJP-regular'); }); + it('uses PascalCase basenames for uninstallAsync', async () => { + const font = new GoogleFont({ family: 'Open Sans' }); + systemFont.uninstall.mockResolvedValue(['/fonts/OpenSans-regular.ttf']); + + const promise = font.uninstallAsync(['regular']); + pendingRequests[0].emit('success', JSON.stringify({ + variants: [{ id: 'regular', ttf: 'https://cdn.example.com/open-sans-regular.ttf' }] + })); + + await expect(promise).resolves.toEqual([ + { family: 'Open Sans', variant: 'regular', path: '/fonts/OpenSans-regular.ttf' } + ]); + expect(toPascalCase).toHaveBeenCalledWith('Open Sans'); + expect(systemFont.uninstall).toHaveBeenCalledWith('OpenSans-regular'); + }); + it('collects partial save results and throws AggregateError for failures', async () => { const font = new GoogleFont({ family: 'Inter' }); systemFont.saveAt diff --git a/test/system-font.test.js b/test/system-font.test.js index af56f14..387b6c4 100644 --- a/test/system-font.test.js +++ b/test/system-font.test.js @@ -46,6 +46,7 @@ describe('SystemFont', () => { expect(typeof systemFont.install).toBe('function'); expect(typeof systemFont.saveAt).toBe('function'); expect(typeof systemFont.saveHere).toBe('function'); + expect(typeof systemFont.uninstall).toBe('function'); }); it('_checkDestFolder resolves absolute paths', async () => { @@ -133,4 +134,24 @@ describe('SystemFont', () => { expect(systemFont._isValidFontFile(undefined, '.ttf')).toBe(true); expect(systemFont._isValidFontFile(undefined, '.pdf')).toBe(false); }); + + it('uninstall removes existing font files and returns their paths', async () => { + jest.spyOn(fsPromises, 'unlink').mockResolvedValue(undefined); + const execSpy = jest.spyOn(require('child_process'), 'exec').mockImplementation((cmd, cb) => { + if (typeof cb === 'function') cb(null, '', ''); + return /** @type {any} */ ({}); + }); + + const result = await systemFont.uninstall('TestFont-regular'); + + expect(fsPromises.unlink).toHaveBeenCalled(); + expect(result.length).toBeGreaterThan(0); + execSpy.mockRestore(); + }); + + it('uninstall throws when no matching files exist', async () => { + jest.spyOn(fsPromises, 'unlink').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(systemFont.uninstall('MissingFont-regular')).rejects.toThrow('Font file not found'); + }); }); From 23e58a8131c661c372960a6adcdc7c017ca861d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 22 Mar 2026 15:57:09 +0000 Subject: [PATCH 2/2] Add CLI remove command for installed Google Fonts Introduces gfcli remove with optional --variants, matching install behavior for multi-family and partial success reporting. Co-authored-by: Tin Sever --- cli.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/cli.js b/cli.js index 28f77f1..7e6b731 100755 --- a/cli.js +++ b/cli.js @@ -200,6 +200,63 @@ program } }); +program + .command("remove ") + .description("Remove installed Google Font files from the system") + .option("-v, --variants ", "Variants separated by comma") + .action(async (family, options) => { + const refresh = program.opts().refreshCache; + const variants = options.variants ? options.variants.split(",") : false; + const families = splitFamilies(family); + + try { + await ensureFontsLoaded(refresh); + /** @type {FontResult[]} */ + let allResults = []; + let successCount = 0; + let failCount = 0; + + for (const term of families) { + try { + const filteredList = await getFontByNameAsync(term); + if (filteredList.data.length !== 1) { + handleMatchError("Removal", term, null); + failCount++; + continue; + } + const font = filteredList.getFirst(); + if (!font) { + handleMatchError("Removal", term, null); + failCount++; + continue; + } + const result = await font.uninstallAsync(variants); + allResults = allResults.concat(result); + successCount++; + } catch (err) { + handleMatchError("Removal", term, /** @type {Error} */ (err)); + failCount++; + } + } + + if (allResults.length > 0) { + printResult(null, allResults); + } + + if (failCount > 0 && successCount === 0) { + console.error(pc.red(pc.bold(`\nAll ${failCount} font removal(s) failed.`))); + process.exit(1); + } + + if (failCount > 0 && successCount > 0) { + console.log(pc.yellow(`\n${successCount} font(s) removed successfully, ${failCount} failed.`)); + } + } catch (err) { + console.error(pc.red(/** @type {Error} */ (err).toString())); + process.exit(1); + } + }); + program .command("copy ") .description("Copy Google Fonts stylesheet link to clipboard")