feat(wiki): подсветка синтаксиса JS и Lua в код-блоках

Раньше код был монотонным — все белое на чёрном.
Сейчас цветной syntax highlighting в стиле Dracula:
- розовый (#ff79c6) — ключевые слова (let/const/function/local/end/then)
- голубой (#8be9fd) — встроенные (game/workspace/Math/Vector3)
- жёлтый (#f1fa8c) — строки
- фиолетовый (#bd93f9) — числа
- серый (#6272a4) italic — комментарии
- зелёный (#50fa7b) — имена функций (id с () после)

Реализация: docsLang.highlightCode() — простой regex-токенизатор.
<Code> компонент авто-детектит lang ('js'/'lua') по содержимому
(паттерны local/then/--/:Connect), либо принимает явный prop lang=.

Без внешних библиотек — ~80 строк регулярки, легко поддерживать.
This commit is contained in:
min 2026-06-09 03:14:44 +03:00
parent 35cd304b0e
commit d6ba23ae8d
2 changed files with 124 additions and 5 deletions

View File

@ -1,6 +1,6 @@
import React from 'react';
import DocIcon from './docsIcons';
import { LangTabs } from './docsLang';
import { LangTabs, highlightCode } from './docsLang';
/**
* docsData.jsx контент вики редактора Рублокс (разделы A-J).
@ -22,10 +22,20 @@ import { LangTabs } from './docsLang';
* только SVG-иконки (см. docsIcons.jsx).
*/
// Код-блок
export const Code = ({ children }) => (
<pre className="docCode"><code>{children}</code></pre>
);
// Код-блок с подсветкой синтаксиса
// lang='js' (default) | 'lua'. Если не указан автодетект по содержимому.
export const Code = ({ children, lang }) => {
const text = typeof children === 'string' ? children
: Array.isArray(children) ? children.join('') : String(children);
const resolved = lang || (
/\blocal\b|\bthen\b|\bend\b|\b:Connect\b|\bfunction\(|--\s/.test(text) ? 'lua' : 'js'
);
return (
<pre className="docCode" data-lang={resolved}>
<code dangerouslySetInnerHTML={{ __html: highlightCode(text, resolved) }} />
</pre>
);
};
// Плашка «куда писать скрипт»
// kind="global" глобальный скрипт (создаётся в категории «Скрипты»)

View File

@ -14,6 +14,105 @@
*/
import React, { createContext, useContext, useEffect, useState } from 'react';
//
// Простая подсветка синтаксиса для JS и Lua
//
const JS_KEYWORDS = new Set([
'let', 'const', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
'do', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class',
'extends', 'super', 'true', 'false', 'null', 'undefined', 'try', 'catch',
'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'async', 'await',
'import', 'export', 'from', 'default', 'delete', 'void',
]);
const JS_BUILTINS = new Set([
'game', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean', 'JSON',
'console', 'setTimeout', 'setInterval', 'Promise', 'document', 'window',
]);
const LUA_KEYWORDS = new Set([
'local', 'function', 'end', 'if', 'then', 'else', 'elseif', 'for', 'while',
'do', 'repeat', 'until', 'return', 'break', 'and', 'or', 'not', 'true',
'false', 'nil', 'in', 'goto',
]);
const LUA_BUILTINS = new Set([
'game', 'workspace', 'script', 'Instance', 'Vector3', 'Vector2', 'Color3',
'CFrame', 'UDim2', 'UDim', 'BrickColor', 'Enum', 'math', 'string', 'table',
'task', 'print', 'warn', 'pairs', 'ipairs', 'pcall', 'tostring', 'tonumber',
'TweenInfo', 'wait', 'tick', 'type', 'require', 'next', 'setmetatable',
'getmetatable', 'rawget', 'rawset',
]);
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Возвращает HTML-строку с раскрашенным кодом. lang: 'js' | 'lua'. */
export function highlightCode(text, lang) {
if (typeof text !== 'string') return escapeHtml(String(text || ''));
const isLua = lang === 'lua';
const keywords = isLua ? LUA_KEYWORDS : JS_KEYWORDS;
const builtins = isLua ? LUA_BUILTINS : JS_BUILTINS;
// Регулярки для токенов порядок важен: сначала комменты и строки,
// потом числа, потом identifier'ы.
// JS: //... и /*...*/. Lua: --... и --[[...]].
const commentRe = isLua
? /--\[\[[\s\S]*?\]\]|--[^\n]*/g
: /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
// Строки: одинарные, двойные, в JS ещё бэктики.
const stringRe = isLua
? /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\[\[[\s\S]*?\]\]/g
: /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`/g;
const numRe = /\b\d+(?:\.\d+)?\b/g;
const idRe = /[A-Za-zА-Яа-я_$][A-Za-zА-Яа-я0-9_$]*/g;
// Берём весь текст, делим на токены через одну общую регулярку.
const tokens = [];
const combined = new RegExp(
commentRe.source + '|' + stringRe.source + '|' + numRe.source + '|' + idRe.source,
'g'
);
let lastIndex = 0;
let match;
while ((match = combined.exec(text)) !== null) {
const start = match.index;
const tok = match[0];
if (start > lastIndex) {
tokens.push({ type: 'raw', text: text.slice(lastIndex, start) });
}
// Классифицируем
if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) {
tokens.push({ type: 'comment', text: tok });
} else if (/^["'`\[]/.test(tok)) {
tokens.push({ type: 'string', text: tok });
} else if (/^\d/.test(tok)) {
tokens.push({ type: 'number', text: tok });
} else if (keywords.has(tok)) {
tokens.push({ type: 'keyword', text: tok });
} else if (builtins.has(tok)) {
tokens.push({ type: 'builtin', text: tok });
} else {
// Идентификатор проверим, идёт ли за ним ( функция
const rest = text.slice(start + tok.length);
if (/^\s*\(/.test(rest)) {
tokens.push({ type: 'fn', text: tok });
} else {
tokens.push({ type: 'ident', text: tok });
}
}
lastIndex = start + tok.length;
}
if (lastIndex < text.length) {
tokens.push({ type: 'raw', text: text.slice(lastIndex) });
}
return tokens.map(t => {
const safe = escapeHtml(t.text);
if (t.type === 'raw' || t.type === 'ident') return safe;
return `<span class="hl-${t.type}">${safe}</span>`;
}).join('');
}
const LS_KEY = 'rublox.docs.lang';
const DEFAULT_LANG = 'js';
@ -310,4 +409,14 @@ export const DOCS_LANG_STYLES = `
cursor: pointer;
}
.langChoiceCancel:hover { background: #232842; color: #fff; }
/*
Подсветка синтаксиса в код-блоках
*/
.docCode .hl-keyword { color: #ff79c6; font-weight: 600; } /* let/const/local/function */
.docCode .hl-builtin { color: #8be9fd; } /* game / workspace / Math */
.docCode .hl-string { color: #f1fa8c; } /* 'строки' "строки" */
.docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */
.docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */
.docCode .hl-fn { color: #50fa7b; } /* myFunc() */
`;