Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
9f89b13
feat: modernize codebase with async/await and updated dependencies
Dec 31, 2025
f2f346e
Merge pull request #18 from tinsever/feature/modernize-dependencies
tinsever Dec 31, 2025
9623fdc
feat: cache font list and add batch ops
Dec 31, 2025
9eb3740
fix: remove async deps and improve refresh UX
Dec 31, 2025
c76529a
Merge branch 'v3-dev' into feat/cache-batch-handling
tinsever Dec 31, 2025
563a10e
Merge pull request #19 from tinsever/feat/cache-batch-handling
tinsever Dec 31, 2025
7b5ccda
feat: add comprehensive test suite with Jest
Dec 31, 2025
f14ef88
Merge pull request #20 from tinsever/feat/add-tests-v3
tinsever Dec 31, 2025
378496b
feat: add JSDoc type annotations and TypeScript type checking
Dec 31, 2025
8bdef1d
Update lib/system-font.js
tinsever Dec 31, 2025
f3393e1
Update lib/system-font.js
tinsever Dec 31, 2025
dfd2224
Initial plan
Copilot Dec 31, 2025
ef26a1e
Merge pull request #22 from tinsever/copilot/sub-pr-21
tinsever Dec 31, 2025
a3b3027
Merge pull request #21 from tinsever/feat/jsdoc-addition
tinsever Dec 31, 2025
0535acd
fix sys font
Dec 31, 2025
c6a2e33
fix typos
Dec 31, 2025
b48570e
3.0.0
Dec 31, 2025
882d1ee
fix jsdoc; fix workflow
Dec 31, 2025
d7ded1b
Update lib/system-font.js
tinsever Dec 31, 2025
01e3b53
Initial plan
Copilot Dec 31, 2025
26ae223
Initial plan
Copilot Dec 31, 2025
128cefa
Add missing semicolon on line 113
Copilot Dec 31, 2025
b8a6688
Update README.md
tinsever Dec 31, 2025
1bee581
Initial plan
Copilot Dec 31, 2025
92c35f0
Update package.json
tinsever Dec 31, 2025
ae1d01f
Update lib/google-font.js
tinsever Dec 31, 2025
0a1bf7e
Improve batch operation error handling for download and install commands
Copilot Dec 31, 2025
67301e2
Update test/system-font.test.js
tinsever Dec 31, 2025
ce5cb5e
Update README.md
tinsever Dec 31, 2025
9ed014f
Fix edge cases in error tracking: handle null fonts and count success…
Copilot Dec 31, 2025
37277c7
Merge pull request #24 from tinsever/copilot/sub-pr-23
tinsever Dec 31, 2025
aba0386
Merge pull request #25 from tinsever/copilot/sub-pr-23-again
tinsever Dec 31, 2025
7d40513
Merge pull request #26 from tinsever/copilot/sub-pr-23-another-one
tinsever Dec 31, 2025
9da4dce
fix: remove unused util import
Jan 1, 2026
19ee48d
refactor: use const instead of var
Jan 1, 2026
34f465f
refactor: extract DEFAULT_FORMAT constant for magic string
Jan 1, 2026
8620efc
fix: improve variant normalization with 'normal' alias support
Jan 1, 2026
d8918cc
fix: collect errors in saveAtAsync instead of silently swallowing
Jan 1, 2026
ed4cdbf
fix: throw AggregateError instead of attaching errors to array
Jan 1, 2026
3835296
Merge pull request #30 from tinsever/fix/google-font-cleanup
tinsever Jan 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ jobs:

- run: npm ci

- name: Run tests
run: npm test

- name: Publish to NPM
run: npm publish --provenance

Expand Down
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ node_modules

# Optional REPL history
.node_repl_history
test/

.DS_Store
.DS_Store
*.zip
*.tar
*.tar.gz
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ $ gfcli install "Open Sans" -v regular,700
- [homebrew](#homebrew-using-tap)
- [CLI](#cli)
- [Search a font](#search-a-font)
- [Caching](#caching)
- [Download a font](#download-a-font)
- [Install a font](#install-a-font)
- [Copy font CSS URL](#copy-font-css-url)
Expand Down Expand Up @@ -74,6 +75,12 @@ $ brew install gfcli

From your terminal emulator, you can use the command `gfcli`

### Caching

- The Google Fonts metadata list is cached for 24h at `~/.gfcli/cache.json`.
- Use `--refresh-cache` on any command to force a fresh download.
- If the cache is valid, commands skip the download step for faster startup.

### Search a font

```
Expand All @@ -85,7 +92,7 @@ For instance, search for _Source Sans_ or _Sans Source_ will produce the same re
### Download a font

```
$ gfcli download [family_name] [-d|--dest destination_folder] [-v|--variants comma_separeted_variants] [--ttf|--woff2]
$ gfcli download [family_name|"family1,family2"] [-d|--dest destination_folder] [-v|--variants comma_separated_variants] [--ttf|--woff2] [--refresh-cache]
```

If **family_name** will match more than one family, nothing will be downloaded: a list of alternatives will help you better specify the font family name.
Expand All @@ -95,16 +102,20 @@ Download command accepts these options:
- `-v` or `--variants` let you specify which variants of the font will be downloaded. You have to write each variant separated by the other with a comma. For example `$ gfcli download Source Sans Pro -v 300,400`. If omitted, all variants will be downloaded.
- `--ttf` downloads the font in TTF format (default)
- `--woff2` downloads the font in WOFF2 format (optimized for web use)
- Multiple families: pass a comma-separated list in quotes, e.g. `$ gfcli download "Inter,Roboto" --woff2 -d ./fonts`
- `--refresh-cache` forces a fresh font list download and ignores the local cache

### Install a font
```
$ gfcli install [family_name] [-v|--variants comma_separeted_variants]
$ gfcli install [family_name|"family1,family2"] [-v|--variants comma_separated_variants] [--refresh-cache]
```

If **family_name** will match more than one family, nothing will be installed: a list of alternatives will help you better specify the font family name.

Install command accepts only one option:
- `-v` or `--variants` let you specify which variants of the font will be installed. You have to write each variant separated by the other with a comma. For example `$ gfcli install Source Sans Pro -v 300,400`. If omitted, all variants will be downloaded.
- Multiple families: pass a comma-separated list in quotes, e.g. `$ gfcli install "Inter,Roboto" -v 400,700`
- `--refresh-cache` forces a fresh font list download and ignores the local cache

### Copy font CSS url
```
Expand Down Expand Up @@ -154,6 +165,21 @@ Inter variant 200 downloaded in /home/user/someFolder/Inter-200.woff2
...
```

**Download Inter and Roboto in one command (WOFF2)**
```
$ gfcli download "Inter,Roboto" --woff2 -d ./fonts
```

**Install Inter and Roboto with selected variants**
```
$ gfcli install "Inter,Roboto" -v regular,700
```

**Force refresh of the cached font list**
```
$ gfcli search inter --refresh-cache
```

**Install Lato 100**
```
$ gfcli install lato -v 100
Expand Down
206 changes: 176 additions & 30 deletions cli.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,79 @@
#!/usr/bin/env node
// @ts-check

"use strict";

const program = require("commander");
/**
* @typedef {import('./lib/types').GoogleFontListInstance} GoogleFontListInstance
* @typedef {import('./lib/types').GoogleFontInstance} GoogleFontInstance
* @typedef {import('./lib/types').FontResult} FontResult
*/

const { Command } = require("commander");
const pc = require("picocolors");
const ncp = require("copy-paste-win32fix");
const GoogleFontList = require("./lib/google-font-list");
const pjson = require("./package.json");

/** @type {any} */
const fontList = new GoogleFontList();
const program = new Command();

program.option("--refresh-cache", "Refresh the cached Google font list");

/**
* Helper to wrap fontList initialization in a Promise
* Split comma-separated font family arguments into an array
* @param {string[]} familyArgs - Array of family arguments
* @returns {string[]} Array of trimmed family names
*/
const ensureFontsLoaded = () =>
new Promise((resolve) => {
if (fontList.loaded) return resolve();
console.log(pc.bold(pc.blue("\nDownloading Google Font List...\n")));
fontList.on("success", resolve);
fontList.on("error", (err) => {
console.error(pc.bold(pc.red("Error loading font list!")));
console.error(pc.red(err.toString()));
process.exit(1);
const splitFamilies = (familyArgs) =>
familyArgs
.join(" ")
.split(",")
.map((s) => s.trim())
.filter(Boolean);

/**
* Promisified wrapper for getFontByName
* @param {string} term - Font family name to search
* @returns {Promise<any>} Filtered font list
*/
const getFontByNameAsync = (term) =>
new Promise((resolve, reject) => {
fontList.getFontByName(term, (/** @type {Error | null} */ err, /** @type {any} */ filtered) => {
if (err) return reject(err);
resolve(filtered);
});
});

/**
* Helper to wrap fontList initialization in a Promise
* @param {boolean} [refreshCache=false] - Whether to force refresh the cache
* @returns {Promise<void>}
*/
const ensureFontsLoaded = async (refreshCache = false) => {
if (refreshCache) {
fontList.loaded = false;
console.log(pc.bold(pc.blue("\nRefreshing Google Font List cache...\n")));
}
if (fontList.loaded) return;
try {
await fontList.load(refreshCache);
} catch (err) {
console.error(pc.bold(pc.red("Error loading font list!")));
console.error(pc.red(err.toString()));
process.exit(1);
}
};

program.version(pjson.version);

program
.command("search [family...]")
.description("Search for a font family")
.action(async (family) => {
await ensureFontsLoaded();
const refresh = program.opts().refreshCache;
await ensureFontsLoaded(refresh);
const term = family ? family.join(" ") : "";
fontList.searchFontByName(term, printFontList);
});
Expand All @@ -44,53 +86,137 @@ program
.option("--ttf", "Download TTF format (default)")
.option("--woff2", "Download WOFF2 format")
.action(async (family, options) => {
await ensureFontsLoaded();
const term = family.join(" ");
const refresh = program.opts().refreshCache;
const variants = options.variants ? options.variants.split(",") : false;
const format = options.woff2 ? "woff2" : "ttf";
const families = splitFamilies(family);

fontList.getFontByName(term, (err, filteredList) => {
if (err || filteredList.data.length !== 1) {
handleMatchError("Download", term, err);
return;
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("Download", term, null);
failCount++;
continue;
}
const font = filteredList.getFirst();
if (!font) {
handleMatchError("Download", term, null);
failCount++;
continue;
}
const result = await font.saveAtAsync(variants, options.dest, format);
allResults = allResults.concat(result);
successCount++;
} catch (err) {
handleMatchError("Download", term, /** @type {Error} */ (err));
failCount++;
}
}
filteredList.getFirst().saveAt(variants, options.dest, format, printResult);
});

if (allResults.length > 0) {
printResult(null, allResults);
}

// If all operations failed, exit with error
if (failCount > 0 && successCount === 0) {
console.error(pc.red(pc.bold(`\nAll ${failCount} font download(s) failed.`)));
process.exit(1);
}

// Report partial failures
if (failCount > 0 && successCount > 0) {
console.log(pc.yellow(`\n${successCount} font(s) downloaded successfully, ${failCount} failed.`));
}
} catch (err) {
console.error(pc.red(/** @type {Error} */ (err).toString()));
process.exit(1);
}
});

program
.command("install <family...>")
.description("Install a font family to the system")
.option("-v, --variants <variants>", "Variants separated by comma")
.action(async (family, options) => {
await ensureFontsLoaded();
const term = family.join(" ");
const refresh = program.opts().refreshCache;
const variants = options.variants ? options.variants.split(",") : false;
const families = splitFamilies(family);

fontList.getFontByName(term, (err, filteredList) => {
if (err || filteredList.data.length !== 1) {
handleMatchError("Installation", term, err);
return;
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("Installation", term, null);
failCount++;
continue;
}
const font = filteredList.getFirst();
if (!font) {
handleMatchError("Installation", term, null);
failCount++;
continue;
}
const result = await font.installAsync(variants);
allResults = allResults.concat(result);
successCount++;
} catch (err) {
handleMatchError("Installation", term, /** @type {Error} */ (err));
failCount++;
}
}
filteredList.getFirst().install(variants, printResult);
});

if (allResults.length > 0) {
printResult(null, allResults);
}

// If all operations failed, exit with error
if (failCount > 0 && successCount === 0) {
console.error(pc.red(pc.bold(`\nAll ${failCount} font installation(s) failed.`)));
process.exit(1);
}

// Report partial failures
if (failCount > 0 && successCount > 0) {
console.log(pc.yellow(`\n${successCount} font(s) installed successfully, ${failCount} failed.`));
}
} catch (err) {
console.error(pc.red(/** @type {Error} */ (err).toString()));
process.exit(1);
}
});

program
.command("copy <family...>")
.description("Copy Google Fonts stylesheet link to clipboard")
.option("-v, --variants <variants>", "Variants separated by comma")
.action(async (family, options) => {
await ensureFontsLoaded();
const refresh = program.opts().refreshCache;
await ensureFontsLoaded(refresh);
const term = family.join(" ");
const variants = options.variants ? options.variants.split(",") : false;

fontList.getFontByName(term, (err, filteredList) => {
fontList.getFontByName(term, (/** @type {Error | null} */ err, /** @type {any} */ filteredList) => {
if (err || filteredList.data.length !== 1) {
handleMatchError("Copy", term, err);
return;
}
const font = filteredList.getFirst();
if (!font) return;
const url = variants
? `${font.getCssUrl()}:${variants.join(",")}`
: font.getCssUrl();
Expand All @@ -112,6 +238,13 @@ if (program.args.length === 0) {
* Output Helpers
*/

/**
* Handle and display match errors for font operations
* @param {string} action - Action being performed (e.g., "Download", "Install")
* @param {string} term - Font family name that failed
* @param {Error | null} err - Error object or null
* @returns {void}
*/
function handleMatchError(action, term, err) {
if (err) {
console.error(pc.red(err.toString()));
Expand All @@ -123,6 +256,13 @@ function handleMatchError(action, term, err) {
}
}

/**
* Print a list of fonts to the console
* @param {Error | null} err - Error object or null
* @param {GoogleFontListInstance} list - Font list to display
* @param {string} [message="Search results for:"] - Header message
* @returns {void}
*/
function printFontList(err, list, message = "Search results for:") {
if (err) return console.error(pc.red(err.toString()));
if (list.data.length === 0) {
Expand All @@ -139,6 +279,12 @@ function printFontList(err, list, message = "Search results for:") {
});
}

/**
* Print font operation results to the console
* @param {Error | null} err - Error object or null
* @param {FontResult[]} result - Array of font results to display
* @returns {void}
*/
function printResult(err, result) {
if (err) return console.error(pc.red(err.toString()));
console.log("");
Expand Down
13 changes: 13 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/test/**/*.test.js'],
collectCoverageFrom: [
'lib/**/*.js',
'!lib/windows/**/*.js'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
verbose: true,
testTimeout: 10000,
maxWorkers: 1
};
Loading