@marcovega/svg-tidy 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marco Vega
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # svg-tidy
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@marcovega/svg-tidy.svg)](https://www.npmjs.com/package/@marcovega/svg-tidy)
4
+
5
+ A tiny CLI that optimizes every `.svg` file in a directory using
6
+ [SVGO](https://github.com/svg/svgo) with the same plugin settings as the
7
+ [SVGOMG](https://svgomg.net/) preset.
8
+
9
+ On the **first** run inside a directory it writes a `.svgtidy.json` file with
10
+ the default plugin settings. On subsequent runs it reads that file. Commit it
11
+ to your repo so everyone on the team optimizes icons the same way.
12
+
13
+ It rewrites each `.svg` file **in place**, so the result is ready to drop into
14
+ your assets folder.
15
+
16
+ ## Quick start
17
+
18
+ No install needed — just run it with `npx`:
19
+
20
+ ```bash
21
+ cd path/to/your/svgs
22
+ npx @marcovega/svg-tidy
23
+ ```
24
+
25
+ Or install globally (the binary is `svg-tidy`):
26
+
27
+ ```bash
28
+ npm install -g @marcovega/svg-tidy
29
+ svg-tidy
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```text
35
+ svg-tidy [paths...] [options]
36
+
37
+ Arguments:
38
+ paths Files or directories to process. Defaults to the current
39
+ working directory.
40
+
41
+ Options:
42
+ -r, --recursive Recurse into subdirectories when a directory is given.
43
+ -n, --dry-run Show what would change without writing files.
44
+ -q, --quiet Only print the final summary.
45
+ --init Write a default .svgtidy.json (if missing) and exit.
46
+ --config PATH Use a specific config file. Defaults to ./.svgtidy.json.
47
+ -h, --help Show this help.
48
+ ```
49
+
50
+ Examples:
51
+
52
+ ```bash
53
+ npx @marcovega/svg-tidy # optimize every .svg in this dir
54
+ npx @marcovega/svg-tidy -r # ...recursively
55
+ npx @marcovega/svg-tidy ./icons logo.svg # specific paths
56
+ npx @marcovega/svg-tidy -n # preview savings, don't write
57
+ npx @marcovega/svg-tidy --init # just create .svgtidy.json and stop
58
+ ```
59
+
60
+ Each file is overwritten only when SVGO actually changes its contents.
61
+
62
+ ## The `.svgtidy.json` config file
63
+
64
+ The first time you run `svg-tidy` in a directory it creates
65
+ `.svgtidy.json` next to your icons. The file uses the SVGOMG-style boolean
66
+ plugin map:
67
+
68
+ ```json
69
+ {
70
+ "multipass": true,
71
+ "js2svg": { "indent": 2, "pretty": false },
72
+ "plugins": {
73
+ "cleanupAttrs": true,
74
+ "cleanupIDs": true,
75
+ "cleanupListOfValues": false,
76
+ "removeViewBox": false,
77
+ "removeXMLNS": false,
78
+ "removeScriptElement": false,
79
+ "removeStyleElement": false
80
+ }
81
+ }
82
+ ```
83
+
84
+ To customize: edit the file, set a plugin to `false` to disable it, or pass
85
+ custom plugin params with an object value:
86
+
87
+ ```json
88
+ {
89
+ "plugins": {
90
+ "removeDesc": { "removeAny": true },
91
+ "cleanupIds": { "minify": false }
92
+ }
93
+ }
94
+ ```
95
+
96
+ Commit `.svgtidy.json` to your repo so collaborators (and CI) get the same
97
+ optimizations every time someone runs `svg-tidy`.
98
+
99
+ ### Notes on renamed SVGO plugins
100
+
101
+ SVGO renamed a couple plugins between the SVGOMG UI and modern SVGO. Both
102
+ old and new names are accepted in `.svgtidy.json`:
103
+
104
+ | SVGOMG name | SVGO v4 name |
105
+ | --------------------- | ----------------- |
106
+ | `cleanupIDs` | `cleanupIds` |
107
+ | `removeScriptElement` | `removeScripts` |
108
+
109
+ ## How `npx` finds this package
110
+
111
+ `npx some-package` does one of three things, in order:
112
+
113
+ 1. Runs `some-package` from the current project's `node_modules/.bin/`.
114
+ 2. Runs it from your global `PATH`.
115
+ 3. Downloads it from the npm registry to a temp cache and runs it.
116
+
117
+ Because `@marcovega/svg-tidy` is published to npm, option 3 just works — no
118
+ `npm link` required for end users.
119
+
120
+ ## Local development
121
+
122
+ ```bash
123
+ git clone git@github.com:marcovega/svg-tidy.git
124
+ cd svg-tidy
125
+ npm install
126
+ npm link # makes `svg-tidy` available globally for testing
127
+ ```
128
+
129
+ To remove the global symlink later: `npm unlink -g @marcovega/svg-tidy`.
130
+
131
+ ## Updating SVGO
132
+
133
+ ```bash
134
+ npm install svgo@latest
135
+ ```
136
+
137
+ ## License
138
+
139
+ [MIT](./LICENSE)
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
3
+ import { parseArgs } from 'node:util';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { optimize } from 'svgo';
7
+ import {
8
+ CONFIG_FILENAME,
9
+ loadOrCreateConfig,
10
+ resolveConfigPath,
11
+ toSvgoOptions,
12
+ } from '../lib/config.js';
13
+
14
+ const HELP = `svg-tidy - optimize SVG files in place using SVGO
15
+
16
+ Usage:
17
+ svg-tidy [paths...] [options]
18
+
19
+ Arguments:
20
+ paths Files or directories to process. Defaults to the current
21
+ working directory.
22
+
23
+ Options:
24
+ -r, --recursive Recurse into subdirectories when a directory is given.
25
+ -n, --dry-run Show what would change without writing files.
26
+ -q, --quiet Only print the final summary.
27
+ --init Write a default ${CONFIG_FILENAME} (if missing) and exit.
28
+ --config PATH Use a specific config file. Defaults to ./${CONFIG_FILENAME}.
29
+ -h, --help Show this help.
30
+
31
+ Config:
32
+ On first run in a directory svg-tidy writes ${CONFIG_FILENAME} with the
33
+ SVGOMG-style default plugin settings. Edit that file (or commit it) to
34
+ customize how SVGs are optimized. Subsequent runs reuse it.
35
+
36
+ Examples:
37
+ svg-tidy # optimize every .svg in this dir
38
+ svg-tidy -r # ...recursively
39
+ svg-tidy ./icons logo.svg # specific paths
40
+ svg-tidy -n # preview savings, don't write
41
+ svg-tidy --init # just create the default config and stop
42
+ `;
43
+
44
+ const { values, positionals } = parseArgs({
45
+ allowPositionals: true,
46
+ options: {
47
+ recursive: { type: 'boolean', short: 'r', default: false },
48
+ 'dry-run': { type: 'boolean', short: 'n', default: false },
49
+ quiet: { type: 'boolean', short: 'q', default: false },
50
+ init: { type: 'boolean', default: false },
51
+ config: { type: 'string' },
52
+ help: { type: 'boolean', short: 'h', default: false },
53
+ },
54
+ });
55
+
56
+ if (values.help) {
57
+ process.stdout.write(HELP);
58
+ process.exit(0);
59
+ }
60
+
61
+ const recursive = values.recursive;
62
+ const dryRun = values['dry-run'];
63
+ const quiet = values.quiet;
64
+ const initOnly = values.init;
65
+ const targets = positionals.length > 0 ? positionals : [process.cwd()];
66
+
67
+ function formatBytes(n) {
68
+ if (n < 1024) return `${n} B`;
69
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
70
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
71
+ }
72
+
73
+ function pct(before, after) {
74
+ if (before === 0) return '0.0%';
75
+ return `${(((before - after) / before) * 100).toFixed(1)}%`;
76
+ }
77
+
78
+ async function collectSvgs(target) {
79
+ const resolved = path.resolve(target);
80
+ let entryStat;
81
+ try {
82
+ entryStat = await stat(resolved);
83
+ } catch (err) {
84
+ throw new Error(`Cannot access ${target}: ${err.message}`);
85
+ }
86
+
87
+ if (entryStat.isFile()) {
88
+ return resolved.toLowerCase().endsWith('.svg') ? [resolved] : [];
89
+ }
90
+
91
+ if (!entryStat.isDirectory()) return [];
92
+
93
+ const files = [];
94
+ const entries = await readdir(resolved, { withFileTypes: true });
95
+ for (const entry of entries) {
96
+ const full = path.join(resolved, entry.name);
97
+ if (entry.isDirectory()) {
98
+ if (recursive) files.push(...(await collectSvgs(full)));
99
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.svg')) {
100
+ files.push(full);
101
+ }
102
+ }
103
+ return files;
104
+ }
105
+
106
+ async function processFile(file, svgoOptions) {
107
+ const original = await readFile(file, 'utf8');
108
+ const result = optimize(original, { path: file, ...svgoOptions });
109
+
110
+ if (result.error) throw new Error(result.error);
111
+
112
+ const optimized = result.data;
113
+ const beforeBytes = Buffer.byteLength(original, 'utf8');
114
+ const afterBytes = Buffer.byteLength(optimized, 'utf8');
115
+ const changed = optimized !== original;
116
+
117
+ if (changed && !dryRun) {
118
+ await writeFile(file, optimized, 'utf8');
119
+ }
120
+
121
+ return { file, beforeBytes, afterBytes, changed };
122
+ }
123
+
124
+ async function main() {
125
+ const cwd = process.cwd();
126
+ const configPath = resolveConfigPath(cwd, values.config);
127
+
128
+ const { config, created } = await loadOrCreateConfig(configPath);
129
+ const svgoOptions = toSvgoOptions(config);
130
+ const relConfig = path.relative(cwd, configPath) || configPath;
131
+
132
+ if (created) {
133
+ console.log(`Wrote default config to ${relConfig}.`);
134
+ console.log('Edit it to customize plugins, then re-run svg-tidy.');
135
+ } else if (!quiet) {
136
+ console.log(`Using config: ${relConfig}`);
137
+ }
138
+
139
+ if (initOnly) {
140
+ if (!created) console.log(`${relConfig} already exists. No changes made.`);
141
+ return;
142
+ }
143
+
144
+ const allFiles = new Set();
145
+ for (const target of targets) {
146
+ const found = await collectSvgs(target);
147
+ for (const f of found) allFiles.add(f);
148
+ }
149
+ const files = [...allFiles].sort();
150
+
151
+ if (files.length === 0) {
152
+ console.error('No .svg files found.');
153
+ process.exit(1);
154
+ }
155
+
156
+ let totalBefore = 0;
157
+ let totalAfter = 0;
158
+ let changedCount = 0;
159
+ let failedCount = 0;
160
+
161
+ for (const file of files) {
162
+ try {
163
+ const r = await processFile(file, svgoOptions);
164
+ totalBefore += r.beforeBytes;
165
+ totalAfter += r.afterBytes;
166
+ if (r.changed) changedCount += 1;
167
+ if (!quiet) {
168
+ const rel = path.relative(cwd, r.file) || r.file;
169
+ const tag = r.changed
170
+ ? `${formatBytes(r.beforeBytes)} -> ${formatBytes(r.afterBytes)} (${pct(r.beforeBytes, r.afterBytes)})`
171
+ : 'no change';
172
+ console.log(`${dryRun && r.changed ? '[dry] ' : ''}${rel}: ${tag}`);
173
+ }
174
+ } catch (err) {
175
+ failedCount += 1;
176
+ console.error(`FAILED ${file}: ${err.message}`);
177
+ }
178
+ }
179
+
180
+ const verb = dryRun ? 'Would optimize' : 'Optimized';
181
+ console.log(
182
+ `\n${verb} ${changedCount}/${files.length} file(s). ` +
183
+ `Total: ${formatBytes(totalBefore)} -> ${formatBytes(totalAfter)} ` +
184
+ `(${pct(totalBefore, totalAfter)} smaller)` +
185
+ (failedCount ? `. ${failedCount} failed.` : '.'),
186
+ );
187
+
188
+ if (failedCount > 0) process.exit(1);
189
+ }
190
+
191
+ main().catch((err) => {
192
+ console.error(err.stack || err.message || err);
193
+ process.exit(1);
194
+ });
package/lib/config.js ADDED
@@ -0,0 +1,87 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { DEFAULT_CONFIG, PLUGIN_ALIASES } from './svgo-config.js';
4
+
5
+ export const CONFIG_FILENAME = '.svgtidy.json';
6
+
7
+ /**
8
+ * Resolve where the config file lives.
9
+ *
10
+ * - If `configPath` is provided, use it verbatim.
11
+ * - Otherwise look in `cwd` for `.svgtidy.json`.
12
+ */
13
+ export function resolveConfigPath(cwd, configPath) {
14
+ if (configPath) return path.resolve(cwd, configPath);
15
+ return path.join(cwd, CONFIG_FILENAME);
16
+ }
17
+
18
+ /**
19
+ * Load `.svgtidy.json` from disk. If it doesn't exist, write the default config
20
+ * to that path first.
21
+ *
22
+ * Returns `{ config, path, created }`.
23
+ */
24
+ export async function loadOrCreateConfig(configPath) {
25
+ let raw;
26
+ let created = false;
27
+ try {
28
+ raw = await readFile(configPath, 'utf8');
29
+ } catch (err) {
30
+ if (err.code !== 'ENOENT') throw err;
31
+ raw = JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n';
32
+ await writeFile(configPath, raw, 'utf8');
33
+ created = true;
34
+ }
35
+
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(raw);
39
+ } catch (err) {
40
+ throw new Error(`Invalid JSON in ${configPath}: ${err.message}`);
41
+ }
42
+
43
+ return { config: parsed, path: configPath, created };
44
+ }
45
+
46
+ /**
47
+ * Convert a svg-tidy config (with a boolean/params plugin map) into the
48
+ * options object expected by SVGO's `optimize()` function.
49
+ */
50
+ export function toSvgoOptions(config) {
51
+ if (!config || typeof config !== 'object') {
52
+ throw new Error('Config must be an object.');
53
+ }
54
+
55
+ const { multipass, js2svg, plugins: pluginMap } = config;
56
+
57
+ if (pluginMap && typeof pluginMap !== 'object') {
58
+ throw new Error('Config "plugins" must be an object map of name -> bool|params.');
59
+ }
60
+
61
+ const seen = new Set();
62
+ const plugins = [];
63
+
64
+ for (const [rawName, value] of Object.entries(pluginMap || {})) {
65
+ if (value === false || value == null) continue;
66
+
67
+ const name = PLUGIN_ALIASES[rawName] || rawName;
68
+ if (seen.has(name)) continue;
69
+ seen.add(name);
70
+
71
+ if (value === true) {
72
+ plugins.push(name);
73
+ } else if (typeof value === 'object') {
74
+ plugins.push({ name, params: value });
75
+ } else {
76
+ throw new Error(
77
+ `Config plugin "${rawName}" must be a boolean or an object, got ${typeof value}.`,
78
+ );
79
+ }
80
+ }
81
+
82
+ return {
83
+ multipass: Boolean(multipass),
84
+ js2svg: js2svg || undefined,
85
+ plugins,
86
+ };
87
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Default SVGO plugin configuration for svg-tidy.
3
+ *
4
+ * `DEFAULT_CONFIG.plugins` is a SVGOMG-style boolean map (the same shape used
5
+ * in the amazing-facts theme docs). Each key is a plugin name, the value is:
6
+ * - `true` -> enable the plugin with its default params
7
+ * - `false` -> disable it (omitted from SVGO's plugin list)
8
+ * - an `object` (params) -> enable with custom params
9
+ *
10
+ * The persisted `.svgtidy.json` file uses this exact shape so it's easy to
11
+ * read, edit, and diff.
12
+ *
13
+ * SVGO renamed two plugins between the SVGOMG UI and modern SVGO; both old and
14
+ * new names are accepted in the config file and normalized via PLUGIN_ALIASES.
15
+ */
16
+
17
+ export const PLUGIN_ALIASES = {
18
+ cleanupIDs: 'cleanupIds',
19
+ removeScriptElement: 'removeScripts',
20
+ };
21
+
22
+ export const DEFAULT_CONFIG = {
23
+ multipass: true,
24
+ js2svg: {
25
+ indent: 2,
26
+ pretty: false,
27
+ },
28
+ plugins: {
29
+ cleanupAttrs: true,
30
+ cleanupEnableBackground: true,
31
+ cleanupIDs: true,
32
+ cleanupListOfValues: false,
33
+ cleanupNumericValues: true,
34
+ collapseGroups: true,
35
+ convertColors: true,
36
+ convertEllipseToCircle: true,
37
+ convertPathData: true,
38
+ convertShapeToPath: true,
39
+ convertStyleToAttrs: true,
40
+ convertTransform: true,
41
+ inlineStyles: true,
42
+ mergePaths: true,
43
+ mergeStyles: true,
44
+ minifyStyles: true,
45
+ moveElemsAttrsToGroup: true,
46
+ moveGroupAttrsToElems: true,
47
+ removeComments: true,
48
+ removeDesc: true,
49
+ removeDimensions: true,
50
+ removeDoctype: true,
51
+ removeEditorsNSData: true,
52
+ removeEmptyAttrs: true,
53
+ removeEmptyContainers: true,
54
+ removeEmptyText: true,
55
+ removeHiddenElems: true,
56
+ removeMetadata: true,
57
+ removeNonInheritableGroupAttrs: true,
58
+ removeRasterImages: true,
59
+ removeScriptElement: false,
60
+ removeStyleElement: false,
61
+ removeTitle: true,
62
+ removeUnknownsAndDefaults: true,
63
+ removeUnusedNS: true,
64
+ removeUselessDefs: true,
65
+ removeUselessStrokeAndFill: true,
66
+ removeViewBox: false,
67
+ removeXMLNS: false,
68
+ removeXMLProcInst: true,
69
+ reusePaths: false,
70
+ sortAttrs: false,
71
+ sortDefsChildren: true,
72
+ },
73
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@marcovega/svg-tidy",
3
+ "version": "0.2.0",
4
+ "description": "Optimize SVG icons in a directory using SVGO. Writes a .svgtidy.json config on first run so you can customize and commit settings per project.",
5
+ "type": "module",
6
+ "bin": {
7
+ "svg-tidy": "bin/svg-tidy.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.3.0"
17
+ },
18
+ "scripts": {
19
+ "start": "node bin/svg-tidy.js"
20
+ },
21
+ "dependencies": {
22
+ "svgo": "^4.0.1"
23
+ },
24
+ "keywords": [
25
+ "svg",
26
+ "svgo",
27
+ "svgomg",
28
+ "optimize",
29
+ "minify",
30
+ "icons",
31
+ "cli",
32
+ "npx"
33
+ ],
34
+ "author": "Marco Vega",
35
+ "license": "MIT",
36
+ "homepage": "https://github.com/marcovega/svg-tidy#readme",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/marcovega/svg-tidy.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/marcovega/svg-tidy/issues"
43
+ }
44
+ }