Skip to content

Commit

Permalink
feat: add getAllExtensions() method
Browse files Browse the repository at this point in the history
  • Loading branch information
broofa committed Sep 13, 2023
1 parent 7d37527 commit f22b6f0
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 123 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"prepublishOnly": "npm run build:types",
"release": "standard-version",
"test": "node --test",
"test:watch": "clear && node --test --watch test"
"test:watch": "clear && node --enable-source-maps --test --watch test"
},
"keywords": [
"extension",
Expand Down
90 changes: 59 additions & 31 deletions src/Mime.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
type TypeMap = { [key: string]: string[] };

export default class Mime {
#types = new Map<string, string>();
#extensions = new Map<string, string>();
#extensionToType = new Map<string, string>();
#typeToExtension = new Map<string, string>();
#typeToExtensions = new Map<string, Set<string>>();

constructor(...args: TypeMap[]) {
for (const arg of args) {
Expand All @@ -18,38 +19,47 @@ export default class Mime {
* e.g. mime.define({'audio/ogg', ['oga', 'ogg', 'spx']});
*
* If a mapping for an extension has already been defined an error will be
* thrown unless the `force` argument is set to `true`. Alternatively,
* extensions maybe prefixed with a "*" to map the type to the extension
* without mapping the extension to the type.
* thrown unless the `force` argument is set to `true`.
*
* e.g. mime.define({'audio/wav', ['wav']}, {'audio/x-wav', ['*wav']});
*/
define(typeMap: TypeMap, force = false) {
for (let [userType, userExtensions] of Object.entries(typeMap)) {
// lowercase-ify
userExtensions = userExtensions.map((t) => t.toLowerCase());
userType = userType.toLowerCase();
for (let [type, extensions] of Object.entries(typeMap)) {
// Lowercase thingz
type = type.toLowerCase();
extensions = extensions.map((ext) => ext.toLowerCase());

for (const ext of userExtensions) {
// '*' prefix = not the preferred type for this extension. So fixup the
// extension, and skip it.
if (ext.startsWith('*')) continue;
if (!this.#typeToExtensions.has(type)) {
this.#typeToExtensions.set(type, new Set<string>());
}
const allExtensions = this.#typeToExtensions.get(type);

if (!force && this.#types.has(ext)) {
throw new Error(
`"${ext}" extension already maps to "${this.#types.get(
ext,
)}". Pass \`force=true\` to override this setting, otherwise remove "${ext}" from the list of extensions for "${userType}".`,
);
let first = true;
for (let extension of extensions) {
const starred = extension.startsWith('*');

extension = starred ? extension.slice(1) : extension;

// Add to list of extensions for the type
allExtensions?.add(extension);

if (first) {
// Map type to default extension (first in list)
this.#typeToExtension.set(type, extension);
}
first = false;

this.#types.set(ext, userType);
}
// Starred types are not eligible to be the default extension
if (starred) continue;

// Use first extension as default
if (force || !this.#extensions.has(userType)) {
const ext = userExtensions[0];
this.#extensions.set(userType, ext[0] !== '*' ? ext : ext.slice(1));
// Map extension to type
const currentType = this.#extensionToType.get(extension);
if (currentType && currentType != type && !force) {
throw new Error(
`"${type} -> ${extension}" conflicts with "${currentType} -> ${extension}". Pass \`force=true\` to override this definition.`,
);
}
this.#extensionToType.set(extension, type);
}
}

Expand All @@ -60,7 +70,8 @@ export default class Mime {
* Lookup a mime type based on extension
*/
getType(path: string) {
path = String(path);
if (typeof path !== 'string') return null;

// Remove chars preceeding `/` or `\`
const last = path.replace(/^.*[/\\]/, '').toLowerCase();

Expand All @@ -73,17 +84,29 @@ export default class Mime {
// Extension-less file?
if (!hasDot && hasPath) return null;

return this.#types.get(ext) ?? null;
return this.#extensionToType.get(ext) ?? null;
}

/**
* Return file extension associated with a mime type
*/
getExtension(type: string) {
if (typeof type !== 'string') return null;

// Remove http header parameter(s) (specifically, charset)
type = type?.split?.(';')[0];

return (type && this.#extensions.get(type.trim().toLowerCase())) ?? null;
return (
(type && this.#typeToExtension.get(type.trim().toLowerCase())) ?? null
);
}

getAllExtensions(type: string) {
if (typeof type !== 'string') return [];

const extensions = this.#typeToExtensions.get(type.toLowerCase());

return extensions ? [...extensions] : [];
}

//
Expand All @@ -92,17 +115,22 @@ export default class Mime {

_freeze() {
this.define = () => {
throw new Error('mime.define() is not allowed on default Mime objects.');
throw new Error('define() not allowed for built-in Mime objects.');
};

Object.freeze(this);

for (const extensions of this.#typeToExtensions.values()) {
Object.freeze([...extensions]);
}

return this;
}

_getTestState() {
return {
types: this.#types,
extensions: this.#extensions,
types: this.#extensionToType,
extensions: this.#typeToExtension,
};
}
}
11 changes: 10 additions & 1 deletion test/mime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,16 @@ describe('class Mime', (t) => {
it('define()', (t) => {
const mime = new Mime({ 'text/a': ['a'] }, { 'text/b': ['b'] });

// Should throw when trying to override an existing type->ext mapping
assert.throws(() => mime.define({ 'text/c': ['b'] }));
assert.doesNotThrow(() => mime.define({ 'text/c': ['b'] }, true));

// Should not throw if it's a mapping we already have
assert.doesNotThrow(() => mime.define({ 'text/c': ['b', 'c'] }, true));

testGetType(t, mime, [
{ input: 'a', expected: 'text/a' },
{ input: 'b', expected: 'text/c' },
{ input: 'c', expected: 'text/c' },
]);

testGetExtension(t, mime, [
Expand Down Expand Up @@ -145,6 +149,11 @@ describe('class Mime', (t) => {
});
});

it('getAllExtensions()', () => {
const mime = new Mime({ 'text/a': ['a', 'b'] }, { 'text/a': ['b', 'c'] });
assert.deepEqual(mime.getAllExtensions('text/a').sort(), ['a', 'b', 'c']);
});

describe('DB', () => {
it('Consistency', () => {
const { types, extensions } = mime._getTestState();
Expand Down
184 changes: 94 additions & 90 deletions test/vendor.test.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import chalk from 'chalk';
import mimeTypes from 'mime-types';
import { after, it } from 'node:test';
import { after, describe, it } from 'node:test';
import mime from '../dist/src/index.js';

const diffs = [];
after((t) => {

after(() => {
if (diffs.length) {
console.log(
'\n[INFO] The following inconsistencies with MDN (https://goo.gl/lHrFU6) and/or mime-types (https://github.com/jshttp/mime-types) are expected:',
Expand All @@ -19,94 +20,97 @@ after((t) => {
}
});

it('MDN types', () => {
// MDN types listed at https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
const MDN = {
aac: 'audio/aac',
abw: 'application/x-abiword',
arc: 'application/x-freearc',
avi: 'video/x-msvideo',
azw: 'application/vnd.amazon.ebook',
bin: 'application/octet-stream',
bmp: 'image/bmp',
bz: 'application/x-bzip',
bz2: 'application/x-bzip2',
csh: 'application/x-csh',
css: 'text/css',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
eot: 'application/vnd.ms-fontobject',
epub: 'application/epub+zip',
gz: 'application/gzip',
gif: 'image/gif',
htm: 'text/html',
html: 'text/html',
ico: 'image/vnd.microsoft.icon',
ics: 'text/calendar',
jar: 'application/java-archive',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
js: 'text/javascript',
json: 'application/json',
jsonld: 'application/ld+json',
mid: 'audio/x-midi',
midi: 'audio/x-midi',
mjs: 'text/javascript',
mp3: 'audio/mpeg',
mpeg: 'video/mpeg',
mpkg: 'application/vnd.apple.installer+xml',
odp: 'application/vnd.oasis.opendocument.presentation',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
odt: 'application/vnd.oasis.opendocument.text',
oga: 'audio/ogg',
ogv: 'video/ogg',
ogx: 'application/ogg',
opus: 'audio/opus',
otf: 'font/otf',
png: 'image/png',
pdf: 'application/pdf',
php: 'application/php',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
rar: 'application/vnd.rar',
rtf: 'application/rtf',
sh: 'application/x-sh',
svg: 'image/svg+xml',
swf: 'application/x-shockwave-flash',
tar: 'application/x-tar',
tif: 'image/tiff',
tiff: 'image/tiff',
ts: 'video/mp2t',
ttf: 'font/ttf',
txt: 'text/plain',
vsd: 'application/vnd.visio',
wav: 'audio/wav',
weba: 'audio/webm',
webm: 'video/webm',
webp: 'image/webp',
woff: 'font/woff',
woff2: 'font/woff2',
xhtml: 'application/xhtml+xml',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
xml: 'application/xml',
xul: 'application/vnd.mozilla.xul+xml',
zip: 'application/zip',
'3gp': 'video/3gpp',
'3g2': 'video/3gpp2',
'7z': 'application/x-7z-compressed',
};
describe('Mime vendor tests', () => {
it('MDN types', () => {
// MDN types listed at https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
const MDN = {
aac: 'audio/aac',
abw: 'application/x-abiword',
arc: 'application/x-freearc',
avi: 'video/x-msvideo',
azw: 'application/vnd.amazon.ebook',
bin: 'application/octet-stream',
bmp: 'image/bmp',
bz: 'application/x-bzip',
bz2: 'application/x-bzip2',
csh: 'application/x-csh',
css: 'text/css',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
eot: 'application/vnd.ms-fontobject',
epub: 'application/epub+zip',
gz: 'application/gzip',
gif: 'image/gif',
htm: 'text/html',
html: 'text/html',
ico: 'image/vnd.microsoft.icon',
ics: 'text/calendar',
jar: 'application/java-archive',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
js: 'text/javascript',
json: 'application/json',
jsonld: 'application/ld+json',
mid: 'audio/x-midi',
midi: 'audio/x-midi',
mjs: 'text/javascript',
mp3: 'audio/mpeg',
mpeg: 'video/mpeg',
mpkg: 'application/vnd.apple.installer+xml',
odp: 'application/vnd.oasis.opendocument.presentation',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
odt: 'application/vnd.oasis.opendocument.text',
oga: 'audio/ogg',
ogv: 'video/ogg',
ogx: 'application/ogg',
opus: 'audio/opus',
otf: 'font/otf',
png: 'image/png',
pdf: 'application/pdf',
php: 'application/php',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
rar: 'application/vnd.rar',
rtf: 'application/rtf',
sh: 'application/x-sh',
svg: 'image/svg+xml',
swf: 'application/x-shockwave-flash',
tar: 'application/x-tar',
tif: 'image/tiff',
tiff: 'image/tiff',
ts: 'video/mp2t',
ttf: 'font/ttf',
txt: 'text/plain',
vsd: 'application/vnd.visio',
wav: 'audio/wav',
weba: 'audio/webm',
webm: 'video/webm',
webp: 'image/webp',
woff: 'font/woff',
woff2: 'font/woff2',
xhtml: 'application/xhtml+xml',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
xml: 'application/xml',
xul: 'application/vnd.mozilla.xul+xml',
zip: 'application/zip',
'3gp': 'video/3gpp',
'3g2': 'video/3gpp2',
'7z': 'application/x-7z-compressed',
};

for (const ext in MDN) {
const expected = MDN[ext];
const actual = mime.getType(ext);
if (actual !== expected) diffs.push(['MDN', ext, expected, actual]);
}
for (const ext in MDN) {
const expected = MDN[ext];
const actual = mime.getType(ext);
if (actual !== expected) diffs.push(['MDN', ext, expected, actual]);
}

for (const ext in mimeTypes.types) {
const expected = mimeTypes.types[ext];
const actual = mime.getType(ext);
if (actual !== expected) diffs.push(['mime-types', ext, expected, actual]);
}
for (const ext in mimeTypes.types) {
const expected = mimeTypes.types[ext];
const actual = mime.getType(ext);
if (actual !== expected)
diffs.push(['mime-types', ext, expected, actual]);
}
});
});

0 comments on commit f22b6f0

Please sign in to comment.