├── LICENSE ├── README.md ├── index.mjs ├── out.js ├── package.json ├── public ├── command-palette │ ├── index.html │ ├── index.js │ └── style.css ├── context-menu │ ├── index.html │ ├── index.js │ └── style.css ├── filter.js ├── index.html ├── index.js ├── lib.js ├── log-adder.js ├── style.css ├── tags.js └── worker │ ├── highlight.js │ └── index.js ├── tsconfig.json └── types.d.ts /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 EmNudge 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logpipe ![logpipe logo][logo] 2 | 3 | *Get clarity on development logs* 4 | 5 | 6 | 7 | Checkout [the docs][docs] or see it in action in [this online demo][demo]! 8 | 9 | ## Installation 10 | 11 | There is no build step in this codebase, so you can install it directly from git if you want. 12 | 13 | ```sh 14 | npm i -g https://github.com/EmNudge/logpipe.git 15 | # or 16 | npm i -g @emnudge/logpipe 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```sh 22 | $ some-other-program | logpipe 23 | 24 | > server running on http://localhost:7381 25 | ``` 26 | 27 | Then go to the URL and inspect away! 28 | 29 | `logpipe` parameters: 30 | - `--port ` 31 | - Choose a specific port (instead of random). Useful for command runners like `nodemon`. 32 | - `--title ` 33 | - Title for the page. Useful if you have multiple `logpipe`s open at once. 34 | 35 | 36 | ### Redirecting Stderr 37 | 38 | Many programs will output their logs to `stderr` instead of `stdout`. If `logpipe` is not capturing anything and you still see output in your terminal, this is probably what's happening. 39 | 40 | You can use bash redirection to fix this. 41 | 42 | ```sh 43 | my-program 2>&1 | logpipe # note the "2>&1" 44 | ``` 45 | 46 | Check out [the docs](https://logpipe.dev/guide/shell-redirection.html#shell-redirection) for more info on shell redirection. 47 | 48 | ## Motivation 49 | 50 | When dealing with various codebases in development, you'll come across perhaps hundreds of logs per minute. Some of these are supposed to be useful. 51 | As it's just sending out unstructured text, it can be hard to find what you want. Furthermore, it often lacks syntax highlighting. It is hard to know where one log ends and another starts. 52 | 53 | In contrast, something like your browser's dev console allows filtering, highlighting, and inspection of "unstructured" logs. The goal is to supercharge this to be used for any system that outputs any kind of logs. 54 | 55 | ## Behavior 56 | 57 | This is a tool meant primarily for development. Therefore, the intention is to value assistance over correctness. 58 | 59 | This allows us the following interesting features: 60 | - **Logpipe** will syntax highlight logs that previously had no syntax highlighting. 61 | - It will automatically apply tags to logs for you to search over. 62 | - It will attempt to group logs that seem related (based on indentation or language grammar) 63 | 64 | It also allows us to live-filter logs while retaining the log state - something already present in most log inspection tools. 65 | 66 | ## Advanced Usage, Alternative Tools, Contribution Guide, etc 67 | 68 | A lot of info has been moved to [the docs][docs]. Here are some quick links: 69 | 70 | - [Alternatives](https://logpipe.emnudge.dev/guide/alternatives.html) 71 | - [Shell Redirection](https://logpipe.emnudge.dev/guide/shell-redirection.html) 72 | - [Filtering](https://logpipe.emnudge.dev/guide/filtering.html) 73 | - [Contribution Guide](https://logpipe.emnudge.dev/guide/contribution-guide.html) 74 | 75 | 76 | [logo]: https://github.com/EmNudge/logpipe/assets/24513691/8526ba7d-e8a1-460a-8fad-60c488b5b15e 77 | [demo]: https://logpipe.pages.dev 78 | [docs]: https://logpipe.dev 79 | 80 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import http from "http"; 4 | import fs from "fs/promises"; 5 | import { join } from "path"; 6 | import { fileURLToPath } from "url"; 7 | 8 | const argv = /** @type {string[]} */ (process.argv.slice(2)); 9 | /** @param {string} name */ 10 | const findArg = (name) => { 11 | const index = argv.indexOf(name); 12 | if (index === -1) return; 13 | if (!argv[index + 1] || argv[index + 1].startsWith("-")) return "true"; 14 | return argv[index + 1]; 15 | }; 16 | 17 | if (findArg("--version") || findArg("-v")) { 18 | const packageJsonpath = join( 19 | fileURLToPath(import.meta.url), 20 | "..", 21 | "package.json" 22 | ); 23 | const packageJson = await fs.readFile(packageJsonpath, "utf-8"); 24 | const { version } = JSON.parse(packageJson); 25 | console.log(version); 26 | process.exit(); 27 | } 28 | 29 | // user asked for CLI help 30 | if (findArg("--help") || findArg("-h")) { 31 | console.log( 32 | [ 33 | "Usage: logpipe [--port PORT] [--title TITLE]", 34 | "", 35 | "Options:", 36 | " --port The port to run the web server on", 37 | " --title The title of the web page", 38 | " --help This menu", 39 | " --version The version of logpipe", 40 | "", 41 | "Examples:", 42 | " your-program | logpipe", 43 | ' your-program | logpipe --port 8080 --title "My CLI Input"', 44 | ' your-program | logpipe -p 8080 -t "My CLI Input"', 45 | "", 46 | ].join("\n") 47 | ); 48 | process.exit(); 49 | } 50 | 51 | const port = Number(findArg("--port") ?? findArg("-p")) || 0; 52 | const DEFAULT_TITLE = "CLI Input"; 53 | const title = findArg("--title") ?? findArg("-t") ?? "CLI Input"; 54 | 55 | /** @typedef {import('./types.d.ts').CliInput} CliInput */ 56 | 57 | /** @type {CliInput[]} */ 58 | const lines = []; 59 | 60 | /** @type {Set<(lines: CliInput[], newLines: CliInput[]) => void>} */ 61 | const notifiers = new Set(); 62 | 63 | process.stdin.on("data", (data) => { 64 | /** @type {string} */ 65 | const input = data.toString(); 66 | const date = Date.now(); 67 | const id = String(lines.length); 68 | 69 | /** @type {CliInput[]} */ 70 | const newLines = []; 71 | let prevWhitespace = 0; 72 | for (const line of input.trim().split("\n")) { 73 | const curWhitespace = line.match(/^\s+/)?.[0].length; 74 | if ((curWhitespace > prevWhitespace || line === "}") && newLines.length) { 75 | const { input } = newLines.pop(); 76 | newLines.push({ input: [input, line].join("\n"), date, id }); 77 | } else { 78 | newLines.push({ input: line, date, id }); 79 | } 80 | } 81 | 82 | lines.push(...newLines); 83 | 84 | for (const func of notifiers) { 85 | func(lines, newLines); 86 | } 87 | }); 88 | 89 | process.stdin.on("close", () => { 90 | console.log("\x1b[31mInput has ended.\x1b[0m"); 91 | console.log("Use Ctrl+C to close the web server."); 92 | }); 93 | 94 | /** @param {string} path */ 95 | const getMimeTypeForFile = (path) => { 96 | const ext = path.split(".").slice(-1)[0]; 97 | 98 | /** @type {string} */ 99 | const mimeType = { 100 | js: "text/javascript", 101 | css: "text/css", 102 | html: "text/html", 103 | png: "image/png", 104 | jpg: "image/jpg", 105 | jpeg: "image/jpeg", 106 | ico: "image/x-icon", 107 | svg: "image/svg+xml", 108 | json: "application/json", 109 | }[ext]; 110 | return mimeType ?? "text/plain"; 111 | }; 112 | 113 | const PUBLIC_DIR = join(fileURLToPath(import.meta.url), "..", "public"); 114 | 115 | const publicFiles = new Set(); 116 | fs.readdir(PUBLIC_DIR).then((files) => 117 | Promise.all( 118 | files.map(async (file) => { 119 | const stat = await fs.stat(join(PUBLIC_DIR, file)); 120 | if (!stat.isDirectory()) { 121 | publicFiles.add(file); 122 | return; 123 | } 124 | const curFolder = join(PUBLIC_DIR, file); 125 | for (const subFile of await fs.readdir(curFolder)) { 126 | publicFiles.add(join(file, subFile)); 127 | } 128 | }) 129 | ) 130 | ); 131 | 132 | const server = http 133 | .createServer(async (req, res) => { 134 | if (req.method !== "GET") return; 135 | 136 | if (req.url === "/_/cli-input") { 137 | res.writeHead(200, { 138 | "Content-Type": "text/event-stream", 139 | Connection: "keep-alive", 140 | "Cache-Control": "no-cache", 141 | }); 142 | 143 | res.write(`data: ${JSON.stringify(lines)}\n\n`); 144 | notifiers.add((_lines, newLines) => { 145 | res.write(`data: ${JSON.stringify(newLines)}\n\n`); 146 | }); 147 | return; 148 | } 149 | 150 | // We could directly write to FS, but we shouldn't trust the user to specify download location information correctly. 151 | // This means we'd need a lot of error handling. It's easier to let the browser handle file downloads. 152 | if (req.url === "/_/logs" && req.method === "GET") { 153 | res.writeHead(200, { "Content-Type": "application/json" }); 154 | res.write(JSON.stringify(lines)); 155 | res.end(); 156 | return; 157 | } 158 | 159 | if (req.url == "/" || req.url == "/index.html") { 160 | res.writeHead(200, { 161 | "Content-Type": "text/html", 162 | "X-Custom-Title": title, 163 | }); 164 | /** @type {string} */ 165 | const site = (await fs.readFile(join(PUBLIC_DIR, "index.html"), "utf8")) 166 | .replace(/<title>.+?<\/title>/g, `<title>${title}`) 167 | .replace(/

.+?<\/h1>/g, `

${title}

`); 168 | res.write(site); 169 | res.end(); 170 | return; 171 | } else if (publicFiles.has(req.url.slice(1))) { 172 | res.writeHead(200, { "Content-Type": getMimeTypeForFile(req.url) }); 173 | res.write(await fs.readFile(join(PUBLIC_DIR, req.url.slice(1)))); 174 | res.end(); 175 | return; 176 | } 177 | 178 | res.writeHead(404, { "Content-Type": "text/html" }); 179 | res.write("Resource not found"); 180 | res.end(); 181 | }) 182 | .listen(port); 183 | 184 | if (!server.address() && port) { 185 | console.error("\nServer could not bind to port", port); 186 | console.log( 187 | "Specify a different port or allow the server to choose a random port." 188 | ); 189 | process.exit(1); 190 | } 191 | 192 | const address = `http://localhost:${server.address().port}`; 193 | let log = `Logs are displayed on \x1b[32;1;4m${address}\x1b[0m` 194 | if (title !== DEFAULT_TITLE) { 195 | log += ` with title \x1b[34;3m"${title}"\x1b[0m`; 196 | } 197 | console.log(`\n${log}\n`); 198 | -------------------------------------------------------------------------------- /out.js: -------------------------------------------------------------------------------- 1 | const { iterations, delay } = (() => { 2 | const args = process.argv.slice(2).join(" "); 3 | 4 | const iterations = (() => { 5 | const match = 6 | args.match(/--iterations\s+(\d+)/) ?? args.match(/-i\s*(\d+)/); 7 | return match ? Number(match[1]) : Infinity; 8 | })(); 9 | const delay = (() => { 10 | const match = args.match(/--delay\s+(\d+)/) ?? args.match(/-d\s*(\d+)/); 11 | return match ? Number(match[1]) : 2000; 12 | })(); 13 | 14 | return { iterations, delay }; 15 | })(); 16 | 17 | let count = 0; 18 | const intId = setInterval(logRandom, delay); 19 | function logRandom() { 20 | if (++count > iterations) { 21 | clearInterval(intId); 22 | return; 23 | } 24 | 25 | const branches = [ 26 | "\x1B]8;;https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles\x1B\\`dev` profile [unoptimized + debuginfo]\x1B]8;;\x1B\\", 27 | "\x1B[31m [ANSI] Hello There! \x1B[0m", 28 | `[INFO] ${new Date().toISOString()} "log random" ${count}`, 29 | "Info 2024-04-08 19:10:00,779 /opt/tmp/file_seq.data", 30 | "[Error(danger)] An error occurred [42] seconds ago due to a false value when expecting a true value", 31 | "failed to throw an error. This is surprisingly not a good thing.", 32 | "[INFO] /opt/file_thing.sock:19 took 232.21ms | all systems go", 33 | "on http://localhost:8080 (127.0.0.1) someone cool made a GET request (source: file_thing.js:32)", 34 | '["this is not a tag"] action took 32h2m1.13s at file_info.go:(12,31)', 35 | `stringified ${JSON.stringify( 36 | { a: 1, b: 2, c: Math.random() * 50 }, 37 | null, 38 | 2 39 | )}`, 40 | "Debug received POST, GET, PATCH, PUT, and DELETE from 192.168.1.1 (somehow)", 41 | ]; 42 | const log = branches[(branches.length * Math.random()) | 0]; 43 | console.log(log); 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@emnudge/logpipe", 3 | "version": "0.0.9", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/EmNudge/logpipe.git" 7 | }, 8 | "scripts": { 9 | "dev": "nodemon --exec 'node out.js | node index.mjs -p 7280' -e ts,html,js,mjs,css" 10 | }, 11 | "bin": { 12 | "logpipe": "./index.mjs" 13 | }, 14 | "author": "EmNudge", 15 | "license": "MIT" 16 | } -------------------------------------------------------------------------------- /public/command-palette/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 |
11 | 16 | 75 |
76 | 77 |
78 | 83 | 86 |
87 | 88 |
89 | 94 | 95 | 98 |
99 | 100 |
101 |
102 |
My logs aren't showing!
103 |
104 |

105 | Your logs may be outputting to stderr. Try using bash redirection. 106 |

107 |

108 | my-program |& logpipe 109 |

110 |
111 | 112 |
How do I specify a port?
113 |
114 |

115 | The command line supports the options --port and 116 | --title for specifying these things. 117 |

118 |

119 | my-program | logpipe --port 8080 --title "Backend System" 122 |

123 |
124 | 125 |
How do I copy a log or timestamp?
126 |
127 |

128 | Try out the right-click menu! Right click a log for a list of 129 | options. 130 |

131 |
132 | 133 |
How do I jump to a log's source location?
134 |
135 |

Also in the right-click menu!

136 |
137 | 138 |
How do I search for all URLs?
139 |
140 |

Use the logpipe query language!

141 |

142 | @@url 143 |

144 |
145 | 146 |
What are the supported query types?
147 |
148 |

Here is a query which includes all of them!

149 |

150 | @@string,number,tag,url,ip,error,file,http-method,key,value,keyword 153 |

154 |
155 | 156 |
What are the keyboard shortcuts?
157 |
158 |

Cmd+k - Open the command palette

159 |

/ - Focus filter box

160 |
161 |
162 | 163 | 166 |
167 |
168 | 169 |
170 | Psst... try Cmd + k 171 |
172 | 173 | -------------------------------------------------------------------------------- /public/command-palette/index.js: -------------------------------------------------------------------------------- 1 | import { $, downloadResource, toggleParsingAnsi } from "../lib.js"; 2 | import { reAddAllLogs } from "../log-adder.js"; 3 | 4 | const commandPaletteEl = 5 | /** @type {HTMLElement & { [key: string]: () => Promise }} */ ( 6 | $("sl-dialog.command-palette") 7 | ); 8 | 9 | const palletFormEl = $(".palette-form"); 10 | const menuEl = $(".palette-form .menu"); 11 | 12 | /** @param {HTMLElement} newSelection */ 13 | const changeSelection = (newSelection) => { 14 | const selectedEl = menuEl.querySelector('[aria-selected="true"]'); 15 | selectedEl.setAttribute("aria-selected", "false"); 16 | 17 | newSelection.setAttribute("aria-selected", "true"); 18 | newSelection.scrollIntoView(); 19 | }; 20 | 21 | /** @param {string} key */ 22 | const moveSelection = (key) => { 23 | if (key !== "ArrowDown" && key !== "ArrowUp") return; 24 | 25 | const selectedEl = menuEl.querySelector('[aria-selected="true"]'); 26 | const els = [...menuEl.querySelectorAll('[role="menuitem"]')].filter( 27 | (el) => el.getAttribute("aria-hidden") !== "true" 28 | ); 29 | if (els.length < 2) return; 30 | 31 | const index = [...els].indexOf(selectedEl); 32 | if (key === "ArrowDown") { 33 | const nextEl = els[(index + 1) % els.length]; 34 | changeSelection(/** @type {HTMLElement} **/ (nextEl)); 35 | } else { 36 | const nextEl = els[(els.length + index - 1) % els.length]; 37 | changeSelection(/** @type {HTMLElement} **/ (nextEl)); 38 | } 39 | }; 40 | 41 | menuEl.addEventListener("click", (e) => { 42 | if (!(e.target instanceof HTMLElement)) return; 43 | if (e.target.getAttribute("role") !== "menuitem") return; 44 | 45 | changeSelection(e.target); 46 | const submitEvent = new FormDataEvent("submit", { formData: new FormData() }); 47 | palletFormEl.dispatchEvent(submitEvent); 48 | }); 49 | 50 | /** @param {Element} formEl @param {string} title */ 51 | const showForm = (formEl, title = "Command Palette") => { 52 | for (const form of commandPaletteEl.querySelectorAll("form")) { 53 | form.classList.add("hide"); 54 | } 55 | formEl.classList.remove("hide"); 56 | commandPaletteEl.setAttribute("label", title); 57 | 58 | if (commandPaletteEl.open) { 59 | // @ts-ignore 60 | formEl.querySelector("sl-input, sl-button")?.focus(); 61 | } 62 | }; 63 | 64 | const rootFormEl = commandPaletteEl.querySelector("form.palette-form"); 65 | commandPaletteEl.addEventListener("sl-after-hide", () => { 66 | showForm(rootFormEl); 67 | }); 68 | 69 | /** @type {HTMLInputElement} */ 70 | const inputEl = commandPaletteEl.querySelector("sl-input.palette-filter"); 71 | 72 | const setTitleFormEl = commandPaletteEl.querySelector("form.title-form"); 73 | setTitleFormEl.addEventListener("submit", (e) => { 74 | e.preventDefault(); 75 | 76 | /** @type {HTMLInputElement} */ 77 | const inputEl = setTitleFormEl.querySelector("sl-input"); 78 | document.querySelector("main > h1").textContent = inputEl.value; 79 | document.title = inputEl.value; 80 | commandPaletteEl.hide(); 81 | }); 82 | 83 | const versionEl = commandPaletteEl.querySelector("span.about-version"); 84 | versionEl.textContent = $("meta[name=version]").getAttribute("content"); 85 | const aboutFormEl = commandPaletteEl.querySelector("form.about-menu"); 86 | aboutFormEl.addEventListener("submit", (e) => { 87 | e.preventDefault(); 88 | commandPaletteEl.hide(); 89 | }); 90 | 91 | const helpFormEl = commandPaletteEl.querySelector("form.help-menu"); 92 | helpFormEl.addEventListener("submit", (e) => { 93 | e.preventDefault(); 94 | commandPaletteEl.hide(); 95 | }); 96 | 97 | document.body.addEventListener("keydown", (e) => { 98 | if (e.key !== "k") return; 99 | 100 | if ((navigator.userAgent.includes(" Mac") && e.metaKey) || e.ctrlKey) { 101 | commandPaletteEl.show(); 102 | e.preventDefault(); 103 | } 104 | }); 105 | 106 | inputEl.addEventListener("input", () => { 107 | const filterText = inputEl.value; 108 | 109 | /** @type {HTMLElement} */ 110 | let firstVisible; 111 | 112 | for (const menuItem of menuEl.querySelectorAll('[role="menuitem"]')) { 113 | if (menuItem.textContent.toLowerCase().includes(filterText)) { 114 | menuItem.removeAttribute("aria-hidden"); 115 | // @ts-ignore 116 | if (!firstVisible) firstVisible = menuItem; 117 | } else { 118 | menuItem.setAttribute("aria-hidden", "true"); 119 | } 120 | } 121 | 122 | if (firstVisible) { 123 | changeSelection(firstVisible); 124 | } 125 | }); 126 | inputEl.addEventListener("keydown", (e) => { 127 | if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; 128 | 129 | moveSelection(e.key); 130 | }); 131 | 132 | const containerEl = $("div.container"); 133 | const mainEl = $("main"); 134 | const tagsDialogEl = $(".tags-dialog"); 135 | function toggleExpandTerminal() { 136 | containerEl.classList.toggle("expand"); 137 | mainEl.classList.toggle("expand"); 138 | tagsDialogEl.classList.toggle("expand"); 139 | 140 | commandPaletteEl.hide(); 141 | } 142 | 143 | palletFormEl.addEventListener("submit", (e) => { 144 | e.preventDefault(); 145 | 146 | const menuItemEl = menuEl.querySelector('[aria-selected="true"]'); 147 | if (menuItemEl.getAttribute("aria-hidden") === "true") return; 148 | 149 | /** @typedef {import('../../types.d.ts').CommandPaletteAction} ActionType */ 150 | const action = /** @type {ActionType} */ (menuItemEl.getAttribute("value")); 151 | 152 | if (action === "set-title") { 153 | showForm(setTitleFormEl, "Set Title"); 154 | } else if (action === "expand") { 155 | toggleExpandTerminal(); 156 | } else if (action === "theme") { 157 | $("html").classList.toggle("sl-theme-dark"); 158 | $("html").classList.toggle("sl-theme-light"); 159 | commandPaletteEl.hide(); 160 | } else if (action === "ansi") { 161 | toggleParsingAnsi(); 162 | // TODO: only re-highlight if it contains ANSI 163 | reAddAllLogs(); 164 | commandPaletteEl.hide(); 165 | } else if (action === "save") { 166 | downloadResource("/_/logs", "logs"); 167 | commandPaletteEl.hide(); 168 | } else if (action === "about") { 169 | showForm(aboutFormEl, "About"); 170 | } else if (action === "help") { 171 | showForm(helpFormEl, "Help Menu"); 172 | } 173 | }); 174 | 175 | const cmdPlatteHint = $(".command-palette-hint"); 176 | cmdPlatteHint.classList.remove("hide"); 177 | $("h1").insertAdjacentElement("afterend", cmdPlatteHint); 178 | 179 | if (!navigator.userAgent.includes(" Mac")) { 180 | cmdPlatteHint.querySelector(".modifier").textContent = "Ctrl"; 181 | } 182 | -------------------------------------------------------------------------------- /public/command-palette/style.css: -------------------------------------------------------------------------------- 1 | sl-dialog form:not(.palette-form, .hide) { 2 | display: grid; 3 | gap: var(--sl-spacing-medium); 4 | } 5 | 6 | .menu { 7 | height: 250px; 8 | overflow: auto; 9 | } 10 | .menu .menu-item { 11 | padding: var(--sl-spacing-small) var(--sl-spacing-medium); 12 | cursor: pointer; 13 | 14 | &:hover { 15 | background: var(--sl-color-primary-50); 16 | } 17 | 18 | &[aria-selected="true"] { 19 | background: var(--sl-color-primary-100); 20 | } 21 | &[aria-hidden="true"] { 22 | display: none; 23 | } 24 | } 25 | 26 | form.help-menu { 27 | dl { 28 | height: 400px; 29 | overflow: auto; 30 | padding: 0 var(--sl-spacing-x-large); 31 | } 32 | dt { 33 | color: var(--sl-color-neutral-900); 34 | } 35 | dd { 36 | color: var(--sl-color-neutral-700); 37 | } 38 | code { 39 | background: var(--sl-color-neutral-300); 40 | padding: var(--sl-spacing-2x-small) var(--sl-spacing-x-small); 41 | border-radius: var(--sl-border-radius-small); 42 | } 43 | code.multiline { 44 | display: block; 45 | } 46 | } 47 | 48 | form.about-menu ul { 49 | font-family: monospace; 50 | line-height: 2; 51 | font-size: 1rem; 52 | list-style: none; 53 | text-align: center; 54 | padding: 0; 55 | } 56 | 57 | .form-footer { 58 | display: flex; 59 | justify-content: flex-end; 60 | } 61 | 62 | .expand { 63 | main& { 64 | width: 1200px; 65 | } 66 | .container& { 67 | height: 900px; 68 | } 69 | .tags-dialog& { 70 | --width: 1000px; 71 | } 72 | } 73 | 74 | .command-palette-hint { 75 | margin-top: -1.25rem; 76 | scale: 0.75; 77 | translate: 10px; 78 | transform-origin: left; 79 | opacity: 0.4; 80 | } 81 | -------------------------------------------------------------------------------- /public/context-menu/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Copy log contents 7 | Copy ID 8 | Copy date 9 | Jump to location 10 | 11 | 12 | 13 | 14 | Copied to clipboard 15 | 16 | -------------------------------------------------------------------------------- /public/context-menu/index.js: -------------------------------------------------------------------------------- 1 | import { setFilter } from "../filter.js"; 2 | import { $ } from "../lib.js"; 3 | 4 | const logContainer = $(".container"); 5 | const contextMenu = $(".contextmenu"); 6 | 7 | const copyNotifEl = 8 | /** @type {HTMLElement & { [key: string]: () => Promise }} */ ( 9 | $(".copy-notif") 10 | ); 11 | 12 | /** @param {string} text @param {string} desc */ 13 | const copyText = async (text, desc) => { 14 | await copyNotifEl.hide(); 15 | copyNotifEl.querySelector(".copy-label").textContent = desc; 16 | 17 | await navigator.clipboard.writeText(text); 18 | copyNotifEl.toast(); 19 | }; 20 | 21 | /** @type {HTMLElement | undefined} */ 22 | let selectedLog; 23 | 24 | /** @param {HTMLElement} logEl @param {number} x @param {number} y */ 25 | const openContextMenu = (logEl, x, y) => { 26 | closeContextMenu(); 27 | contextMenu.classList.add("show"); 28 | contextMenu.style.setProperty("--x", String(x)); 29 | contextMenu.style.setProperty("--y", String(y)); 30 | selectedLog = logEl; 31 | selectedLog.classList.add("selected"); 32 | }; 33 | const closeContextMenu = () => { 34 | contextMenu.classList.remove("show"); 35 | selectedLog?.classList.remove("selected"); 36 | selectedLog = undefined; 37 | }; 38 | 39 | document.addEventListener( 40 | "click", 41 | (/** @type {Event & { target: Element }} e */ e) => { 42 | if (!contextMenu.contains(e.target)) { 43 | closeContextMenu(); 44 | } 45 | } 46 | ); 47 | 48 | contextMenu.addEventListener( 49 | "sl-select", 50 | async (/** @type {Event & { detail: any }} e */ e) => { 51 | /** @type {'copy-log' | 'copy-id' | 'copy-date' | 'jump'} */ 52 | const action = e.detail.item.value; 53 | 54 | if (!selectedLog) { 55 | throw new Error( 56 | `could not perform action ${action} on undefined element` 57 | ); 58 | } 59 | 60 | if (action === "copy-log") { 61 | copyText(selectedLog.textContent, "log contents"); 62 | } else if (action === "copy-date") { 63 | copyText(selectedLog.dataset.date, "log date"); 64 | } else if (action === "copy-id") { 65 | copyText(selectedLog.dataset.id, "log ID"); 66 | } else { 67 | setFilter('', true); 68 | selectedLog.scrollIntoView(); 69 | } 70 | 71 | contextMenu.classList.remove("show"); 72 | } 73 | ); 74 | 75 | logContainer.addEventListener("contextmenu", (e) => { 76 | // ignore any modifiers to allow browser-native debugging 77 | if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; 78 | 79 | const logEl = (() => { 80 | let el = e.target; 81 | while (el instanceof HTMLSpanElement) { 82 | el = el.parentElement; 83 | } 84 | 85 | return /** @type {HTMLElement} */ (el); 86 | })(); 87 | if (!logEl.classList.contains("log")) return; 88 | 89 | e.preventDefault(); 90 | openContextMenu(logEl, e.clientX, e.clientY); 91 | }); 92 | -------------------------------------------------------------------------------- /public/context-menu/style.css: -------------------------------------------------------------------------------- 1 | .contextmenu { 2 | position: absolute; 3 | left: calc(var(--x) * 1px); 4 | top: calc(var(--y) * 1px); 5 | display: none; 6 | &.show { 7 | display: block; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/filter.js: -------------------------------------------------------------------------------- 1 | import { $, $$ } from "./lib.js"; 2 | 3 | /** @typedef {import('../types.d.ts').CliInput} CliInput */ 4 | /** @typedef {import('../types.d.ts').TagGroup} TagGroup */ 5 | 6 | const filterInputEl = /** @type {HTMLInputElement} */ ($("sl-input.filter")); 7 | 8 | let filterText = ""; 9 | let filterItemsCount = 0; 10 | 11 | /** 12 | * Syntax: \@@ (,[=""])+ 13 | * e.g. `@@tag="[INFO]"` 14 | * e.g. `@@tag,number,string` 15 | * e.g. `@@tag="[INFO]",number` 16 | */ 17 | const getSelector = () => 18 | new RegExp( 19 | [ 20 | /@@/, 21 | /[\w-]+(?:="((?:\\"|[^"])+)")?/, // match with optional ="" 22 | /(?:,[\w-]+(?:="((?:\\"|[^"])+)")?)*/, // optionally match multiple instances separated by comma 23 | ] 24 | .map((r) => r.source) 25 | .join(""), 26 | "g" 27 | ); 28 | 29 | /** 30 | * @param {string} filterText 31 | * @returns {TagGroup[]} 32 | */ 33 | const extractTagGroups = (filterText) => { 34 | return [...filterText.matchAll(getSelector())].map(([match]) => { 35 | return match 36 | .slice(2) 37 | .split(",") 38 | .map((tagText) => { 39 | const [tag, textValue] = tagText.split("="); 40 | return { tag, textValue: textValue?.slice(1, -1) }; 41 | }); 42 | }); 43 | }; 44 | 45 | /** 46 | * Match tags against input. Tags work on HTML DOM instead of text. 47 | * 48 | * @param {TagGroup[]} tagGroups 49 | * @param {HTMLElement} logEl 50 | */ 51 | const matchTags = (tagGroups, logEl) => { 52 | // all groups must match for it to match log 53 | for (const tagGroup of tagGroups) { 54 | // if any tag in group matches, this group is a match 55 | const matches = (() => { 56 | for (const { tag, textValue } of tagGroup) { 57 | const elements = logEl.querySelectorAll(`span.${tag}`); 58 | if (!elements.length) continue; 59 | 60 | if (!textValue) return true; 61 | 62 | const oneMatch = [...elements].some(el => el.textContent === textValue); 63 | if (oneMatch) return true; 64 | } 65 | return false; 66 | })(); 67 | 68 | if (!matches) return false; 69 | } 70 | 71 | return true; 72 | }; 73 | 74 | /** 75 | * Checks if log matches filter (based on global variable filterText) 76 | * @param {HTMLElement} logEl DOM element for log 77 | */ 78 | const elMatchesFilter = (logEl) => { 79 | let filter = filterText; 80 | 81 | const tags = extractTagGroups(filterText); 82 | if (tags.length) { 83 | if (!matchTags(tags, logEl)) return false; 84 | 85 | filter = filter.replace(getSelector(), "").trim(); 86 | if (!filter) return true; 87 | } 88 | 89 | return logEl.textContent.toLowerCase().includes(filterText.toLowerCase()); 90 | }; 91 | 92 | /** 93 | * Applies filter to element based off of input content 94 | * @param {HTMLElement} logEl */ 95 | export const applyFilter = (logEl) => { 96 | const shouldDisplay = elMatchesFilter(logEl); 97 | logEl.style.display = shouldDisplay ? "" : "none"; 98 | if (shouldDisplay) filterItemsCount++; 99 | }; 100 | 101 | /** 102 | * @param {string} newText 103 | * @param {boolean} changeInput 104 | */ 105 | export const setFilter = (newText, changeInput = false, dispatchEvent = changeInput) => { 106 | filterText = newText; 107 | if (changeInput) { 108 | filterInputEl.value = newText; 109 | if (dispatchEvent) { 110 | filterInputEl.dispatchEvent(new KeyboardEvent('input')); 111 | } 112 | } 113 | 114 | let logs = $$(".container .log"); 115 | let filterCount = logs.length; 116 | let filter = filterText; 117 | 118 | if (!filter.length) { 119 | for (const logEl of logs) { 120 | logEl.style.display = ""; 121 | } 122 | return; 123 | } 124 | 125 | for (const logEl of logs) { 126 | const shouldDisplay = elMatchesFilter(logEl); 127 | 128 | if (!shouldDisplay) filterCount--; 129 | logEl.style.display = shouldDisplay ? "" : "none"; 130 | } 131 | 132 | filterItemsCount = filterCount; 133 | 134 | // set filter log count text 135 | $(".log-count .filtered").textContent = filterText.length 136 | ? `filtered: (${filterItemsCount})` 137 | : ""; 138 | }; 139 | 140 | filterInputEl.addEventListener("input", () => setFilter(filterInputEl.value)); 141 | 142 | document.addEventListener("keydown", (e) => { 143 | if (e.key !== "/" || e.metaKey || document.activeElement === filterInputEl) 144 | return; 145 | 146 | filterInputEl.focus(); 147 | e.preventDefault(); 148 | }); 149 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CLI Input 8 | 9 | 11 | 12 | 13 | 14 | 15 | 19 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |

CLI Input

28 |
29 |
30 | 31 | 32 |
33 |
34 |
35 | 36 | View Tags 37 | 30 38 | 39 | 40 |
41 |
42 |
43 |
44 | 45 | 46 |
47 |
48 | 49 | 50 |
51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | // import { highlightText } from "./worker/highlight.js"; 2 | import { $, loadHtmlComponent } from "./lib.js"; 3 | import { addLogs } from "./log-adder.js"; 4 | 5 | loadHtmlComponent("command-palette"); 6 | loadHtmlComponent("context-menu"); 7 | 8 | const logContainer = $(".container"); 9 | 10 | // scroll to bottom of container 11 | const downButton = $(".down-button"); 12 | downButton.addEventListener("click", () => { 13 | logContainer.children[logContainer.children.length - 1].scrollIntoView(); 14 | }); 15 | let showButton = downButton.classList.contains("show"); 16 | const GOAL_DIST = 150; 17 | logContainer.addEventListener("scroll", (e) => { 18 | const dist = Math.abs( 19 | logContainer.scrollHeight - 20 | logContainer.scrollTop - 21 | logContainer.clientHeight 22 | ); 23 | 24 | if (dist > GOAL_DIST && !showButton) { 25 | downButton.classList.add("show"); 26 | showButton = true; 27 | } else if (dist < GOAL_DIST && showButton) { 28 | downButton.classList.remove("show"); 29 | showButton = false; 30 | } 31 | }); 32 | 33 | const cliSource = new EventSource("/_/cli-input"); 34 | /** @param {Event & { data: string }} event */ 35 | cliSource.onmessage = async (event) => { 36 | const data = JSON.parse(event.data); 37 | 38 | if (Array.isArray(data)) { 39 | await addLogs(data) 40 | return; 41 | } 42 | 43 | console.log("unknown data received", data); 44 | }; 45 | -------------------------------------------------------------------------------- /public/lib.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Document query selector alias 3 | * @type {(selector: string) => HTMLElement} 4 | */ 5 | export const $ = (s) => document.querySelector(s); 6 | 7 | /** 8 | * Document querySelectorAll alias 9 | * @type {(selector: string) => HTMLElement[]} 10 | */ 11 | export const $$ = (s) => 12 | /**@type {HTMLElement[]}*/ ([...document.querySelectorAll(s)]); 13 | 14 | const cloneMap = new Map(); 15 | /** 16 | * Clones a "template" element based off of a selector. 17 | * It will only clone the element if it contains the `template` class. 18 | * 19 | * @param {string} selector 20 | * @param {Record | undefined} properties 21 | * @returns {HTMLElement} 22 | */ 23 | export const cloneTemplate = (selector, properties = {}) => { 24 | if (cloneMap.has(selector)) { 25 | const node = cloneMap.get(selector).cloneNode(true); 26 | Object.assign(node, properties); 27 | return node; 28 | } 29 | 30 | const tempEl = document 31 | .querySelector(`${selector}.template`) 32 | ?.cloneNode(true); 33 | if (!(tempEl instanceof HTMLElement)) { 34 | throw new Error(`No element found for selector: ${selector}`); 35 | } 36 | 37 | tempEl.classList.remove("template"); 38 | cloneMap.set(selector, tempEl); 39 | const node = /** @type {HTMLElement} */ (tempEl.cloneNode(true)); 40 | return Object.assign(node, properties); 41 | }; 42 | 43 | /** 44 | * Download resource via an tag 45 | * @param {string} url raw URL or data URL 46 | * @param {string} name name for file ("file" by default) 47 | */ 48 | export function downloadResource(url, name = "file") { 49 | const a = document.createElement("a"); 50 | a.href = url; 51 | a.setAttribute("download", name); 52 | document.body.append(a); 53 | a.click(); 54 | a.remove(); 55 | } 56 | 57 | /** 58 | * Loads a folder and its associated code. 59 | * @param {string} folder component folder name 60 | */ 61 | export async function loadHtmlComponent(folder) { 62 | const html = await fetch(`/${folder}/index.html`).then((res) => res.text()); 63 | 64 | // temp element used to deserialize HTML 65 | const span = document.createElement("span"); 66 | // replace relative imports with absolute imports 67 | span.innerHTML = html.replace( 68 | /"\.\/(.+?)"/g, 69 | (_, path) => `/${folder}/${path}` 70 | ); 71 | 72 | // Extract script tag sources because external scripts are blocked by the browser when appending 73 | const modules = [...span.querySelectorAll("script")].map((el) => el.src); 74 | 75 | document.body.append(...span.children); 76 | 77 | // We need to wait for the HTML to be added to the document before requesting 78 | await Promise.all(modules.map((src) => import(src))); 79 | } 80 | 81 | /** @param {number} ms */ 82 | export const sleep = (ms) => new Promise((res) => setTimeout(res, ms)); 83 | 84 | const highlightWorker = new Worker("./worker/index.js", { type: "module" }); 85 | 86 | let stripAnsiEscape = false; 87 | export const toggleParsingAnsi = () => { 88 | stripAnsiEscape = !stripAnsiEscape; 89 | } 90 | 91 | /** 92 | * Highlights text! 93 | * Calls the worker and deserializes the response into DOM nodes. 94 | * @param {string} input 95 | * @returns {Promise} 96 | * */ 97 | export const highlightText = (input) => { 98 | /** @param {any} obj */ 99 | const getElementForObj = (obj) => { 100 | if (typeof obj == "string") return obj; 101 | 102 | const { name, children, ...rest } = obj; 103 | const element = Object.assign(document.createElement(name), rest); 104 | for (const child of obj?.children ?? []) { 105 | element.append(getElementForObj(child)); 106 | } 107 | return element; 108 | }; 109 | 110 | return new Promise((res) => { 111 | const id = crypto.randomUUID(); 112 | 113 | /** @param {MessageEvent<{ nodes: any[], id: string }>} e */ 114 | const listener = ({ data }) => { 115 | if (data.id !== id) return; 116 | highlightWorker.removeEventListener('message', listener); 117 | 118 | const elements = data.nodes.map((obj) => getElementForObj(obj)); 119 | res(elements); 120 | }; 121 | 122 | highlightWorker.addEventListener("message", listener); 123 | highlightWorker.postMessage({ input, stripAnsiEscape, id }); 124 | }); 125 | }; 126 | -------------------------------------------------------------------------------- /public/log-adder.js: -------------------------------------------------------------------------------- 1 | import { applyFilter } from "./filter.js"; 2 | import { $, cloneTemplate, highlightText } from "./lib.js"; 3 | import { maybeAddTag } from "./tags.js"; 4 | 5 | /** @typedef {import('../types.d.ts').CliInput} CliInput */ 6 | 7 | /** @type {CliInput[]} */ 8 | const logs = []; 9 | 10 | const logContainer = $(".container"); 11 | 12 | /** @param {CliInput} cliInput */ 13 | async function getLogEl({ input, date, id }) { 14 | const logEl = cloneTemplate(".log"); 15 | const elements = await highlightText(input); 16 | logEl.append(...elements); 17 | maybeAddTag(logEl); 18 | 19 | logEl.setAttribute("data-id", id); 20 | logEl.setAttribute( 21 | "data-date", 22 | new Date(date).toLocaleDateString("en-US", { 23 | hour: "numeric", 24 | minute: "numeric", 25 | second: "numeric", 26 | }) 27 | ); 28 | 29 | applyFilter(logEl); 30 | 31 | return logEl; 32 | } 33 | 34 | /** @param {Element[]} logEls */ 35 | async function appendLog(...logEls) { 36 | const lastElement = logContainer.lastElementChild; 37 | 38 | const shouldScrollDown = (() => { 39 | if (!lastElement) return false; 40 | const logBottom = lastElement?.getBoundingClientRect().bottom; 41 | const parentBottom = logContainer.getBoundingClientRect().bottom; 42 | 43 | return Math.abs(parentBottom - logBottom) < 10; 44 | })(); 45 | 46 | logContainer.append(...logEls); 47 | if (shouldScrollDown) { 48 | lastElement.scrollIntoView(); 49 | } 50 | } 51 | 52 | /** @param {CliInput[]} newLogs */ 53 | export async function addLogs(newLogs) { 54 | logs.push(...newLogs); 55 | $(".log-count .total").textContent = `(${logs.length})`; 56 | 57 | const logEls = await Promise.all(newLogs.map((log) => getLogEl(log))); 58 | await appendLog(...logEls); 59 | } 60 | 61 | export async function reAddAllLogs() { 62 | logContainer.innerHTML = ''; 63 | const logEls = await Promise.all(logs.map((log) => getLogEl(log))); 64 | await appendLog(...logEls); 65 | } -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: var(--sl-font-sans, monospace); 3 | background-color: var(--sl-color-neutral-50, #111111); 4 | 5 | display: flex; 6 | justify-content: center; 7 | } 8 | 9 | main { 10 | width: 800px; 11 | max-width: 100%; 12 | } 13 | header { 14 | display: flex; 15 | justify-content: space-between; 16 | align-items: center; 17 | padding: 4px; 18 | 19 | .log-count { 20 | color: grey; 21 | font-size: 12px; 22 | margin-top: 4px; 23 | } 24 | .tags { 25 | display: flex; 26 | gap: 4px; 27 | } 28 | .tags-overflow .tags { 29 | display: flex; 30 | gap: 10px; 31 | flex-wrap: wrap; 32 | } 33 | .tags sl-badge { 34 | cursor: pointer; 35 | } 36 | } 37 | 38 | .template, 39 | .hide { 40 | display: none; 41 | } 42 | 43 | .container { 44 | overflow-y: auto; 45 | height: 400px; 46 | color: #d3d3d3; 47 | } 48 | .input-container { 49 | position: relative; 50 | 51 | sl-button { 52 | position: absolute; 53 | top: -50px; 54 | right: 20px; 55 | opacity: 0; 56 | transition: 0.25s; 57 | 58 | &.show { 59 | opacity: 1; 60 | } 61 | } 62 | } 63 | 64 | @keyframes fade-in { 65 | from { 66 | opacity: 0; 67 | } 68 | to { 69 | opacity: 1; 70 | } 71 | } 72 | 73 | .log { 74 | padding: 4px 8px; 75 | margin: 2px 0; 76 | background: var(--sl-color-neutral-200, #424242); 77 | font-family: monospace; 78 | border-radius: var(--sl-border-radius-medium, 4px); 79 | white-space: pre-wrap; 80 | animation: forwards fade-in 0.25s; 81 | 82 | @media (prefers-color-scheme: light) { 83 | & { 84 | background: var(--sl-color-neutral-800); 85 | } 86 | } 87 | 88 | &:focus, 89 | &.selected { 90 | background-color: var(--sl-color-primary-100); 91 | outline: none; 92 | 93 | &:after { 94 | content: attr(data-date); 95 | float: right; 96 | opacity: 0.5; 97 | } 98 | } 99 | 100 | @media (prefers-color-scheme: light) { 101 | &:focus, 102 | &.selected { 103 | background-color: var(--sl-color-primary-900); 104 | } 105 | } 106 | 107 | .hide { 108 | display: none; 109 | } 110 | 111 | .tag { 112 | color: #aae2aa; 113 | } 114 | .number, 115 | .ip, 116 | .url { 117 | color: yellow; 118 | } 119 | .time { 120 | color: #f4b1f4; 121 | } 122 | .date { 123 | color: grey; 124 | } 125 | .string { 126 | color: #f8b652; 127 | } 128 | .value, 129 | .file { 130 | color: #fff3ae; 131 | } 132 | .path { 133 | color: #fffcde; 134 | } 135 | .error, 136 | .keyword { 137 | color: rgb(255, 98, 98); 138 | } 139 | .keyword { 140 | font-style: italic; 141 | } 142 | 143 | .http-method { 144 | padding: 0 4px; 145 | 146 | &.http-method-get { 147 | background-color: #318331; 148 | } 149 | &.http-method-post { 150 | background-color: #8f5e14; 151 | } 152 | &.http-method-patch, 153 | &.http-method-put { 154 | background-color: #4d4d89; 155 | } 156 | &.http-method-delete { 157 | background: #8d2626; 158 | } 159 | } 160 | 161 | /* ANSI escape sequences */ 162 | .ansi-bold { 163 | font-weight: bold; 164 | } 165 | .ansi-dim { 166 | opacity: 0.5; 167 | } 168 | .ansi-underline { 169 | text-decoration: underline; 170 | } 171 | .ansi-italic { 172 | font-style: italic; 173 | } 174 | @keyframes blink { 175 | 0% { 176 | opacity: 1; 177 | } 178 | 50% { 179 | opacity: 0; 180 | } 181 | 100% { 182 | opacity: 1; 183 | } 184 | } 185 | .ansi-blink { 186 | animation: 1s blink infinite; 187 | } 188 | .ansi-31 { 189 | color: #ff6d6d; 190 | } 191 | .ansi-32 { 192 | color: #2cfe2c; 193 | } 194 | .ansi-33 { 195 | color: yellow; 196 | } 197 | .ansi-34 { 198 | color: #a6a6ff; 199 | } 200 | .ansi-black { 201 | color: black; 202 | } 203 | .ansi-red { 204 | color: red; 205 | } 206 | .ansi-green { 207 | color: green; 208 | } 209 | .ansi-yellow { 210 | color: yellow; 211 | } 212 | .ansi-blue { 213 | color: blue; 214 | } 215 | .ansi-magenta { 216 | color: magenta; 217 | } 218 | .ansi-cyan { 219 | color: cyan; 220 | } 221 | .ansi-white { 222 | color: white; 223 | } 224 | .ansi-gray { 225 | color: gray; 226 | } 227 | .ansi-red { 228 | color: red; 229 | } 230 | .ansi-brightgreen { 231 | color: greenyellow; 232 | } 233 | .ansi-yellow { 234 | color: yellow; 235 | } 236 | .ansi-dodgerblue { 237 | color: dodgerblue; 238 | } 239 | .ansi-pink { 240 | color: pink; 241 | } 242 | .ansi-aqua { 243 | color: aqua; 244 | } 245 | .ansi-white { 246 | color: white; 247 | } 248 | } 249 | html.sl-theme-light { 250 | .log { 251 | background: var(--sl-color-neutral-800); 252 | } 253 | .log:focus, 254 | .log.selected { 255 | background-color: var(--sl-color-primary-900); 256 | } 257 | } 258 | html.sl-theme-dark { 259 | .log { 260 | background: var(--sl-color-neutral-200, #424242); 261 | } 262 | .log:focus, 263 | .log.selected { 264 | background-color: var(--sl-color-primary-100); 265 | } 266 | } 267 | 268 | kbd { 269 | background: #2c2c2c; 270 | border-radius: 4px; 271 | border: 1px solid #585858; 272 | padding: 0.1em 0.6em; 273 | margin: 0.2em; 274 | font-family: monospace; 275 | text-shadow: #000000 0px 1px; 276 | box-shadow: inset 0 1px 0 0 #5f5f5f; 277 | 278 | @media (prefers-color-scheme: light) { 279 | & { 280 | background: #e2e2e2; 281 | border: 1px solid #bcbcbc; 282 | text-shadow: #ffffff 0px 1px; 283 | box-shadow: inset 0 1px 0 0 #ffffff; 284 | } 285 | } 286 | } 287 | html.sl-theme-light kbd { 288 | background: #e2e2e2; 289 | border: 1px solid #bcbcbc; 290 | text-shadow: #ffffff 0px 1px; 291 | box-shadow: inset 0 1px 0 0 #ffffff; 292 | } 293 | html.sl-theme-dark kbd { 294 | background: #2c2c2c; 295 | border: 1px solid #585858; 296 | text-shadow: #000000 0px 1px; 297 | box-shadow: inset 0 1px 0 0 #5f5f5f; 298 | } -------------------------------------------------------------------------------- /public/tags.js: -------------------------------------------------------------------------------- 1 | import { setFilter } from "./filter.js"; 2 | import { $, $$, cloneTemplate } from "./lib.js"; 3 | 4 | const tagsContainer = $(".tags"); 5 | const tagsOverflowContainer = $(".tags-overflow"); 6 | const tagsDialogContainer = $(".tags-dialog"); 7 | 8 | const viewTagsButton = $(".view-tags-btn"); 9 | const dialogEl = /** @type {HTMLElement & { show: () => Promise }}*/ ( 10 | $(".tags-dialog") 11 | ); 12 | const tagsCount = $(".view-tags-btn sl-badge"); 13 | 14 | viewTagsButton.addEventListener("click", () => { 15 | dialogEl.show(); 16 | }); 17 | 18 | const setFilterForTags = () => { 19 | const tagStrings = $$(".tags sl-badge") 20 | .filter((el) => el.getAttribute("aria-pressed") === "true") 21 | .map((el) => `tag="${el.textContent}"`); 22 | 23 | const tagGroup = tagStrings.length ? `@@${tagStrings.join(",")}` : ""; 24 | setFilter(tagGroup, true, false); 25 | }; 26 | 27 | tagsContainer.addEventListener("click", (e) => { 28 | const tagEl = e.target; 29 | if (!(tagEl instanceof HTMLElement)) return; 30 | if (tagEl.tagName !== "SL-BADGE") return; 31 | 32 | if (tagEl.getAttribute("variant") === "neutral") { 33 | tagEl.setAttribute("variant", "primary"); 34 | tagEl.setAttribute("aria-pressed", "true"); 35 | setFilterForTags(); 36 | } else { 37 | tagEl.setAttribute("variant", "neutral"); 38 | tagEl.setAttribute("aria-pressed", "false"); 39 | setFilterForTags(); 40 | } 41 | }); 42 | 43 | const filterEl = $("sl-input.filter"); 44 | filterEl.addEventListener("input", () => { 45 | // reset tags 46 | for (const tag of $$(".tags sl-badge")) { 47 | tag.setAttribute("variant", "neutral"); 48 | tag.setAttribute("aria-pressed", "false"); 49 | } 50 | }); 51 | 52 | /** @type {Set} */ 53 | const tagsSet = new Set(); 54 | 55 | /** 56 | * Finds all tag elements in a log. 57 | * Only adds a new tag if it is not already present. 58 | * 59 | * @param {HTMLElement} logEl 60 | */ 61 | export function maybeAddTag(logEl) { 62 | const newTags = [...logEl.querySelectorAll(".tag")] 63 | .map((el) => el.textContent) 64 | .filter((tag) => !tagsSet.has(tag)); 65 | 66 | for (const tagText of newTags) { 67 | tagsSet.add(tagText); 68 | const tag = cloneTemplate(".badge", { textContent: tagText }); 69 | tagsContainer.append(tag); 70 | tagsCount.textContent = String(tagsContainer.children.length); 71 | } 72 | 73 | // wait a tick to check size 74 | setTimeout(() => { 75 | // move tags into overflow if the size is too large 76 | if (tagsOverflowContainer.classList.contains("hide")) { 77 | const tagsLength = tagsContainer.getBoundingClientRect().width; 78 | const parentLength = 79 | tagsContainer.parentElement.getBoundingClientRect().width; 80 | if (tagsLength > parentLength - 100) { 81 | tagsOverflowContainer.classList.remove("hide"); 82 | tagsDialogContainer.append(tagsContainer); 83 | } 84 | } 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /public/worker/highlight.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import('../../types.d.ts').ElementObject} ElementObject */ 2 | 3 | /** 4 | * @param {string} name 5 | * @param {Record} properties 6 | * @returns {ElementObject} 7 | */ 8 | const getEl = (name, properties) => ({ name, ...properties }); 9 | 10 | /** 11 | * @param {string} className 12 | * @param {string} textContent 13 | * @param {Record} properties 14 | */ 15 | const getSpan = (className, textContent = "", properties = {}) => { 16 | return getEl("span", { className, textContent, ...properties }); 17 | }; 18 | 19 | const VARIATION_SELECTOR_100 = String.fromCodePoint(917843); 20 | 21 | /** 22 | * @param {string} text text to transform 23 | * @param {(...el: ElementObject[]) => string} getReplacement function for inserting replacements. 24 | * @param {boolean} stripAnsiEscape 25 | * @returns {string} html 26 | */ 27 | function replaceAnsi(text, getReplacement, stripAnsiEscape) { 28 | if (stripAnsiEscape) { 29 | return text.replace(/\x1B(?:]8;;|\\|\[(?:\d+|;)+?m)/g, ''); 30 | } 31 | 32 | const withReplacedLinks = text 33 | // replace links (yes, these exist) 34 | .replace(/\x1B]8;;(.+?)\x1B\\(.+?)\x1B]8;;\x1B\\/g, (_, link, text) => { 35 | return getReplacement( 36 | getEl("a", { className: "url", href: link, textContent: text }) 37 | ); 38 | }); 39 | 40 | /** @type {string[]} */ 41 | const ansiClassNames = []; 42 | return withReplacedLinks 43 | .replace( 44 | /\x1B\[((?:\d+|;)+?)m([^\x1B]+)/g, 45 | (_, /** @type {string} */ numbers, /** @type {string} */ text) => { 46 | if (numbers === "0") { 47 | ansiClassNames.length = 0; 48 | return text; 49 | } 50 | 51 | if (numbers.startsWith("38;5")) { 52 | const ansiColor = Number(numbers.split(";").slice(-1)[0]); 53 | const colors = [ 54 | "black", 55 | "red", 56 | "green", 57 | "yellow", 58 | "blue", 59 | "magenta", 60 | "cyan", 61 | "white", 62 | "gray", 63 | "red", 64 | "brightgreen", 65 | "yellow", 66 | "dodgerblue", 67 | "pink", 68 | "aqua", 69 | "white", 70 | ]; 71 | 72 | const className = colors[ansiColor] 73 | ? `ansi-${colors[ansiColor]}` 74 | : `ansi-256-foreground-${ansiColor}`; 75 | ansiClassNames.push(className); 76 | return getReplacement(getSpan(ansiClassNames.join(" "), text)); 77 | } 78 | 79 | const styles = numbers 80 | .split(";") 81 | .map((code) => { 82 | if (code === "1") return "bold"; 83 | if (code === "2") return "dim"; 84 | if (code === "3") return "italic"; 85 | if (code === "4") return "underline"; 86 | if (code === "5") return "blink"; 87 | return code; 88 | }) 89 | .map((name) => `ansi-${name}`); 90 | ansiClassNames.push(...styles); 91 | 92 | return getReplacement(getSpan(ansiClassNames.join(" "), text)); 93 | } 94 | ) 95 | .replace(/\x1B\[0m/g, ""); 96 | } 97 | 98 | /** 99 | * @param {string} text text to transform 100 | * @param {(...el: ElementObject[]) => string} getReplacement function for inserting replacements. 101 | * @returns {string} html 102 | */ 103 | function replaceURLs(text, getReplacement) { 104 | return text.replace( 105 | // Lord forgive me, for I have sinned 106 | /\b(https?:)\/\/(\w+(?:\.\w+)*)(:\d+)?((?:\/[\w\.]+)*\/?)((?:\?(?:&?\w+=\w+)*))?/gi, 107 | (_, protocol, host, port, path, params) => { 108 | const urlContainer = getSpan("url"); 109 | urlContainer.children = []; 110 | urlContainer.children.push( 111 | getSpan("url-protocol", protocol), 112 | "//", 113 | getSpan("url-host", host) 114 | ); 115 | for (const [key, value] of Object.entries({ port, path, params })) { 116 | if (!value) continue; 117 | urlContainer.children.push(getSpan(`url-${key}`, value)); 118 | } 119 | 120 | return getReplacement(urlContainer); 121 | } 122 | ); 123 | } 124 | 125 | /** 126 | * Highlights some text based off of various heuristics. 127 | * Returns html as a string 128 | * 129 | * @param {string} text text to transform 130 | * @param {(...els: ElementObject[]) => string} getReplacement function for inserting replacements. 131 | * @returns {string} html 132 | */ 133 | function replaceDate(text, getReplacement) { 134 | return ( 135 | text 136 | .replace(/\b\d+[-\/]\d+[-\/]\d+ \d+:\d+:\d+(?:[.,]\d+)?/g, (m) => { 137 | return getReplacement(getSpan("date", m)); 138 | }) 139 | // parse ISO date 140 | .replace(/\S+/g, (m) => { 141 | const dateObj = new Date(m); 142 | if (Number.isNaN(dateObj.valueOf())) { 143 | return m; 144 | } 145 | if (dateObj.toISOString() === m) { 146 | return getReplacement(getSpan("date", m)); 147 | } 148 | 149 | return m; 150 | }) 151 | ); 152 | } 153 | 154 | /** 155 | * @param {string} text text to transform 156 | * @param {(...els: ElementObject[]) => string} getReplacement function for inserting replacements. 157 | * @returns {string} html 158 | */ 159 | function replacePath(text, getReplacement) { 160 | return text 161 | .replace(/(\/?(?:[\w.-]+\/)+)(\S+)/g, (_, folder, file) => { 162 | return getReplacement(getSpan("path", folder), getSpan("file", file)); 163 | }) 164 | .replace(/[\w-]+\.[a-zA-Z]+(?::(?:\d+|\(\d+,\d+\)))?/g, (m) => { 165 | return getReplacement(getSpan("file", m)); 166 | }); 167 | } 168 | 169 | /** 170 | * @param {string} text text to transform 171 | * @param {(...els: ElementObject[]) => string} getReplacement function for inserting replacements. 172 | * @returns {string} html 173 | */ 174 | function replaceTags(text, getReplacement) { 175 | return text 176 | .replace(/^info\b|^warn\b|^error\b|^debug\b|^trace\b/i, (m) => { 177 | return getReplacement(getSpan("tag", m)); 178 | }) 179 | .replace(/\[\w+(?:\(\w+\))?\]/g, (m) => { 180 | // don't parse numbers and IPs as tags 181 | if (/\[[\d\.:]+\]/.test(m)) return m; 182 | return getReplacement(getSpan("tag", m)); 183 | }); 184 | } 185 | 186 | /** 187 | * Highlights some text based off of various heuristics. 188 | * Returns html as a string 189 | * 190 | * @param {string} text 191 | * @param {boolean} stripAnsiEscape 192 | * @returns {(string | ElementObject)[]} html 193 | */ 194 | export function getHighlightObjects(text, stripAnsiEscape = true) { 195 | /** @type {Map} */ 196 | const map = new Map(); 197 | let ident = 0; 198 | const getIdent = () => 199 | `${VARIATION_SELECTOR_100}${ident++}${VARIATION_SELECTOR_100}`; 200 | /** @param {(ElementObject | string)[]} el */ 201 | const getReplacement = (...el) => { 202 | const placeholder = getIdent(); 203 | map.set(placeholder, el); 204 | return placeholder; 205 | }; 206 | 207 | // Remove specific invisible character we will be using for regex replacing 208 | let modified = text.replace(new RegExp(VARIATION_SELECTOR_100, "g"), ""); 209 | modified = replaceAnsi(text, getReplacement, stripAnsiEscape); 210 | modified = replaceURLs(modified, getReplacement); 211 | modified = replaceDate(modified, getReplacement); 212 | modified = replacePath(modified, getReplacement); 213 | modified = replaceTags(modified, getReplacement); 214 | 215 | modified = modified 216 | // parse key=value pairs 217 | .replace(/(\S+?)=(\S+)/g, (_, key, value) => { 218 | return getReplacement( 219 | getSpan("key", key), 220 | "=", 221 | getSpan("value", value) 222 | ); 223 | }) 224 | // parse IP addrs 225 | .replace(/\b\d+\.\d+\.\d+\.\d+\b/g, (m) => { 226 | return getReplacement(getSpan("ip", m)); 227 | }) 228 | // parse quoted strings 229 | .replace(/"(?:\\"|[^"])*?"|'(?:\\'|[^'])*?'/g, (m) => { 230 | return getReplacement(getSpan("string", m)); 231 | }) 232 | // parse time 233 | .replace(/(?:\d+(\.\d+)?(?:h|ms?|s))+/g, (m) => { 234 | return getReplacement(getSpan("time", m)); 235 | }) 236 | // parse numbers 237 | .replace( 238 | new RegExp( 239 | String.raw`(?:${VARIATION_SELECTOR_100})?\b(?:-|\+)?\d+(?:\.\d+)?\b`, 240 | "g" 241 | ), 242 | (m) => { 243 | if (m.startsWith(VARIATION_SELECTOR_100)) return m; 244 | return getReplacement(getSpan("number", m)); 245 | } 246 | ) 247 | .replace(/\b(?:GET|POST|PUT|PATCH|DELETE)\b/g, (m) => { 248 | return getReplacement( 249 | getSpan("http-method http-method-" + m.toLowerCase(), m) 250 | ); 251 | }) 252 | // parse keywords 253 | .replace(/\b(?:true|false|null|undefined)\b/gi, (m) => { 254 | return getReplacement(getSpan("keyword", m)); 255 | }) 256 | // parse errors 257 | .replace(/\b(?:error|fail(?:ure|ed))\b/gi, (m) => { 258 | return getReplacement(getSpan("error", m)); 259 | }); 260 | 261 | if (!modified) return []; 262 | 263 | return modified 264 | .match( 265 | new RegExp( 266 | `${VARIATION_SELECTOR_100}\\d+${VARIATION_SELECTOR_100}|[^${VARIATION_SELECTOR_100}]+`, 267 | "g" 268 | ) 269 | ) 270 | .flatMap( 271 | /** @returns {(ElementObject | string)[]} */ (str) => { 272 | if (!str.startsWith(VARIATION_SELECTOR_100)) return [str]; 273 | return map.get(str); 274 | } 275 | ); 276 | } 277 | -------------------------------------------------------------------------------- /public/worker/index.js: -------------------------------------------------------------------------------- 1 | import { getHighlightObjects } from "./highlight.js"; 2 | 3 | /** @typedef {import('../../types.d.ts').HighlightWorkerRequest} Payload */ 4 | 5 | self.addEventListener("message", (/** @type {MessageEvent} */ e) => { 6 | const { input, stripAnsiEscape, id } = e.data; 7 | const highlighted = getHighlightObjects(input, stripAnsiEscape); 8 | self.postMessage({ nodes: highlighted, id }); 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "baseUrl": "public", 5 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 | "module": "nodenext", 7 | "target": "ESNext", 8 | "checkJs": true 9 | } 10 | } -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | export interface CliInput { 2 | input: string; 3 | date: number; 4 | id: string; 5 | } 6 | 7 | export interface Payload { 8 | input: string; 9 | stripAnsiEscape: boolean; 10 | id: string; 11 | } 12 | 13 | export type CommandPaletteAction = 14 | | "set-title" 15 | | "expand" 16 | | "theme" 17 | | "ansi" 18 | | "save" 19 | | "about" 20 | | "help"; 21 | 22 | export type ElementObject = { 23 | name: string; 24 | [key: string]: any; 25 | }; 26 | 27 | export interface HighlightWorkerRequest { 28 | input: string; 29 | stripAnsiEscape: boolean; 30 | id: string; 31 | } 32 | 33 | export interface HighlightWorkerResponse { 34 | nodes: (ElementObject | string)[]; 35 | id: string; 36 | } 37 | 38 | interface Tag { 39 | tag: string; 40 | textValue?: string; 41 | } 42 | export type TagGroup = Tag[]; 43 | --------------------------------------------------------------------------------