diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1a6bd45
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+package-lock.json binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9ed75c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,104 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# Next.js build output
+.next
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and *not* Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
diff --git a/Gruntfile.js b/Gruntfile.js
new file mode 100644
index 0000000..acad4ee
--- /dev/null
+++ b/Gruntfile.js
@@ -0,0 +1,112 @@
+const path = require("path");
+
+module.exports = grunt => {
+ grunt.initConfig({
+ pkg: grunt.file.readJSON("package.json"),
+ clean: {
+ default: ["dist/"],
+ },
+ copy: {
+ html: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "dist/"}]
+ },
+ },
+ focus: {
+ dev: {
+ include: ["html", "js", "link"],
+ },
+ },
+ replace: {
+ dev: {
+ src: ["./dist/*.html", "./dist/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>+" + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")
+ }
+ ],
+ overwrite: true
+ },
+ deploy: {
+ src: ["./dist/*.html", "./dist/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>"
+ }
+ ],
+ overwrite: true
+ },
+ },
+ watch: {
+ html: {
+ files: ["src/main/**/*.html"],
+ tasks: ["copy:html"],
+ },
+ js: {
+ files: ["src/main/**/*.js"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
+ link: {
+ files: ["node_modules/@fwdekker/*/dist/**"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
+ },
+ webpack: {
+ options: {
+ entry: "./src/main/js/index.js",
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ exclude: /node_modules/,
+ },
+ ],
+ },
+ resolve: {
+ extensions: [".js"],
+ },
+ output: {
+ filename: "bundle.js",
+ path: path.resolve(__dirname, "dist/"),
+ },
+ },
+ dev: {
+ mode: "development",
+ devtool: "inline-source-map",
+ },
+ deploy: {
+ mode: "production",
+ },
+ },
+ });
+
+ grunt.loadNpmTasks("grunt-contrib-clean");
+ grunt.loadNpmTasks("grunt-contrib-copy");
+ grunt.loadNpmTasks("grunt-contrib-watch");
+ grunt.loadNpmTasks("grunt-focus");
+ grunt.loadNpmTasks("grunt-text-replace");
+ grunt.loadNpmTasks("grunt-webpack");
+
+ grunt.registerTask("dev", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:html",
+ // Compile JS
+ "webpack:dev",
+ "replace:dev",
+ ]);
+ grunt.registerTask("dev:server", ["dev", "focus:dev"]);
+ grunt.registerTask("deploy", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:html",
+ // Compile JS
+ "webpack:deploy",
+ "replace:deploy",
+ ]);
+
+ grunt.registerTask("default", ["dev"]);
+};
diff --git a/README.md b/README.md
index c2a30f0..adc9f04 100644
--- a/README.md
+++ b/README.md
@@ -2,3 +2,23 @@
An online tool to convert values between number systems.
Quickly convert hexadecimal to binary or base64 to ASCII.
Everything happens in the browser, so nothing is submitted to any server.
+
+## Development
+### Requirements
+* [npm](https://www.npmjs.com/)
+
+### Setting up
+```shell script
+# Install dependencies (only needed once)
+$> npm ci
+```
+
+### Building
+```shell script
+# Build the tool in `dist/` for development
+$> npm run dev
+# Same as above, but automatically rerun it whenever files are changed
+$> npm run dev:server
+# Build the tool in `dist/` for deployment
+$> npm run deploy
+```
diff --git a/index.html b/index.html
deleted file mode 100644
index 78fc9be..0000000
--- a/index.html
+++ /dev/null
@@ -1,290 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Converter | FWDekker
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..6903f20
Binary files /dev/null and b/package-lock.json differ
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..caa027f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "converter",
+ "version": "1.4.7",
+ "description": "Convert numbers to and from various bases.",
+ "author": "Felix W. Dekker",
+ "browser": "dist/bundle.js",
+ "repository": {
+ "type": "git",
+ "url": "git@git.fwdekker.com:FWDekker/converter.git"
+ },
+ "private": true,
+ "scripts": {
+ "clean": "grunt clean",
+ "dev": "grunt dev",
+ "dev:server": "grunt dev:server",
+ "deploy": "grunt deploy"
+ },
+ "dependencies": {
+ "@fwdekker/template": "^0.0.14",
+ "big-integer": "^1.6.48"
+ },
+ "devDependencies": {
+ "grunt": "^1.1.0",
+ "grunt-cli": "^1.3.2",
+ "grunt-contrib-clean": "^2.0.0",
+ "grunt-contrib-copy": "^1.0.0",
+ "grunt-contrib-watch": "^1.1.0",
+ "grunt-focus": "^1.0.0",
+ "grunt-text-replace": "^0.4.0",
+ "grunt-webpack": "^3.1.3",
+ "webpack": "^4.42.1",
+ "webpack-cli": "^3.3.11"
+ }
+}
diff --git a/src/main/index.html b/src/main/index.html
new file mode 100644
index 0000000..d29e23f
--- /dev/null
+++ b/src/main/index.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+ Converter | FWDekker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/js/index.js b/src/main/js/index.js
new file mode 100644
index 0000000..e0a1a9d
--- /dev/null
+++ b/src/main/js/index.js
@@ -0,0 +1,241 @@
+import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template";
+import bigInt from "big-integer"
+
+
+/**
+ * Replaces the character at the given index with the given replacement.
+ *
+ * @param str the string to replace in
+ * @param index the index in the given string to replace at
+ * @param replacement the replacement to insert into the string
+ * @returns {string} the input string with one character replaced
+ */
+const stringReplaceAt = (str, index, replacement) =>
+ str.substr(0, index) + replacement + str.substr(index + replacement.length);
+
+/**
+ * Replaces all instances of the target with the replacement.
+ *
+ * @param str the string to replace in
+ * @param target the character to replace
+ * @param replacement the replacement to insert into the string
+ * @returns {string} the input string with all instances of the target replaced
+ */
+const stringReplaceAll = (str, target, replacement) =>
+ str.split(target).join(replacement);
+
+/**
+ * Runs `stringReplaceAll` for each character in `targets` and `replacements`.
+ *
+ * @param str the string to replace in
+ * @param targets the characters to replace
+ * @param replacements the replacements to insert into the string; each character here corresponds to a character
+ * in the targets string
+ * @returns {string} the input string with all instances of the targets replaced
+ */
+const stringReplaceAlls = (str, targets, replacements) =>
+ Array.from(targets).reduce((output, target, index) =>
+ stringReplaceAll(output, target, replacements[index]), str);
+
+
+class NumeralSystem {
+ constructor(base, alphabet, caseSensitive) {
+ this.base = base;
+ this.alphabet = alphabet;
+ this.caseSensitive = caseSensitive;
+ }
+
+
+ decimalToBase(decimalNumber) {
+ return decimalNumber.toString(this.base, this.alphabet);
+ }
+
+ baseToDecimal(baseString) {
+ return bigInt(baseString, this.base, this.alphabet, this.caseSensitive);
+ }
+
+ filterBaseString(baseString) {
+ // Regex from https://stackoverflow.com/a/3561711/
+ const alphabet = this.caseSensitive
+ ? this.alphabet
+ : this.alphabet.toLowerCase() + this.alphabet.toUpperCase();
+ const regexSafeAlphabet = alphabet.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
+
+ return baseString.replace(new RegExp(`[^${regexSafeAlphabet}]`, "g"), "");
+ }
+}
+
+class NumeralSystemInput {
+ constructor(name, numeralSystem) {
+ this.name = name;
+ this.numeralSystem = numeralSystem;
+
+ this.label = document.createElement("label");
+ this.label.setAttribute("for", `${this.name}Input`);
+ this.label.innerHTML = this.name;
+
+ this.textarea = document.createElement("textarea");
+ this.textarea.id = `${this.name}Input`;
+ this.textarea.className = "numberInput";
+ this.textarea.oninput = () => {
+ if (this.textarea.value === undefined || this.textarea.value === null || this.textarea.value === "")
+ return;
+
+ this.textarea.value = this.numeralSystem.filterBaseString(this.textarea.value);
+ updateAllInputs(this, this.numeralSystem.baseToDecimal(this.textarea.value));
+ };
+ }
+
+
+ addToParent(parent) {
+ parent.appendChild(this.label);
+ parent.appendChild(this.textarea);
+ }
+
+ update(decimalNumber) {
+ this.textarea.value = this.numeralSystem.decimalToBase(decimalNumber);
+ }
+}
+
+class Base64NumeralSystem extends NumeralSystem {
+ // TODO Convert static methods to static properties once supported by Firefox
+ static defaultAlphabet() {
+ return "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
+ }
+
+
+ /**
+ * Constructs a new base 64 numeral system.
+ *
+ * @param alphabet the 64 characters to encode numbers with, and the padding character at the end
+ */
+ constructor(alphabet) {
+ super(64, alphabet, true);
+ }
+
+
+ decimalToBase(decimalNumber) {
+ const hex = decimalNumber.toString(16);
+ const b64 = Array.from(hex.padStart(hex.length + hex.length % 2))
+ .reduce((result, value, index, array) => {
+ if (index % 2 === 0) result.push(array.slice(index, index + 2));
+ return result;
+ }, [])
+ .map(pair => String.fromCharCode(parseInt(pair.join(""), 16)))
+ .join("");
+
+ return stringReplaceAlls(btoa(b64), Base64NumeralSystem.defaultAlphabet(), this.alphabet);
+ }
+
+ baseToDecimal(baseString) {
+ if (baseString.length % 4 === 1) throw new Error("Invalid input string length.");
+
+ const normalBaseString = stringReplaceAlls(baseString, this.alphabet, Base64NumeralSystem.defaultAlphabet());
+ const hex = Array.from(atob(normalBaseString))
+ .map(char => char.charCodeAt(0).toString(16).padStart(2, "0")).join("");
+ return bigInt(hex, 16);
+ }
+}
+
+class Base64NumeralSystemInput extends NumeralSystemInput {
+ // TODO Convert static methods to static properties once supported by Firefox
+ static dropdownOptions() {
+ return {"Standard": ['+', '/'], "Filename": ['-', '_'], "IMAP": ['+', ',']};
+ }
+
+
+ constructor(name) {
+ super(name, new Base64NumeralSystem(Base64NumeralSystem.defaultAlphabet()));
+
+ this.dropdown = document.createElement("select");
+ this.dropdown.id = `${this.name}Dropdown`;
+ this.dropdown.onchange = () => {
+ const selectedOption = Base64NumeralSystemInput.dropdownOptions()[this.dropdown.value];
+ this.setLastDigits(selectedOption[0], selectedOption[1]);
+ };
+
+ this.dropdownDiv = document.createElement("div");
+ this.dropdownDiv.classList.add("float-right");
+
+ this.options =
+ Object.keys(Base64NumeralSystemInput.dropdownOptions()).map(key => {
+ const option = document.createElement("option");
+ option.value = key;
+ option.text = key + ": " + Base64NumeralSystemInput.dropdownOptions()[key].join("");
+ return option;
+ });
+ }
+
+
+ setLastDigits(c62, c63) {
+ const oc62 = this.numeralSystem.alphabet[62];
+ const oc63 = this.numeralSystem.alphabet[63];
+
+ this.numeralSystem.alphabet =
+ stringReplaceAt(stringReplaceAt(this.numeralSystem.alphabet, 62, c62), 63, c63);
+ this.textarea.value =
+ stringReplaceAll(stringReplaceAll(this.textarea.value, oc62, c62), oc63, c63);
+ }
+
+ addToParent(parent) {
+ this.options.forEach(option => this.dropdown.appendChild(option));
+ this.dropdownDiv.appendChild(this.dropdown);
+ parent.appendChild(this.dropdownDiv);
+
+ parent.appendChild(this.label);
+ parent.appendChild(this.textarea);
+ }
+}
+
+
+const inputs = [
+ new NumeralSystemInput("Binary", new NumeralSystem(2, "01")),
+ new NumeralSystemInput("Octal", new NumeralSystem(8, "01234567")),
+ new NumeralSystemInput("Decimal", new NumeralSystem(10, "0123456789")),
+ new NumeralSystemInput("Duodecimal", new NumeralSystem(12, "0123456789ab", false)),
+ new NumeralSystemInput("Hexadecimal", new NumeralSystem(16, "0123456789abcdef", false)),
+ new Base64NumeralSystemInput("Base64"),
+ new NumeralSystemInput(
+ "ASCII",
+ new NumeralSystem(
+ 256,
+ new Array(256).fill(0).map((_, it) => String.fromCharCode(it)).join(""),
+ true
+ )
+ ),
+];
+
+const updateAllInputs = (source, newValue) => {
+ for (const input of inputs)
+ if (input !== source)
+ input.update(newValue);
+};
+
+
+doAfterLoad(() => {
+ $("#nav").appendChild(nav());
+ $("#header").appendChild(header({
+ title: "Converter",
+ description: "Convert numbers to and from various bases"
+ }));
+ $("#footer").appendChild(footer({
+ author: "Felix W. Dekker",
+ authorURL: "https://fwdekker.com/",
+ license: "MIT License",
+ licenseURL: "https://git.fwdekker.com/FWDekker/converter/src/branch/master/LICENSE",
+ vcs: "git",
+ vcsURL: "https://git.fwdekker.com/FWDekker/converter/",
+ version: "v%%VERSION_NUMBER%%"
+ }));
+ $("main").style.display = null;
+});
+
+doAfterLoad(() => {
+ const inputParent = $("#inputs");
+
+ for (const input of inputs)
+ input.addToParent(inputParent);
+
+ updateAllInputs(undefined, bigInt(42));
+ inputs[0].textarea.focus();
+});