Дефолт в DEFAULT_LANG уже был 'js', но у части юзеров в localStorage залип 'lua' с прошлого посещения (rublox.docs.lang). Фикс: бамп ключа на 'rublox.docs.lang.v2' + удаление старого ключа при инициализации. У всех теперь старт с JS, переключение на Lua сохраняется по новому ключу как раньше.
464 lines
17 KiB
JavaScript
464 lines
17 KiB
JavaScript
/**
|
||
* docsLang.jsx — поддержка вкладок JS/Lua в статьях вики.
|
||
*
|
||
* Компоненты:
|
||
* <DocsLangProvider> — оборачивает страницу статьи, хранит выбранный язык
|
||
* в localStorage 'rublox.docs.lang' ('js' | 'lua').
|
||
* <DocsLangPicker /> — большой переключатель JS/Lua над статьёй.
|
||
* <LangTabs js lua /> — вкладка-переключатель внутри статьи. Показывает
|
||
* либо js, либо lua, согласно текущему языку.
|
||
* useDocsLang() — хук: возвращает {lang, setLang}.
|
||
*
|
||
* Если в статье нет ни одного <LangTabs> — она одинаково выглядит на обоих
|
||
* языках (общая теория, не зависящая от языка скриптов).
|
||
*/
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
/** Возвращает 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('');
|
||
}
|
||
|
||
|
||
// v2 — раньше при первом включении lua-режима сохранялся в LS и юзер
|
||
// потом всегда видел Lua-таб по умолчанию. Бамп ключа = сброс на JS
|
||
// у всех уже-открытых вкладок.
|
||
const LS_KEY = 'rublox.docs.lang.v2';
|
||
const LS_KEY_OLD = 'rublox.docs.lang';
|
||
const DEFAULT_LANG = 'js';
|
||
|
||
const DocsLangContext = createContext({
|
||
lang: DEFAULT_LANG,
|
||
setLang: () => {},
|
||
});
|
||
|
||
export function DocsLangProvider({ children }) {
|
||
const [lang, setLangState] = useState(() => {
|
||
try {
|
||
// Очищаем старый ключ — у части юзеров там залип 'lua'
|
||
localStorage.removeItem(LS_KEY_OLD);
|
||
const v = localStorage.getItem(LS_KEY);
|
||
return v === 'lua' ? 'lua' : 'js';
|
||
} catch (_) {
|
||
return DEFAULT_LANG;
|
||
}
|
||
});
|
||
const setLang = (next) => {
|
||
const v = next === 'lua' ? 'lua' : 'js';
|
||
setLangState(v);
|
||
try { localStorage.setItem(LS_KEY, v); } catch (_) {}
|
||
};
|
||
useEffect(() => {
|
||
// Слушаем смену из других вкладок
|
||
const onStorage = (e) => {
|
||
if (e.key === LS_KEY && (e.newValue === 'js' || e.newValue === 'lua')) {
|
||
setLangState(e.newValue);
|
||
}
|
||
};
|
||
window.addEventListener('storage', onStorage);
|
||
return () => window.removeEventListener('storage', onStorage);
|
||
}, []);
|
||
return (
|
||
<DocsLangContext.Provider value={{ lang, setLang }}>
|
||
{children}
|
||
</DocsLangContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useDocsLang() {
|
||
return useContext(DocsLangContext);
|
||
}
|
||
|
||
/** Большой переключатель над статьёй: «На каком языке смотреть код?» */
|
||
export function DocsLangPicker() {
|
||
const { lang, setLang } = useDocsLang();
|
||
return (
|
||
<div className="docsLangPicker">
|
||
<div className="docsLangPicker__label">
|
||
Язык скриптов в этой статье:
|
||
</div>
|
||
<div className="docsLangPicker__tabs">
|
||
<button
|
||
type="button"
|
||
className={
|
||
'docsLangPicker__tab docsLangPicker__tab--js' +
|
||
(lang === 'js' ? ' is-active' : '')
|
||
}
|
||
onClick={() => setLang('js')}
|
||
>
|
||
JavaScript
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={
|
||
'docsLangPicker__tab docsLangPicker__tab--lua' +
|
||
(lang === 'lua' ? ' is-active' : '')
|
||
}
|
||
onClick={() => setLang('lua')}
|
||
>
|
||
Lua
|
||
</button>
|
||
</div>
|
||
<div className="docsLangPicker__hint">
|
||
Не знаешь что выбрать? Смотри статью <b>D0. Скриптинг: JS или Lua?</b>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Локальный переключатель вкладок внутри статьи. Если js/lua —
|
||
* прямой контент (children), если на странице нет <DocsLangProvider> —
|
||
* показываем оба заголовками.
|
||
*
|
||
* Использование:
|
||
* <LangTabs
|
||
* js={<Code>game.log('Привет')</Code>}
|
||
* lua={<Code>print('Привет')</Code>}
|
||
* />
|
||
*/
|
||
export function LangTabs({ js, lua }) {
|
||
const { lang, setLang } = useDocsLang();
|
||
const hasJs = js !== undefined && js !== null;
|
||
const hasLua = lua !== undefined && lua !== null;
|
||
if (!hasJs && !hasLua) return null;
|
||
// Если есть только один язык — показываем без переключателя
|
||
if (hasJs && !hasLua) return <>{js}</>;
|
||
if (!hasJs && hasLua) return <>{lua}</>;
|
||
return (
|
||
<div className="docsLangTabs">
|
||
<div className="docsLangTabs__head">
|
||
<button
|
||
type="button"
|
||
className={'docsLangTabs__tab' + (lang === 'js' ? ' is-active' : '')}
|
||
onClick={() => setLang('js')}
|
||
>
|
||
JS
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={'docsLangTabs__tab' + (lang === 'lua' ? ' is-active' : '')}
|
||
onClick={() => setLang('lua')}
|
||
>
|
||
Lua
|
||
</button>
|
||
</div>
|
||
<div className="docsLangTabs__body">
|
||
{lang === 'lua' ? lua : js}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const DOCS_LANG_STYLES = `
|
||
.docsLangPicker {
|
||
background: linear-gradient(135deg, #1a1d2e 0%, #14172b 100%);
|
||
border: 1px solid #2a3050;
|
||
border-radius: 10px;
|
||
padding: 14px 18px;
|
||
margin: 16px 0 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.docsLangPicker__label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #c8cce0;
|
||
}
|
||
.docsLangPicker__tabs {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.docsLangPicker__tab {
|
||
flex: 1;
|
||
padding: 10px 16px;
|
||
border-radius: 6px;
|
||
border: 1px solid transparent;
|
||
background: #232842;
|
||
color: #aab0c8;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.docsLangPicker__tab:hover { background: #2a304f; color: #fff; }
|
||
.docsLangPicker__tab--js.is-active {
|
||
background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%);
|
||
color: #1a1a1c;
|
||
border-color: #d4b500;
|
||
}
|
||
.docsLangPicker__tab--lua.is-active {
|
||
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
|
||
color: #fff;
|
||
border-color: #1565c0;
|
||
}
|
||
.docsLangPicker__hint {
|
||
font-size: 12px;
|
||
color: #8a90a8;
|
||
font-style: italic;
|
||
}
|
||
|
||
.docsLangTabs {
|
||
margin: 12px 0;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
border: 1px solid #e0e6f0;
|
||
background: #fff;
|
||
}
|
||
.docsLangTabs__head {
|
||
display: flex;
|
||
background: #f4f6fb;
|
||
border-bottom: 1px solid #e0e6f0;
|
||
}
|
||
.docsLangTabs__tab {
|
||
padding: 9px 18px;
|
||
border: none;
|
||
background: transparent;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
letter-spacing: 0.5px;
|
||
border-bottom: 2px solid transparent;
|
||
}
|
||
.docsLangTabs__tab:hover { color: #1e293b; }
|
||
.docsLangTabs__tab.is-active {
|
||
color: #1e3a8a;
|
||
border-bottom-color: #3357ff;
|
||
background: #fff;
|
||
}
|
||
.docsLangTabs__body {
|
||
padding: 0;
|
||
background: #fff;
|
||
}
|
||
.docsLangTabs__body > pre,
|
||
.docsLangTabs__body > .docCode { margin: 0; border-radius: 0; }
|
||
|
||
/* Заголовки колонок таблицы (th) — в основных стилях вики не определены.
|
||
Делаем светлыми чтобы не сливались с фоном таблицы. */
|
||
.docTable th {
|
||
padding: 9px 14px;
|
||
background: #eef2ff;
|
||
color: #1e3a8a;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
text-align: left;
|
||
border-bottom: 1px solid #d4dcef;
|
||
border-right: 1px solid #eef2f7;
|
||
}
|
||
.docTable th:last-child { border-right: none; }
|
||
.docTable thead tr:first-child th:first-child { border-top-left-radius: 12px; }
|
||
.docTable thead tr:first-child th:last-child { border-top-right-radius: 12px; }
|
||
|
||
.langChoiceOverlay {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0,0,0,0.75);
|
||
display: flex; align-items: center; justify-content: center;
|
||
z-index: 10000;
|
||
}
|
||
.langChoiceDialog {
|
||
background: #1a1d2e;
|
||
border: 1px solid #2a3050;
|
||
border-radius: 14px;
|
||
padding: 28px;
|
||
width: 100%;
|
||
max-width: 520px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||
}
|
||
.langChoiceTitle {
|
||
font-size: 20px;
|
||
margin: 0 0 8px;
|
||
color: #fff;
|
||
}
|
||
.langChoiceSub {
|
||
margin: 0 0 20px;
|
||
font-size: 13px;
|
||
color: #aab0c8;
|
||
line-height: 1.5;
|
||
}
|
||
.langChoiceBtns {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.langChoiceBtn {
|
||
padding: 18px 16px;
|
||
border-radius: 10px;
|
||
border: 2px solid transparent;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
color: #fff;
|
||
}
|
||
.langChoiceBtn--js {
|
||
background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%);
|
||
color: #1a1a1c;
|
||
}
|
||
.langChoiceBtn--js:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(247,223,30,0.3); }
|
||
.langChoiceBtn--lua {
|
||
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
|
||
}
|
||
.langChoiceBtn--lua:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(33,150,243,0.4); }
|
||
.langChoiceBtn__name {
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
margin-bottom: 4px;
|
||
}
|
||
.langChoiceBtn__hint {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
opacity: 0.85;
|
||
}
|
||
.langChoiceCancel {
|
||
width: 100%;
|
||
padding: 10px;
|
||
background: transparent;
|
||
border: 1px solid #2a3050;
|
||
color: #aab0c8;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
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() */
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
Баннер «Lua-скрипты для урока»
|
||
══════════════════════════════════════════════════════════════════ */
|
||
.luaLessonBanner {
|
||
background: #eef4ff;
|
||
border: 1px solid #c7d8f5;
|
||
border-radius: 10px;
|
||
padding: 14px 18px;
|
||
margin: 14px 0 22px;
|
||
}
|
||
.luaLessonBanner--missing {
|
||
background: #fff7e0;
|
||
border-color: #f0d599;
|
||
color: #5a4500;
|
||
}
|
||
.luaLessonBanner--missing p { margin: 4px 0 0; font-size: 13px; }
|
||
.luaLessonBanner__head { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
|
||
.luaLessonBanner__head b { font-size: 14px; color: #1e3a8a; }
|
||
.luaLessonBanner__hint { font-size: 12px; color: #475569; font-style: italic; }
|
||
.luaLessonBanner__script { margin: 6px 0; }
|
||
.luaLessonBanner__script summary {
|
||
cursor: pointer;
|
||
padding: 8px 12px;
|
||
background: #fff;
|
||
border-radius: 6px;
|
||
border: 1px solid #d0dcf0;
|
||
font-family: Consolas, monospace;
|
||
font-size: 13px;
|
||
color: #1e3a8a;
|
||
font-weight: 600;
|
||
}
|
||
.luaLessonBanner__script summary:hover { background: #f4f8ff; }
|
||
.luaLessonBanner__script[open] summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
|
||
.luaLessonBanner__script pre { margin: 0; border-top-left-radius: 0; border-top-right-radius: 0; }
|
||
`;
|