Initial commit: basic homepage with routing
This commit is contained in:
commit
8eed5b60da
|
@ -0,0 +1,16 @@
|
|||
# Formats Exposed
|
||||
|
||||
To develop, start a static file server in `public/`.
|
||||
|
||||
To deploy, copy the files from `public/` to a static file host.
|
||||
|
||||
To run tests, run `deno test`.
|
||||
|
||||
To format code, run `deno fmt`.
|
||||
|
||||
To lint, run `deno lint`.
|
||||
|
||||
To run type checks, run `deno task typecheck`.
|
||||
|
||||
To run all checks (tests, linting, code formatting, and type checks), run
|
||||
`deno task check-all`.
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"tasks": {
|
||||
"typecheck": "deno check **/*.js **/*.ts",
|
||||
"check-all": "deno fmt --check && deno lint && deno task typecheck && deno test --allow-read"
|
||||
},
|
||||
"imports": {
|
||||
"assert": "https://deno.land/std@0.196.0/testing/asserts.ts"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": ["deno.ns", "dom"]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"version": "2",
|
||||
"remote": {
|
||||
"https://deno.land/std@0.196.0/_util/diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea",
|
||||
"https://deno.land/std@0.196.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9",
|
||||
"https://deno.land/std@0.196.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7",
|
||||
"https://deno.land/std@0.196.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
|
||||
"https://deno.land/std@0.196.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c",
|
||||
"https://deno.land/std@0.196.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9",
|
||||
"https://deno.land/std@0.196.0/assert/assert_equals.ts": "a0ee60574e437bcab2dcb79af9d48dc88845f8fd559468d9c21b15fd638ef943",
|
||||
"https://deno.land/std@0.196.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7",
|
||||
"https://deno.land/std@0.196.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc",
|
||||
"https://deno.land/std@0.196.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8",
|
||||
"https://deno.land/std@0.196.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9",
|
||||
"https://deno.land/std@0.196.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b",
|
||||
"https://deno.land/std@0.196.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754",
|
||||
"https://deno.land/std@0.196.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22",
|
||||
"https://deno.land/std@0.196.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0",
|
||||
"https://deno.land/std@0.196.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad",
|
||||
"https://deno.land/std@0.196.0/assert/assert_object_match.ts": "27439c4f41dce099317566144299468ca822f556f1cc697f4dc8ed61fe9fee4c",
|
||||
"https://deno.land/std@0.196.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057",
|
||||
"https://deno.land/std@0.196.0/assert/assert_strict_equals.ts": "5cf29b38b3f8dece95287325723272aa04e04dbf158d886d662fa594fddc9ed3",
|
||||
"https://deno.land/std@0.196.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c",
|
||||
"https://deno.land/std@0.196.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd",
|
||||
"https://deno.land/std@0.196.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
|
||||
"https://deno.land/std@0.196.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece",
|
||||
"https://deno.land/std@0.196.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278",
|
||||
"https://deno.land/std@0.196.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee",
|
||||
"https://deno.land/std@0.196.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a",
|
||||
"https://deno.land/std@0.196.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536",
|
||||
"https://deno.land/std@0.196.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc",
|
||||
"https://deno.land/std@0.196.0/testing/asserts.ts": "b4e4b1359393aeff09e853e27901a982c685cb630df30426ed75496961931946"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* Create a DOM element. Inspired by [Crel][0].
|
||||
* [0]: https://npm.im/crel
|
||||
*
|
||||
* @param {string} tagName
|
||||
* @param {object} [attributes={}]
|
||||
* @param {...(string | Node)} children
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export default function crel(tagName, attributes = {}, ...children) {
|
||||
const el = document.createElement(tagName);
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
|
||||
el.append(...children);
|
||||
|
||||
return el;
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
font-size: 18pt;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: mediumblue;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
font: inherit;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>formats.exposed</title>
|
||||
<link rel="stylesheet" href="common/global.css">
|
||||
<link rel="stylesheet" href="index/index.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<h1>formats.exposed</h1>
|
||||
|
||||
<p>Learn what makes up your files.<noscript> Sorry, but you need JavaScript to use this website.</noscript></p>
|
||||
|
||||
<div id="app"></div>
|
||||
<script src="index/index.js" type="module"></script>
|
||||
|
||||
<p>
|
||||
Created by
|
||||
<a href="https://evanhahn.com" target="_blank" rel="noreferrer noopener">Evan Hahn</a>
|
||||
for
|
||||
<a href="https://handmade.network/" target="_blank" rel="noreferrer noopener">Handmade Network</a>.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,23 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {object} SupportedFileType
|
||||
* @prop {string} name The English name of a file type.
|
||||
* @prop {string[]} extensions File extension for this type, with the leading dot.
|
||||
* @prop {string} mimeType MIME type for this file type.
|
||||
* @prop {string} route The route to the file type's page.
|
||||
* @prop {ArrayLike<number>} mimeSniffBytePattern The byte pattern to use for MIME sniffing, lifted from the "MIME Sniffing" spec.
|
||||
* @prop {ArrayLike<number>} mimeSniffPatternMask The pattern mask to use for MIME sniffing, lifted from the "MIME Sniffing" spec.
|
||||
*/
|
||||
|
||||
/** @type {SupportedFileType[]} */
|
||||
export const SUPPORTED_FILE_TYPES = [
|
||||
{
|
||||
name: "PNG",
|
||||
extensions: [".png"],
|
||||
mimeType: "image/png",
|
||||
route: "/png",
|
||||
mimeSniffBytePattern: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
|
||||
mimeSniffPatternMask: [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
},
|
||||
];
|
|
@ -0,0 +1,67 @@
|
|||
// @ts-check
|
||||
|
||||
import { SUPPORTED_FILE_TYPES } from "./constants.js";
|
||||
|
||||
/**
|
||||
* Figure out the route for a given file.
|
||||
*
|
||||
* For example, PNG files should get routed to `/png`.
|
||||
*
|
||||
* @param {File} file
|
||||
* @returns {Promise<null | string>} The route for the file, or null if the file is not supported.
|
||||
*/
|
||||
export const routeFile = async (file) => {
|
||||
const fileType = findFileTypeUsingMimeType(file.type) ??
|
||||
fineFileTypeUsingFileName(file.name) ??
|
||||
(await findFileTypeWithMimeSniffing(file));
|
||||
return fileType ? fileType.route : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} mimeType
|
||||
*/
|
||||
const findFileTypeUsingMimeType = (mimeType) =>
|
||||
SUPPORTED_FILE_TYPES.find((t) => t.mimeType === mimeType);
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
const fineFileTypeUsingFileName = (name) => {
|
||||
if (!name.includes(".")) return;
|
||||
return SUPPORTED_FILE_TYPES.find((fileType) =>
|
||||
fileType.extensions.some((extension) => name.endsWith(extension))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* See the ["MIME Sniffing" spec][0].
|
||||
* [0]: https://mimesniff.spec.whatwg.org
|
||||
* @param {Blob} file
|
||||
*/
|
||||
const findFileTypeWithMimeSniffing = async (file) => {
|
||||
// We only need to sample the first few bytes.
|
||||
const sampleLength = SUPPORTED_FILE_TYPES
|
||||
.map((t) => t.mimeSniffBytePattern.length)
|
||||
.reduce((a, b) => Math.max(a, b), 0);
|
||||
const sampleAsBlob = file.slice(0, sampleLength);
|
||||
const sampleAsBuffer = await sampleAsBlob.arrayBuffer();
|
||||
const sample = new Uint8Array(sampleAsBuffer);
|
||||
|
||||
return SUPPORTED_FILE_TYPES.find((fileType) => {
|
||||
// Roughly follows the [pattern matching algorithm from the spec][1].
|
||||
// [1]: https://mimesniff.spec.whatwg.org/#pattern-matching-algorithm
|
||||
|
||||
const { mimeSniffBytePattern, mimeSniffPatternMask } = fileType;
|
||||
|
||||
console.assert(mimeSniffBytePattern.length === mimeSniffPatternMask.length);
|
||||
|
||||
if (sample.length < mimeSniffBytePattern.length) return false;
|
||||
|
||||
for (let p = 0; p < mimeSniffBytePattern.length; p++) {
|
||||
const maskedData = sample[p] & mimeSniffPatternMask[p];
|
||||
if (maskedData !== mimeSniffBytePattern[p]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
body {
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
noscript {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
// @ts-check
|
||||
|
||||
import { SUPPORTED_FILE_TYPES } from "./constants.js";
|
||||
import crel from "../common/crel.js";
|
||||
import { routeFile } from "./fileRouter.js";
|
||||
|
||||
const accept = SUPPORTED_FILE_TYPES
|
||||
.flatMap((t) => [t.mimeType, ...t.extensions])
|
||||
.join(",");
|
||||
const inputEl = /** @type {HTMLInputElement} */ (
|
||||
crel("input", {
|
||||
type: "file",
|
||||
id: "file-input",
|
||||
accept,
|
||||
})
|
||||
);
|
||||
|
||||
const supportedFileTypeNameString = SUPPORTED_FILE_TYPES
|
||||
.map((t, index, array) => (
|
||||
(array.length > 1 && (index === array.length - 1))
|
||||
? `and ${t.name}`
|
||||
: t.name
|
||||
))
|
||||
.join(", ");
|
||||
const labelParagraphEl = crel(
|
||||
"p",
|
||||
{},
|
||||
crel(
|
||||
"label",
|
||||
{ "for": "file-input" },
|
||||
`Upload something! Supports ${supportedFileTypeNameString} files, with more on the way.`,
|
||||
),
|
||||
);
|
||||
|
||||
const disclaimerParagraphEl = crel(
|
||||
"p",
|
||||
{},
|
||||
crel("small", {}, "Your files do not leave your computer."),
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {Blob} blob
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const formatBytes = async (blob) => {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
const string = String.fromCharCode(...bytes);
|
||||
const base64 = btoa(string);
|
||||
return base64
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "");
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
const appEl = document.getElementById("app");
|
||||
if (!appEl) throw new Error("HTML is not set up correctly");
|
||||
|
||||
inputEl.addEventListener("change", async () => {
|
||||
const file = inputEl.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// TODO: Prevent large files.
|
||||
|
||||
const route = await routeFile(file);
|
||||
if (!route) {
|
||||
console.warn(
|
||||
"Uploaded a file that was accepted but not routed. This may indicate a bug.",
|
||||
);
|
||||
// TODO: Show something better than this.
|
||||
alert("Unsupported file type.");
|
||||
return;
|
||||
}
|
||||
|
||||
location.href = route + "#" + JSON.stringify({
|
||||
name: file.name,
|
||||
bytes: await formatBytes(file),
|
||||
});
|
||||
});
|
||||
|
||||
appEl.append(labelParagraphEl, inputEl, disclaimerParagraphEl);
|
||||
};
|
||||
|
||||
main();
|
|
@ -0,0 +1,22 @@
|
|||
import { assertEquals } from "assert";
|
||||
import { SUPPORTED_FILE_TYPES } from "../../public/index/constants.js";
|
||||
|
||||
Deno.test("no duplicate extensions in supported file types", () => {
|
||||
const allExtensions = SUPPORTED_FILE_TYPES.flatMap((t) => t.extensions);
|
||||
assertEquals(
|
||||
allExtensions.length,
|
||||
new Set(allExtensions).size,
|
||||
"Duplicate extensions found in SUPPORTED_FILE_TYPES",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("MIME sniffing patterns are the same size as their masks", () => {
|
||||
for (const fileType of SUPPORTED_FILE_TYPES) {
|
||||
const { name, mimeSniffBytePattern, mimeSniffPatternMask } = fileType;
|
||||
assertEquals(
|
||||
mimeSniffBytePattern.length,
|
||||
mimeSniffPatternMask.length,
|
||||
`Pattern and mask for ${name} are not the same size`,
|
||||
);
|
||||
}
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import { assertEquals } from "assert";
|
||||
import { routeFile } from "../../public/index/fileRouter.js";
|
||||
|
||||
Deno.test("routes files correctly", async () => {
|
||||
const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
const testCases = new Map<File, null | string>([
|
||||
// No matching type
|
||||
[new File([], "foo"), null],
|
||||
[new File([], "foo.txt"), null],
|
||||
[new File([], "foo.txt", { type: "text/plain" }), null],
|
||||
// PNG
|
||||
[new File([], "foo", { type: "image/png" }), "/png"],
|
||||
[new File([], "foo.png"), "/png"],
|
||||
[new File([], "foo.png", { type: "text/plain" }), "/png"],
|
||||
[new File([PNG_SIGNATURE], "foo"), "/png"],
|
||||
]);
|
||||
|
||||
for (const [file, expected] of testCases) {
|
||||
const actual = await routeFile(file);
|
||||
assertEquals(
|
||||
actual,
|
||||
expected,
|
||||
`Expected file (${JSON.stringify(file.name)}, type ${
|
||||
JSON.stringify(file.type)
|
||||
}, sized ${file.size}b) to route to ${expected} (got ${actual})`,
|
||||
);
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue