diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..236c7c0
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "vendor/prism"]
+ path = vendor/prism
+ url = https://github.com/PrismJS/prism.git
diff --git a/dataoptions.js b/dataoptions.js
new file mode 100644
index 0000000..aa067a6
--- /dev/null
+++ b/dataoptions.js
@@ -0,0 +1,81 @@
+import { PLAINTEXT, LANGUAGES } from "./languages.js";
+
+/**
+ * Data options are stored in a binary header before the payload.
+ *
+ * Header format:
+ *
+ * Byte 1
+ * |xxx----y|
+ * x = amount of extra bytes (other than this byte) in the header (uint)
+ * y = highest bit of language ID (the rest in byte 2)
+ *
+ * Byte 2 (+ lowest bit of byte 1) is language ID (uint), i.e. index of the
+ * language in the LANGUAGE_NAMES list.
+ *
+ * ---
+ *
+ * NOTE: If options are set to their default values, the header is minimised
+ * to not include those bytes if possible.
+ */
+export class DataOptions {
+ language = PLAINTEXT;
+
+ /**
+ * Parse options from uncompressed bytes.
+ * @param {Uint8Array} data
+ * @returns {Uint8Array} The data without the header.
+ */
+ parseFrom(data) {
+ const byte1 = data[0];
+ const totalBytes = (byte1 & 0b11100000) >>> 5;
+
+ if (totalBytes >= 1) {
+ const languageIDLowByte = data[1];
+ const languageIDHighBit = (byte1 & 0b00000001);
+ const languageID = (languageIDHighBit << 8) | languageIDLowByte;
+
+ if (LANGUAGES.has(languageID)) {
+ this.language = LANGUAGES.get(languageID);
+ } else {
+ this.language = PLAINTEXT;
+ }
+ } else {
+ this.language = PLAINTEXT;
+ }
+
+ return data.subarray(totalBytes + 1);
+ }
+
+ /**
+ * Serialize options to uncompressed bytes.
+ * @param {Uint8Array} data
+ * @returns {Uint8Array} Data with the options in a header.
+ */
+ serializeTo(data) {
+ let byte1LowBit = null;
+ const extra_bytes = [];
+
+ if (this.language !== PLAINTEXT) {
+ const languageID = LANGUAGES.get(this.language);
+ const languageIDLowByte = languageID & 0b011111111;
+ const languageIDHighBit = languageID & 0b100000000;
+ byte1LowBit = languageIDHighBit >>> 8;
+
+ extra_bytes.unshift(languageIDLowByte);
+ }
+
+ let byte1 = (extra_bytes.length & 0b00000111) << 5;
+ if (byte1LowBit !== null) {
+ byte1 |= byte1LowBit;
+ }
+
+ const headerBytes = new Uint8Array([byte1, ...extra_bytes]);
+
+ const combined = new Uint8Array(1 + extra_bytes.length + data.length);
+ combined.set(headerBytes, 0);
+ combined.set(data, headerBytes.length);
+
+ return combined;
+ }
+}
diff --git a/index.html b/index.html
index 9598205..ebfcf5b 100644
--- a/index.html
+++ b/index.html
@@ -8,21 +8,34 @@
Tahnaroskakori
+
+
-
+
- Tahnaroskakori
+
-
+
+
+
+
+ Loading...
+
+
+
+
diff --git a/languages.js b/languages.js
new file mode 100644
index 0000000..f1991c1
--- /dev/null
+++ b/languages.js
@@ -0,0 +1,345 @@
+/**
+ * Language names from Prism.js files
+ */
+export const LANGUAGE_NAMES = [
+ "plaintext",
+ "abap",
+ "abnf",
+ "actionscript",
+ "ada",
+ "agda",
+ "al",
+ "antlr4",
+ "apacheconf",
+ "apex",
+ "apl",
+ "applescript",
+ "aql",
+ "arduino",
+ "arff",
+ "armasm",
+ "arturo",
+ "asciidoc",
+ "asm6502",
+ "asmatmel",
+ "aspnet",
+ "autohotkey",
+ "autoit",
+ "avisynth",
+ "avro-idl",
+ "awk",
+ "bash",
+ "basic",
+ "batch",
+ "bbcode",
+ "bbj",
+ "bicep",
+ "birb",
+ "bison",
+ "bnf",
+ "bqn",
+ "brainfuck",
+ "brightscript",
+ "bro",
+ "bsl",
+ "c",
+ "cfscript",
+ "chaiscript",
+ "cil",
+ "cilkc",
+ "cilkcpp",
+ "clike",
+ "clojure",
+ "cmake",
+ "cobol",
+ "coffeescript",
+ "concurnas",
+ "cooklang",
+ "coq",
+ "cpp",
+ "crystal",
+ "csharp",
+ "cshtml",
+ "csp",
+ // "css-extras",
+ "css",
+ "csv",
+ "cue",
+ "cypher",
+ "d",
+ "dart",
+ "dataweave",
+ "dax",
+ "dhall",
+ "diff",
+ "django",
+ "dns-zone-file",
+ "docker",
+ "dot",
+ "ebnf",
+ "editorconfig",
+ "eiffel",
+ "ejs",
+ "elixir",
+ "elm",
+ "erb",
+ "erlang",
+ "etlua",
+ "excel-formula",
+ "factor",
+ "false",
+ "firestore-security-rules",
+ "flow",
+ "fortran",
+ "fsharp",
+ "ftl",
+ "gap",
+ "gcode",
+ "gdscript",
+ "gedcom",
+ "gettext",
+ "gherkin",
+ "git",
+ "glsl",
+ "gml",
+ "gn",
+ "go-module",
+ "go",
+ "gradle",
+ "graphql",
+ "groovy",
+ "haml",
+ "handlebars",
+ "haskell",
+ "haxe",
+ "hcl",
+ "hlsl",
+ "hoon",
+ "hpkp",
+ "hsts",
+ "http",
+ "ichigojam",
+ "icon",
+ "icu-message-format",
+ "idris",
+ "iecst",
+ "ignore",
+ "inform7",
+ "ini",
+ "io",
+ "j",
+ "java",
+ "javadoc",
+ "javadoclike",
+ "javascript",
+ "javastacktrace",
+ "jexl",
+ "jolie",
+ "jq",
+ "js-extras",
+ "js-templates",
+ "jsdoc",
+ "json",
+ "json5",
+ "jsonp",
+ "jsstacktrace",
+ "jsx",
+ "julia",
+ "keepalived",
+ "keyman",
+ "kotlin",
+ "kumir",
+ "kusto",
+ "latex",
+ "latte",
+ "less",
+ "lilypond",
+ "linker-script",
+ "liquid",
+ "lisp",
+ "livescript",
+ "llvm",
+ "log",
+ "lolcode",
+ "lua",
+ "magma",
+ "makefile",
+ "markdown",
+ "markup-templating",
+ "markup",
+ "mata",
+ "matlab",
+ "maxscript",
+ "mel",
+ "mermaid",
+ "metafont",
+ "mizar",
+ "mongodb",
+ "monkey",
+ "moonscript",
+ "n1ql",
+ "n4js",
+ "nand2tetris-hdl",
+ "naniscript",
+ "nasm",
+ "neon",
+ "nevod",
+ "nginx",
+ "nim",
+ "nix",
+ "nsis",
+ "objectivec",
+ "ocaml",
+ "odin",
+ "opencl",
+ "openqasm",
+ "oz",
+ "parigp",
+ "parser",
+ "pascal",
+ "pascaligo",
+ "pcaxis",
+ "peoplecode",
+ "perl",
+ // "php-extras",
+ "php",
+ "phpdoc",
+ "plant-uml",
+ "plsql",
+ "powerquery",
+ "powershell",
+ "processing",
+ "prolog",
+ "promql",
+ "properties",
+ "protobuf",
+ "psl",
+ "pug",
+ "puppet",
+ "pure",
+ "purebasic",
+ "purescript",
+ "python",
+ "q",
+ "qml",
+ "qore",
+ "qsharp",
+ "r",
+ "racket",
+ "reason",
+ "regex",
+ "rego",
+ "renpy",
+ "rescript",
+ "rest",
+ "rip",
+ "roboconf",
+ "robotframework",
+ "ruby",
+ "rust",
+ "sas",
+ "sass",
+ "scala",
+ "scheme",
+ "scss",
+ "shell-session",
+ "smali",
+ "smalltalk",
+ "smarty",
+ "sml",
+ "solidity",
+ "solution-file",
+ "soy",
+ "sparql",
+ "splunk-spl",
+ "sqf",
+ "sql",
+ "squirrel",
+ "stan",
+ "stata",
+ "stylus",
+ "supercollider",
+ "swift",
+ "systemd",
+ "t4-cs",
+ "t4-templating",
+ "t4-vb",
+ "tap",
+ "tcl",
+ "textile",
+ "toml",
+ "tremor",
+ "tsx",
+ "tt2",
+ "turtle",
+ "twig",
+ "typescript",
+ "typoscript",
+ "unrealscript",
+ "uorazor",
+ "uri",
+ "v",
+ "vala",
+ "vbnet",
+ "velocity",
+ "verilog",
+ "vhdl",
+ "vim",
+ "visual-basic",
+ "warpscript",
+ "wasm",
+ "web-idl",
+ "wgsl",
+ "wiki",
+ "wolfram",
+ "wren",
+ "xeora",
+ "xml-doc",
+ "xojo",
+ "xquery",
+ "yaml",
+ "yang",
+ "zig",
+];
+
+export const LANGUAGES = new Map();
+
+for (let i = 0; i < LANGUAGE_NAMES.length; ++i) {
+ LANGUAGES.set(i, LANGUAGE_NAMES[i]);
+ LANGUAGES.set(LANGUAGE_NAMES[i], i);
+}
+
+export const PLAINTEXT = LANGUAGE_NAMES[0];
+
+export async function getDepsData() {
+ const resp = await fetch("./vendor/prism/components.json");
+ const depsData = await resp.json();
+ const ret = new Map();
+ for (const [lang, data] of Object.entries(depsData.languages)) {
+ if ("require" in data) {
+ const req = Array.isArray(data.require) ? data.require : [data.require];
+ ret.set(lang, req);
+ }
+ }
+
+ return ret;
+}
+
+export function getDependenciesForLanguage(depsData, language) {
+ const ret = [];
+ const langsToProcess = [language];
+
+ while (langsToProcess.length > 0) {
+ const currentLang = langsToProcess.shift();
+ const deps = depsData.get(currentLang) ?? [];
+
+ for (const dep of deps) {
+ langsToProcess.push(dep);
+
+ // Deps have to be loaded first
+ ret.unshift(dep);
+ }
+ }
+
+ return Array.from(new Set(ret));
+}
diff --git a/licenses.txt b/licenses.txt
index 465d13a..222f4d2 100644
--- a/licenses.txt
+++ b/licenses.txt
@@ -215,3 +215,29 @@ brotli-wasm
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+
+---
+
+Prism.js
+
+MIT LICENSE
+
+Copyright (c) 2012 Lea Verou
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/tahnaroskakori.css b/tahnaroskakori.css
index d6980f6..589736a 100644
--- a/tahnaroskakori.css
+++ b/tahnaroskakori.css
@@ -1,5 +1,11 @@
@charset "utf-8";
+:root {
+ --bg-color: #111;
+ --bg-color-active: #333;
+ --color: #4c4;
+}
+
html,
body {
height: 100%;
@@ -11,9 +17,9 @@ body {
}
html,
-#code {
- background-color: #111;
- color: #4c4;
+#code-edit {
+ background-color: var(--bg-color);
+ color: var(--color);
}
main {
@@ -26,24 +32,67 @@ main {
display: flex;
flex-direction: column;
align-items: stretch;
+ row-gap: 5px;
margin: 10px;
}
-#code {
+#code-view,
+#code-edit {
flex-grow: 1;
- font-size: 1.2rem;
- font-family: monospace;
-
overflow-y: auto;
+}
+
+#code-view {
+ white-space: pre-wrap;
+}
+
+#code-view-content {
+ /* Fix line number alignment bug: https://github.com/PrismJS/prism/issues/1132#issue-225969024 */
+ display: inline-block;
+}
+
+#code-edit {
+ display: none;
+
+ font-family: monospace;
resize: none;
}
+button,
+select {
+ background-color: var(--bg-color);
+ color: var(--color);
+
+ font-family: monospace;
+}
+
+button {
+ font-size: 1.5rem;
+
+ padding: 0.7rem;
+
+ border: 1px solid var(--color);
+ border-radius: 5px;
+}
+
+button:active {
+ background-color: var(--bg-color-active);
+}
+
#length {
font-size: 1.2rem;
}
+header {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
footer {
display: flex;
flex-direction: row;
diff --git a/tahnaroskakori.js b/tahnaroskakori.js
index 11e811b..b6c0621 100644
--- a/tahnaroskakori.js
+++ b/tahnaroskakori.js
@@ -1,5 +1,13 @@
-import brotliInit, { compress, decompress, DecompressStream, BrotliStreamResult } from "./vendor/brotli_wasm.js";
+import brotliInit, { compress, DecompressStream, BrotliStreamResult } from "./vendor/brotli_wasm.js";
import base from "./vendor/base-x.js";
+import "./vendor/prism/prism.js";
+import "./vendor/prism/plugins/line-numbers/prism-line-numbers.js";
+import "./vendor/prism/dependencies.js";
+
+import { waitForLoad } from "./utils.js";
+import { DataOptions } from "./dataoptions.js";
+import { ViewMode } from "./viewmode.js";
+import { getDependenciesForLanguage, getDepsData, LANGUAGES, LANGUAGE_NAMES, PLAINTEXT } from "./languages.js";
const COMPRESS_WAIT = 500;
const ENCODER = new TextEncoder();
@@ -10,25 +18,107 @@ const MAX_URL = 2048;
const DECOMPRESS_CHUNK_SIZE = 1000;
const DECOMPRESS_CHUNK_TIMEOUT = 100;
+let languageDependencies = null;
let compressTimeout = null;
let rootURLSize = 0;
+
let statusEl = null;
-let codeEl = null;
+let codeEditEl = null;
+let codeViewEl = null;
+let codeViewContentEl = null;
+let viewModeSwitcherEl = null;
+let languageSelectEl = null;
+
+let currentCode = "";
+let dataOptions = new DataOptions();
+let viewMode = ViewMode.VIEW;
+
+function getLanguageClassName(language) {
+ if (language === PLAINTEXT) {
+ return "language-plain";
+ } else {
+ return `language-${language}`;
+ }
+}
/**
- * Returns a promise that resolves when the page DOM has been loaded.
- * @returns {Promise}
+ * Render all the language choices in the dropdown.
*/
-function waitForLoad() {
- return new Promise(resolve => {
- // If already loaded, fire immediately
- if (/complete|interactive|loaded/.test(document.readyState)) {
- resolve();
+function renderLanguageOptions() {
+ for (const language of LANGUAGE_NAMES) {
+ const option = document.createElement("option");
+ option.value = language;
+ option.textContent = language;
+ languageSelectEl.appendChild(option);
+ }
+}
+
+/**
+ * Use Prism to highlight the current language.
+ *
+ * The language definition will be imported if necessary.
+ */
+async function highlightLanguage() {
+ if (viewMode !== ViewMode.VIEW) {
+ return;
+ }
+
+ codeViewEl.classList.add(getLanguageClassName(dataOptions.language));
+
+ if (dataOptions.language !== PLAINTEXT) {
+ const deps = getDependenciesForLanguage(languageDependencies, dataOptions.language);
+ for (const dep of [...deps, dataOptions.language]) {
+ await import(`./vendor/prism/components/prism-${dep}.js`)
}
- else {
- document.addEventListener('DOMContentLoaded', resolve);
+
+ if (viewMode === ViewMode.VIEW) {
+ Prism.highlightElement(codeViewContentEl);
}
- });
+ } else {
+ Prism.highlightElement(codeViewContentEl);
+ }
+}
+
+/**
+ * Render the currently active code on the page.
+ */
+function renderCode() {
+ if (viewMode === ViewMode.VIEW) {
+ codeViewContentEl.textContent = currentCode;
+
+ highlightLanguage();
+ } else {
+ codeEditEl.value = currentCode;
+ }
+}
+
+/**
+ * Render the current view, hiding the other.
+ */
+function renderView() {
+ if (viewMode === ViewMode.VIEW) {
+ codeViewEl.style.display = "block";
+ codeEditEl.style.display = "none";
+ viewModeSwitcherEl.textContent = "✍️";
+ } else {
+ codeEditEl.style.display = "block";
+ codeViewEl.style.display = "none";
+ viewModeSwitcherEl.textContent = "👁";
+ }
+}
+
+/**
+ * Switch the mode between editing and viewing.
+ */
+function switchMode() {
+ if (viewMode === ViewMode.VIEW) {
+ viewMode = ViewMode.EDIT;
+ } else {
+ viewMode = ViewMode.VIEW;
+ }
+
+ renderView();
+ renderCode();
}
/**
@@ -46,12 +136,12 @@ function maxHashLength() {
}
/**
- * Compress the given data in a synchronous way (all at once) and update the status.
- * @param {string} data
+ * Compress the current code in a synchronous way (all at once) and update the status.
*/
-async function syncCompress(data) {
- const utf8 = ENCODER.encode(data);
- const compressed = compress(utf8, { quality: QUALITY });
+async function syncCompress() {
+ const utf8 = ENCODER.encode(currentCode);
+ const dataWithHeader = dataOptions.serializeTo(utf8);
+ const compressed = compress(dataWithHeader, { quality: QUALITY });
const encoded = BASECODEC.encode(compressed);
statusEl.textContent = `Length: ${utf8.length} B -> ${compressed.length} B -> ${encoded.length}/${maxHashLength()} chars`;
@@ -70,7 +160,9 @@ async function syncCompress(data) {
*/
async function streamDecompress(data) {
statusEl.textContent = "Initializing decompress...";
- codeEl.value = "";
+ currentCode = "";
+
+ let decompressedChunks = 0;
const inputStream = new ReadableStream({
start(controller) {
@@ -101,16 +193,33 @@ async function streamDecompress(data) {
}
});
- let decompressedChunks = 0;
+ const optionsPickerStream = new TransformStream({
+ firstChunk: false,
+ start() { },
+ transform(chunk, controller) {
+ if (!this.firstChunk) {
+ const rest = dataOptions.parseFrom(chunk);
+
+ languageSelectEl.value = dataOptions.language;
+
+ controller.enqueue(rest);
+ this.firstChunk = true;
+ } else {
+ controller.enqueue(chunk);
+ }
+ }
+ });
const textDecoderStream = new TextDecoderStream();
const outputStream = new WritableStream({
write(chunk) {
- codeEl.value += chunk;
+ currentCode += chunk;
++decompressedChunks;
statusEl.textContent = `Decompressing: ${decompressedChunks} chunks...`;
+ renderCode();
+
// Delay stream between every chunk to avoid zip bombing
return new Promise(resolve => setTimeout(resolve, DECOMPRESS_CHUNK_TIMEOUT));
}
@@ -118,40 +227,82 @@ async function streamDecompress(data) {
await inputStream
.pipeThrough(decompressionRunner)
+ .pipeThrough(optionsPickerStream)
.pipeThrough(textDecoderStream)
.pipeTo(outputStream);
- await syncCompress(codeEl.value);
+ await syncCompress();
+}
+
+/**
+ * Callback for language being changed in language selector.
+ */
+async function languageSelected() {
+ codeViewEl.classList.remove(getLanguageClassName(dataOptions.language));
+ codeViewContentEl.classList.remove(getLanguageClassName(dataOptions.language));
+
+ if (LANGUAGES.has(languageSelectEl.value)) {
+ dataOptions.language = languageSelectEl.value;
+
+ highlightLanguage();
+ } else {
+ dataOptions.language = PLAINTEXT;
+
+ highlightLanguage();
+ }
+
+ // Ensure language info is stored into URL when it's changed.
+ await syncCompress();
+}
+
+/**
+ * Callback for code being edited.
+ */
+function codeEdited() {
+ if (compressTimeout) {
+ clearTimeout(compressTimeout);
+ }
+
+ currentCode = codeEditEl.value;
+
+ if (currentCode === "") {
+ history.replaceState(null, "", "#");
+ statusEl.textContent = "Waiting...";
+ return;
+ }
+
+ compressTimeout = setTimeout(async () => {
+ await syncCompress();
+ }, COMPRESS_WAIT);
}
async function init() {
- await brotliInit();
-
- codeEl = document.getElementById("code");
+ codeEditEl = document.getElementById("code-edit");
+ codeViewEl = document.getElementById("code-view");
+ codeViewContentEl = document.getElementById("code-view-content");
statusEl = document.getElementById("length");
+ viewModeSwitcherEl = document.getElementById("view-mode-switcher");
+ languageSelectEl = document.getElementById("language-select");
- codeEl.addEventListener("input", () => {
- if (compressTimeout) {
- clearTimeout(compressTimeout);
- }
+ renderLanguageOptions();
+ renderView();
- if (codeEl.value === "") {
- history.replaceState(null, "", "#");
- statusEl.textContent = "Waiting...";
- return;
- }
+ [languageDependencies,] = await Promise.all([
+ getDepsData(),
+ brotliInit(),
+ ]);
- compressTimeout = setTimeout(async () => {
- const content = codeEl.value;
- await syncCompress(content);
- }, COMPRESS_WAIT);
- });
+
+ codeEditEl.addEventListener("input", codeEdited);
+ viewModeSwitcherEl.addEventListener("click", switchMode);
+ languageSelectEl.addEventListener("change", languageSelected);
if (window.location.hash.length > 1) {
try {
const bytes = BASECODEC.decode(window.location.hash.substring(1));
await streamDecompress(bytes);
} catch (e) {
- codeEl.textContent = e.stack;
+ currentCode = `Unable to open the paste. Perhaps the URL is mistyped?\n\n${e.stack}`;
+ renderCode();
}
}
}
diff --git a/utils.js b/utils.js
new file mode 100644
index 0000000..6751db9
--- /dev/null
+++ b/utils.js
@@ -0,0 +1,15 @@
+/**
+ * Returns a promise that resolves when the page DOM has been loaded.
+ * @returns {Promise}
+ */
+export function waitForLoad() {
+ return new Promise(resolve => {
+ // If already loaded, fire immediately
+ if (/complete|interactive|loaded/.test(document.readyState)) {
+ resolve();
+ }
+ else {
+ document.addEventListener('DOMContentLoaded', resolve);
+ }
+ });
+}
diff --git a/vendor/prism b/vendor/prism
new file mode 160000
index 0000000..59e5a34
--- /dev/null
+++ b/vendor/prism
@@ -0,0 +1 @@
+Subproject commit 59e5a3471377057de1f401ba38337aca27b80e03
diff --git a/viewmode.js b/viewmode.js
new file mode 100644
index 0000000..7efb84f
--- /dev/null
+++ b/viewmode.js
@@ -0,0 +1,6 @@
+export class ViewMode {
+ static VIEW = Symbol("view");
+ static EDIT = Symbol("edit");
+}
+
+Object.freeze(ViewMode);