repoimage 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/.claude/settings.local.json +8 -0
- package/AGENTS.md +28 -0
- package/PROJECT-AGENTS.md +55 -0
- package/README.md +153 -0
- package/TODO.md +132 -0
- package/client/index.html +12 -0
- package/client/package.json +23 -0
- package/client/src/App.tsx +599 -0
- package/client/src/components/FsBrowser.tsx +210 -0
- package/client/src/components/Settings.tsx +81 -0
- package/client/src/index.css +797 -0
- package/client/src/lib/api.ts +69 -0
- package/client/src/lib/collect.ts +204 -0
- package/client/src/lib/format.ts +96 -0
- package/client/src/lib/session.ts +58 -0
- package/client/src/main.tsx +10 -0
- package/client/src/vite-env.d.ts +1 -0
- package/client/tsconfig.json +18 -0
- package/client/vite.config.ts +27 -0
- package/docs/README.md +28 -0
- package/docs/api/overview.md +65 -0
- package/docs/api/scan.md +188 -0
- package/docs/architecture.md +155 -0
- package/docs/design/invariants.md +19 -0
- package/docs/design/role-system.md +50 -0
- package/docs/development/README.md +94 -0
- package/docs/features/README.md +21 -0
- package/docs/features/compression-score.md +75 -0
- package/docs/features/exclusions.md +63 -0
- package/docs/features/session.md +64 -0
- package/package.json +37 -0
- package/server/dist/cli.d.ts +3 -0
- package/server/dist/cli.d.ts.map +1 -0
- package/server/dist/cli.js +54 -0
- package/server/dist/cli.js.map +1 -0
- package/server/dist/fs-list.d.ts +3 -0
- package/server/dist/fs-list.d.ts.map +1 -0
- package/server/dist/fs-list.js +73 -0
- package/server/dist/fs-list.js.map +1 -0
- package/server/dist/paths.d.ts +3 -0
- package/server/dist/paths.d.ts.map +1 -0
- package/server/dist/paths.js +12 -0
- package/server/dist/paths.js.map +1 -0
- package/server/dist/scan.d.ts +16 -0
- package/server/dist/scan.d.ts.map +1 -0
- package/server/dist/scan.js +158 -0
- package/server/dist/scan.js.map +1 -0
- package/server/dist/server.d.ts +6 -0
- package/server/dist/server.d.ts.map +1 -0
- package/server/dist/server.js +313 -0
- package/server/dist/server.js.map +1 -0
- package/server/package.json +22 -0
- package/server/src/cli.ts +63 -0
- package/server/src/fs-list.ts +70 -0
- package/server/src/paths.ts +6 -0
- package/server/src/scan.ts +203 -0
- package/server/src/server.ts +356 -0
- package/server/tsconfig.json +9 -0
- package/shared/package.json +10 -0
- package/shared/src/constants.ts +37 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/role-guess.ts +103 -0
- package/shared/src/schema.ts +18 -0
- package/shared/src/types.ts +36 -0
- package/shared/tsconfig.json +9 -0
- package/test/cli.test.js +56 -0
- package/test/fs-list.test.js +39 -0
- package/test/role-guess.test.js +50 -0
- package/test/scan.test.js +150 -0
- package/test/server.test.js +308 -0
- package/tsconfig.base.json +14 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ROLES } from './schema';
|
|
2
|
+
import type { ImageRole } from './types';
|
|
3
|
+
|
|
4
|
+
const PATH_THUMB_WORD =
|
|
5
|
+
/\b(thumb|thumbnail|thumbs)\b|\/thumb\/|[-_.]thumb[-_.]/i;
|
|
6
|
+
const PATH_HERO = /\b(hero|banner|splash)\b|\/hero\/|[-_.]hero[-_.]/i;
|
|
7
|
+
const PATH_CONTENT = /\b(content|article|inline|screenshot|figures?)\b/i;
|
|
8
|
+
|
|
9
|
+
const SMALL_DIM_SUFFIX = /[-_.](\d{2,3})x(\d{2,3})(?:[-_.]|\.|$)/gi;
|
|
10
|
+
const SMALL_DIM_TOKEN = /\b(\d{2,3})x(\d{2,3})\b/gi;
|
|
11
|
+
const WIDTH_ONLY_THUMB_SUFFIX = /[-_.](\d{2,3})x(?:\.|[-_]|$)/gi;
|
|
12
|
+
const SMALL_DIM_MAX = 256;
|
|
13
|
+
|
|
14
|
+
export const THUMB_MAX_DIMENSION = 200;
|
|
15
|
+
export const HERO_MIN_WIDTH = 1200;
|
|
16
|
+
export const HERO_MIN_ASPECT = 1.5;
|
|
17
|
+
|
|
18
|
+
function pathHasSmallDimensionHint(lowerPath: string): boolean {
|
|
19
|
+
for (const re of [SMALL_DIM_SUFFIX, SMALL_DIM_TOKEN]) {
|
|
20
|
+
re.lastIndex = 0;
|
|
21
|
+
let match: RegExpExecArray | null;
|
|
22
|
+
while ((match = re.exec(lowerPath)) !== null) {
|
|
23
|
+
const w = Number(match[1]);
|
|
24
|
+
const h = Number(match[2]);
|
|
25
|
+
if (w > 0 && h > 0 && w <= SMALL_DIM_MAX && h <= SMALL_DIM_MAX) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
WIDTH_ONLY_THUMB_SUFFIX.lastIndex = 0;
|
|
32
|
+
let widthOnly: RegExpExecArray | null;
|
|
33
|
+
while ((widthOnly = WIDTH_ONLY_THUMB_SUFFIX.exec(lowerPath)) !== null) {
|
|
34
|
+
const w = Number(widthOnly[1]);
|
|
35
|
+
if (w > 0 && w <= SMALL_DIM_MAX) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function finiteDimension(value: unknown): number | null {
|
|
44
|
+
const n = Number(value);
|
|
45
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GuessRoleInput {
|
|
49
|
+
path?: string;
|
|
50
|
+
width?: number | null;
|
|
51
|
+
height?: number | null;
|
|
52
|
+
size?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GuessRoleResult {
|
|
56
|
+
roleGuess: ImageRole;
|
|
57
|
+
roleGuessReason?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function guessRole(entry: GuessRoleInput): GuessRoleResult {
|
|
61
|
+
const filePath = String(entry.path ?? '').replace(/\\/g, '/');
|
|
62
|
+
const lower = filePath.toLowerCase();
|
|
63
|
+
const width = finiteDimension(entry.width);
|
|
64
|
+
const height = finiteDimension(entry.height);
|
|
65
|
+
const maxDim = width != null && height != null ? Math.max(width, height) : null;
|
|
66
|
+
const aspect = width != null && height != null ? width / height : null;
|
|
67
|
+
|
|
68
|
+
if (PATH_THUMB_WORD.test(lower) || pathHasSmallDimensionHint(lower)) {
|
|
69
|
+
return { roleGuess: 'thumbnail', roleGuessReason: 'path suggests thumbnail' };
|
|
70
|
+
}
|
|
71
|
+
if (PATH_HERO.test(lower)) {
|
|
72
|
+
return { roleGuess: 'hero', roleGuessReason: 'path suggests hero or banner' };
|
|
73
|
+
}
|
|
74
|
+
if (PATH_CONTENT.test(lower)) {
|
|
75
|
+
return { roleGuess: 'content', roleGuessReason: 'path suggests inline content' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (maxDim != null && maxDim <= THUMB_MAX_DIMENSION) {
|
|
79
|
+
return {
|
|
80
|
+
roleGuess: 'thumbnail',
|
|
81
|
+
roleGuessReason: `max dimension ≤ ${THUMB_MAX_DIMENSION}px`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
width != null &&
|
|
87
|
+
height != null &&
|
|
88
|
+
width >= HERO_MIN_WIDTH &&
|
|
89
|
+
aspect != null &&
|
|
90
|
+
aspect >= HERO_MIN_ASPECT
|
|
91
|
+
) {
|
|
92
|
+
return {
|
|
93
|
+
roleGuess: 'hero',
|
|
94
|
+
roleGuessReason: `wide image (≥${HERO_MIN_WIDTH}px wide, aspect ≥${HERO_MIN_ASPECT})`
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { roleGuess: 'unknown' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isKnownRoleGuess(roleGuess: unknown): roleGuess is ImageRole {
|
|
102
|
+
return typeof roleGuess === 'string' && (ROLES as readonly string[]).includes(roleGuess);
|
|
103
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ImageRole } from './types';
|
|
2
|
+
|
|
3
|
+
export const CONFIG_FILENAME = '.repoimage.json';
|
|
4
|
+
export const CONFIG_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export const ROLES: readonly ImageRole[] = Object.freeze([
|
|
7
|
+
'hero',
|
|
8
|
+
'content',
|
|
9
|
+
'thumbnail',
|
|
10
|
+
'unknown'
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export const REPOIMAGE_GENERATOR =
|
|
14
|
+
'Roles confirmed with RepoImage — https://github.com/geroconnell/repoimage';
|
|
15
|
+
|
|
16
|
+
export function isValidRole(role: unknown): role is ImageRole {
|
|
17
|
+
return typeof role === 'string' && (ROLES as readonly string[]).includes(role);
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ImageRole = 'hero' | 'content' | 'thumbnail' | 'unknown';
|
|
2
|
+
|
|
3
|
+
export interface ScanIssue {
|
|
4
|
+
code: string;
|
|
5
|
+
path: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ImageRow {
|
|
10
|
+
path: string;
|
|
11
|
+
size: number;
|
|
12
|
+
width: number | null;
|
|
13
|
+
height: number | null;
|
|
14
|
+
uncompressedSize: number | null;
|
|
15
|
+
compressionRatio: string | null;
|
|
16
|
+
role: ImageRole | null;
|
|
17
|
+
roleGuess: ImageRole;
|
|
18
|
+
roleGuessReason?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RawImageEntry {
|
|
22
|
+
path: string;
|
|
23
|
+
size: number;
|
|
24
|
+
width?: number | null;
|
|
25
|
+
height?: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FsListEntry {
|
|
29
|
+
name: string;
|
|
30
|
+
path: string;
|
|
31
|
+
type: 'directory' | 'file' | 'symlink' | 'other';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type FsListResult =
|
|
35
|
+
| { path: string; entries: FsListEntry[] }
|
|
36
|
+
| { error: string; message: string };
|
package/test/cli.test.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
|
|
10
|
+
const CLI_JS = path.join(__dirname, '..', 'server', 'dist', 'cli.js');
|
|
11
|
+
|
|
12
|
+
const MINI_PNG = Buffer.from(
|
|
13
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
|
14
|
+
'base64'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
function tempDir() {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'repoimage-cli-'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('CLI --json returns images envelope', () => {
|
|
22
|
+
const dir = tempDir();
|
|
23
|
+
fs.writeFileSync(path.join(dir, 'one.png'), MINI_PNG);
|
|
24
|
+
|
|
25
|
+
const out = execFileSync(process.execPath, [CLI_JS, '--json', dir], { encoding: 'utf8' });
|
|
26
|
+
const data = JSON.parse(out.trim());
|
|
27
|
+
|
|
28
|
+
assert.ok(Array.isArray(data.images));
|
|
29
|
+
assert.ok(Array.isArray(data.issues));
|
|
30
|
+
assert.equal(data.images.length, 1);
|
|
31
|
+
assert.equal(data.images[0].path, 'one.png');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('CLI --json --sort-by-score still returns valid JSON', () => {
|
|
35
|
+
const dir = tempDir();
|
|
36
|
+
fs.writeFileSync(path.join(dir, 'a.png'), MINI_PNG);
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'b.png'), MINI_PNG);
|
|
38
|
+
|
|
39
|
+
const out = execFileSync(process.execPath, [CLI_JS, '--json', '--sort-by-score', dir], {
|
|
40
|
+
encoding: 'utf8'
|
|
41
|
+
});
|
|
42
|
+
const data = JSON.parse(out.trim());
|
|
43
|
+
|
|
44
|
+
assert.equal(data.images.length, 2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('CLI exits 1 with usage when no folders', () => {
|
|
48
|
+
assert.throws(
|
|
49
|
+
() =>
|
|
50
|
+
execFileSync(process.execPath, [CLI_JS, '--json'], {
|
|
51
|
+
encoding: 'utf8',
|
|
52
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
53
|
+
}),
|
|
54
|
+
/Command failed/
|
|
55
|
+
);
|
|
56
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const test = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
|
|
9
|
+
const { listDirectory } = require('../server/dist/fs-list');
|
|
10
|
+
|
|
11
|
+
function tempDir() {
|
|
12
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'repoimage-fs-'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('listDirectory returns entries for a folder', () => {
|
|
16
|
+
const dir = tempDir();
|
|
17
|
+
fs.mkdirSync(path.join(dir, 'sub'));
|
|
18
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'x');
|
|
19
|
+
|
|
20
|
+
const out = listDirectory(dir);
|
|
21
|
+
assert.ok(!('error' in out));
|
|
22
|
+
assert.equal(out.path, dir.split(path.sep).join('/'));
|
|
23
|
+
const names = out.entries.map(e => e.name).sort();
|
|
24
|
+
assert.deepEqual(names, ['a.txt', 'sub']);
|
|
25
|
+
const sub = out.entries.find(e => e.name === 'sub');
|
|
26
|
+
assert.equal(sub.type, 'directory');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('listDirectory ENOENT for missing path', () => {
|
|
30
|
+
const out = listDirectory(path.join(os.tmpdir(), 'repoimage-missing-' + Date.now()));
|
|
31
|
+
assert.equal(out.error, 'ENOENT');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('listDirectory ENOTDIR for file path', () => {
|
|
35
|
+
const f = path.join(tempDir(), 'f.txt');
|
|
36
|
+
fs.writeFileSync(f, 'hi');
|
|
37
|
+
const out = listDirectory(f);
|
|
38
|
+
assert.equal(out.error, 'ENOTDIR');
|
|
39
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { guessRole, isKnownRoleGuess } = require('../shared/dist/role-guess');
|
|
7
|
+
const { ROLES, isValidRole } = require('../shared/dist/schema');
|
|
8
|
+
|
|
9
|
+
/** @type {Array<{ name: string, entry: object, roleGuess: string, reason?: boolean }>} */
|
|
10
|
+
const FIXTURES = [
|
|
11
|
+
{ name: 'thumb segment', entry: { path: 'assets/thumbs/preview.png', width: 800, height: 600 }, roleGuess: 'thumbnail', reason: true },
|
|
12
|
+
{ name: 'thumbnail in name', entry: { path: 'img/thumbnail.jpg', width: 400, height: 300 }, roleGuess: 'thumbnail', reason: true },
|
|
13
|
+
{ name: 'hero path', entry: { path: 'public/hero-banner.jpg', width: 400, height: 300 }, roleGuess: 'hero', reason: true },
|
|
14
|
+
{ name: 'banner path', entry: { path: 'images/banner.png', width: 200, height: 200 }, roleGuess: 'hero', reason: true },
|
|
15
|
+
{ name: '150x suffix', entry: { path: 'icons/close-150x.png', width: 450, height: 450 }, roleGuess: 'thumbnail', reason: true },
|
|
16
|
+
{ name: '150x150 token', entry: { path: 'sprites/150x150.png', width: 450, height: 450 }, roleGuess: 'thumbnail', reason: true },
|
|
17
|
+
{ name: 'wide hero dimensions', entry: { path: 'assets/photo.jpg', width: 1600, height: 900 }, roleGuess: 'hero', reason: true },
|
|
18
|
+
{ name: 'content path', entry: { path: 'docs/article-screenshot.png', width: 900, height: 600 }, roleGuess: 'content', reason: true },
|
|
19
|
+
{ name: 'ambiguous generic', entry: { path: 'assets/photo.jpg', width: 800, height: 600 }, roleGuess: 'unknown' },
|
|
20
|
+
{ name: 'ambiguous no dimensions', entry: { path: 'misc/file.png' }, roleGuess: 'unknown' },
|
|
21
|
+
{ name: 'small dimensions only', entry: { path: 'ui/icon.png', width: 64, height: 64 }, roleGuess: 'thumbnail', reason: true },
|
|
22
|
+
{ name: 'large 800x600 in path not thumb', entry: { path: 'export/slide-800x600.png', width: 800, height: 600 }, roleGuess: 'unknown' }
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const fixture of FIXTURES) {
|
|
26
|
+
test(`guessRole: ${fixture.name}`, () => {
|
|
27
|
+
const result = guessRole(fixture.entry);
|
|
28
|
+
assert.equal(result.roleGuess, fixture.roleGuess);
|
|
29
|
+
assert.ok(isKnownRoleGuess(result.roleGuess));
|
|
30
|
+
if (fixture.reason) {
|
|
31
|
+
assert.ok(typeof result.roleGuessReason === 'string' && result.roleGuessReason.length > 0);
|
|
32
|
+
} else if (fixture.roleGuess === 'unknown') {
|
|
33
|
+
assert.equal(result.roleGuessReason, undefined);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('guessRole returns only valid roles', () => {
|
|
39
|
+
for (const entry of FIXTURES.map(f => f.entry)) {
|
|
40
|
+
const { roleGuess } = guessRole(entry);
|
|
41
|
+
assert.ok(ROLES.includes(roleGuess));
|
|
42
|
+
assert.ok(isValidRole(roleGuess));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('schema isValidRole rejects invalid values', () => {
|
|
47
|
+
assert.ok(isValidRole('hero'));
|
|
48
|
+
assert.ok(!isValidRole('banner'));
|
|
49
|
+
assert.ok(!isValidRole(null));
|
|
50
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const test = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
findImagesInFolders,
|
|
11
|
+
sortByCompressionScore,
|
|
12
|
+
enrichImageEntry
|
|
13
|
+
} = require('../server/dist/scan');
|
|
14
|
+
const { ROLES } = require('../shared/dist/schema');
|
|
15
|
+
|
|
16
|
+
const MINI_PNG = Buffer.from(
|
|
17
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
|
18
|
+
'base64'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
function tempDir() {
|
|
22
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'repoimage-test-'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('findImagesInFolders finds a PNG and reports dimensions', () => {
|
|
26
|
+
const dir = tempDir();
|
|
27
|
+
fs.writeFileSync(path.join(dir, 'pixel.png'), MINI_PNG);
|
|
28
|
+
|
|
29
|
+
const { images, issues } = findImagesInFolders([dir], { verbose: false });
|
|
30
|
+
|
|
31
|
+
assert.equal(issues.length, 0);
|
|
32
|
+
assert.equal(images.length, 1);
|
|
33
|
+
assert.equal(images[0].path, 'pixel.png');
|
|
34
|
+
assert.equal(images[0].width, 1);
|
|
35
|
+
assert.equal(images[0].height, 1);
|
|
36
|
+
assert.ok(typeof images[0].size === 'number' && images[0].size > 0);
|
|
37
|
+
assert.ok(images[0].uncompressedSize != null);
|
|
38
|
+
assert.ok(Number.isFinite(parseFloat(images[0].compressionRatio)));
|
|
39
|
+
assert.ok(ROLES.includes(images[0].roleGuess));
|
|
40
|
+
assert.equal(images[0].role, null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('findImagesInFolders walks nested directories', () => {
|
|
44
|
+
const dir = tempDir();
|
|
45
|
+
const nested = path.join(dir, 'a', 'b');
|
|
46
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(nested, 'deep.png'), MINI_PNG);
|
|
48
|
+
|
|
49
|
+
const { images, issues } = findImagesInFolders([dir], { verbose: false });
|
|
50
|
+
|
|
51
|
+
assert.equal(issues.length, 0);
|
|
52
|
+
assert.equal(images.length, 1);
|
|
53
|
+
assert.equal(images[0].path, 'a/b/deep.png');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('findImagesInFolders records missing folders in issues', () => {
|
|
57
|
+
const bad = path.join(os.tmpdir(), 'repoimage-nonexistent-' + Date.now());
|
|
58
|
+
const { images, issues } = findImagesInFolders([bad], { verbose: false });
|
|
59
|
+
|
|
60
|
+
assert.equal(images.length, 0);
|
|
61
|
+
assert.equal(issues.length, 1);
|
|
62
|
+
assert.equal(issues[0].code, 'MISSING_FOLDER');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('enrichImageEntry builds row from path and dimensions', () => {
|
|
66
|
+
const row = enrichImageEntry({
|
|
67
|
+
path: 'assets/x.png',
|
|
68
|
+
size: 100,
|
|
69
|
+
width: 10,
|
|
70
|
+
height: 10
|
|
71
|
+
});
|
|
72
|
+
assert.ok(row);
|
|
73
|
+
assert.equal(row.path, 'assets/x.png');
|
|
74
|
+
assert.equal(row.width, 10);
|
|
75
|
+
assert.ok(row.uncompressedSize != null);
|
|
76
|
+
assert.ok(row.compressionRatio != null);
|
|
77
|
+
assert.equal(row.role, null);
|
|
78
|
+
assert.ok(ROLES.includes(row.roleGuess));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('enrichImageEntry adds roleGuess for hero path', () => {
|
|
82
|
+
const row = enrichImageEntry({
|
|
83
|
+
path: 'assets/hero.jpg',
|
|
84
|
+
size: 5000,
|
|
85
|
+
width: 400,
|
|
86
|
+
height: 300
|
|
87
|
+
});
|
|
88
|
+
assert.ok(row);
|
|
89
|
+
assert.equal(row.roleGuess, 'hero');
|
|
90
|
+
assert.ok(row.roleGuessReason);
|
|
91
|
+
assert.equal(row.role, null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('enrichImageEntry omits roleGuessReason when unknown', () => {
|
|
95
|
+
const row = enrichImageEntry({
|
|
96
|
+
path: 'misc/photo.png',
|
|
97
|
+
size: 1000,
|
|
98
|
+
width: 800,
|
|
99
|
+
height: 600
|
|
100
|
+
});
|
|
101
|
+
assert.ok(row);
|
|
102
|
+
assert.equal(row.roleGuess, 'unknown');
|
|
103
|
+
assert.equal(row.roleGuessReason, undefined);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('enrichImageEntry rejects path traversal', () => {
|
|
107
|
+
assert.equal(enrichImageEntry({ path: '../x.png', size: 1, width: 1, height: 1 }), null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('sortByCompressionScore orders by score descending', () => {
|
|
111
|
+
const a = { path: 'a.png', compressionRatio: '1.0', size: 1 };
|
|
112
|
+
const b = { path: 'b.png', compressionRatio: '5.0', size: 1 };
|
|
113
|
+
const c = { path: 'c.png', compressionRatio: null, size: 1 };
|
|
114
|
+
const sorted = sortByCompressionScore([a, b, c]);
|
|
115
|
+
assert.deepEqual(sorted.map(x => x.path), ['b.png', 'a.png', 'c.png']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('findImagesInFolders excludes common folders when enabled', () => {
|
|
119
|
+
const dir = tempDir();
|
|
120
|
+
const nodeModulesDir = path.join(dir, 'node_modules');
|
|
121
|
+
const assetsDir = path.join(dir, 'assets');
|
|
122
|
+
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
|
123
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
fs.writeFileSync(path.join(nodeModulesDir, 'img.png'), MINI_PNG);
|
|
126
|
+
fs.writeFileSync(path.join(assetsDir, 'logo.png'), MINI_PNG);
|
|
127
|
+
|
|
128
|
+
const { images: withoutExclude } = findImagesInFolders([dir], { verbose: false, excludeCommon: false });
|
|
129
|
+
const { images: withExclude } = findImagesInFolders([dir], { verbose: false, excludeCommon: true });
|
|
130
|
+
|
|
131
|
+
assert.equal(withoutExclude.length, 2);
|
|
132
|
+
assert.equal(withExclude.length, 1);
|
|
133
|
+
assert.equal(withExclude[0].path, 'assets/logo.png');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('findImagesInFolders excludes multiple common folders', () => {
|
|
137
|
+
const dir = tempDir();
|
|
138
|
+
fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true });
|
|
139
|
+
fs.mkdirSync(path.join(dir, 'dist'), { recursive: true });
|
|
140
|
+
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
141
|
+
|
|
142
|
+
fs.writeFileSync(path.join(dir, 'node_modules', 'a.png'), MINI_PNG);
|
|
143
|
+
fs.writeFileSync(path.join(dir, 'dist', 'b.png'), MINI_PNG);
|
|
144
|
+
fs.writeFileSync(path.join(dir, 'src', 'c.png'), MINI_PNG);
|
|
145
|
+
|
|
146
|
+
const { images } = findImagesInFolders([dir], { verbose: false, excludeCommon: true });
|
|
147
|
+
|
|
148
|
+
assert.equal(images.length, 1);
|
|
149
|
+
assert.equal(images[0].path, 'src/c.png');
|
|
150
|
+
});
|