Implement line numbers, code highlighting, protocol header

This commit is contained in:
Mikko Ahlroth 2022-10-28 23:18:56 +03:00
parent bee38949e2
commit 149d1053d6
10 changed files with 740 additions and 50 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "vendor/prism"]
path = vendor/prism
url = https://github.com/PrismJS/prism.git

81
dataoptions.js Normal file
View file

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

View file

@ -8,21 +8,34 @@
<title>Tahnaroskakori</title>
<link rel="stylesheet" type="text/css" href="./tahnaroskakori.css" />
<link rel="stylesheet" type="text/css" href="./vendor/prism/themes/prism-tomorrow.css" />
<link rel="stylesheet" type="text/css" href="./vendor/prism/plugins/line-numbers/prism-line-numbers.css" />
<link rel="icon" type="image/x-icon"
href="data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAgAAAAAAAAAAAAAAAEAAAAAAAAABG1BcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERERERERERERERERERERERAAAAAAAAAREREREREREREQAAEAAAAAEREREREREREREAAAAQAAAAERERERERERERAAEAAAAAAREREREREREREQAAABEREREREREREREREREAEAAAEBERERERERERERERAAAAAQABABEREREREREREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" />
</head>
<body>
<body class="line-numbers">
<main>
<header>
<h1>Tahnaroskakori</h1>
<button id="view-mode-switcher" type="button">✍️</button>
</header>
<textarea id="code"></textarea>
<div id="settings-container">
<select id="language-select"></select>
</div>
<pre id="code-view"><code id="code-view-content">Loading...</code></pre>
<noscript>JavaScript is needed to use this website.</noscript>
<textarea id="code-edit"></textarea>
<footer>
<div id="length">Waiting...</div>
<div id="info">
v1.2.0 | © Nicd 2022 | <a href="https://gitlab.com/Nicd/tahnaroskakori" target="_blank">Source</a> | <a
v2.0.0 | © Nicd 2022 | <a href="https://gitlab.com/Nicd/tahnaroskakori" target="_blank">Source</a> | <a
href="./licenses.txt" target="_blank">Licenses</a>
</div>
</footer>

345
languages.js Normal file
View file

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

View file

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

View file

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

View file

@ -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<void>}
* 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);
}
else {
document.addEventListener('DOMContentLoaded', resolve);
}
/**
* 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`)
}
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();
}
async function init() {
await brotliInit();
/**
* Callback for language being changed in language selector.
*/
async function languageSelected() {
codeViewEl.classList.remove(getLanguageClassName(dataOptions.language));
codeViewContentEl.classList.remove(getLanguageClassName(dataOptions.language));
codeEl = document.getElementById("code");
statusEl = document.getElementById("length");
if (LANGUAGES.has(languageSelectEl.value)) {
dataOptions.language = languageSelectEl.value;
codeEl.addEventListener("input", () => {
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);
}
if (codeEl.value === "") {
currentCode = codeEditEl.value;
if (currentCode === "") {
history.replaceState(null, "", "#");
statusEl.textContent = "Waiting...";
return;
}
compressTimeout = setTimeout(async () => {
const content = codeEl.value;
await syncCompress(content);
await syncCompress();
}, COMPRESS_WAIT);
});
}
async function init() {
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");
renderLanguageOptions();
renderView();
[languageDependencies,] = await Promise.all([
getDepsData(),
brotliInit(),
]);
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();
}
}
}

15
utils.js Normal file
View file

@ -0,0 +1,15 @@
/**
* Returns a promise that resolves when the page DOM has been loaded.
* @returns {Promise<void>}
*/
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);
}
});
}

1
vendor/prism vendored Submodule

@ -0,0 +1 @@
Subproject commit 59e5a3471377057de1f401ba38337aca27b80e03

6
viewmode.js Normal file
View file

@ -0,0 +1,6 @@
export class ViewMode {
static VIEW = Symbol("view");
static EDIT = Symbol("edit");
}
Object.freeze(ViewMode);