better-asset 0.1.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 Sazzadur
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 all
13
+ 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 THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # better-asset
2
+
3
+ [![npm version](https://img.shields.io/npm/v/better-asset)](https://www.npmjs.com/package/better-asset)
4
+ [![license](https://img.shields.io/npm/l/better-asset)](./LICENSE)
5
+
6
+ Config-driven image asset generator. Resize, convert, and generate favicons from a single JSON config.
7
+
8
+ > Zero config UI. Just a JSON file and one command.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install -g better-asset
14
+ ```
15
+
16
+ Or use without installing:
17
+
18
+ ```bash
19
+ npx better-asset
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ # Create a starter config
26
+ better-asset init
27
+
28
+ # Edit assets.config.json, then generate
29
+ better-asset
30
+ ```
31
+
32
+ ## CLI
33
+
34
+ ```
35
+ better-asset Generate assets from config
36
+ better-asset init Create a starter config file
37
+ better-asset --config <path> Use a custom config file
38
+ better-asset --help Show help
39
+ better-asset --version Show version
40
+ ```
41
+
42
+ ## Supported Formats
43
+
44
+ **Input:** svg, png, jpg/jpeg, webp, avif
45
+
46
+ **Output:** png (default), webp, jpeg, avif, ico
47
+
48
+ ## Config File
49
+
50
+ By default `better-asset` looks for `assets.config.json` in the current directory.
51
+
52
+ Add a `$schema` key for editor autocompletion:
53
+
54
+ ```json
55
+ {
56
+ "$schema": "https://unpkg.com/better-asset/assets.config.schema.json"
57
+ }
58
+ ```
59
+
60
+ ### Example
61
+
62
+ ```json
63
+ {
64
+ "$schema": "https://unpkg.com/better-asset/assets.config.schema.json",
65
+ "source_dir": "public/assets",
66
+
67
+ "assets": [
68
+ {
69
+ "name": "icon",
70
+ "source": "icon.svg",
71
+ "sizes": [48, 96, 144, 512],
72
+ "destination": "public/icons"
73
+ },
74
+ {
75
+ "name": "favicon",
76
+ "source": "favicon.svg",
77
+ "sizes": [16, 32, 48],
78
+ "destination": "public/icons",
79
+ "ico": {
80
+ "required": true,
81
+ "size": 96
82
+ }
83
+ },
84
+ {
85
+ "name": "opengraph-image",
86
+ "source": "icon.svg",
87
+ "sizes": ["1200x630"],
88
+ "destination": "public/icons",
89
+ "format": "jpeg"
90
+ }
91
+ ]
92
+ }
93
+ ```
94
+
95
+ ### Asset Fields
96
+
97
+ | Field | Type | Required | Default | Description |
98
+ | ------------- | ------- | -------- | ------- | -------------------------------------------- |
99
+ | `name` | string | yes | — | Base name for generated files |
100
+ | `source` | string | yes | — | Source filename (relative to `source_dir`) |
101
+ | `sizes` | array | yes | — | Sizes to generate |
102
+ | `destination` | string | yes | — | Output directory (relative to project root) |
103
+ | `format` | string | no | `"png"` | Output format: `png`, `webp`, `jpeg`, `avif` |
104
+ | `sizeInName` | boolean | no | auto | Force include/exclude size in filename |
105
+ | `ico` | object | no | — | Generate a `favicon.ico` |
106
+
107
+ ## Size Rules
108
+
109
+ **Square** — use a number:
110
+
111
+ ```
112
+ 48 → 48×48
113
+ ```
114
+
115
+ **Rectangular** — use a string:
116
+
117
+ ```
118
+ "1200x630" → 1200×630
119
+ ```
120
+
121
+ ## sizeInName
122
+
123
+ By default:
124
+
125
+ - **Multiple sizes** → size is included (`icon-48.png`, `icon-96.png`)
126
+ - **Single size** → size is omitted (`apple-touch-icon.png`)
127
+
128
+ Override per asset:
129
+
130
+ ```json
131
+ { "sizeInName": true }
132
+ { "sizeInName": false }
133
+ ```
134
+
135
+ ## ICO Generation
136
+
137
+ Add an `ico` object to generate a real `favicon.ico`:
138
+
139
+ ```json
140
+ {
141
+ "ico": {
142
+ "required": true,
143
+ "size": 96
144
+ }
145
+ }
146
+ ```
147
+
148
+ | Field | Type | Default | Description |
149
+ | ---------- | ------- | ------- | -------------------------------------- |
150
+ | `required` | boolean | — | Whether to generate the `.ico` file |
151
+ | `size` | number | `64` | Size of the embedded PNG in the `.ico` |
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "better-asset Config",
4
+ "description": "Configuration for better-asset — a config-driven image asset generator.",
5
+ "type": "object",
6
+ "required": ["source_dir", "assets"],
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string",
10
+ "description": "Path to the JSON schema file (for editor support)."
11
+ },
12
+ "source_dir": {
13
+ "type": "string",
14
+ "description": "Directory containing source images, relative to the project root."
15
+ },
16
+ "assets": {
17
+ "type": "array",
18
+ "description": "List of asset definitions to generate.",
19
+ "items": {
20
+ "type": "object",
21
+ "required": ["name", "source", "sizes", "destination"],
22
+ "properties": {
23
+ "name": {
24
+ "type": "string",
25
+ "description": "Base name for generated files."
26
+ },
27
+ "source": {
28
+ "type": "string",
29
+ "description": "Source image filename (relative to source_dir). Supports: svg, png, jpg, jpeg, webp, avif."
30
+ },
31
+ "sizes": {
32
+ "type": "array",
33
+ "description": "Sizes to generate. Use a number for square (e.g. 48 → 48x48) or a string for rectangular (e.g. \"1200x630\").",
34
+ "items": {
35
+ "oneOf": [
36
+ {
37
+ "type": "integer",
38
+ "minimum": 1,
39
+ "description": "Square size in pixels."
40
+ },
41
+ {
42
+ "type": "string",
43
+ "pattern": "^\\d+x\\d+$",
44
+ "description": "Rectangular size as \"WIDTHxHEIGHT\"."
45
+ }
46
+ ]
47
+ },
48
+ "minItems": 1
49
+ },
50
+ "destination": {
51
+ "type": "string",
52
+ "description": "Output directory for generated files, relative to the project root."
53
+ },
54
+ "format": {
55
+ "type": "string",
56
+ "enum": ["png", "webp", "jpeg", "jpg", "avif"],
57
+ "default": "png",
58
+ "description": "Output image format (default: png)."
59
+ },
60
+ "sizeInName": {
61
+ "type": "boolean",
62
+ "description": "Force include/exclude size in filename. Default: included when multiple sizes, omitted for single size."
63
+ },
64
+ "ico": {
65
+ "type": "object",
66
+ "description": "Generate a favicon.ico file from this asset.",
67
+ "required": ["required"],
68
+ "properties": {
69
+ "required": {
70
+ "type": "boolean",
71
+ "description": "Whether to generate the .ico file."
72
+ },
73
+ "size": {
74
+ "type": "integer",
75
+ "minimum": 1,
76
+ "default": 64,
77
+ "description": "Size of the embedded PNG in the .ico file (default: 64)."
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from "path";
4
+ import fs from "fs/promises";
5
+ import { generate } from "../src/generate.js";
6
+ import { defaultConfig } from "../src/config.js";
7
+ import * as log from "../src/log.js";
8
+
9
+ const HELP = `
10
+ \x1b[1m\x1b[35mbetter-asset\x1b[0m — config-driven image asset generator
11
+
12
+ \x1b[1mUsage\x1b[0m
13
+
14
+ $ better-asset Generate assets from config
15
+ $ better-asset init Create a starter config file
16
+ $ better-asset --config <path> Use a custom config file
17
+ $ better-asset --help Show this help
18
+ $ better-asset --version Show version
19
+
20
+ \x1b[1mExamples\x1b[0m
21
+
22
+ $ better-asset
23
+ $ better-asset --config my-config.json
24
+ $ better-asset init
25
+
26
+ \x1b[1mLearn More\x1b[0m
27
+ For configuration and usage details, see the documentation:
28
+ https://npmjs.com/package/better-asset
29
+ `;
30
+
31
+ async function getVersion() {
32
+ const pkgPath = new URL("../package.json", import.meta.url);
33
+ const raw = await fs.readFile(pkgPath, "utf-8");
34
+ return JSON.parse(raw).version;
35
+ }
36
+
37
+ async function initConfig(cwd) {
38
+ const configPath = path.join(cwd, "assets.config.json");
39
+
40
+ try {
41
+ await fs.access(configPath);
42
+ log.warn(`assets.config.json already exists — skipping.`);
43
+ return;
44
+ } catch {
45
+ // doesn't exist, good
46
+ }
47
+
48
+ await fs.writeFile(configPath, JSON.stringify(defaultConfig(), null, "\t") + "\n");
49
+ log.banner();
50
+ log.success("assets.config.json", "created");
51
+ console.log();
52
+ log.info("Edit the config, then run \x1b[1mbetter-asset\x1b[0m to generate.");
53
+ console.log();
54
+ }
55
+
56
+ async function main() {
57
+ const args = process.argv.slice(2);
58
+
59
+ if (args.includes("--help") || args.includes("-h")) {
60
+ console.log(HELP);
61
+ return;
62
+ }
63
+
64
+ if (args.includes("--version") || args.includes("-v")) {
65
+ console.log(await getVersion());
66
+ return;
67
+ }
68
+
69
+ const cwd = process.cwd();
70
+
71
+ if (args[0] === "init") {
72
+ await initConfig(cwd);
73
+ return;
74
+ }
75
+
76
+ const configFlagIndex = args.indexOf("--config");
77
+ const configFlag = configFlagIndex !== -1 ? args[configFlagIndex + 1] : undefined;
78
+
79
+ await generate(cwd, configFlag);
80
+ }
81
+
82
+ main().catch(err => {
83
+ log.error(err.message || err);
84
+ process.exit(1);
85
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "better-asset",
3
+ "version": "0.1.0",
4
+ "description": "Config-driven image asset generator. Resize, convert, and generate favicons from a single JSON config.",
5
+ "type": "module",
6
+ "bin": {
7
+ "better-asset": "bin/better-asset.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "assets.config.schema.json",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "image",
17
+ "asset",
18
+ "generator",
19
+ "favicon",
20
+ "ico",
21
+ "icon",
22
+ "sharp",
23
+ "resize",
24
+ "convert",
25
+ "webp",
26
+ "avif",
27
+ "png",
28
+ "cli"
29
+ ],
30
+ "author": "Sazzadur Rahman",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "dependencies": {
36
+ "sharp": "^0.34.5"
37
+ }
38
+ }
package/src/clean.js ADDED
@@ -0,0 +1,18 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { fileExists } from "./fs-utils.js";
4
+
5
+ export async function cleanGeneratedFiles(destinationDir, assetName) {
6
+ if (!(await fileExists(destinationDir))) return;
7
+
8
+ const files = await fs.readdir(destinationDir);
9
+
10
+ const patterns = [
11
+ new RegExp(`^${assetName}(-.*)?\\.(png|webp|jpeg|jpg|avif)$`),
12
+ ...(assetName === "favicon" ? [/^favicon\.ico$/] : []),
13
+ ];
14
+
15
+ await Promise.all(
16
+ files.filter(file => patterns.some(regex => regex.test(file))).map(file => fs.unlink(path.join(destinationDir, file))),
17
+ );
18
+ }
package/src/config.js ADDED
@@ -0,0 +1,91 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+
4
+ const DEFAULT_CONFIG_NAME = "assets.config.json";
5
+
6
+ /**
7
+ * Resolve the config file path.
8
+ * Priority: explicit path → default file in cwd.
9
+ */
10
+ export function resolveConfigPath(cwd, configFlag) {
11
+ if (configFlag) {
12
+ return path.isAbsolute(configFlag) ? configFlag : path.join(cwd, configFlag);
13
+ }
14
+ return path.join(cwd, DEFAULT_CONFIG_NAME);
15
+ }
16
+
17
+ /**
18
+ * Load and validate the external JSON config.
19
+ */
20
+ export async function loadConfig(configPath) {
21
+ try {
22
+ await fs.access(configPath);
23
+ } catch {
24
+ throw new Error(`Config file not found: ${configPath}\nRun \`better-asset init\` to create one.`);
25
+ }
26
+
27
+ const raw = await fs.readFile(configPath, "utf-8");
28
+ let config;
29
+
30
+ try {
31
+ config = JSON.parse(raw);
32
+ } catch (err) {
33
+ throw new Error(`Invalid JSON in config file: ${configPath}\n${err.message}`);
34
+ }
35
+
36
+ if (!config.source_dir || typeof config.source_dir !== "string") {
37
+ throw new Error(`Config must include a "source_dir" string.`);
38
+ }
39
+
40
+ if (!Array.isArray(config.assets) || config.assets.length === 0) {
41
+ throw new Error(`Config must include a non-empty "assets" array.`);
42
+ }
43
+
44
+ for (const asset of config.assets) {
45
+ for (const field of ["name", "source", "destination"]) {
46
+ if (!asset[field] || typeof asset[field] !== "string") {
47
+ throw new Error(`Each asset must have a "${field}" string. Problem in: ${JSON.stringify(asset)}`);
48
+ }
49
+ }
50
+ if (!Array.isArray(asset.sizes) || asset.sizes.length === 0) {
51
+ throw new Error(`Asset "${asset.name}" must have a non-empty "sizes" array.`);
52
+ }
53
+ }
54
+
55
+ return config;
56
+ }
57
+
58
+ /**
59
+ * Default config template for `better-asset init`.
60
+ */
61
+ export function defaultConfig() {
62
+ return {
63
+ $schema: "https://unpkg.com/better-asset/assets.config.schema.json",
64
+ source_dir: "public/assets",
65
+ assets: [
66
+ {
67
+ name: "icon",
68
+ source: "icon.svg",
69
+ sizes: [48, 96, 144, 512],
70
+ destination: "public/icons",
71
+ },
72
+ {
73
+ name: "favicon",
74
+ source: "favicon.svg",
75
+ sizes: [16, 32, 48],
76
+ destination: "public/icons",
77
+ ico: {
78
+ required: true,
79
+ size: 96,
80
+ },
81
+ },
82
+ {
83
+ name: "opengraph-image",
84
+ source: "icon.svg",
85
+ sizes: ["1200x630"],
86
+ destination: "public/icons",
87
+ format: "jpeg",
88
+ },
89
+ ],
90
+ };
91
+ }
@@ -0,0 +1,14 @@
1
+ import fs from "fs/promises";
2
+
3
+ export async function ensureDir(dir) {
4
+ await fs.mkdir(dir, { recursive: true });
5
+ }
6
+
7
+ export async function fileExists(filePath) {
8
+ try {
9
+ await fs.access(filePath);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
@@ -0,0 +1,57 @@
1
+ import path from "path";
2
+ import { resolveConfigPath, loadConfig } from "./config.js";
3
+ import { ensureDir, fileExists } from "./fs-utils.js";
4
+ import { parseSize, buildFileName, generateImage, generateIco } from "./image.js";
5
+ import { cleanGeneratedFiles } from "./clean.js";
6
+ import * as log from "./log.js";
7
+
8
+ export async function generate(cwd, configFlag) {
9
+ const configPath = resolveConfigPath(cwd, configFlag);
10
+
11
+ log.banner();
12
+ log.configPath(path.relative(cwd, configPath) || configPath);
13
+
14
+ const config = await loadConfig(configPath);
15
+ const start = performance.now();
16
+ let totalFiles = 0;
17
+
18
+ for (const asset of config.assets) {
19
+ const sourcePath = path.join(cwd, config.source_dir, asset.source);
20
+ const destinationDir = path.join(cwd, asset.destination);
21
+
22
+ if (!(await fileExists(sourcePath))) {
23
+ log.warn(`Skipping ${asset.name} — source not found: ${asset.source}`);
24
+ continue;
25
+ }
26
+
27
+ log.groupStart(asset.name, asset.source);
28
+
29
+ await ensureDir(destinationDir);
30
+ await cleanGeneratedFiles(destinationDir, asset.name);
31
+
32
+ const totalSizes = asset.sizes.length;
33
+ const format = asset.format || "png";
34
+
35
+ for (const rawSize of asset.sizes) {
36
+ const { width, height, label } = parseSize(rawSize);
37
+ const fileName = buildFileName(asset.name, label, asset.sizeInName, totalSizes, format);
38
+ const outputPath = path.join(destinationDir, fileName);
39
+
40
+ await generateImage(sourcePath, outputPath, width, height, format);
41
+
42
+ log.success(fileName, `${width}×${height}`);
43
+ totalFiles++;
44
+ }
45
+
46
+ if (asset.ico?.required) {
47
+ const icoPath = path.join(destinationDir, "favicon.ico");
48
+ await generateIco(sourcePath, icoPath, asset.ico.size || 64);
49
+
50
+ log.success("favicon.ico", `${asset.ico.size || 64}×${asset.ico.size || 64}`);
51
+ totalFiles++;
52
+ }
53
+ }
54
+
55
+ const elapsed = Math.round(performance.now() - start);
56
+ log.done(totalFiles, elapsed);
57
+ }
package/src/image.js ADDED
@@ -0,0 +1,48 @@
1
+ import sharp from "sharp";
2
+ import path from "path";
3
+
4
+ export function parseSize(size) {
5
+ if (typeof size === "number") {
6
+ return { width: size, height: size, label: `${size}` };
7
+ }
8
+
9
+ if (typeof size === "string") {
10
+ const [w, h] = size.split("x").map(Number);
11
+ if (!w || !h) throw new Error(`Invalid size format: ${size}`);
12
+ return { width: w, height: h, label: `${w}x${h}` };
13
+ }
14
+
15
+ throw new Error(`Unsupported size type: ${size}`);
16
+ }
17
+
18
+ export function buildFileName(name, label, sizeInName, totalSizes, format) {
19
+ const includeSize = sizeInName === true || (sizeInName === undefined && totalSizes > 1);
20
+ const ext = format || "png";
21
+ return includeSize ? `${name}-${label}.${ext}` : `${name}.${ext}`;
22
+ }
23
+
24
+ export async function generateImage(inputPath, outputPath, width, height, format) {
25
+ let pipeline = sharp(inputPath).resize(width, height);
26
+
27
+ switch (format) {
28
+ case "webp":
29
+ pipeline = pipeline.webp();
30
+ break;
31
+ case "jpeg":
32
+ case "jpg":
33
+ pipeline = pipeline.jpeg();
34
+ break;
35
+ case "avif":
36
+ pipeline = pipeline.avif();
37
+ break;
38
+ default:
39
+ pipeline = pipeline.png();
40
+ }
41
+
42
+ await pipeline.toFile(outputPath);
43
+ }
44
+
45
+ export async function generateIco(inputPath, outputPath, size) {
46
+ const buffer = await sharp(inputPath).resize(size, size).png().toBuffer();
47
+ await sharp(buffer).toFormat("png").toFile(outputPath);
48
+ }
package/src/log.js ADDED
@@ -0,0 +1,52 @@
1
+ const RESET = "\x1b[0m";
2
+ const BOLD = "\x1b[1m";
3
+ const DIM = "\x1b[2m";
4
+ const RED = "\x1b[31m";
5
+ const GREEN = "\x1b[32m";
6
+ const YELLOW = "\x1b[33m";
7
+ const BLUE = "\x1b[34m";
8
+ const MAGENTA = "\x1b[35m";
9
+ const CYAN = "\x1b[36m";
10
+
11
+ const CHECKMARK = `${GREEN}✔${RESET}`;
12
+ const CROSS = `${RED}✖${RESET}`;
13
+ const WARN = `${YELLOW}⚠${RESET}`;
14
+ const ARROW = `${DIM}→${RESET}`;
15
+ const DOT = `${DIM}·${RESET}`;
16
+
17
+ export function banner() {
18
+ console.log();
19
+ console.log(` ${BOLD}${MAGENTA}better-asset${RESET} ${DIM}— config-driven image asset generator${RESET}`);
20
+ console.log();
21
+ }
22
+
23
+ export function info(msg) {
24
+ console.log(` ${BLUE}info${RESET} ${msg}`);
25
+ }
26
+
27
+ export function success(file, size) {
28
+ console.log(` ${CHECKMARK} ${file} ${DIM}${size}${RESET}`);
29
+ }
30
+
31
+ export function warn(msg) {
32
+ console.log(` ${WARN} ${YELLOW}${msg}${RESET}`);
33
+ }
34
+
35
+ export function error(msg) {
36
+ console.error(` ${CROSS} ${RED}${msg}${RESET}`);
37
+ }
38
+
39
+ export function groupStart(name, source) {
40
+ console.log();
41
+ console.log(` ${BOLD}${CYAN}${name}${RESET} ${DIM}← ${source}${RESET}`);
42
+ }
43
+
44
+ export function done(count, elapsed) {
45
+ console.log();
46
+ console.log(` ${CHECKMARK} ${BOLD}${count} file${count === 1 ? "" : "s"}${RESET} generated ${DIM}in ${elapsed}ms${RESET}`);
47
+ console.log();
48
+ }
49
+
50
+ export function configPath(p) {
51
+ console.log(` ${DOT} config ${DIM}${p}${RESET}`);
52
+ }