Every browser tool that edits a file ends the same way: it hands a new file back. And the moment it does, it has to answer a question nobody thinks about while building the editing part: what should the new file be called?
On Meme Tap, the tools I build answer it in a predictable way. The online audio trimmer takes the original name, turns unsafe characters into hyphens and adds -trim, so bruh.mp3 comes back as bruh-trim.mp3. The audio visualizer does the same with -visual.webm. It sounds trivial. It isn't, because the "original name" is whatever was on someone's phone or laptop, and people name files in every language, with emoji, with brackets, with five dots, sometimes with nothing at all.
This post builds a small downloadName() function in plain JavaScript, explains each rule, and tests it with node:test. The code runs in browsers and in Node 20+.
Why not just use the original name?
Because the download name is the one place where your tool writes something onto the user's disk that they didn't type themselves. A bad name causes small but real problems:
-
Overwriting. If the trimmed clip keeps the exact original name, it lands next to the original in the downloads folder and the browser either renames it (
bruh (1).mp3) or, in some setups, people save over the source by accident. -
Characters the file system rejects. Windows refuses
<>:"/\|?*and a handful of reserved names. A name that's fine on a phone can fail when the file is copied to a PC later. -
Path pieces. Browsers strip directories from
File.name, but names can also come from other places: a URL, a drag from another app, a value you stored earlier. Treating any slash as part of a name is asking for trouble. -
Unreadable results. The opposite mistake is cleaning too hard. Replace every non-ASCII character with a hyphen and an Urdu or Korean filename becomes
------.mp3. That's safe and useless.
So the goal is a name that is safe everywhere, still recognisable to the person who chose the original, and clearly marked as the edited copy.
The rules
Here is what downloadName() does, in order:
- Keep only the last path piece, then split off the extension, but only if it looks like a real one (one to five letters or digits).
notes.final drafthas no extension; it's just a name with a dot in it. - Normalise with NFKC, so full-width and half-width forms and other look-alike characters become their ordinary versions.
- Fold accents on Latin letters (
café→cafe), and leave every other script alone. - Replace anything that isn't a letter, a number, a combining mark,
_or-with a hyphen. That covers spaces, brackets, dots, slashes, control characters and emoji. - Collapse repeated hyphens and trim them from the ends.
- Cut the name to a fixed number of graphemes, not code units.
- Fall back to
audioif nothing is left, and add-fileto Windows reserved names likeCON. - Add the suffix (
-trim), unless the name already ends with it. - Lowercase the extension, or use the format you're actually exporting.
The code
// safe-name.mjs: build a download filename from whatever name the user's file had.
const RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
const MAX_BASE = 80; // characters (graphemes) kept from the original name
export function splitName(name) {
name = name.split(/[\\/]/).pop(); // keep the last path piece only
const dot = name.lastIndexOf(".");
// ".bashrc" has no extension; "clip." has an empty one
if (dot <= 0) return { base: name, ext: "" };
const ext = name.slice(dot + 1);
// "notes.final draft" is not an extension, so the whole thing is the base
if (ext && !/^[a-z0-9]{1,5}$/i.test(ext)) return { base: name, ext: "" };
return { base: name.slice(0, dot), ext };
}
function takeGraphemes(s, n) {
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
let out = "", count = 0;
for (const { segment } of seg.segment(s)) {
if (count++ === n) break;
out += segment;
}
return out;
}
export function cleanBase(raw) {
let s = raw.normalize("NFKC");
// drop accents only from Latin letters: "café" -> "cafe", but keep Urdu, Korean, Japanese as they are
s = s.normalize("NFD").replace(/(\p{Script=Latin})\p{M}+/gu, "$1").normalize("NFC");
s = s.replace(/[^\p{L}\p{N}\p{M}_-]+/gu, "-"); // anything else (spaces, dots, slashes, emoji) -> hyphen
s = s.replace(/-{2,}/g, "-").replace(/^[-_]+|[-_]+$/g, "");
s = takeGraphemes(s, MAX_BASE).replace(/[-_]+$/, "");
if (!s) s = "audio";
if (RESERVED.test(s)) s += "-file";
return s;
}
export function downloadName(original, { suffix = "trim", ext } = {}) {
const { base, ext: oldExt } = splitName(original || "");
let b = cleanBase(base);
const tag = suffix ? "-" + suffix : "";
if (tag && !b.toLowerCase().endsWith(tag)) b += tag;
const e = (ext ?? oldExt).toLowerCase().replace(/[^a-z0-9]/g, "") || "bin";
return `${b}.${e}`;
}
export function saveBlob(blob, name) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.append(a);
a.click();
a.remove();
// give the browser a moment to start the download before the URL is released
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
A few lines deserve a closer look.
Unicode property escapes do the heavy lifting
/[^\p{L}\p{N}\p{M}_-]+/gu means "one or more characters that are not a letter, a number or a mark in any script". The u flag is required for \p{...}. This single expression is what lets بھائی یہ کیا ہو گیا.mp3 become بھائی-یہ-کیا-ہو-گیا-trim.mp3 instead of a row of hyphens. Keeping \p{M} matters for scripts like Urdu and Hindi, where combining marks are part of how words are written. Remove them and you change the word.
Accent folding only for Latin
The NFD step splits é into e plus a combining accent, and the replacement removes marks only when they follow a Latin letter. I fold Latin accents because those names are often typed on keyboards that make accents awkward, and cafe-trim.mp3 is easier to type into a search box later. I don't fold anything else, because for most other scripts the marks aren't decoration.
This is a choice, not a law. If your users are mostly French or Turkish speakers, you might prefer to keep the accents too. Modern file systems handle them fine. The important part is that the rule is deliberate and the same every time.
NFKC before anything else
Half-width katakana (テスト) and full-width Latin letters (bruh) look like the normal characters but aren't. NFKC maps them to the standard forms, so two names that look the same end up the same. Without it, a user can end up with two "identical" files that sort and search differently.
Cutting on graphemes, not on string length
str.slice(0, 80) counts UTF-16 code units. An emoji with a skin tone modifier is several code units, and slicing through the middle leaves a lone surrogate, which some systems show as � and some refuse outright. Intl.Segmenter with granularity: "grapheme" walks the string in user-perceived characters, so the cut always lands between two whole characters.
In this function emoji are replaced with hyphens anyway, so the segmenter mostly protects letters that carry several combining marks. I keep it because the cleaning rules might change later and the length limit should stay correct whatever they become.
Why 80? There's nothing magic about it. File systems allow much longer names, but a name that long is already hard to read in a downloads list, and the limit leaves room for the suffix and extension.
Reserved names
Windows still treats CON, PRN, AUX, NUL, COM1–COM9 and LPT1–LPT9 as device names, with or without an extension. A clip called con.wav downloads fine on a Mac and then can't be copied onto a Windows machine. Adding -file is enough to make it an ordinary name. With the default suffix it becomes con-file-trim.wav, which is a little odd, and that's fine. It's a rare case and the name is still clear.
Not adding the suffix twice
People trim a clip, listen to it, decide it's still too long, and trim the trimmed file. Without a check they get bruh-trim-trim.mp3, then bruh-trim-trim-trim.mp3. The function checks whether the cleaned name already ends with the suffix and leaves it alone if so. The browser will still add (1) if the same name already exists in the folder, and that's the right layer to handle duplicates.
Tests
The rules are easy to get subtly wrong, so they're written down as tests:
import { test } from "node:test";
import assert from "node:assert/strict";
import { downloadName, splitName } from "./safe-name.mjs";
test("plain names keep their shape", () => {
assert.equal(downloadName("bruh.mp3"), "bruh-trim.mp3");
assert.equal(downloadName("bruh.mp3", { ext: "wav" }), "bruh-trim.wav");
});
test("spaces, brackets and dots become single hyphens", () => {
assert.equal(downloadName("vine boom (final) v2.final.MP3"), "vine-boom-final-v2-final-trim.mp3");
});
test("path pieces and control characters cannot survive", () => {
assert.equal(downloadName("../../etc/passwd"), "passwd-trim.bin");
assert.equal(downloadName("a\u0000b\nc.wav"), "a-b-c-trim.wav");
});
test("Latin accents are folded, other scripts are kept", () => {
assert.equal(downloadName("café.wav"), "cafe-trim.wav");
assert.equal(downloadName("بھائی یہ کیا ہو گیا.mp3"), "بھائی-یہ-کیا-ہو-گیا-trim.mp3");
assert.equal(downloadName("웃음 소리.mp3"), "웃음-소리-trim.mp3");
assert.equal(downloadName("テスト.mp3"), "テスト-trim.mp3"); // NFKC turns half-width katakana into full width
});
test("emoji-only or empty names fall back", () => {
assert.equal(downloadName("😂😂.mp3"), "audio-trim.mp3");
assert.equal(downloadName(""), "audio-trim.bin");
assert.equal(downloadName(".mp3"), "mp3-trim.bin");
});
test("Windows reserved names get a tail", () => {
assert.equal(downloadName("CON.wav", { suffix: "" }), "CON-file.wav");
assert.equal(downloadName("con.wav"), "con-file-trim.wav");
});
test("the suffix is not repeated on a second trim", () => {
assert.equal(downloadName("bruh-trim.mp3"), "bruh-trim.mp3");
});
test("long names are cut on grapheme boundaries", () => {
const long = "👍🏽x".repeat(60) + ".mp3";
const out = downloadName(long);
assert.ok(out.endsWith("-trim.mp3"));
assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out), "no lone high surrogate");
assert.ok([...new Intl.Segmenter().segment(out.replace(/-trim\.mp3$/, ""))].length <= 80);
});
test("splitName treats dotfiles as having no extension", () => {
assert.deepEqual(splitName(".hidden"), { base: ".hidden", ext: "" });
assert.deepEqual(splitName("clip."), { base: "clip", ext: "" });
assert.deepEqual(splitName("notes.final draft"), { base: "notes.final draft", ext: "" });
});
Running them with Node's built-in runner (node --test safe-name.test.mjs) gives:
✔ plain names keep their shape (9.843215ms)
✔ spaces, brackets and dots become single hyphens (0.931285ms)
✔ path pieces and control characters cannot survive (0.20769ms)
✔ Latin accents are folded, other scripts are kept (0.816456ms)
✔ emoji-only or empty names fall back (0.186507ms)
✔ Windows reserved names get a tail (0.136795ms)
✔ the suffix is not repeated on a second trim (0.101686ms)
✔ long names are cut on grapheme boundaries (0.263701ms)
✔ splitName treats dotfiles as having no extension (0.692443ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 62.898157
The Urdu and Korean cases are the ones I'd keep even if you change everything else. They're the tests that fail first when someone "simplifies" the regular expression to [^a-z0-9].
Saving the file
The last function in the module, saveBlob(), is the usual pattern for downloading something made in the page:
- create an object URL for the
Blob, - point a temporary
<a download="...">at it and click it, - release the URL a moment later so the memory is freed.
For object URLs the download attribute is respected, so the name you build is the name the user gets, apart from the browser adding (1) for duplicates. The delay before revokeObjectURL is there because revoking right after click() has been known to cancel the download in some browsers.
Using it after an export looks like this:
import { downloadName, saveBlob } from "./safe-name.mjs";
async function exportClip(file, encodeMp3) {
const blob = await encodeMp3(); // your encoder returns a Blob
saveBlob(blob, downloadName(file.name, { suffix: "trim", ext: "mp3" }));
}
Pass the extension of the format you actually produced. If someone drops a WAV and exports MP3, clip-trim.wav containing MP3 data is a bug that only shows up later, in another app.
When a fixed name is better
Not every tool should derive the name from the input. Meme Tap's meme caption generator always downloads meme-tap-caption.png or .jpg, and that's on purpose. A captioned meme is a new picture, often made from a starter photo the user never named, and the original filename (IMG_20261104_093112.jpg) says nothing about the joke. For audio, the original name usually is the description of the clip, so keeping it makes sense.
The rule of thumb I use: if the output is clearly "a version of that file", derive the name from it. If it's a new thing, give it a stable name and let the browser number duplicates.
Wrapping up
Filenames are a small part of a tool, but they're the part that stays on someone's device long after the tab is closed. Clean them on purpose, keep the scripts people actually use, cut on whole characters, avoid the few names Windows still reserves, and mark the copy clearly.
If you want to see the result in practice, drop a file with an awkward name into the audio trimmer and look at what comes back. The trimmer guide explains the rest of the export: WAV or MP3, and how the file is encoded in the tab.
I'm Abid Ali, a freelance full-stack developer in Karachi. I build Meme Tap, a meme soundboard with free browser tools for trimming sounds, turning them into videos and captioning images.
Top comments (0)