mediasnacks 0.0.1
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/.editorconfig +11 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/TODO.md +4 -0
- package/package.json +14 -0
- package/src/cli-avif.js +74 -0
- package/src/cli-moov2front.js +73 -0
- package/src/cli-resize.js +103 -0
- package/src/cli.js +47 -0
- package/src/ffmpeg.js +51 -0
- package/src/fs-utils.js +22 -0
- package/src/fs-utils.test.js +21 -0
package/.editorconfig
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Eric Fortis
|
|
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,66 @@
|
|
|
1
|
+
# mediasnacks
|
|
2
|
+
|
|
3
|
+
Utilities optimizing and preparing video and images for web.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Usage Overview
|
|
7
|
+
**FFmpeg and Node.js must be installed.**
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
npx mediasnacks <command> <args>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
- `avif` Converts images to AVIF
|
|
15
|
+
- `resize` Resizes videos or images
|
|
16
|
+
- `moov2front` Rearranges .mov and .mp4 metadata for fast-start streaming
|
|
17
|
+
|
|
18
|
+
<br/>
|
|
19
|
+
|
|
20
|
+
### Converting Images to AVIF
|
|
21
|
+
```shell
|
|
22
|
+
npx mediasnacks avif [-y | --overwrite] [--output-dir=<dir>] <images>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
<br/>
|
|
26
|
+
|
|
27
|
+
### Resizing Images or Videos
|
|
28
|
+
Resizes videos and images. The aspect ratio is preserved when only one dimension is specified.
|
|
29
|
+
|
|
30
|
+
`--width` and `--height` are `-2` by default:
|
|
31
|
+
- `-1` auto-compute while preserving the aspect ratio (may result in an odd number)
|
|
32
|
+
- `-2` same as `-1` but rounds to the nearest even number
|
|
33
|
+
|
|
34
|
+
```shell
|
|
35
|
+
npx mediasnacks resize [--width=<num>] [--height=<num>] [-y | --overwrite] [--output-dir=<dir>] <files>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Example: Overwrites the input file (-y)
|
|
39
|
+
```shell
|
|
40
|
+
npx mediasnacks resize -y --width 480 'dir-a/**/*.png' 'dir-b/**/*.mp4'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Example: Output directory (-o)
|
|
44
|
+
```shell
|
|
45
|
+
npx mediasnacks resize --height 240 --output-dir /tmp/out video.mov
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
<br/>
|
|
49
|
+
|
|
50
|
+
### Fast-Start Streaming Video
|
|
51
|
+
Rearranges .mov and .mp4 metadata to the start of the file for fast-start streaming.
|
|
52
|
+
|
|
53
|
+
**Files are overwritten**
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
npx mediasnacks moov2front <videos>
|
|
57
|
+
```
|
|
58
|
+
hat is Fast Start?
|
|
59
|
+
- https://wiki.avblocks.com/avblocks-for-cpp/muxer-parameters/mp4
|
|
60
|
+
- https://trac.ffmpeg.org/wiki/HowToCheckIfFaststartIsEnabledForPlayback
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
<br/>
|
|
64
|
+
|
|
65
|
+
### License
|
|
66
|
+
MIT
|
package/TODO.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mediasnacks",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Utilities for preparing videos, images, and audio for the web",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eric Fortis",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"mediasnacks": "src/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test"
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/cli-avif.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
|
|
6
|
+
import { glob, replaceExt, lstat } from './fs-utils.js'
|
|
7
|
+
import { ffmpeg, assertUserHasFFmpeg } from './ffmpeg.js'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const USAGE = `
|
|
11
|
+
Usage: npx mediasnacks avif [-y | --overwrite] [--output-dir=<dir>] <images>
|
|
12
|
+
|
|
13
|
+
Converts images to AVIF.
|
|
14
|
+
`.trim()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const { values, positionals } = parseArgs({
|
|
19
|
+
options: {
|
|
20
|
+
'output-dir': { type: 'string', default: '' },
|
|
21
|
+
overwrite: { short: 'y', type: 'boolean', default: false },
|
|
22
|
+
help: { short: 'h', type: 'boolean', default: false },
|
|
23
|
+
},
|
|
24
|
+
allowPositionals: true
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
if (values.help) {
|
|
28
|
+
console.log(USAGE)
|
|
29
|
+
process.exit(0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!positionals.length)
|
|
33
|
+
throw new Error('No images specified. See npx mediasnacks avif --help')
|
|
34
|
+
|
|
35
|
+
await assertUserHasFFmpeg()
|
|
36
|
+
|
|
37
|
+
console.log('AVIF…')
|
|
38
|
+
for (const g of positionals)
|
|
39
|
+
for (const file of await glob(g))
|
|
40
|
+
await toAvif({
|
|
41
|
+
file,
|
|
42
|
+
outFile: join(values['output-dir'], replaceExt(file, 'avif')),
|
|
43
|
+
overwrite: values.overwrite
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function toAvif({ file, outFile, overwrite }) {
|
|
48
|
+
const stImg = lstat(file)
|
|
49
|
+
const stAvif = lstat(outFile)
|
|
50
|
+
|
|
51
|
+
if (!overwrite && stAvif?.isFile()) {
|
|
52
|
+
console.log('(skipped: output file exists but --overwrite=false)', file)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (stAvif?.mtimeMs > stImg?.mtimeMs) {
|
|
56
|
+
console.log('(skipped: avif is newer)', file)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// TODO test on linux
|
|
61
|
+
console.log(file)
|
|
62
|
+
await ffmpeg([
|
|
63
|
+
'-y', // overwrites
|
|
64
|
+
'-i', file,
|
|
65
|
+
'-c:v', 'libsvtav1',
|
|
66
|
+
'-svtav1-params', 'avif=1',
|
|
67
|
+
outFile
|
|
68
|
+
])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch(err => {
|
|
72
|
+
console.error(err.message)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { unlink, rename } from 'node:fs/promises'
|
|
6
|
+
|
|
7
|
+
import { glob, makeTempDir, makeDirFor } from './fs-utils.js'
|
|
8
|
+
import { ffmpeg, assertUserHasFFmpeg } from './ffmpeg.js'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
const USAGE = `
|
|
12
|
+
Usage: npx mediasnacks moov2front <videos>
|
|
13
|
+
|
|
14
|
+
Rearranges .mov and .mp4 metadata to the start of the file for fast-start streaming.
|
|
15
|
+
|
|
16
|
+
Files are overwritten.
|
|
17
|
+
`.trim()
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const { positionals } = parseArgs({ allowPositionals: true })
|
|
21
|
+
|
|
22
|
+
if (!positionals.length)
|
|
23
|
+
throw new Error(USAGE)
|
|
24
|
+
|
|
25
|
+
await assertUserHasFFmpeg()
|
|
26
|
+
console.log('Optimizing videos for progressive download…')
|
|
27
|
+
|
|
28
|
+
for (const g of positionals)
|
|
29
|
+
for (const file of await glob(g))
|
|
30
|
+
await moov2front(file)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async function moov2front(file) {
|
|
35
|
+
if (!/\.(mp4|mov)$/i.test(file)) {
|
|
36
|
+
console.log('(skipped: not mp4/mov)', file)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (await moovIsBeforeMdat(file)) {
|
|
40
|
+
console.log('(skipped: no changes needed)', file)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log(file)
|
|
45
|
+
const tmp = join(await makeTempDir(), file) // FFmpeg can’t edit in-place
|
|
46
|
+
await makeDirFor(tmp)
|
|
47
|
+
|
|
48
|
+
await ffmpeg([
|
|
49
|
+
'-hide_banner',
|
|
50
|
+
'-i', file,
|
|
51
|
+
'-movflags', '+faststart',
|
|
52
|
+
tmp
|
|
53
|
+
])
|
|
54
|
+
await unlink(file)
|
|
55
|
+
await rename(tmp, file)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function moovIsBeforeMdat(file) {
|
|
59
|
+
const { stderr } = await ffmpeg([
|
|
60
|
+
'-hide_banner',
|
|
61
|
+
'-v', 'trace',
|
|
62
|
+
'-i', file,
|
|
63
|
+
'-f', 'null', '-'
|
|
64
|
+
])
|
|
65
|
+
const firstMatchedAtom = stderr.match(/type:'(moov|mdat)'/)?.[1]
|
|
66
|
+
return firstMatchedAtom === 'moov'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
main().catch(err => {
|
|
71
|
+
console.error(err.message)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { rename } from 'node:fs/promises'
|
|
5
|
+
import { parseArgs } from 'node:util'
|
|
6
|
+
|
|
7
|
+
import { glob, isFile, makeTempDir, makeDirFor } from './fs-utils.js'
|
|
8
|
+
import { ffmpeg, videoAttrs, assertUserHasFFmpeg } from './ffmpeg.js'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const USAGE = `
|
|
13
|
+
Usage: npx mediasnacks resize [--width=<num>] [--height=<num>] [-y | --overwrite] [--output-dir=<dir>] <files>
|
|
14
|
+
|
|
15
|
+
Resizes videos and images. The aspect ratio is preserved when only one dimension is specified.
|
|
16
|
+
|
|
17
|
+
Example: Overwrites the input file (-y)
|
|
18
|
+
npx mediasnacks resize -y --width 480 'dir-a/**/*.png' 'dir-b/**/*.mp4'
|
|
19
|
+
|
|
20
|
+
Example: Output directory (-o)
|
|
21
|
+
npx mediasnacks resize --height 240 --output-dir /tmp/out video.mov
|
|
22
|
+
|
|
23
|
+
Details:
|
|
24
|
+
--width and --height are -2 by default:
|
|
25
|
+
-1 = auto-compute while preserving the aspect ratio (may result in an odd number)
|
|
26
|
+
-2 = same as -1 but rounds to the nearest even number
|
|
27
|
+
`.trim()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
const { values, positionals } = parseArgs({
|
|
32
|
+
options: {
|
|
33
|
+
width: { type: 'string', default: '-2' },
|
|
34
|
+
height: { type: 'string', default: '-2' },
|
|
35
|
+
'output-dir': { type: 'string', default: '' },
|
|
36
|
+
overwrite: { short: 'y', type: 'boolean', default: false },
|
|
37
|
+
help: { short: 'h', type: 'boolean', default: false },
|
|
38
|
+
},
|
|
39
|
+
allowPositionals: true
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (values.help) {
|
|
43
|
+
console.log(USAGE)
|
|
44
|
+
process.exit(0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const width = Number(values.width)
|
|
48
|
+
const height = Number(values.height)
|
|
49
|
+
|
|
50
|
+
if (width <= 0 && height <= 0)
|
|
51
|
+
throw new Error('--width or --height need to be greater than 0')
|
|
52
|
+
|
|
53
|
+
if (!positionals.length)
|
|
54
|
+
throw new Error('No video files specified')
|
|
55
|
+
|
|
56
|
+
await assertUserHasFFmpeg()
|
|
57
|
+
|
|
58
|
+
console.log('Resizing…')
|
|
59
|
+
for (const g of positionals)
|
|
60
|
+
for (const file of await glob(g))
|
|
61
|
+
await resize({
|
|
62
|
+
file,
|
|
63
|
+
outFile: join(values['output-dir'], file),
|
|
64
|
+
overwrite: values.overwrite,
|
|
65
|
+
width,
|
|
66
|
+
height,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async function resize({ file, outFile, overwrite, width, height }) {
|
|
72
|
+
const v = await videoAttrs(file, 'width', 'height')
|
|
73
|
+
if (width === v.width && height === v.height
|
|
74
|
+
|| width < 0 && height === v.height
|
|
75
|
+
|| height < 0 && width === v.width) {
|
|
76
|
+
console.log('(skipped: no changes needed)', file)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!overwrite && isFile(outFile)) {
|
|
81
|
+
console.log('(skipped: output file exists but --overwrite=false)', file)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(file)
|
|
86
|
+
const tmp = join(await makeTempDir(), file) // FFmpeg can’t edit in-place
|
|
87
|
+
await makeDirFor(tmp)
|
|
88
|
+
await makeDirFor(outFile)
|
|
89
|
+
|
|
90
|
+
await ffmpeg([
|
|
91
|
+
'-i', file,
|
|
92
|
+
'-vf', `scale=${width}:${height}`,
|
|
93
|
+
'-movflags', '+faststart',
|
|
94
|
+
tmp
|
|
95
|
+
])
|
|
96
|
+
await rename(tmp, outFile)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
main().catch(err => {
|
|
101
|
+
console.error(err.message)
|
|
102
|
+
process.exit(1)
|
|
103
|
+
})
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import pkgJSON from '../package.json' with { type: 'json' }
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const COMMANDS = {
|
|
9
|
+
avif: join(import.meta.dirname, 'cli-avif.js'),
|
|
10
|
+
resize: join(import.meta.dirname, 'cli-resize.js'),
|
|
11
|
+
moov2front: join(import.meta.dirname, 'cli-moov2front.js')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const USAGE = `
|
|
15
|
+
Usage: npx mediasnacks <command> <args>
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
avif: Converts images to AVIF
|
|
19
|
+
resize: Resizes videos or images
|
|
20
|
+
moov2front: Rearranges .mov and .mp4 metadata for fast-start streaming
|
|
21
|
+
`.trim()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
const [, , opt, ...args] = process.argv
|
|
25
|
+
|
|
26
|
+
if (opt === '-v' || opt === '--version') {
|
|
27
|
+
console.log(pkgJSON.version)
|
|
28
|
+
process.exit(0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (opt === '-h' || opt === '--help') {
|
|
32
|
+
console.log(USAGE)
|
|
33
|
+
process.exit(0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!opt) {
|
|
37
|
+
console.log(USAGE)
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!Object.hasOwn(COMMANDS, opt)) {
|
|
42
|
+
console.error(`'${opt}' is not a mediasnacks command. See npx mediasnacks --help\n`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
spawn(process.execPath, [COMMANDS[opt], ...args], { stdio: 'inherit' })
|
|
47
|
+
.on('exit', code => process.exit(code))
|
package/src/ffmpeg.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export async function ffmpeg(args) {
|
|
5
|
+
return run('ffmpeg', args)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function assertUserHasFFmpeg() {
|
|
9
|
+
try {
|
|
10
|
+
await run('ffmpeg', ['-version'])
|
|
11
|
+
await run('ffprobe', ['-version'])
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error('ffmpeg not found. Please install ffmpeg.')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function videoAttrs(v, ...props) {
|
|
19
|
+
const { stdout } = await run('ffprobe', [
|
|
20
|
+
'-v', 'error',
|
|
21
|
+
'-select_streams', 'v:0',
|
|
22
|
+
'-show_entries', `stream=${props.join(',')}`,
|
|
23
|
+
'-of', 'json',
|
|
24
|
+
v
|
|
25
|
+
])
|
|
26
|
+
return JSON.parse(stdout).streams[0]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async function run(program, args) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const stdout = []
|
|
33
|
+
const stderr = []
|
|
34
|
+
|
|
35
|
+
const p = spawn(program, args)
|
|
36
|
+
p.stdout.on('data', chunk => { stdout.push(chunk) })
|
|
37
|
+
p.stderr.on('data', chunk => { stderr.push(chunk) })
|
|
38
|
+
|
|
39
|
+
p.on('error', reject)
|
|
40
|
+
p.on('close', code => {
|
|
41
|
+
if (code === 0)
|
|
42
|
+
resolve({
|
|
43
|
+
stdout: Buffer.concat(stdout).toString(),
|
|
44
|
+
stderr: Buffer.concat(stderr).toString(),
|
|
45
|
+
})
|
|
46
|
+
else
|
|
47
|
+
reject(new Error(`${program} failed with code ${code}\n${Buffer.concat(stderr).toString()}`))
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
package/src/fs-utils.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { join, dirname } from 'node:path'
|
|
4
|
+
import { mkdtemp, mkdir } from 'node:fs/promises'
|
|
5
|
+
import { lstatSync, glob as _glob } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export const glob = promisify(_glob)
|
|
9
|
+
|
|
10
|
+
export const lstat = f => lstatSync(f, { throwIfNoEntry: false })
|
|
11
|
+
export const isFile = path => lstat(path)?.isFile()
|
|
12
|
+
|
|
13
|
+
export const makeDirFor = async file => mkdir(dirname(file), { recursive: true })
|
|
14
|
+
export const makeTempDir = async () => mkdtemp(join(tmpdir(), 'mediasnacks-'))
|
|
15
|
+
|
|
16
|
+
export const replaceExt = (f, ext) => {
|
|
17
|
+
const parts = f.split('.')
|
|
18
|
+
if (parts.length > 1 && parts[0])
|
|
19
|
+
parts.pop()
|
|
20
|
+
parts.push(ext)
|
|
21
|
+
return parts.join('.')
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { equal } from 'node:assert/strict'
|
|
2
|
+
import test, { describe } from 'node:test'
|
|
3
|
+
import { replaceExt } from './fs-utils.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('replaceExt', () => {
|
|
7
|
+
test('replaces a simple extension', () =>
|
|
8
|
+
equal(replaceExt('file.txt', 'md'), 'file.md'))
|
|
9
|
+
|
|
10
|
+
test('replaces a multi-part extension', () =>
|
|
11
|
+
equal(replaceExt('archive.tar.gz', 'zip'), 'archive.tar.zip'))
|
|
12
|
+
|
|
13
|
+
test('adds extension when none exists', () =>
|
|
14
|
+
equal(replaceExt('README', 'md'), 'README.md'))
|
|
15
|
+
|
|
16
|
+
test('handles empty filename', () =>
|
|
17
|
+
equal(replaceExt('', 'ext'), '.ext'))
|
|
18
|
+
|
|
19
|
+
test('handles dot-files', () =>
|
|
20
|
+
equal(replaceExt('.env', 'txt'), '.env.txt'))
|
|
21
|
+
})
|