Skip to main content

Content & marketing

Code Diff code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/code-diff.blade.php

@props([
    'lines' => [],
    'filename' => null,
    'lang' => null,
    'numbers' => false,
    'startLine' => 1,
    'copy' => true,
])

@php
    use Tempest\Highlight\Highlighter;

    /*
    | Git-style diff viewer. Removed lines read red and added lines read green,
    | with server-side syntax highlighting via tempest/highlight. Theme-aware:
    | light surface by default, dark surface under the site's `.dark` theme.
    |
    | Pass a `:lines` array. Each entry is either:
    |   - a string prefixed with `+ ` (added) or `- ` (removed); anything else is
    |     context and keeps its indentation, or
    |   - an array shape ['type' => 'add'|'del'|'context', 'text' => '…'].
    |
    | Usage:
    |   <x-ui.code-diff filename="routes/web.php" lang="php" numbers :lines="[
    |       'Route::get(\'/\', fn () => view(\'welcome\'));',
    |       '- Route::get(\'/old\', [OldController::class, \'index\']);',
    |       '+ Route::get(\'/new\', [NewController::class, \'index\']);',
    |   ]" />
    |
    | A trailing slot is appended after the parsed lines for fully manual rows
    | (use .diff-line with .diff-add / .diff-del).
    */
    $parsed = [];
    foreach ($lines as $line) {
        if (is_array($line)) {
            $type = $line['type'] ?? 'context';
            $text = (string) ($line['text'] ?? '');
        } else {
            $text = (string) $line;
            $first = $text[0] ?? '';

            if ($first === '+') {
                $type = 'add';
                $text = preg_replace('/^\+ ?/', '', $text);
            } elseif ($first === '-') {
                $type = 'del';
                $text = preg_replace('/^- ?/', '', $text);
            } else {
                $type = 'context';
            }
        }

        $parsed[] = ['type' => $type, 'text' => $text];
    }

    // Highlight each line independently so a removed/added marker never bleeds
    // into the next line's tokenizer state. Falls back to plain (escaped) text
    // for unknown languages.
    $grammar = $lang ? strtolower($lang) : 'text';
    $highlighter = new Highlighter();
    foreach ($parsed as $i => $line) {
        $parsed[$i]['html'] = $line['text'] === ''
            ? ''
            : $highlighter->parse($line['text'], $grammar);
    }

    $additions = count(array_filter($parsed, fn ($l) => $l['type'] === 'add'));
    $deletions = count(array_filter($parsed, fn ($l) => $l['type'] === 'del'));

    // Reconstruct a copy-able unified diff body (keeps the +/- markers).
    $plain = implode("\n", array_map(
        fn ($l) => ($l['type'] === 'add' ? '+ ' : ($l['type'] === 'del' ? '- ' : '  ')).$l['text'],
        $parsed,
    ));

    $rows = [
        'add' => 'bg-green-500/10 border-green-500/60 dark:bg-green-500/10',
        'del' => 'bg-red-500/10 border-red-500/60 dark:bg-red-500/10',
        'context' => 'border-transparent',
    ];

    $signs = [
        'add' => ['+', 'text-green-600 dark:text-green-400'],
        'del' => ['-', 'text-red-600 dark:text-red-400'],
        'context' => ['', 'text-gray-400 dark:text-gray-600'],
    ];

    // Map tempest/highlight token classes onto the Harbour palette so snippets
    // read on-brand in both light and dark surfaces.
    $tokens = '[&_.hl-keyword]:text-brand-700 dark:[&_.hl-keyword]:text-brand-300 '
        .'[&_.hl-type]:text-amber-700 dark:[&_.hl-type]:text-amber-300 '
        .'[&_.hl-property]:text-amber-600 dark:[&_.hl-property]:text-amber-200 '
        .'[&_.hl-value]:text-green-700 dark:[&_.hl-value]:text-green-300 '
        .'[&_.hl-number]:text-amber-600 dark:[&_.hl-number]:text-amber-200 '
        .'[&_.hl-literal]:text-amber-600 dark:[&_.hl-literal]:text-amber-200 '
        .'[&_.hl-comment]:text-gray-400 dark:[&_.hl-comment]:text-gray-500 [&_.hl-comment]:italic '
        .'[&_.hl-operator]:text-gray-500 dark:[&_.hl-operator]:text-gray-400 '
        .'[&_.hl-variable]:text-gray-800 dark:[&_.hl-variable]:text-gray-100 '
        .'[&_.hl-attribute]:text-brand-700 dark:[&_.hl-attribute]:text-brand-300 '
        .'[&_.hl-generic]:text-gray-600 dark:[&_.hl-generic]:text-gray-300 '
        .'[&_.hl-injection]:text-brand-700 dark:[&_.hl-injection]:text-brand-300';

    $oldNo = (int) $startLine;
    $newNo = (int) $startLine;
@endphp

<div
    x-data="{
        copied: false,
        copyDiff() {
            navigator.clipboard.writeText(@js($plain)).then(() => {
                this.copied = true;
                clearTimeout(this._timer);
                this._timer = setTimeout(() => (this.copied = false), 1800);
            });
        },
    }"
    {{ $attributes->merge(['class' => 'group/diff overflow-hidden rounded-xl border border-gray-200/80 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-2xl shadow-gray-900/10 dark:shadow-gray-950/40 ring-1 ring-gray-900/5 dark:ring-white/5']) }}
>
    {{-- Title bar --}}
    <div class="flex items-center gap-3 border-b border-gray-200/80 dark:border-gray-800 bg-gray-50/80 dark:bg-gray-900/60 px-4 py-2.5">
        @if ($filename)
            <span class="flex items-center gap-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">
                <x-ui.icon name="file-code" size="sm" />
                {{ $filename }}
            </span>
        @endif

        <div class="ml-auto flex items-center gap-3">
            @if ($additions || $deletions)
                <span class="flex items-center gap-2 font-mono text-[11px] font-semibold">
                    <span class="text-green-600 dark:text-green-400">+{{ $additions }}</span>
                    <span class="text-red-600 dark:text-red-400">-{{ $deletions }}</span>
                </span>
            @endif

            @if ($lang)
                <span class="hidden text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-600 sm:inline">{{ $lang }}</span>
            @endif

            @if ($copy)
                <button
                    type="button"
                    @click="copyDiff()"
                    aria-label="Copy diff"
                    class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 transition hover:bg-gray-200/70 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-200 cursor-pointer"
                >
                    <x-ui.icon x-show="! copied" name="copy" size="sm" />
                    <x-ui.icon x-show="copied" x-cloak name="check" weight="bold" size="sm" class="text-brand-500 dark:text-brand-400" />
                    <span x-text="copied ? 'Copied' : 'Copy'"></span>
                </button>
            @endif
        </div>
    </div>

    {{-- Diff body --}}
    <div class="overflow-x-auto py-2 font-mono text-[13px] leading-relaxed text-gray-700 dark:text-gray-300 {{ $tokens }}">
        @foreach ($parsed as $line)
            @php
                [$sign, $signColor] = $signs[$line['type']] ?? $signs['context'];

                $showOld = $line['type'] !== 'add';
                $showNew = $line['type'] !== 'del';
                $old = $showOld ? $oldNo++ : null;
                $new = $showNew ? $newNo++ : null;
            @endphp

            <div class="diff-line flex border-l-2 {{ $rows[$line['type']] ?? $rows['context'] }}">
                @if ($numbers)
                    <span class="w-10 flex-none select-none px-2 text-right text-[11px] text-gray-400 dark:text-gray-600">{{ $old }}</span>
                    <span class="w-10 flex-none select-none px-2 text-right text-[11px] text-gray-400 dark:text-gray-600">{{ $new }}</span>
                @endif

                <span class="w-5 flex-none select-none text-center {{ $signColor }}">{{ $sign }}</span>
                <span class="flex-1 whitespace-pre pr-4 sm:pr-5">{!! $line['html'] !!}</span>
            </div>
        @endforeach

        {{ $slot }}
    </div>
</div>