studio/src/community/docsLang.jsx
min 09a927bbfc fix(docs): дефолт языка в статьях — JS, не Lua
Дефолт в DEFAULT_LANG уже был 'js', но у части юзеров в localStorage
залип 'lua' с прошлого посещения (rublox.docs.lang).

Фикс: бамп ключа на 'rublox.docs.lang.v2' + удаление старого ключа
при инициализации. У всех теперь старт с JS, переключение на Lua
сохраняется по новому ключу как раньше.
2026-06-09 23:43:12 +03:00

464 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, '&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('');
}
// 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; }
`;