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
38 changes: 38 additions & 0 deletions app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,32 @@ body {
border-radius: 2px;
}

.tiptap p.md-code-block {
font-family: var(--font-mono);
font-size: 0.9em;
background-color: var(--color-border);
max-width: none;
padding: 0 1em;
}

.tiptap p.md-code-block-open {
padding-top: 0.5em;
}

.tiptap p.md-code-block-close {
padding-bottom: 0.5em;
}

/* Syntax highlighting tokens (sugar-high) */
.sh-keyword { color: #8b5cf6; }
.sh-string { color: #16a34a; }
.sh-comment { color: var(--color-muted); font-style: italic; }
.sh-class { color: #0891b2; }
.sh-property { color: #2563eb; }
.sh-entity { color: #c026d3; }
.sh-jsxliterals { color: #0891b2; }
.sh-sign { color: var(--color-muted); }

.md-strikethrough {
text-decoration: line-through;
}
Expand Down Expand Up @@ -333,6 +359,12 @@ body {
[data-theme="dark"] .cm-addition { color: #4ade80; }
[data-theme="dark"] .cm-deletion { color: #f87171; }
[data-theme="dark"] .preview a { color: #60a5fa; }
[data-theme="dark"] .sh-keyword { color: #a78bfa; }
[data-theme="dark"] .sh-string { color: #4ade80; }
[data-theme="dark"] .sh-class { color: #22d3ee; }
[data-theme="dark"] .sh-property { color: #60a5fa; }
[data-theme="dark"] .sh-entity { color: #e879f9; }
[data-theme="dark"] .sh-jsxliterals { color: #22d3ee; }

/* Dark theme — auto mode follows system preference */
@media (prefers-color-scheme: dark) {
Expand All @@ -348,6 +380,12 @@ body {
[data-theme="auto"] .cm-addition { color: #4ade80; }
[data-theme="auto"] .cm-deletion { color: #f87171; }
[data-theme="auto"] .preview a { color: #60a5fa; }
[data-theme="auto"] .sh-keyword { color: #a78bfa; }
[data-theme="auto"] .sh-string { color: #4ade80; }
[data-theme="auto"] .sh-class { color: #22d3ee; }
[data-theme="auto"] .sh-property { color: #60a5fa; }
[data-theme="auto"] .sh-entity { color: #e879f9; }
[data-theme="auto"] .sh-jsxliterals { color: #22d3ee; }
}

/* Onboarding marquee button */
Expand Down
184 changes: 184 additions & 0 deletions app/lib/markdown-decorations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { tokenize, SugarHigh } from "sugar-high";
import * as presets from "sugar-high/presets";

export type PatternType = "inline" | "prefix" | "heading" | "link";

Expand Down Expand Up @@ -84,6 +86,174 @@ export const MARKDOWN_PATTERNS: MarkdownPattern[] = [
},
];

export const CODE_FENCE_REGEX = /^(`{3,})(.*)?$/;

const TOKEN_TYPE_NAMES = SugarHigh.TokenTypes as unknown as string[];

// Token types that get no special colour — skip them to avoid unnecessary DOM nodes
const SKIP_TOKEN_TYPES = new Set(["identifier", "break", "space"]);

const LANGUAGE_PRESETS: Record<string, typeof presets.css | undefined> = {
css: presets.css,
rust: presets.rust,
rs: presets.rust,
python: presets.python,
py: presets.python,
c: presets.c,
cpp: presets.c,
"c++": presets.c,
h: presets.c,
go: presets.go,
golang: presets.go,
java: presets.java,
};

export function getLanguageOptions(lang: string | undefined) {
if (!lang) return undefined;
return LANGUAGE_PRESETS[lang.toLowerCase()];
}

export function highlightLine(
text: string,
basePos: number,
lang: string | undefined,
): Decoration[] {
if (text.length === 0) return [];

const options = getLanguageOptions(lang);
const tokens = tokenize(text, options ?? undefined);
const decorations: Decoration[] = [];
let offset = 0;

for (const [typeIndex, tokenText] of tokens) {
const typeName = TOKEN_TYPE_NAMES[typeIndex];
const len = tokenText.length;
if (!SKIP_TOKEN_TYPES.has(typeName) && len > 0) {
decorations.push(
Decoration.inline(basePos + offset, basePos + offset + len, {
class: `sh-${typeName}`,
}),
);
}
offset += len;
}

return decorations;
}

interface ParagraphInfo {
node: Parameters<Parameters<typeof import("@tiptap/pm/model").Node.prototype.descendants>[0]>[0];
pos: number;
}

export function findCodeBlockDecorations(
paragraphs: ParagraphInfo[],
): { decorations: Decoration[]; codeBlockRanges: Array<{ from: number; to: number }> } {
const decorations: Decoration[] = [];
const codeBlockRanges: Array<{ from: number; to: number }> = [];
let i = 0;

while (i < paragraphs.length) {
const { node: openNode, pos: openPos } = paragraphs[i];
const openText = openNode.textContent;
const openMatch = CODE_FENCE_REGEX.exec(openText);

if (!openMatch) {
i++;
continue;
}

const fenceChar = openMatch[1];
const fenceLen = fenceChar.length;

// Search for closing fence
let j = i + 1;
let closedAt = -1;
while (j < paragraphs.length) {
const closeText = paragraphs[j].node.textContent;
const closeMatch = CODE_FENCE_REGEX.exec(closeText);
if (closeMatch && closeMatch[1].length >= fenceLen && !closeMatch[2]?.trim()) {
closedAt = j;
break;
}
j++;
}

if (closedAt === -1) {
// No closing fence — not a code block
i++;
continue;
}

// Track the range from opening fence node start to closing fence node end
const blockFrom = openPos;
const closePara = paragraphs[closedAt];
const blockTo = closePara.pos + closePara.node.nodeSize;
codeBlockRanges.push({ from: blockFrom, to: blockTo });

// Opening fence: node decoration + inline delimiter
decorations.push(
Decoration.node(openPos, openPos + openNode.nodeSize, {
class: "md-code-block md-code-block-open",
}),
);
if (openNode.textContent.length > 0) {
decorations.push(
Decoration.inline(openPos + 1, openPos + 1 + openNode.textContent.length, {
class: "md-delimiter",
}),
);
}

// Extract language from fence info string (e.g. "```js" → "js")
const lang = openMatch[2]?.trim() || undefined;

// Inner lines: node decoration for background + monospace, plus syntax highlighting
for (let k = i + 1; k < closedAt; k++) {
const { node: innerNode, pos: innerPos } = paragraphs[k];
decorations.push(
Decoration.node(innerPos, innerPos + innerNode.nodeSize, {
class: "md-code-block",
}),
);
// Syntax highlight the text content (pos + 1 to skip paragraph open token)
const innerText = innerNode.textContent;
if (innerText.length > 0) {
decorations.push(...highlightLine(innerText, innerPos + 1, lang));
}
}

// Closing fence: node decoration + inline delimiter
decorations.push(
Decoration.node(closePara.pos, closePara.pos + closePara.node.nodeSize, {
class: "md-code-block md-code-block-close",
}),
);
if (closePara.node.textContent.length > 0) {
decorations.push(
Decoration.inline(closePara.pos + 1, closePara.pos + 1 + closePara.node.textContent.length, {
class: "md-delimiter",
}),
);
}

i = closedAt + 1;
}

return { decorations, codeBlockRanges };
}

function posInsideCodeBlock(
pos: number,
nodeSize: number,
codeBlockRanges: Array<{ from: number; to: number }>,
): boolean {
for (const range of codeBlockRanges) {
if (pos >= range.from && pos + nodeSize <= range.to) return true;
}
return false;
}

export function findDecorations(
text: string,
basePos: number,
Expand Down Expand Up @@ -231,8 +401,22 @@ export function markdownDecorations(): Plugin[] {
decorations(state) {
const decorations: Decoration[] = [];

// First pass: collect paragraphs and find code blocks
const paragraphs: ParagraphInfo[] = [];
state.doc.descendants((node, pos) => {
if (node.type.name === "paragraph") {
paragraphs.push({ node, pos });
}
});

const { decorations: codeBlockDecos, codeBlockRanges } =
findCodeBlockDecorations(paragraphs);
decorations.push(...codeBlockDecos);

// Second pass: inline patterns, skipping nodes inside code blocks
state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
if (posInsideCodeBlock(pos, node.nodeSize, codeBlockRanges)) return;
for (const pattern of MARKDOWN_PATTERNS) {
decorations.push(...findDecorations(node.text, pos, pattern));
}
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.10.0",
"sugar-high": "^1.1.0",
"y-protocols": "^1.0.7",
"yaml": "^2.8.2",
"yjs": "^13.6.29"
Expand Down
Loading
Loading