feat(composer): offer to format pasted code as a code block (#107)
CI / Build & Quality Checks (push) Successful in 1m52s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m45s
CI / Build & Quality Checks (push) Successful in 1m52s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m45s
When a multi-line paste looks like code, a chip above the composer asks "That looks like code. Format it as a code block (js)?" with Format as code / No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and Settings → Editor → "Offer to Format Pasted Code" turns it off. - utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation with depth changes, statement terminators, operators + brackets, keywords, monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted replies, URL lists, long sentence-punctuated lines, plain-word lines, markdown lists. Language guess only when fairly sure (js/ts/python/sql/ rust/c/php). - Accept rebuilds the pasted paragraphs as one code block from their plain text (code lines hold text only) and leaves the caret after it. - Code blocks carry an optional lang → <code class="language-js"> (whitelisted identifier only). Verified in Chromium: pasting a JS function shows the chip; Format as code → sent formatted_body is <pre><code class="language-js"> with indentation intact; a three-line prose paste shows no chip; typing after a paste dismisses it. Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e91b5fe10e
commit
75d55e861b
@@ -89,3 +89,22 @@ test('markdown: no math conversion inside a backtick span, math outside still co
|
||||
assert.ok(/<code[^>]*>\$x\$<\/code>/.test(out), 'backtick span stays literal');
|
||||
assert.ok(out.includes('data-mx-maths="y"'));
|
||||
});
|
||||
|
||||
test('[#107] a code block language becomes class="language-…"', () => {
|
||||
const block = {
|
||||
...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('let a = 1;')])]) as object),
|
||||
lang: 'js',
|
||||
} as unknown as Descendant;
|
||||
assert.equal(
|
||||
toMatrixCustomHTML(block, OPTS),
|
||||
'<pre><code class="language-js">let a = 1;\n</code></pre>',
|
||||
);
|
||||
});
|
||||
|
||||
test('[#107] an unsafe language value is dropped', () => {
|
||||
const block = {
|
||||
...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('x')])]) as object),
|
||||
lang: 'js" onclick="x',
|
||||
} as unknown as Descendant;
|
||||
assert.equal(toMatrixCustomHTML(block, OPTS), '<pre><code>x\n</code></pre>');
|
||||
});
|
||||
|
||||
@@ -86,7 +86,10 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
|
||||
case BlockType.CodeLine:
|
||||
return `${children}\n`;
|
||||
case BlockType.CodeBlock:
|
||||
return `<pre><code>${children}</code></pre>`;
|
||||
// [Gitea #107] Only a plain identifier is ever put in the class.
|
||||
return node.lang && /^[a-z0-9+#-]{1,20}$/.test(node.lang)
|
||||
? `<pre><code class="language-${node.lang}">${children}</code></pre>`
|
||||
: `<pre><code>${children}</code></pre>`;
|
||||
case BlockType.QuoteLine:
|
||||
return `${children}<br/>`;
|
||||
case BlockType.BlockQuote:
|
||||
|
||||
Vendored
+2
@@ -64,6 +64,8 @@ export type CodeLineElement = {
|
||||
};
|
||||
export type CodeBlockElement = {
|
||||
type: BlockType.CodeBlock;
|
||||
/** [Gitea #107] Optional language → `<code class="language-…">`. */
|
||||
lang?: string;
|
||||
children: CodeLineElement[];
|
||||
};
|
||||
export type QuoteLineElement = {
|
||||
|
||||
@@ -13,8 +13,9 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Transforms, Editor } from 'slate';
|
||||
import { Transforms, Editor, Element as SlateElement, Node as SlateNode, Range } from 'slate';
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Dialog,
|
||||
Icon,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
getBeginCommand,
|
||||
trimCommand,
|
||||
getMentions,
|
||||
BlockType,
|
||||
} from '../../components/editor';
|
||||
import { EmojiBoardTab } from '../../components/emoji-board/types';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
@@ -99,6 +101,7 @@ import { filesToUploadItems } from '../../utils/uploadItems';
|
||||
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
|
||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { looksLikeCode } from '../../utils/looksLikeCode';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import {
|
||||
ComposerToolbarButtonKey,
|
||||
@@ -297,6 +300,48 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
|
||||
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||
// [Gitea #107] "Paste as code block?" offer for the top-level blocks a
|
||||
// paste just filled. Dismissed by typing, by No, or after 8 s.
|
||||
const [offerCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
|
||||
const [codeOffer, setCodeOffer] = useState<{ start: number; end: number; lang?: string }>();
|
||||
useEffect(() => {
|
||||
if (!codeOffer) return undefined;
|
||||
const t = window.setTimeout(() => setCodeOffer(undefined), 8000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [codeOffer]);
|
||||
const acceptCodeOffer = useCallback(() => {
|
||||
if (!codeOffer) return;
|
||||
const { start, end, lang } = codeOffer;
|
||||
setCodeOffer(undefined);
|
||||
if (end >= editor.children.length || start > end) return;
|
||||
const blocks = editor.children.slice(start, end + 1);
|
||||
// Only plain paragraphs (what a paste produces); anything else, leave it.
|
||||
if (blocks.some((b) => !SlateElement.isElement(b) || b.type !== BlockType.Paragraph)) return;
|
||||
// Rebuild from plain text: code lines hold text only, and a paste may
|
||||
// have produced links/mentions.
|
||||
const lines = blocks.map((b) => SlateNode.string(b));
|
||||
Editor.withoutNormalizing(editor, () => {
|
||||
for (let i = end; i >= start; i -= 1) Transforms.removeNodes(editor, { at: [i] });
|
||||
Transforms.insertNodes(
|
||||
editor,
|
||||
{
|
||||
type: BlockType.CodeBlock,
|
||||
lang,
|
||||
children: lines.map((text) => ({ type: BlockType.CodeLine, children: [{ text }] })),
|
||||
},
|
||||
{ at: [start] },
|
||||
);
|
||||
if (start + 1 >= editor.children.length) {
|
||||
Transforms.insertNodes(
|
||||
editor,
|
||||
{ type: BlockType.Paragraph, children: [{ text: '' }] },
|
||||
{ at: [start + 1] },
|
||||
);
|
||||
}
|
||||
});
|
||||
Transforms.select(editor, Editor.start(editor, [start + 1]));
|
||||
ReactEditor.focus(editor);
|
||||
}, [codeOffer, editor]);
|
||||
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||
@@ -411,7 +456,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const handlePaste = useCallback<React.ClipboardEventHandler>(
|
||||
(evt) => {
|
||||
handleFilePaste(evt);
|
||||
if (evt.defaultPrevented || !stripTracking) return;
|
||||
if (evt.defaultPrevented) return;
|
||||
if (offerCodePaste && editor.selection) {
|
||||
const pasted = evt.clipboardData?.getData('text/plain') ?? '';
|
||||
const startBlock = Range.start(editor.selection).path[0];
|
||||
const startNode = editor.children[startBlock];
|
||||
const inCode =
|
||||
SlateElement.isElement(startNode) && startNode.type === BlockType.CodeBlock;
|
||||
const guess =
|
||||
!inCode && pasted
|
||||
? looksLikeCode(pasted, evt.clipboardData?.getData('text/html'))
|
||||
: undefined;
|
||||
if (guess?.isCode) {
|
||||
// Slate inserts after this handler returns; read where it ended.
|
||||
window.setTimeout(() => {
|
||||
const endBlock = editor.selection ? Range.end(editor.selection).path[0] : startBlock;
|
||||
setCodeOffer({ start: startBlock, end: endBlock, lang: guess.lang });
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
if (!stripTracking) return;
|
||||
const text = evt.clipboardData?.getData('text/plain');
|
||||
if (!text) return;
|
||||
const cleaned = stripTrackingParamsInText(text);
|
||||
@@ -424,7 +488,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
dt.setData('text/plain', cleaned);
|
||||
ReactEditor.insertData(editor, dt);
|
||||
},
|
||||
[handleFilePaste, stripTracking, editor],
|
||||
[handleFilePaste, stripTracking, editor, offerCodePaste],
|
||||
);
|
||||
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
||||
const { gifApiKey } = useClientConfig();
|
||||
@@ -853,6 +917,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
// Typing (not Tab/arrows/modifiers) dismisses the paste-as-code offer.
|
||||
if (
|
||||
evt.key.length === 1 ||
|
||||
evt.key === 'Backspace' ||
|
||||
evt.key === 'Delete' ||
|
||||
evt.key === 'Enter'
|
||||
) {
|
||||
setCodeOffer(undefined);
|
||||
}
|
||||
if (
|
||||
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
|
||||
!isComposing(evt)
|
||||
@@ -1149,6 +1222,43 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{codeOffer && (
|
||||
<Box
|
||||
role="group"
|
||||
aria-label="Paste as code block"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
wrap="Wrap"
|
||||
style={{
|
||||
margin: `0 ${config.space.S300} ${config.space.S100}`,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Icon size="100" src={Icons.BlockCode} style={{ flexShrink: 0 }} />
|
||||
<Text size="T200" style={{ flexGrow: 1 }}>
|
||||
That looks like code. Format it as a code block
|
||||
{codeOffer.lang ? ` (${codeOffer.lang})` : ''}?
|
||||
</Text>
|
||||
<Button size="300" variant="Primary" radii="300" onClick={acceptCodeOffer}>
|
||||
<Text size="B300">Format as code</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setCodeOffer(undefined);
|
||||
ReactEditor.focus(editor);
|
||||
}}
|
||||
>
|
||||
<Text size="B300">No thanks</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<ScheduledMessagesTray roomId={roomId} />
|
||||
<CustomEditor
|
||||
editableName={editableName}
|
||||
|
||||
@@ -1405,6 +1405,7 @@ function ComposerToolbarReorder({
|
||||
function Editor() {
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [offerCodePaste, setOfferCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
|
||||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
|
||||
settingsAtom,
|
||||
@@ -1455,6 +1456,13 @@ function Editor() {
|
||||
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Offer to Format Pasted Code"
|
||||
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
|
||||
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Formatting Toolbar"
|
||||
|
||||
@@ -268,6 +268,9 @@ export interface Settings {
|
||||
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
|
||||
// send, and from links rendered in the timeline. Local only.
|
||||
stripTrackingParams: boolean;
|
||||
// [Gitea #107] After a paste that looks like code, offer to make it a code
|
||||
// block (a chip above the composer; never converts on its own).
|
||||
offerCodePaste: boolean;
|
||||
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
|
||||
// uploads without re-encoding. Default on.
|
||||
stripImageMetadata: boolean;
|
||||
@@ -397,6 +400,7 @@ const defaultSettings: Settings = {
|
||||
warnOnUnverifiedDevices: false,
|
||||
|
||||
stripTrackingParams: true,
|
||||
offerCodePaste: true,
|
||||
stripImageMetadata: true,
|
||||
callRejoinAfterRestart: 'ask',
|
||||
hapticFeedback: true,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { looksLikeCode } from './looksLikeCode';
|
||||
|
||||
const code = (text: string, html?: string) => looksLikeCode(text, html);
|
||||
|
||||
describe('looksLikeCode: code', () => {
|
||||
it('JavaScript', () => {
|
||||
const r = code(`function add(a, b) {
|
||||
const sum = a + b;
|
||||
if (sum > 10) {
|
||||
return sum;
|
||||
}
|
||||
return 0;
|
||||
}`);
|
||||
assert.equal(r.isCode, true);
|
||||
assert.equal(r.lang, 'js');
|
||||
});
|
||||
|
||||
it('TypeScript', () => {
|
||||
const r = code(`interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
const u: User = { id: '1', name: 'a' };`);
|
||||
assert.deepEqual(r, { isCode: true, lang: 'ts' });
|
||||
});
|
||||
|
||||
it('Python', () => {
|
||||
const r = code(`def greet(name):
|
||||
if not name:
|
||||
return "hi"
|
||||
return f"hi {name}"
|
||||
print(greet("bob"))`);
|
||||
assert.deepEqual(r, { isCode: true, lang: 'python' });
|
||||
});
|
||||
|
||||
it('SQL', () => {
|
||||
const r = code(`SELECT id, name
|
||||
FROM users
|
||||
WHERE active = 1
|
||||
ORDER BY name;`);
|
||||
assert.deepEqual(r, { isCode: true, lang: 'sql' });
|
||||
});
|
||||
|
||||
it('Rust', () => {
|
||||
const r = code(`pub fn main() {
|
||||
let mut total = 0;
|
||||
for i in 0..10 {
|
||||
total += i;
|
||||
}
|
||||
}`);
|
||||
assert.deepEqual(r, { isCode: true, lang: 'rust' });
|
||||
});
|
||||
|
||||
it('config-ish JSON without a language guess', () => {
|
||||
const r = code(`{
|
||||
"name": "lotus",
|
||||
"version": "1.0.0",
|
||||
"private": true
|
||||
}`);
|
||||
assert.equal(r.isCode, true);
|
||||
});
|
||||
|
||||
it('an editor paste carrying monospace HTML counts as a signal', () => {
|
||||
const text = `server {
|
||||
listen 443 ssl;
|
||||
server_name chat.example.org;
|
||||
}`;
|
||||
assert.equal(code(text, '<div style="font-family: Menlo, monospace">x</div>').isCode, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeCode: not code', () => {
|
||||
it('fewer than three lines', () => {
|
||||
assert.equal(code('const a = 1;\nconst b = 2;').isCode, false);
|
||||
});
|
||||
|
||||
it('prose with line breaks', () => {
|
||||
assert.equal(
|
||||
code(`Hey, are we still on for tonight?
|
||||
I can bring snacks if someone else handles drinks.
|
||||
Let me know what time works, I'm free after six.`).isCode,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('a long paragraph pasted as lines', () => {
|
||||
const line =
|
||||
'This is a long sentence about the plan. It keeps going for a while. And then some more words follow here.';
|
||||
assert.equal(code([line, line, line].join('\n')).isCode, false);
|
||||
});
|
||||
|
||||
it('a markdown list', () => {
|
||||
assert.equal(
|
||||
code(`- milk (2 litres)
|
||||
- eggs: a dozen
|
||||
- bread, the good kind
|
||||
- coffee!`).isCode,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('a quoted reply', () => {
|
||||
assert.equal(
|
||||
code(`> const a = 1;
|
||||
> if (a) { run(); }
|
||||
> return a;`).isCode,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('a list of links', () => {
|
||||
assert.equal(
|
||||
code(`https://example.org/a?b=1
|
||||
https://example.org/c
|
||||
https://example.org/d;e`).isCode,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('a log excerpt', () => {
|
||||
assert.equal(
|
||||
code(`2026-09-25 10:00:01 INFO server started
|
||||
2026-09-25 10:00:02 INFO listening on 8008
|
||||
2026-09-25 10:00:05 WARN slow request 1200ms`).isCode,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* [Gitea #107] Decide whether a multi-line paste looks like source code, so the
|
||||
* composer can OFFER to turn it into a code block (never converts on its own).
|
||||
* Pure and unit-tested; tuned to keep prose, lists, quotes and URL lists out.
|
||||
*/
|
||||
|
||||
export type CodeGuess = {
|
||||
/** Offer "Paste as code block?" */
|
||||
isCode: boolean;
|
||||
/** Language for `class="language-…"`, only when fairly sure. */
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
const KEYWORDS = [
|
||||
/\bfunction\b/,
|
||||
/\bconst\b/,
|
||||
/\blet\b/,
|
||||
/\bvar\b/,
|
||||
/\breturn\b/,
|
||||
/\bimport\b/,
|
||||
/\bexport\b/,
|
||||
/\bclass\b/,
|
||||
/\bdef\b/,
|
||||
/\bfn\b/,
|
||||
/\bpub\b/,
|
||||
/\bstruct\b/,
|
||||
/\bif \(/,
|
||||
/\bfor \(/,
|
||||
/\bwhile \(/,
|
||||
/#include\b/,
|
||||
/\bSELECT\b/i,
|
||||
/\bFROM\b/i,
|
||||
/<\?php/,
|
||||
/<\//,
|
||||
];
|
||||
|
||||
const MONO_HTML = /font-family:[^;"]*(mono|courier|consolas|menlo)|<pre[\s>]|<code[\s>]/i;
|
||||
|
||||
const indentOf = (line: string): number => {
|
||||
const m = /^[ \t]*/.exec(line);
|
||||
return m ? m[0].replace(/\t/g, ' ').length : 0;
|
||||
};
|
||||
|
||||
export function guessLanguage(text: string): string | undefined {
|
||||
if (
|
||||
/^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE)\b/im.test(text) &&
|
||||
/\bFROM\b|\bINTO\b|\bSET\b|\(/i.test(text)
|
||||
)
|
||||
return 'sql';
|
||||
if (/\bpub fn\b|\bfn \w+\(.*\)\s*(->|\{)|\blet mut\b/.test(text)) return 'rust';
|
||||
if (/^\s*#include\s*[<"]/m.test(text)) return 'c';
|
||||
if (
|
||||
/^\s*def \w+\(.*\):\s*$/m.test(text) ||
|
||||
(/^\s*(from \w+ )?import \w+\s*$/m.test(text) && /:\s*$/m.test(text))
|
||||
)
|
||||
return 'python';
|
||||
if (/:\s*(string|number|boolean)\b|\binterface \w+|\btype \w+ =/.test(text) && /[{;]/.test(text))
|
||||
return 'ts';
|
||||
if (/\b(const|let|var)\b|=>/.test(text) && /[{;]/.test(text)) return 'js';
|
||||
if (/^\s*<\?php/m.test(text)) return 'php';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function looksLikeCode(text: string, html?: string): CodeGuess {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
const nonEmpty = lines.filter((l) => l.trim() !== '');
|
||||
if (nonEmpty.length < 3) return { isCode: false };
|
||||
const n = nonEmpty.length;
|
||||
const share = (pred: (l: string) => boolean) => nonEmpty.filter(pred).length / n;
|
||||
|
||||
// ── Prose guards ──
|
||||
if (share((l) => /^\s*>/.test(l)) > 0.5) return { isCode: false }; // quoted reply
|
||||
if (nonEmpty.every((l) => /^\s*(<)?https?:\/\/\S+(>)?\s*$/.test(l))) return { isCode: false };
|
||||
const avgLen = nonEmpty.reduce((sum, l) => sum + l.length, 0) / n;
|
||||
if (avgLen > 90 && share((l) => (l.match(/[.!?] /g) ?? []).length >= 2) > 0.5) {
|
||||
return { isCode: false };
|
||||
}
|
||||
if (share((l) => /^[\p{L}\p{N}\s'’",.!?-]*$/u.test(l)) > 0.6) return { isCode: false };
|
||||
if (share((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)) > 0.6) return { isCode: false }; // list
|
||||
|
||||
// ── Signals: need two ──
|
||||
let signals = 0;
|
||||
const indented = share((l) => /^( {2,}|\t)/.test(l));
|
||||
let depthChanges = 0;
|
||||
for (let i = 1; i < nonEmpty.length; i += 1) {
|
||||
if (indentOf(nonEmpty[i]) !== indentOf(nonEmpty[i - 1])) depthChanges += 1;
|
||||
}
|
||||
if (indented >= 0.4 && depthChanges >= 2) signals += 1;
|
||||
if (share((l) => /[;{}):,]\s*$/.test(l)) >= 0.3) signals += 1;
|
||||
if (
|
||||
nonEmpty.filter((l) => /=>|->|::|\(\)|[^=!<>]=[^=]/.test(l)).length >= 2 &&
|
||||
/[{}[\]]/.test(text)
|
||||
) {
|
||||
signals += 1;
|
||||
}
|
||||
if (KEYWORDS.filter((k) => k.test(text)).length >= 2) signals += 1;
|
||||
if (html && MONO_HTML.test(html)) signals += 1;
|
||||
// SQL has few symbols: lines that open with a clause keyword are its shape.
|
||||
if (
|
||||
share((l) =>
|
||||
/^\s*(SELECT|FROM|WHERE|(LEFT |RIGHT |INNER )?JOIN|ORDER BY|GROUP BY|HAVING|INSERT|VALUES|UPDATE|SET|DELETE|LIMIT|AND|OR)\b/.test(
|
||||
l,
|
||||
),
|
||||
) >= 0.5
|
||||
) {
|
||||
signals += 1;
|
||||
}
|
||||
|
||||
if (signals < 2) return { isCode: false };
|
||||
return { isCode: true, lang: guessLanguage(text) };
|
||||
}
|
||||
Reference in New Issue
Block a user