Skip to content
Open
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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
src/test/fixtures/generated/**
src/test/fixtures/samples/sample_*.*
src/test/fixtures/diff/**
58 changes: 55 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ dev server.
- esm-env - `DEV` flag (required peer)
- magic-string, zimmerframe - build-time preprocessor helpers (`dependencies`;
`svelte_preprocess_fuz_code` only)
- fuz_util (@fuzdev/fuz_util) - preprocessor helper (required peer)
- fuz_util (@fuzdev/fuz_util) - preprocessor helper + diff data for the diff
viewer (required peer)
- fuz_ui (@fuzdev/fuz_ui) - docs system (dev only)

## Scope
Expand All @@ -47,6 +48,8 @@ fuz_code is a **syntax highlighting library**:
- 9 built-in languages (TS, JS, CSS, HTML, JSON, Svelte, Markdown, Shell, Rust)
- Extensible by writing a lexer (`SyntaxLang`)
- Optional Svelte component (`Code.svelte`)
- Syntax-highlighted diff rendering, unified and side-by-side
(`CodeDiff.svelte`/`CodeDiffSplit.svelte` over `diff_html.ts`)

### What fuz_code does NOT include

Expand All @@ -70,13 +73,17 @@ src/
│ ├── syntax_styler_global.ts # pre-configured global instance
│ ├── lexer.ts # lexer substrate: Lexer, TokenTypeRegistry, flat events, HTML render
│ ├── lexer_*.ts # hand-written lexers (json, ts, css, bash, markup, svelte, md, rust)
│ ├── diff_html.ts # diff viewer: render_diff_unified_html over fuz_util's diff data
│ ├── Code.svelte # main Svelte component
│ ├── CodeDiff.svelte # diff viewer component (unified view)
│ ├── CodeDiffSplit.svelte # diff viewer component (side-by-side view)
│ ├── CodeHighlight.svelte # experimental CSS Highlight API
│ ├── CodeTextarea.svelte # experimental live-highlighted textarea
│ ├── highlight_manager.ts # CSS Highlight API manager
│ ├── range_highlighting.svelte.ts # shared range-highlighting helper
│ ├── highlight_priorities.ts # generated token priorities
│ ├── theme.css # token CSS classes
│ ├── theme_diff.css # diff viewer theme (row tints via fuz_css intent variables)
│ ├── theme_variables.css # CSS variable fallbacks
│ └── theme_highlight.css # CSS Highlight API theme
├── test/ # test files and fixtures
Expand All @@ -86,6 +93,7 @@ src/
│ ├── pathological.ts # pathological input generators (tests + benchmark)
│ └── fixtures/
│ ├── samples/ # source of truth sample files
│ ├── diff/ # diff case dirs, each an a/b source pair
│ ├── generated/ # generated fixture outputs
│ ├── check.test.ts # fixture validation
│ └── update.task.ts # fixture regeneration task
Expand Down Expand Up @@ -115,9 +123,33 @@ The lexer emits a flat event stream (`LexedSyntax`) — leaf/open/close records
in one `Int32Array` with interned type ids; plain text is implicit between
events (recovered from offsets). `render_syntax_html` streams HTML from it in
one forward pass, wrapping spans with classes like `.token_keyword`,
`.token_string` (styled by `theme.css`); `syntax_events_to_tokens` flattens it
`.token_string` (styled by `theme.css`); `render_syntax_html_lines` renders
one balanced fragment per source line (spans open at a newline close there
and reopen on the next line), with optional `marks` ranges wrapped in
`<mark>` tags — marks wrap text runs only, cut at token boundaries, so
nesting always stays valid; `syntax_events_to_tokens` flattens it
to `{type, start, end}` for tests and fixtures.

### Diff viewer

`diff_html.ts` renders syntax-highlighted unified diffs:
`render_diff_unified_html(a, b, options)` composes `@fuzdev/fuz_util/diff.ts`
(Myers line diff, hunks, intra-line segments) with whole-document lexing per
side and `render_syntax_html_lines`. Rows are semantic `<ins>`/`<del>`
(context rows are `<span>`) carrying `diff_line` + `diff_add`/`diff_remove`/
`diff_same`, with aria-hidden unselectable gutters, `+`/`-` markers as CSS
generated content (copied text stays clean code), intra-line `<mark>`
emphasis, and elided unchanged regions as zero-JS `<details>` blocks
(options: `'details' | 'omit' | 'none'`). `render_diff_split_html` is the
side-by-side sibling — a flat cell sequence for a two-column grid, pairing
k-th remove with k-th add and padding unpaired sides with `.diff_spacer`
cells. `CodeDiff.svelte`/`CodeDiffSplit.svelte` are the thin wrappers owning
the `<div class="code_diff">`/`<div class="code_diff_split">` elements
(split defaults `wrap` on — half-width panes); `theme_diff.css` (opt-in,
alongside `theme.css`) styles both, with row tints keyed off fuz_css intent
variables (`--positive_*`/`--negative_*`) through `--diff_*` custom
properties.

### Language definitions

One `SyntaxLang` lexer per language, registered via `add_lang`:
Expand Down Expand Up @@ -177,6 +209,20 @@ can't overflow the call stack; past the cap a region stays plain text.
- `nomargin` - boolean for margin control
- `syntax_styler` - custom `SyntaxStyler` (default: `syntax_styler_global`)

**CodeDiff.svelte / CodeDiffSplit.svelte props** (unified / side-by-side):

- `a`, `b` - the original and updated source texts (or `dangerous_raw_html`
like `Code.svelte`)
- `lang` - language identifier (default: 'svelte'; `null` renders plain rows
with diff chrome only)
- `context_lines` - unchanged lines around changes (default: 3)
- `elide` - `'details' | 'omit' | 'none'` for unchanged regions (default:
'details')
- `intraline` - intra-line `<mark>` emphasis on paired lines (default: true)
- `line_numbers` - gutters (default: true)
- `wrap`, `nomargin`, `syntax_styler` - as in `Code.svelte` (`wrap` defaults
on for the split view)

## Supported languages

`ts`, `js`, `css`, `html`, `json`, `svelte`, `md`, `sh`, `rust`/`rs`
Expand All @@ -193,6 +239,12 @@ can't overflow the call stack; past the cap a region stays plain text.
Generated fixtures in `generated/{lang}/` include `.html` (tokenized output) and
`.txt` (debug output with token names).

Diff fixtures follow the same flow with pairs: each `src/test/fixtures/diff/{case}/`
holds an `a.{lang}` + `b.{lang}` source pair (byte-exact — the whole dir is
prettierignored, since trailing newlines and whitespace are diff inputs), and
`generated/diff/{case}` gets `.html` (unified), `.split.html`, and `.txt`
(stats + plain unified-diff text).

## Performance

```bash
Expand Down Expand Up @@ -336,7 +388,7 @@ New languages are written as lexers:

## Demo pages

- `/docs` - tomes: usage, samples, textarea, benchmark, api
- `/docs` - tomes: usage, samples, diff, textarea, benchmark, api
- `/benchmark` - interactive browser benchmark (work vs paint timing)

## Project standards
Expand Down
134 changes: 134 additions & 0 deletions src/lib/CodeDiff.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<script lang="ts">
import type {Snippet} from 'svelte';
import {DEV} from 'esm-env';
import type {SvelteHTMLElements} from 'svelte/elements';

import {syntax_styler_global} from './syntax_styler_global.ts';
import type {SyntaxStyler} from './syntax_styler.ts';
import {render_diff_unified_html, type RenderDiffOptions} from './diff_html.ts';

const {
a,
b,
dangerous_raw_html,
lang = 'svelte',
context_lines = 3,
elide = 'details',
intraline = true,
line_numbers = true,
wrap = false,
nomargin = false,
syntax_styler = syntax_styler_global,
children,
...rest
}: SvelteHTMLElements['div'] &
(
| {
/** The original version of the source. */
a: string;
/** The updated version of the source. */
b: string;
dangerous_raw_html?: undefined;
}
| {
a?: undefined;
b?: undefined;
/**
* Pre-rendered diff rows, bypassing runtime diffing and highlighting.
* Named `dangerous_raw_html` to signal that it bypasses sanitization,
* matching the `{@html}` pattern used by `Code.svelte`.
*/
dangerous_raw_html: string;
}
) & {
/**
* Language identifier for syntax highlighting. `null` disables
* highlighting (rows render as plain text with diff chrome);
* `undefined` falls back to the default ('svelte').
*
* @default 'svelte'
*/
lang?: string | null;
/**
* Unchanged lines of context around changes.
*
* @default 3
*/
context_lines?: number;
/**
* How elided unchanged regions render — see `RenderDiffOptions.elide`.
*
* @default 'details'
*/
elide?: RenderDiffOptions['elide'];
/**
* Whether paired remove/add lines get intra-line emphasis.
*
* @default true
*/
intraline?: boolean;
/**
* Whether to render line-number gutters.
*
* @default true
*/
line_numbers?: boolean;
/**
* Whether to wrap long lines (`pre-wrap` instead of `pre`).
*
* @default false
*/
wrap?: boolean;
/**
* Whether to disable the default margin-bottom.
*
* @default false
*/
nomargin?: boolean;
/**
* Custom `SyntaxStyler` instance.
*
* @default syntax_styler_global
*/
syntax_styler?: SyntaxStyler;
/**
* Optional snippet to customize how the diff rows are rendered.
* Receives the generated HTML string as a parameter.
*/
children?: Snippet<[markup: string]>;
} = $props();

// DEV-only validation warnings
if (DEV) {
$effect(() => {
if (dangerous_raw_html != null) return;

if (lang && !syntax_styler.has_lang(lang)) {
const langs = [...syntax_styler.langs.keys()].join(', ');
// eslint-disable-next-line no-console
console.error(
`[CodeDiff] Language "${lang}" is not supported. ` +
`Rows render as plain text. Supported: ${langs}`,
);
}
});
}

const html_content = $derived.by(() => {
if (dangerous_raw_html != null) return dangerous_raw_html;
return render_diff_unified_html(a, b, {
lang,
syntax_styler,
context_lines,
elide,
intraline,
line_numbers,
});
});
</script>

<!-- eslint-disable svelte/no-at-html-tags -->

<div {...rest} class={['code_diff', rest.class]} class:wrap class:nomargin data-lang={lang}>
{#if children}{@render children(html_content)}{:else}{@html html_content}{/if}
</div>
136 changes: 136 additions & 0 deletions src/lib/CodeDiffSplit.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<script lang="ts">
import type {Snippet} from 'svelte';
import {DEV} from 'esm-env';
import type {SvelteHTMLElements} from 'svelte/elements';

import {syntax_styler_global} from './syntax_styler_global.ts';
import type {SyntaxStyler} from './syntax_styler.ts';
import {render_diff_split_html, type RenderDiffOptions} from './diff_html.ts';

const {
a,
b,
dangerous_raw_html,
lang = 'svelte',
context_lines = 3,
elide = 'details',
intraline = true,
line_numbers = true,
wrap = true,
nomargin = false,
syntax_styler = syntax_styler_global,
children,
...rest
}: SvelteHTMLElements['div'] &
(
| {
/** The original version of the source. */
a: string;
/** The updated version of the source. */
b: string;
dangerous_raw_html?: undefined;
}
| {
a?: undefined;
b?: undefined;
/**
* Pre-rendered diff cells, bypassing runtime diffing and highlighting.
* Named `dangerous_raw_html` to signal that it bypasses sanitization,
* matching the `{@html}` pattern used by `Code.svelte`.
*/
dangerous_raw_html: string;
}
) & {
/**
* Language identifier for syntax highlighting. `null` disables
* highlighting (rows render as plain text with diff chrome);
* `undefined` falls back to the default ('svelte').
*
* @default 'svelte'
*/
lang?: string | null;
/**
* Unchanged lines of context around changes.
*
* @default 3
*/
context_lines?: number;
/**
* How elided unchanged regions render — see `RenderDiffOptions.elide`.
*
* @default 'details'
*/
elide?: RenderDiffOptions['elide'];
/**
* Whether paired remove/add lines get intra-line emphasis.
*
* @default true
*/
intraline?: boolean;
/**
* Whether to render line-number gutters.
*
* @default true
*/
line_numbers?: boolean;
/**
* Whether to wrap long lines. Defaults on for the split view —
* each pane has half the width, so wrapping beats per-cell
* horizontal scrolling.
*
* @default true
*/
wrap?: boolean;
/**
* Whether to disable the default margin-bottom.
*
* @default false
*/
nomargin?: boolean;
/**
* Custom `SyntaxStyler` instance.
*
* @default syntax_styler_global
*/
syntax_styler?: SyntaxStyler;
/**
* Optional snippet to customize how the diff cells are rendered.
* Receives the generated HTML string as a parameter.
*/
children?: Snippet<[markup: string]>;
} = $props();

// DEV-only validation warnings
if (DEV) {
$effect(() => {
if (dangerous_raw_html != null) return;

if (lang && !syntax_styler.has_lang(lang)) {
const langs = [...syntax_styler.langs.keys()].join(', ');
// eslint-disable-next-line no-console
console.error(
`[CodeDiffSplit] Language "${lang}" is not supported. ` +
`Rows render as plain text. Supported: ${langs}`,
);
}
});
}

const html_content = $derived.by(() => {
if (dangerous_raw_html != null) return dangerous_raw_html;
return render_diff_split_html(a, b, {
lang,
syntax_styler,
context_lines,
elide,
intraline,
line_numbers,
});
});
</script>

<!-- eslint-disable svelte/no-at-html-tags -->

<div {...rest} class={['code_diff_split', rest.class]} class:wrap class:nomargin data-lang={lang}>
{#if children}{@render children(html_content)}{:else}{@html html_content}{/if}
</div>
Loading
Loading