Initial commit: basic homepage with routing

This commit is contained in:
Evan Hahn 2023-07-31 21:16:15 -05:00
commit 8eed5b60da
12 changed files with 361 additions and 0 deletions

16
README.md Normal file
View File

@ -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`.

12
deno.json Normal file
View File

@ -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"]
}
}

34
deno.lock Normal file
View File

@ -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"
}
}

22
public/common/crel.js Normal file
View File

@ -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;
}

17
public/common/global.css Normal file
View File

@ -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;
}

25
public/index.html Normal file
View File

@ -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>

23
public/index/constants.js Normal file
View File

@ -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],
},
];

View File

@ -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;
});
};

9
public/index/index.css Normal file
View File

@ -0,0 +1,9 @@
body {
margin: 0 auto;
padding: 1rem;
width: 600px;
}
noscript {
font-weight: bold;
}

85
public/index/index.js Normal file
View File

@ -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();

View File

@ -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`,
);
}
});

View File

@ -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})`,
);
}
});