Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,36 @@
<!-- markdownlint-disable-next-line MD041 -->
![LogHighlighter — Highlight, filter, and share raw logs in your browser. No build, no backend.](assets/banner.png)

# Log Highlighter

[![CI](https://github.com/al-af/LogHighlighter/actions/workflows/ci.yml/badge.svg)](https://github.com/al-af/LogHighlighter/actions/workflows/ci.yml)

A static, no-build browser tool for highlighting and filtering raw logs. Paste log text, define keyword groups (each gets a distinct pastel color), and the output panel highlights matches. Embedded JSON and Apple `NSDictionary` payloads inside log lines are detected and pretty-printed inline. Android `logcat` lines (threadtime, time, and brief formats) are detected and colored by severity (V/D/I/W/E/F/A), so iOS and Android logs both render usefully.

## Quickstart

**Try it instantly** on GitHub Pages: <https://al-af.github.io/LogHighlighter/>.

**Use it in 30 seconds:**

1. Pick a starter preset from the **Presets** dropdown — iOS SDK, Android SDK, React Native bridge, or OkHttp. The groups load immediately.
2. Paste any log into the **Input** panel.
3. Matching lines stay; non-matching lines collapse into `···` separators. Use the **Full** toggle to see everything.
4. Press <kbd>n</kbd> / <kbd>Shift</kbd>+<kbd>N</kbd> to jump between matches. The counter next to **Output** shows your position.
5. Click <kbd>? Guide</kbd> in the header for the full feature tour.

**Make it yours:**

- Define your own keyword groups in the **Keyword Groups** panel (comma-separated, one chip per keyword).
- Click <kbd>+ New preset</kbd> to save a setup with a name. Pick it from the dropdown anytime to reload.
- Click <kbd>Share</kbd> to copy a URL with your groups encoded — send it to a teammate. **Log content is never included.**

**For teams:**

- Share the GitHub Pages URL with your team — no install, no backend.
- Use <kbd>Share</kbd> to standardize keyword setups across the team.
- Click the moon / sun icon to switch between light and dark themes.

## Structure

```
Expand Down
Binary file added assets/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<head>
<meta charset="utf-8">
<title>Log Highlighter</title>
<link rel="icon" type="image/png" href="assets/icon.png">
<link rel="apple-touch-icon" href="assets/icon.png">
<link rel="stylesheet" href="styles/app.css">
</head>
<body>
Expand All @@ -13,6 +15,7 @@ <h1>Log Highlighter</h1>
<button data-mode="filter" class="active">Filter</button>
<button data-mode="full">Full</button>
</div>
<button id="toggleTheme" class="header-btn" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">☾</button>
<button id="openGuide" class="header-btn" type="button" title="Show guide" aria-label="Show guide">? Guide</button>
</div>
</header>
Expand Down Expand Up @@ -48,7 +51,7 @@ <h2>Input</h2>
</section>
</div>
<section class="panel">
<h2>Output</h2>
<h2>Output<span id="matchCount" class="match-count" hidden></span></h2>
<div class="panel-body" id="output">
<div class="placeholder">Add a keyword group and paste logs to begin.</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "loghighlighter",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
8 changes: 7 additions & 1 deletion src/guide.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const HTML = `
<li><kbd>+ New preset</kbd> — opens an empty editor where you give the preset a name and define its keyword groups (one group per line, comma-separated keywords).</li>
<li>Picking a preset from the dropdown <strong>applies it immediately</strong>, replacing your current groups.</li>
<li><kbd>×</kbd> — delete the selected preset (with confirmation).</li>
<li>Four starter presets ship by default: iOS SDK, Android SDK, React Native bridge, OkHttp. Delete the ones you don't use — they won't come back on reload.</li>
</ul>
</section>
<section>
Expand Down Expand Up @@ -60,13 +61,18 @@ const HTML = `
<li><span class="lvl lvl-F">F</span> Fatal — dark red, bold</li>
<li><span class="lvl lvl-A">A</span> Assert — purple tint</li>
</ul>
<p>iOS-style log lines without a logcat level are unaffected.</p>
<p>The same severity scheme applies to Apple <code>os_log</code> output: <code>Debug</code> maps to Verbose, <code>Info</code> stays Info, <code>Default</code>/<code>Notice</code> become Debug-blue, <code>Error</code> stays Error, <code>Fault</code> stays Fatal.</p>
<p>Plain <code>NSLog</code>-style lines without an explicit level marker pass through uncolored.</p>
</section>
<section>
<h3>Tips</h3>
<ul>
<li>Keep input under ~500 KB for smooth rendering.</li>
<li>State auto-saves to <code>localStorage</code> — refresh-safe.</li>
<li>Press <kbd>n</kbd> to jump to the next match in the output, <kbd>Shift</kbd>+<kbd>N</kbd> for the previous. The match count is shown next to the Output heading.</li>
<li>Line numbers reflect the original log line, not the filtered position — handy for sharing references like "see line 4823".</li>
<li>Click the moon / sun icon in the header to switch between light and dark themes. Your choice is remembered.</li>
<li>iOS <code>os_log</code> / <code>log show</code> output is auto-colored by severity (Debug, Info, Default/Notice, Error, Fault) using the same scheme as Android logcat.</li>
<li>Press <kbd>Esc</kbd> to close this guide.</li>
</ul>
</section>
Expand Down
70 changes: 70 additions & 0 deletions src/lineNav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Tracks the set of <mark> elements inside #output and exposes
// next/prev navigation plus a count to render in the header slot.

let currentIdx = -1;

function getMarks() {
return Array.from(document.querySelectorAll('#output mark'));
}

function clearCurrent(marks) {
for (const m of marks) m.classList.remove('mark-current');
}

function applyCurrent(marks) {
if (currentIdx < 0 || currentIdx >= marks.length) return;
const el = marks[currentIdx];
el.classList.add('mark-current');
if (typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}

function renderCounter(total) {
const el = document.getElementById('matchCount');
if (!el) return;
if (total === 0) { el.hidden = true; el.textContent = ''; return; }
el.hidden = false;
el.textContent = currentIdx >= 0
? `— ${currentIdx + 1} of ${total} matches`
: `— ${total} matches`;
}

export function refreshAfterRender() {
currentIdx = -1;
const marks = getMarks();
clearCurrent(marks);
renderCounter(marks.length);
}

function step(delta) {
const marks = getMarks();
if (marks.length === 0) return;
clearCurrent(marks);
currentIdx = currentIdx === -1
? (delta > 0 ? 0 : marks.length - 1)
: (currentIdx + delta + marks.length) % marks.length;
applyCurrent(marks);
renderCounter(marks.length);
}

function isTypingTarget(el) {
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
}

let keyboardAttached = false;

function onKeydown(e) {
if (isTypingTarget(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'n' && !e.shiftKey) { e.preventDefault(); step(1); }
else if (e.key === 'N' || (e.key === 'n' && e.shiftKey)) { e.preventDefault(); step(-1); }
}

export function attachKeyboard() {
if (keyboardAttached) return;
keyboardAttached = true;
document.addEventListener('keydown', onKeydown);
}
9 changes: 9 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { listPresets, savePreset, loadPreset, deletePreset, onChange as onPreset
import { encodeGroups, consumeHash } from './share.js';
import { openGuide } from './guide.js';
import { openPresetEditor } from './presetEditor.js';
import { seedStarterPresets } from './starterPresets.js';
import { attachKeyboard } from './lineNav.js';
import { applyInitialTheme, toggleTheme, syncToggleButton } from './theme.js';

const STATE_KEY = 'loghl:state';

Expand Down Expand Up @@ -193,6 +196,9 @@ document.getElementById('copyShareLink').addEventListener('click', copyShareLink

document.getElementById('openGuide').addEventListener('click', openGuide);

const themeBtn = document.getElementById('toggleTheme');
themeBtn.addEventListener('click', () => syncToggleButton(themeBtn, toggleTheme()));

window.addEventListener('hashchange', () => {
const r = consumeHash();
if (r.ok) {
Expand All @@ -209,9 +215,12 @@ window.addEventListener('hashchange', () => {

// ── initial render ──────────────────────────────────────────────────────────

syncToggleButton(themeBtn, applyInitialTheme());
seedStarterPresets();
renderPresetSelect();
syncModeButtons();
syncShareButton();
syncPresetSelectionButtons();
renderGroups();
renderOutput();
attachKeyboard();
27 changes: 27 additions & 0 deletions src/oslog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Detects Apple `os_log` / `log show` / `log stream` lines and maps their
// level keywords to the same severity letters used by logcat so the
// existing .lc-* CSS classes light up correctly.

// Matches `log show --style syslog` and `log stream` output:
// 2026-05-27 10:08:42.123456+0300 0x1a2b3c Default 0x0 1234 0 MyApp: hello
const OSLOG_SYSLOG = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+[+-]\d{4}\s+0x[\da-fA-F]+\s+(Debug|Info|Notice|Default|Error|Fault)\b/;

// Matches `log show` "compact" / table output where the level is the
// second whitespace-separated column right after the timestamp.
// 2026-05-27 10:08:42.123456+0300 Default com.appsflyer.sdk ...
const OSLOG_TABLE = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+[+-]\d{4}\s+(Debug|Info|Notice|Default|Error|Fault)\b/;

const LEVEL_MAP = {
Debug: 'V',
Info: 'I',
Notice: 'D', // legacy alias for Default in Apple's unified logging
Default: 'D',
Error: 'E',
Fault: 'F',
};

export function parseOSLog(line) {
const m = OSLOG_SYSLOG.exec(line) || OSLOG_TABLE.exec(line);
if (!m) return null;
return { level: LEVEL_MAP[m[1]] };
}
27 changes: 17 additions & 10 deletions src/output.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { state } from './state.js';
import { detectPayload } from './payload.js';
import { parseLogcat } from './logcat.js';
import { parseOSLog } from './oslog.js';
import { refreshAfterRender } from './lineNav.js';
import { escapeHTML, highlight } from './highlight.js';

function lineDiv(line, innerHTML) {
const lc = parseLogcat(line);
const cls = lc ? ` class="lc lc-${lc.level}"` : '';
return `<div${cls}>${innerHTML}</div>`;
function lineDiv(lineText, innerHTML, lineNumber) {
const parsed = parseLogcat(lineText) || parseOSLog(lineText);
const cls = parsed ? `lc lc-${parsed.level}` : '';
return `<div class="line ${cls}"><span class="ln">${lineNumber}</span><span class="content">${innerHTML}</span></div>`;
}

function lineMatches(line) {
Expand Down Expand Up @@ -41,30 +43,35 @@ export function renderOutput() {
const out = document.getElementById('output');
const raw = state.input;
if (!raw) {
out.innerHTML = '<div class="placeholder">Add a keyword group and paste logs to begin. (best with logs under 500KB)</div>';
out.innerHTML = '<div class="placeholder">Paste raw logs in the Input panel, then pick a preset above (or open <kbd>? Guide</kbd>) to start highlighting.<br><small>Best with logs under 500 KB.</small></div>';
refreshAfterRender();
return;
}
const lines = raw.split('\n');
const html = [];
let dropped = 0;
let emittedAny = false;
for (const line of lines) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNumber = i + 1;
const matched = lineMatches(line);
if (state.mode === 'filter') {
if (!matched) { dropped++; continue; }
if (dropped > 0 && emittedAny) html.push('<div class="sep">···</div>');
dropped = 0;
html.push(lineDiv(line, renderLine(line)));
html.push(lineDiv(line, renderLine(line), lineNumber));
emittedAny = true;
} else {
if (matched) html.push(lineDiv(line, renderLine(line)));
else html.push(lineDiv(line, escapeHTML(line)));
if (matched) html.push(lineDiv(line, renderLine(line), lineNumber));
else html.push(lineDiv(line, escapeHTML(line), lineNumber));
emittedAny = true;
}
}
if (!emittedAny) {
out.innerHTML = '<div class="placeholder">No matching lines.</div>';
out.innerHTML = '<div class="placeholder">No matching lines.<br><small>Switch to Full mode to see everything, or adjust your keyword groups.</small></div>';
refreshAfterRender();
return;
}
out.innerHTML = html.join('');
refreshAfterRender();
}
57 changes: 57 additions & 0 deletions src/starterPresets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Built-in preset library seeded once into localStorage on first run.
// Users may delete or overwrite these; the seed flag prevents re-seeding.

import { load, save } from './storage.js';
import { listPresets, savePreset } from './presets.js';

export const STARTER_PRESETS = [
{
name: 'iOS SDK',
groups: [
{ keywords: ['error', 'fatal', 'exception', 'NSError', 'crash'], colorIndex: 9 },
{ keywords: ['warning', 'deprecated'], colorIndex: 0 },
{ keywords: ['didFinishLaunching', 'willTerminate', 'applicationDidBecomeActive'], colorIndex: 4 },
],
},
{
name: 'Android SDK',
groups: [
{ keywords: ['FATAL', 'AndroidRuntime', 'Exception'], colorIndex: 9 },
{ keywords: ['W/', 'deprecated'], colorIndex: 0 },
{ keywords: ['onCreate', 'onResume', 'onDestroy'], colorIndex: 2 },
],
},
{
name: 'React Native bridge',
groups: [
{ keywords: ['ReactNativeJS', 'Unhandled', 'Error:'], colorIndex: 9 },
{ keywords: ['NativeModule', 'bridge', 'callback'], colorIndex: 5 },
{ keywords: ['Metro', 'bundling', 'Bundle'], colorIndex: 3 },
],
},
{
name: 'OkHttp',
groups: [
{ keywords: ['-->', '--> POST', '--> GET'], colorIndex: 4 },
{ keywords: ['<--', '<-- 200', '<-- 4', '<-- 5'], colorIndex: 2 },
{ keywords: ['SocketTimeoutException', 'IOException', 'failed'], colorIndex: 9 },
],
},
];

const SEED_KEY = 'loghl:starterSeeded';
const SEED_VERSION = 1;

export function seedStarterPresets() {
const seeded = load(SEED_KEY);
if (seeded && seeded.version >= SEED_VERSION) return { seeded: false, reason: 'already-seeded' };
const existing = new Set(listPresets());
let added = 0;
for (const preset of STARTER_PRESETS) {
if (existing.has(preset.name)) continue;
const result = savePreset(preset.name, preset.groups);
if (result.ok) added++;
}
save(SEED_KEY, { version: SEED_VERSION, seededAt: Date.now() });
return { seeded: true, added };
}
36 changes: 36 additions & 0 deletions src/theme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// data-theme on <html>; persists to localStorage, falls back to prefers-color-scheme.

import { load, save } from './storage.js';

const KEY = 'loghl:theme';

function systemPrefersDark() {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
}

export function applyInitialTheme() {
const stored = load(KEY);
const theme = (stored === 'light' || stored === 'dark')
? stored
: (systemPrefersDark() ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
return theme;
}

export function getTheme() {
return document.documentElement.getAttribute('data-theme') || 'light';
}

export function toggleTheme() {
const next = getTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
save(KEY, next);
return next;
}

export function syncToggleButton(btn, theme) {
btn.textContent = theme === 'dark' ? '☀' : '☾';
btn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
}
Loading