feat(highlight): integrate Highlight.js for syntax highlighting in JSON and SQL views

This commit is contained in:
Sam Chau
2026-01-02 20:21:03 +10:30
parent 77237b54ac
commit 59b032aab0
9 changed files with 216 additions and 100 deletions

View File

@@ -2,6 +2,73 @@
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap');
@import 'tailwindcss';
/* Highlight.js: atom-one-light for light mode */
@import 'highlight.js/styles/atom-one-light.css';
/* Highlight.js: atom-one-dark for dark mode */
.dark .hljs {
color: #abb2bf;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #5c6370;
font-style: italic;
}
.dark .hljs-doctag,
.dark .hljs-keyword,
.dark .hljs-formula {
color: #c678dd;
}
.dark .hljs-section,
.dark .hljs-name,
.dark .hljs-selector-tag,
.dark .hljs-deletion,
.dark .hljs-subst {
color: #e06c75;
}
.dark .hljs-literal {
color: #56b6c2;
}
.dark .hljs-string,
.dark .hljs-regexp,
.dark .hljs-addition,
.dark .hljs-attribute,
.dark .hljs-meta .hljs-string {
color: #98c379;
}
.dark .hljs-attr,
.dark .hljs-variable,
.dark .hljs-template-variable,
.dark .hljs-type,
.dark .hljs-selector-class,
.dark .hljs-selector-attr,
.dark .hljs-selector-pseudo,
.dark .hljs-number {
color: #d19a66;
}
.dark .hljs-symbol,
.dark .hljs-bullet,
.dark .hljs-link,
.dark .hljs-meta,
.dark .hljs-selector-id,
.dark .hljs-title {
color: #61aeee;
}
.dark .hljs-built_in,
.dark .hljs-title.class_,
.dark .hljs-class .hljs-title {
color: #e6c07b;
}
.dark .hljs-emphasis {
font-style: italic;
}
.dark .hljs-strong {
font-weight: bold;
}
.dark .hljs-link {
text-decoration: underline;
}
@custom-variant dark (&:where(.dark, .dark *));
@theme {

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json';
import sql from 'highlight.js/lib/languages/sql';
hljs.registerLanguage('json', json);
hljs.registerLanguage('sql', sql);
export let data: unknown;
// Check if data has a queries array with SQL strings
$: hasQueries = data && typeof data === 'object' && 'queries' in data && Array.isArray((data as Record<string, unknown>).queries);
// Extract queries separately for SQL highlighting
$: queries = hasQueries ? ((data as Record<string, unknown>).queries as string[]) : [];
// Create data without queries for JSON display
$: dataWithoutQueries = hasQueries
? Object.fromEntries(Object.entries(data as Record<string, unknown>).filter(([k]) => k !== 'queries'))
: data;
$: jsonString = JSON.stringify(dataWithoutQueries, null, 2);
$: highlightedJson = hljs.highlight(jsonString, { language: 'json' }).value;
function highlightSql(query: string): string {
return hljs.highlight(query, { language: 'sql' }).value;
}
</script>
<div class="json-view space-y-4">
<!-- JSON metadata -->
<pre class="!bg-transparent !p-0 !m-0 whitespace-pre-wrap font-mono"><code class="hljs font-mono">{@html highlightedJson}</code></pre>
<!-- SQL Queries -->
{#if queries.length > 0}
<div class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
<div class="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Queries ({queries.length})
</div>
<div class="space-y-2">
{#each queries as query, i}
<div class="rounded border border-neutral-200 bg-neutral-100 p-3 dark:border-neutral-600 dark:bg-neutral-900">
<pre class="!bg-transparent !p-0 !m-0 whitespace-pre-wrap text-xs font-mono"><code class="hljs font-mono">{@html highlightSql(query)}</code></pre>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.json-view :global(.hljs),
.json-view :global(.hljs *) {
background: transparent !important;
font-family: var(--font-mono) !important;
}
</style>

View File

@@ -9,6 +9,15 @@
export let confirmText = 'Confirm';
export let cancelText = 'Cancel';
export let confirmDanger = false; // If true, confirm button is styled as danger (red)
export let size: 'sm' | 'md' | 'lg' | 'xl' | '2xl' = 'md';
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
'2xl': 'max-w-6xl'
};
const dispatch = createEventDispatcher();
@@ -53,7 +62,7 @@
>
<!-- Modal -->
<div
class="relative w-full max-w-md rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
class="relative w-full {sizeClasses[size]} rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">

View File

@@ -105,7 +105,8 @@
return sortDirection === 'desc' ? sorted.reverse() : sorted;
}
$: sortedData = sortData(data);
$: sortedData = sortKey ? sortData(data) : data;
$: sortKey, sortDirection, sortedData = sortData(data);
</script>
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">

View File

@@ -2,6 +2,9 @@
import { Eye, Copy } from 'lucide-svelte';
import { alertStore } from '$alerts/store';
import Modal from '$ui/modal/Modal.svelte';
import JsonView from '$ui/meta/JsonView.svelte';
import Table from '$ui/table/Table.svelte';
import type { Column } from '$ui/table/types';
import LogsActionsBar from './components/LogsActionsBar.svelte';
import { createSearchStore } from '$lib/client/stores/search';
import { invalidateAll } from '$app/navigation';
@@ -45,6 +48,38 @@
ERROR: 'text-red-600 dark:text-red-400'
};
// Table columns
const columns: Column<LogEntry>[] = [
{
key: 'timestamp',
header: 'Timestamp',
sortable: true,
sortAccessor: (row) => new Date(row.timestamp).getTime(),
cell: (row) => ({
html: `<span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">${new Date(row.timestamp).toLocaleString()}</span>`
})
},
{
key: 'level',
header: 'Level',
sortable: true,
cell: (row) => ({
html: `<span class="font-semibold ${levelColors[row.level] || 'text-neutral-600 dark:text-neutral-400'}">${row.level}</span>`
})
},
{
key: 'source',
header: 'Source',
sortable: true,
cell: (row) => row.source || '-'
},
{
key: 'message',
header: 'Message',
cell: (row) => row.message
}
];
// Meta modal state
let showMetaModal = false;
let selectedMeta: unknown = null;
@@ -155,98 +190,31 @@
{/if}
</div>
<!-- Log Container -->
<div
class="overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900"
>
<table class="w-full text-sm">
<thead class="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-700 dark:text-neutral-50"
<!-- Log Table -->
<Table data={filteredLogs} {columns} emptyMessage="No logs found" hoverable={true} compact={true} initialSort={{ key: 'timestamp', direction: 'asc' }}>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-1">
<button
type="button"
on:click={() => copyLog(row)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
title="Copy log entry"
>
<Copy size={14} />
</button>
{#if row.meta}
<button
type="button"
on:click={() => viewMeta(row.meta)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
title="View metadata"
>
Timestamp
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-700 dark:text-neutral-50"
>
Level
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-700 dark:text-neutral-50"
>
Source
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-700 dark:text-neutral-50"
>
Message
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-700 dark:text-neutral-50"
>
Actions
</th>
</tr>
</thead>
<tbody class="[&_tr:last-child_td]:border-b-0">
{#each filteredLogs as log, index (`${log.timestamp}-${log.level}-${log.source}-${log.message}-${index}`)}
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800">
<td
class="border-b border-neutral-200 px-4 py-2 font-mono text-xs text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
{new Date(log.timestamp).toLocaleString()}
</td>
<td
class="border-b border-neutral-200 px-4 py-2 font-semibold dark:border-neutral-800 {levelColors[
log.level
] || 'text-neutral-600 dark:text-neutral-400'}"
>
{log.level}
</td>
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
{log.source || '-'}
</td>
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
{log.message}
</td>
<td class="border-b border-neutral-200 px-4 py-2 text-center dark:border-neutral-800">
<div class="flex items-center justify-center gap-1">
<button
type="button"
on:click={() => copyLog(log)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
title="Copy log entry"
>
<Copy size={14} />
</button>
{#if log.meta}
<button
type="button"
on:click={() => viewMeta(log.meta)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
title="View metadata"
>
<Eye size={14} />
</button>
{/if}
</div>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
No logs found
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Eye size={14} />
</button>
{/if}
</div>
</svelte:fragment>
</Table>
</div>
<!-- Meta Modal -->
@@ -256,14 +224,14 @@
bodyMessage=""
confirmText="Close"
cancelText="Close"
size="2xl"
on:confirm={closeMetaModal}
on:cancel={closeMetaModal}
>
<pre
<div
slot="body"
class="max-h-[400px] overflow-auto rounded-lg bg-neutral-50 p-4 font-mono text-xs whitespace-pre-wrap text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50">{JSON.stringify(
selectedMeta,
null,
2
)}</pre>
class="max-h-[70vh] overflow-auto rounded-lg bg-neutral-50 p-4 font-mono text-sm dark:bg-neutral-800"
>
<JsonView data={selectedMeta} />
</div>
</Modal>