/** * docsLang.jsx — поддержка вкладок JS/Lua в статьях вики. * * Компоненты: * — оборачивает страницу статьи, хранит выбранный язык * в localStorage 'rublox.docs.lang' ('js' | 'lua'). * — большой переключатель JS/Lua над статьёй. * — вкладка-переключатель внутри статьи. Показывает * либо js, либо lua, согласно текущему языку. * useDocsLang() — хук: возвращает {lang, setLang}. * * Если в статье нет ни одного — она одинаково выглядит на обоих * языках (общая теория, не зависящая от языка скриптов). */ 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, '>'); } /** Возвращает 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 `${safe}`; }).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 ( {children} ); } export function useDocsLang() { return useContext(DocsLangContext); } /** Большой переключатель над статьёй: «На каком языке смотреть код?» */ export function DocsLangPicker() { const { lang, setLang } = useDocsLang(); return (
Язык скриптов в этой статье:
Не знаешь что выбрать? Смотри статью D0. Скриптинг: JS или Lua?
); } /** * Локальный переключатель вкладок внутри статьи. Если js/lua — * прямой контент (children), если на странице нет — * показываем оба заголовками. * * Использование: * game.log('Привет')} * lua={print('Привет')} * /> */ 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 (
{lang === 'lua' ? lua : js}
); } 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; } `;