103 changes: 0 additions & 103 deletions lib/content-type-parser.js

This file was deleted.

142 changes: 142 additions & 0 deletions lib/mime-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"use strict";
const parse = require("./parser.js");
const serialize = require("./serializer.js");
const {
asciiLowercase,
solelyContainsHTTPTokenCodePoints,
soleyContainsHTTPQuotedStringTokenCodePoints
} = require("./utils.js");

module.exports = class MIMEType {
constructor(string) {
string = String(string);
const result = parse(string);
if (result === null) {
throw new Error(`Could not parse MIME type string "${string}"`);
}

this._type = result.type;
this._subtype = result.subtype;
this._parameters = new MIMETypeParameters(result.parameters);
}

get essence() {
return `${this.type}/${this.subtype}`;
}

get type() {
return this._type;
}

set type(value) {
value = asciiLowercase(String(value));

if (value.length === 0) {
throw new Error("Invalid type: must be a non-empty string");
}
if (!solelyContainsHTTPTokenCodePoints(value)) {
throw new Error(`Invalid type ${value}: must contain only HTTP token code points`);
}

this._type = value;
}

get subtype() {
return this._subtype;
}

set subtype(value) {
value = asciiLowercase(String(value));

if (value.length === 0) {
throw new Error("Invalid subtype: must be a non-empty string");
}
if (!solelyContainsHTTPTokenCodePoints(value)) {
throw new Error(`Invalid subtype ${value}: must contain only HTTP token code points`);
}

this._subtype = value;
}

get parameters() {
return this._parameters;
}

toString() {
// The serialize function works on both "MIME type records" (i.e. the results of parse) and on this class, since
// this class's interface is identical.
return serialize(this);
}

isXML() {
return (this._subtype === "xml" && (this._type === "text" || this._type === "application")) ||
this._subtype.endsWith("+xml");
}
isHTML() {
return this._subtype === "html" && this._type === "text";
}
};

class MIMETypeParameters {
constructor(map) {
this._map = map;
}

get size() {
return this._map.size;
}

get(name) {
name = asciiLowercase(String(name));
return this._map.get(name);
}

has(name) {
name = asciiLowercase(String(name));
return this._map.has(name);
}

set(name, value) {
name = asciiLowercase(String(name));
value = String(value);

if (!solelyContainsHTTPTokenCodePoints(name)) {
throw new Error(`Invalid MIME type parameter name "${name}": only HTTP token code points are valid.`);
}
if (!soleyContainsHTTPQuotedStringTokenCodePoints(value)) {
throw new Error(`Invalid MIME type parameter value "${value}": only HTTP quoted-string token code points are ` +
`valid.`);
}

return this._map.set(name, value);
}

clear() {
this._map.clear();
}

delete(name) {
name = asciiLowercase(String(name));
return this._map.delete(name);
}

forEach(callbackFn, thisArg) {
this._map.forEach(callbackFn, thisArg);
}

keys() {
return this._map.keys();
}

values() {
return this._map.values();
}

entries() {
return this._map.entries();
}

[Symbol.iterator]() {
return this._map[Symbol.iterator]();
}
}
123 changes: 123 additions & 0 deletions lib/parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"use strict";
const {
removeLeadingAndTrailingASCIIWhitespace,
removeTrailingASCIIWhitespace,
isASCIIWhitespaceChar,
solelyContainsHTTPTokenCodePoints,
soleyContainsHTTPQuotedStringTokenCodePoints,
asciiLowercase
} = require("./utils.js");

module.exports = input => {
input = removeLeadingAndTrailingASCIIWhitespace(input);

let position = 0;
let type = "";
while (position < input.length && input[position] !== "/") {
type += input[position];
++position;
}

if (type.length === 0 || !solelyContainsHTTPTokenCodePoints(type)) {
return null;
}

if (position >= input.length) {
return null;
}

// Skips past "/"
++position;

let subtype = "";
while (position < input.length && input[position] !== ";") {
subtype += input[position];
++position;
}

subtype = removeTrailingASCIIWhitespace(subtype);

if (subtype.length === 0 || !solelyContainsHTTPTokenCodePoints(subtype)) {
return null;
}

const mimeType = {
type: asciiLowercase(type),
subtype: asciiLowercase(subtype),
parameters: new Map()
};

while (position < input.length) {
// Skip past ";"
++position;

while (isASCIIWhitespaceChar(input[position])) {
++position;
}

let parameterName = "";
while (position < input.length && input[position] !== ";" && input[position] !== "=") {
parameterName += input[position];
++position;
}
parameterName = asciiLowercase(parameterName);

if (position < input.length) {
if (input[position] === ";") {
continue;
}

// Skip past "="
++position;
}

let parameterValue = "";
if (position < input.length) {
if (input[position] === "\"") {
++position;

while (true) {
while (position < input.length && input[position] !== "\"" && input[position] !== "\\") {
parameterValue += input[position];
++position;
}

if (position < input.length && input[position] === "\\") {
++position;
if (position < input.length) {
parameterValue += input[position];
++position;
continue;
} else {
parameterValue += "\\";
break;
}
} else {
break;
}
}

while (position < input.length && input[position] !== ";") {
++position;
}
} else {
while (position < input.length && input[position] !== ";") {
parameterValue += input[position];
++position;
}

parameterValue = removeTrailingASCIIWhitespace(parameterValue);
}
}

if (parameterName.length > 0 &&
parameterValue.length > 0 &&
solelyContainsHTTPTokenCodePoints(parameterName) &&
soleyContainsHTTPQuotedStringTokenCodePoints(parameterValue) &&
!mimeType.parameters.has(parameterName)) {
mimeType.parameters.set(parameterName, parameterValue);
}
}

return mimeType;
};
25 changes: 25 additions & 0 deletions lib/serializer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use strict";
const { solelyContainsHTTPTokenCodePoints } = require("./utils.js");

module.exports = mimeType => {
let serialization = `${mimeType.type}/${mimeType.subtype}`;

if (mimeType.parameters.size === 0) {
return serialization;
}

for (let [name, value] of mimeType.parameters) {
serialization += ";";
serialization += name;
serialization += "=";

if (!solelyContainsHTTPTokenCodePoints(value)) {
value = value.replace(/(["\\])/g, "\\$1");
value = `"${value}"`;
}

serialization += value;
}

return serialization;
};
25 changes: 25 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use strict";

exports.removeLeadingAndTrailingASCIIWhitespace = string => {
return string.replace(/^[ \t\n\f\r]+/, "").replace(/[ \t\n\f\r]+$/, "");
};

exports.removeTrailingASCIIWhitespace = string => {
return string.replace(/[ \t\n\f\r]+$/, "");
};

exports.isASCIIWhitespaceChar = char => {
return char === " " || char === "\t" || char === "\n" || char === "\f" || char === "\r";
};

exports.solelyContainsHTTPTokenCodePoints = string => {
return /^[-!#$%&'*+.^_`|~A-Za-z0-9]+$/.test(string);
};

exports.soleyContainsHTTPQuotedStringTokenCodePoints = string => {
return /^[\t\u0020-\u007E\u0080-\u00FF]+$/.test(string);
};

exports.asciiLowercase = string => {
return string.replace(/[A-Z]/g, l => l.toLowerCase());
};
4,083 changes: 4,083 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

39 changes: 29 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
{
"name": "content-type-parser",
"description": "Parse the value of the Content-Type header",
"name": "whatwg-mimetype",
"description": "Parses, serializes, and manipulates MIME types, according to the WHATWG MIME Sniffing Standard",
"keywords": [
"content-type",
"http"
"mime type",
"mimesniff",
"http",
"whatwg"
],
"version": "1.0.2",
"version": "2.0.0-pre",
"author": "Domenic Denicola <d@domenic.me> (https://domenic.me/)",
"license": "MIT",
"repository": "jsdom/content-type-parser",
"main": "lib/content-type-parser.js",
"repository": "jsdom/whatwg-mimetype",
"main": "lib/mime-type.js",
"files": [
"lib/"
],
"scripts": {
"test": "mocha",
"lint": "eslint lib test"
"test": "jest",
"coverage": "jest --coverage",
"lint": "eslint .",
"pretest": "node scripts/get-latest-platform-tests.js"
},
"devDependencies": {
"eslint": "^3.8.0",
"mocha": "^3.1.2"
"eslint": "^4.12.1",
"jest": "^21.2.1",
"printable-string": "^0.3.0",
"request": "^2.83.0",
"whatwg-encoding": "^1.0.3"
},
"jest": {
"coverageDirectory": "coverage",
"coverageReporters": [
"lcov",
"text-summary"
],
"testEnvironment": "node",
"testMatch": [
"<rootDir>/test/**/*.js"
]
}
}
10 changes: 10 additions & 0 deletions scripts/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"env": {
"node": true
},
"rules": {
"no-process-env": "off",
"no-process-exit": "off",
"no-console": "off"
}
}
32 changes: 32 additions & 0 deletions scripts/get-latest-platform-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use strict";

if (process.env.NO_UPDATE) {
process.exit(0);
}

const path = require("path");
const fs = require("fs");
const request = require("request");

process.on("unhandledRejection", err => {
throw err;
});

// Pin to specific version, reflecting the spec version in the readme.
//
// To get the latest commit:
// 1. Go to https://github.com/w3c/web-platform-tests/tree/master/mimesniff
// 2. Press "y" on your keyboard to get a permalink
// 3. Copy the commit hash
const commitHash = "e6a94100ccf5c481df3078cef5e4021769bad370";

const urlPrefix = `https://raw.githubusercontent.com/w3c/web-platform-tests/${commitHash}` +
`/mimesniff/mime-types/resources/`;

const files = ["mime-types.json", "generated-mime-types.json"];

for (const file of files) {
const url = urlPrefix + file;
const targetFile = path.resolve(__dirname, "..", "test", "web-platform-tests", file);
request(url).pipe(fs.createWriteStream(targetFile));
}
4 changes: 2 additions & 2 deletions test/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"env": {
"node": true,
"es6": true,
"mocha": true
"jasmine": true,
"jest": true
}
}
174 changes: 174 additions & 0 deletions test/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"use strict";
const MIMEType = require("..");

describe("Smoke tests via README intro example", () => {
let mimeType;
beforeEach(() => {
mimeType = new MIMEType(`Text/HTML;Charset="utf-8"`);
});

it("serializes correctly", () => {
expect(mimeType.toString()).toEqual("text/html;charset=utf-8");
});

it("has the correct type, subtype, and essence", () => {
expect(mimeType.type).toEqual("text");
expect(mimeType.subtype).toEqual("html");
expect(mimeType.essence).toEqual("text/html");
});

it("has the correct parameters", () => {
expect(mimeType.parameters.size).toEqual(1);
expect(mimeType.parameters.has("charset")).toBe(true);
expect(mimeType.parameters.get("charset")).toEqual("utf-8");
});

it("responds to parameter setting", () => {
mimeType.parameters.set("charset", "windows-1252");
expect(mimeType.parameters.get("charset")).toEqual("windows-1252");
expect(mimeType.toString()).toEqual("text/html;charset=windows-1252");
});
});

describe("Constructor behavior", () => {
it("converts incoming arguments into strings", () => {
const arg = {
toString() {
return "text/HTML";
}
};
const mimeType = new MIMEType(arg);

expect(mimeType.toString()).toEqual("text/html");
});

it("throws on unparseable MIME types", () => {
expect(() => new MIMEType("asdf")).toThrow();
expect(() => new MIMEType("text/html™")).toThrow();
});
});

describe("type manipulation", () => {
let mimeType;
beforeEach(() => {
mimeType = new MIMEType("application/xml;foo=bar");
});

it("responds to type being set", () => {
mimeType.type = "text";
expect(mimeType.type).toEqual("text");
expect(mimeType.essence).toEqual("text/xml");
expect(mimeType.toString()).toEqual("text/xml;foo=bar");
});

it("ASCII-lowercases incoming type strings", () => {
mimeType.type = "TeXT";
expect(mimeType.type).toEqual("text");
expect(mimeType.essence).toEqual("text/xml");
expect(mimeType.toString()).toEqual("text/xml;foo=bar");
});

it("converts the value set to a string", () => {
mimeType.type = {
toString() {
return "TeXT";
}
};
expect(mimeType.type).toEqual("text");
expect(mimeType.essence).toEqual("text/xml");
expect(mimeType.toString()).toEqual("text/xml;foo=bar");
});

it("throws an error for non-HTTP token code points", () => {
// not exhaustive; maybe later
expect(() => {
mimeType.type = "/";
}).toThrow();
});

it("throws an error for an empty string", () => {
expect(() => {
mimeType.type = "";
}).toThrow();
});
});

describe("subtype manipulation", () => {
let mimeType;
beforeEach(() => {
mimeType = new MIMEType("application/xml;foo=bar");
});

it("responds to type being set", () => {
mimeType.subtype = "pdf";
expect(mimeType.subtype).toEqual("pdf");
expect(mimeType.essence).toEqual("application/pdf");
expect(mimeType.toString()).toEqual("application/pdf;foo=bar");
});

it("ASCII-lowercases incoming type strings", () => {
mimeType.subtype = "PdF";
expect(mimeType.subtype).toEqual("pdf");
expect(mimeType.essence).toEqual("application/pdf");
expect(mimeType.toString()).toEqual("application/pdf;foo=bar");
});

it("converts the value set to a string", () => {
mimeType.subtype = {
toString() {
return "PdF";
}
};
expect(mimeType.subtype).toEqual("pdf");
expect(mimeType.essence).toEqual("application/pdf");
expect(mimeType.toString()).toEqual("application/pdf;foo=bar");
});

it("throws an error for non-HTTP token code points", () => {
// not exhaustive; maybe later
expect(() => {
mimeType.subtype = "/";
}).toThrow();
});

it("throws an error for an empty string", () => {
expect(() => {
mimeType.subtype = "";
}).toThrow();
});
});

describe("Group-testing functions", () => {
test("isHTML", () => {
expect((new MIMEType("text/html")).isHTML()).toBe(true);
expect((new MIMEType("text/html;charset=utf-8")).isHTML()).toBe(true);
expect((new MIMEType("text/html;charset=utf-8;foo=bar")).isHTML()).toBe(true);

expect((new MIMEType("text/xhtml")).isHTML()).toBe(false);
expect((new MIMEType("application/html")).isHTML()).toBe(false);
expect((new MIMEType("application/xhtml+xml")).isHTML()).toBe(false);
});

test("isXML", () => {
expect((new MIMEType("application/xml")).isXML()).toBe(true);
expect((new MIMEType("application/xml;charset=utf-8")).isXML()).toBe(true);
expect((new MIMEType("application/xml;charset=utf-8;foo=bar")).isXML()).toBe(true);

expect((new MIMEType("text/xml")).isXML()).toBe(true);
expect((new MIMEType("text/xml;charset=utf-8")).isXML()).toBe(true);
expect((new MIMEType("text/xml;charset=utf-8;foo=bar")).isXML()).toBe(true);

expect((new MIMEType("text/svg+xml")).isXML()).toBe(true);
expect((new MIMEType("text/svg+xml;charset=utf-8")).isXML()).toBe(true);
expect((new MIMEType("text/svg+xml;charset=utf-8;foo=bar")).isXML()).toBe(true);

expect((new MIMEType("application/xhtml+xml")).isXML()).toBe(true);
expect((new MIMEType("application/xhtml+xml;charset=utf-8")).isXML()).toBe(true);
expect((new MIMEType("application/xhtml+xml;charset=utf-8;foo=bar")).isXML()).toBe(true);

expect((new MIMEType("text/xhtml")).isXML()).toBe(false);
expect((new MIMEType("text/svg")).isXML()).toBe(false);
expect((new MIMEType("application/html")).isXML()).toBe(false);
expect((new MIMEType("application/xml+xhtml")).isXML()).toBe(false);
});
});
175 changes: 0 additions & 175 deletions test/tests.js

This file was deleted.

Empty file.
48 changes: 48 additions & 0 deletions test/web-platform.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use strict";
const encodingLabelToName = require("whatwg-encoding").labelToName;
const printableString = require("printable-string");
const testCases = require("./web-platform-tests/mime-types.json");
const generatedTestCases = require("./web-platform-tests/generated-mime-types.json");
const parse = require("../lib/parser.js");
const serialize = require("../lib/serializer.js");

describe("mime-types.json", () => {
runTestCases(testCases);
});

describe("generated-mime-types.json", () => {
runTestCases(generatedTestCases);
});

function runTestCases(cases) {
for (const testCase of cases) {
if (typeof testCase === "string") {
// It's a comment
continue;
}

const printableVersion = printableString(testCase.input);
const testName = printableVersion !== testCase.input ?
`${testCase.input} (${printableString(testCase.input)})` :
testCase.input;

test(testName, () => {
const parsed = parse(testCase.input);

if (testCase.output === null) {
expect(parsed).toEqual(null);
} else {
const serialized = serialize(parsed);
expect(serialized).toEqual(testCase.output);

const charset = parsed.parameters.get("charset");
const encoding = encodingLabelToName(charset);
if (testCase.encoding !== null && testCase.encoding !== undefined) {
expect(encoding).toEqual(testCase.encoding);
} else {
expect(encoding).toEqual(null);
}
}
});
}
}