feat(markdown): add /g/ [code] tag syntax highlighting

Parse [code] blocks on /g/ into Prettify-style highlighted code boxes while leaving the tags as literal text on other boards.
This commit is contained in:
Tommaso Casaburi
2026-06-05 17:02:44 +07:00
parent bdbe44aa90
commit ae16ae3dd3
8 changed files with 508 additions and 27 deletions
@@ -0,0 +1,47 @@
/* Matches 4chan's computed prettyprint styling: bare monospace at the surrounding post-text
size, 5px padding, no border on light themes (white box on the post bg), translucent box
with a border on dark themes. box-sizing/max-width keep it from overflowing on mobile. */
.code {
display: inline-block;
box-sizing: border-box;
/* Center the box on the line so a single-line block sits level with adjacent text;
has no effect on a block that sits alone on its own line (the common 4chan case). */
vertical-align: middle;
max-width: min(600px, 100%);
max-height: 400px;
overflow: auto;
margin: 0;
padding: 5px;
background-color: var(--code-block-background-color);
border: var(--code-block-border, none);
color: var(--code-token-pln-color);
font-family: monospace;
line-height: normal;
white-space: pre;
tab-size: 8;
-moz-tab-size: 8;
}
.pln {
color: var(--code-token-pln-color);
}
.str {
color: var(--code-token-str-color);
}
.kwd {
color: var(--code-token-kwd-color);
}
.com {
color: var(--code-token-com-color);
}
.lit {
color: var(--code-token-lit-color);
}
.pun {
color: var(--code-token-pun-color);
}
+25
View File
@@ -0,0 +1,25 @@
import { useMemo } from 'react';
import { highlightCode } from '../../lib/utils/code-highlight';
import styles from './code-block.module.css';
/**
* Renders a [code] block the way 4chan does on /g/: a flat, square, monospaced box
* that preserves whitespace and syntax-highlights tokens using a Prettify-style palette.
* Uses a block-level <code> element (phrasing content) so it nests validly inside the
* inline markdown <span>.
*/
const CodeBlock = ({ source }: { source: string }) => {
const tokens = useMemo(() => highlightCode(source), [source]);
return (
<code className={styles.code}>
{tokens.map((token) => (
<span key={token.start} className={styles[token.cls]}>
{token.value}
</span>
))}
</code>
);
};
export default CodeBlock;
+1
View File
@@ -0,0 +1 @@
export { default } from './code-block';
@@ -285,6 +285,34 @@ describe('Markdown', () => {
expect(container.textContent).toContain('[spoiler]spoiled text[/spoiler]');
});
it('renders [code] blocks as syntax-highlighted code on /g/', async () => {
await renderMarkdown(
{
content: 'before\n[code]>not a quote\nconst x = 1;[/code]\nafter',
communityAddress: 'technology-posting.bso',
},
'/g/thread/post-1',
);
const code = container.querySelector('code');
expect(code).not.toBeNull();
expect(code?.textContent).toBe('>not a quote\nconst x = 1;');
// Code contents are tokenized into spans and never parsed as greentext.
expect(code?.querySelectorAll('span').length).toBeGreaterThan(0);
expect(container.querySelector('.greentext')).toBeNull();
expect(container.textContent).toContain('before');
expect(container.textContent).toContain('after');
});
it('renders [code] as literal text off /g/', async () => {
await renderMarkdown({
content: '[code]const x = 1;[/code]',
});
expect(container.querySelector('code')).toBeNull();
expect(container.textContent).toBe('[code]const x = 1;[/code]');
});
it('renders greentext for any leading marker run while preserving quote links', async () => {
await renderMarkdown({
content: '>green line\n>>test\n>>>>>>>test\n>>42\n>>>/fit/',
+81 -27
View File
@@ -6,6 +6,7 @@ import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { isCatalogView } from '../../lib/utils/view-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import CommentMedia from '../comment-media';
import CodeBlock from '../code-block';
import styles from './markdown.module.css';
import { Link, useLocation, useParams } from 'react-router-dom';
import { canEmbed } from '../embed';
@@ -708,6 +709,69 @@ const renderLineContent = (line: string, context: RenderContext): React.ReactNod
return elements;
};
// [code]...[/code] blocks (4chan rule 4 of /g/): rendered literally, never parsed for
// greentext/quotelinks/spoilers, and syntax-highlighted via <CodeBlock>.
const CODE_TAG_REGEX = /\[code\]([\s\S]*?)\[\/code\]/gi;
const HAS_CODE_TAG_REGEX = /\[code\]/i;
const CODE_DIRECTORY_CODE = 'g';
type ContentSegment = { type: 'text' | 'code'; value: string; start: number };
const splitCodeSegments = (raw: string): ContentSegment[] => {
const segments: ContentSegment[] = [];
const regex = new RegExp(CODE_TAG_REGEX.source, 'gi');
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(raw)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: 'text', value: raw.slice(lastIndex, match.index), start: lastIndex });
}
// Drop one leading/trailing newline so [code]\n...\n[/code] has no blank first/last line.
segments.push({ type: 'code', value: match[1].replace(/^\n/, '').replace(/\n$/, ''), start: match.index });
lastIndex = regex.lastIndex;
}
if (lastIndex < raw.length) {
segments.push({ type: 'text', value: raw.slice(lastIndex), start: lastIndex });
}
return segments;
};
const renderTextLines = (normalized: string, context: RenderContext, keyPrefix: string): React.ReactNode[] => {
const lines = normalized.split('\n');
const elements: React.ReactNode[] = [];
let lineOffset = 0;
lines.forEach((line, lineIndex) => {
const lineKey = `${keyPrefix}line-${lineOffset}`;
lineOffset += line.length + 1;
if (lineIndex > 0) {
elements.push(<br key={`br-${lineKey}`} />);
}
if (line.length === 0) return;
const isGreentext = isGreentextLine(line);
const lineElements = renderLineContent(line, context);
if (isGreentext) {
elements.push(
<span key={lineKey} className='greentext'>
{lineElements}
</span>,
);
} else {
elements.push(<React.Fragment key={lineKey}>{lineElements}</React.Fragment>);
}
});
return elements;
};
const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = true }: MarkdownProps) => {
const location = useLocation();
const params = useParams();
@@ -716,42 +780,32 @@ const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = t
const enableQstBbcode = location.pathname.split('/').filter(Boolean)[0] === 'qst';
const activeDirectoryCode = getActiveDirectoryCode(location.pathname, communityAddress, directories);
const enableFortuneMarkup = isFortuneDirectoryCode(activeDirectoryCode);
// [code] tags are a /g/ feature (4chan rule 4): enabled when the post's board or the current
// route resolves to /g/, and rendered as literal text everywhere else.
const enableCodeTags =
getDirectoryCodeForIdentifier(getRouteBoardIdentifier(location.pathname), directories) === CODE_DIRECTORY_CODE ||
getDirectoryCodeForIdentifier(communityAddress, directories) === CODE_DIRECTORY_CODE;
const rendered = useMemo(() => {
const normalized = normalizeContent(content || '');
const lines = normalized.split('\n');
const elements: React.ReactNode[] = [];
let lineOffset = 0;
const context = { isInCatalogView, postCid, communityAddress, enableFortuneMarkup, enableQstBbcode, parseSpoilers };
const raw = content || '';
lines.forEach((line, lineIndex) => {
const lineKey = `line-${lineOffset}`;
lineOffset += line.length + 1;
if (!enableCodeTags || !HAS_CODE_TAG_REGEX.test(raw)) {
return renderTextLines(normalizeContent(raw), context, '');
}
if (lineIndex > 0) {
elements.push(<br key={`br-${lineKey}`} />);
}
if (line.length === 0) return;
const isGreentext = isGreentextLine(line);
const lineElements = renderLineContent(line, context);
if (isGreentext) {
elements.push(
<span key={lineKey} className='greentext'>
{lineElements}
</span>,
);
} else {
elements.push(<React.Fragment key={lineKey}>{lineElements}</React.Fragment>);
const elements: React.ReactNode[] = [];
splitCodeSegments(raw).forEach((segment) => {
if (segment.type === 'code') {
elements.push(<CodeBlock key={`code-${segment.start}`} source={segment.value} />);
return;
}
if (!segment.value) return;
elements.push(<React.Fragment key={`text-${segment.start}`}>{renderTextLines(normalizeContent(segment.value), context, `${segment.start}:`)}</React.Fragment>);
});
return elements;
}, [content, isInCatalogView, postCid, communityAddress, enableFortuneMarkup, enableQstBbcode, parseSpoilers]);
}, [content, isInCatalogView, postCid, communityAddress, enableFortuneMarkup, enableQstBbcode, parseSpoilers, enableCodeTags]);
return (
<span className={styles.markdown}>