Compare commits

7 Commits
Author SHA1 Message Date
arswarog 8596500665 chat/widget: отображение статуса подключения бота
Заменяет захардкоженное "Ready to help" на живой индикатор.
Статус берётся из поля status ответа GET /v1/status, капитализируется
и показывается справа от имени бота. Polling каждые 30с, пауза при
скрытой вкладке, recheck по клику, tooltip с API URL.
2026-08-08 16:00:45 +03:00
arswarog 2690754abd chat: добавление чата
Reviewed-on: #7
Co-authored-by: Arswarog <arswarog@yandex.ru>
2026-08-08 15:59:25 +03:00
arswarog ec5d2f14f4 feat(theme): тёмная тема на палитре Nord
## Зачем

Более комфортно для глаз

## Реализация

**Почему не хватило конфига в themeConfig.mermaid.options**

Docusaurus инициализирует Mermaid так:

```ts
mermaid.initialize({
  ...options,          // статично, оба режима
  theme: isDark ? 'base' : 'default',
});
```

`themeVariables` лежат внутри `options` и применяются независимо от `colorMode`. Попытка передать Nord-цвета через `options` дала бы Nord-оттенки и в светлом режиме.

**Почему swizzle, а не CSS-переопределения**

Mermaid рендерит SVG и встраивает финальные hex-значения непосредственно в `<style>` внутри SVG-элемента. CSS снаружи SVG не пробивает эти стили без `!important` на каждый конкретный селектор — хрупко и нестабильно при обновлениях Mermaid.Reviewed-on: #9

Co-authored-by: Arswarog <arswarog@yandex.ru>
2026-08-08 10:37:30 +03:00
arswarog 03f7302317 docuservix/widgets/chat: добавлен компонент чата
Reviewed-on: #6
Co-authored-by: Arswarog <arswarog@yandex.ru>
Co-committed-by: Arswarog <arswarog@yandex.ru>
2026-06-19 18:28:07 +03:00
arswarog f6436d0c83 lint: добавление линтера
Reviewed-on: #5
Co-authored-by: Arswarog <arswarog@yandex.ru>
Co-committed-by: Arswarog <arswarog@yandex.ru>
2026-06-18 13:33:29 +03:00
arswarog 156f3ebe47 feat(action): добавление параметра prefix
Reviewed-on: #3
Co-authored-by: Arswarog <arswarog@yandex.ru>
Co-committed-by: Arswarog <arswarog@yandex.ru>
2026-06-16 15:14:36 +03:00
arswarog f8f100633f refactor: перенос docusaurus в корень, так как больше ничего не планируется в этой репе держать
Reviewed-on: #2
Co-authored-by: Arswarog <arswarog@yandex.ru>
Co-committed-by: Arswarog <arswarog@yandex.ru>
2026-06-16 15:04:58 +03:00
43 changed files with 4229 additions and 285 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
title: "Title example"
title: 'Title example'
project:
org: "example"
repo: "example"
org: 'example'
repo: 'example'
+1
View File
@@ -0,0 +1 @@
yarn lint-staged
+7
View File
@@ -0,0 +1,7 @@
dist
coverage
*.d.ts
node_modules
.idea
logs
report
+29
View File
@@ -0,0 +1,29 @@
{
"printWidth": 100,
"useTabs": false,
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"singleAttributePerLine": true,
"overrides": [
{
"files": [
"*.json"
],
"options": {
"printWidth": 10
}
},
{
"files": [
"*.md",
"*.mdx"
],
"options": {
"proseWrap": "always"
}
}
]
}
+47
View File
@@ -0,0 +1,47 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this
repository.
## Project Overview
Docuservix docs — шаблон документационного сайта на Docusaurus 3.10 (React 19, TypeScript 6).
Конфигурация сайта читается из `.docuservix.yml` (title, project.org, project.repo, dirs). Локаль —
русский (`ru`).
## Commands
Используется **yarn**.
- `yarn start` — dev-сервер
- `yarn build` — production-сборка в `build/`
- `yarn typecheck` — проверка типов (tsc)
- `yarn prettier:check` — проверка форматирования
- `yarn prettier:fix` — автоформатирование
## Architecture
- `docusaurus.config.ts` — главный конфиг; читает `.docuservix.yml` через `js-yaml`
- `src/pages/` — кастомные страницы (index.tsx — главная)
- `src/css/custom.css` — глобальные CSS-переменные (`--ifm-*`)
- `docs/` — Markdown/MDX-документация
- `blog/` — блог (опционально, включается через `dirs.blog` в `.docuservix.yml`)
- Mermaid-диаграммы включены (`@docusaurus/theme-mermaid`)
- Docusaurus future v4 compatibility flag включён
## Code Style
- Prettier: 4 пробела, single quotes, trailing commas, `printWidth: 100`,
`singleAttributePerLine: true`
- JSON: `printWidth: 10` (каждое свойство на отдельной строке)
- Markdown/MDX: `proseWrap: always`
- Husky + lint-staged: prettier запускается автоматически на pre-commit
- CSS Modules (`*.module.css`) с camelCase именами классов
- **Без default export** в shared/UI компонентах; default export допустим только для Docusaurus
route-компонентов (page components)
## Environment
- Node >= 20
- Env vars: `DOCUSERVIX_URL` (production URL), `DOCUSERVIX_ON_BROKEN_LINKS` (override onBrokenLinks)
- Gitea instance: `git.jt4d.ru`
+4 -2
View File
@@ -14,7 +14,8 @@ yarn
yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
This command starts a local development server and opens up a browser window. Most changes are
reflected live without having to restart the server.
## Build
@@ -22,4 +23,5 @@ This command starts a local development server and opens up a browser window. Mo
yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
This command generates static content into the `build` directory and can be served using any static
contents hosting service.
+1 -1
View File
@@ -9,7 +9,7 @@ inputs:
description: 'Behavior on broken links: throw, warn, or ignore'
default: 'throw'
prefix:
description: 'Additional prefix for S3 path'
description: 'Prefix for S3 path'
default: ''
runs:
+67
View File
@@ -1,3 +1,70 @@
# Добро пожаловать в Docuservix!
Вам надо настроить публикацию документации по инструкции в https://git.jt4d.ru/jt4d/docuservix
---
```mermaid
classDiagram
class IApi
class ProductService {
+IProduct[] getProducts
}
ProductService --> IApi
Animal <|-- Duck
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age
Animal : +String gender
Animal: +isMammal()
Animal: +mate()
class Duck{
+String beakColor
+swim()
+quack()
}
class Fish{
-int sizeInFeet
-canEat()
}
class Zebra{
+bool is_wild
+run()
}
```
Для современного мира высококачественный прототип будущего проекта предполагает независимые способы
реализации существующих финансовых и административных условий. Идейные соображения высшего порядка,
а также начало повседневной работы по формированию позиции обеспечивает широкому кругу
(специалистов) участие в формировании существующих финансовых и административных условий.
Безусловно, курс на социально-ориентированный национальный проект прекрасно подходит для реализации
глубокомысленных рассуждений. Каждый из нас понимает очевидную вещь: консультация с широким активом
играет определяющее значение для модели развития. Не следует, однако, забывать, что выбранный нами
инновационный путь создаёт предпосылки для форм воздействия.
Есть над чем задуматься: реплицированные с зарубежных источников, современные исследования призывают
нас к новым свершениям, которые, в свою очередь, должны быть подвергнуты целой серии независимых
исследований. Приятно, граждане, наблюдать, как многие известные личности будут обнародованы.
Следует отметить, что повышение уровня гражданского сознания играет определяющее значение для
экспериментов, поражающих по своей масштабности и грандиозности.
Безусловно, внедрение современных методик создаёт необходимость включения в производственный план
целого ряда внеочередных мероприятий с учётом комплекса позиций, занимаемых участниками в отношении
поставленных задач. Кстати, акционеры крупнейших компаний разоблачены. В своём стремлении улучшить
пользовательский опыт мы упускаем, что диаграммы связей, превозмогая сложившуюся непростую
экономическую ситуацию, разоблачены.
```ts
/**
* Очень важный класс
*/
export class Foo {
bar(value: number): number {
console.log('Bar: ' + value);
return value;
}
}
```
+27 -21
View File
@@ -1,9 +1,12 @@
import fs from 'fs';
import yaml from 'js-yaml';
import {themes as prismThemes} from 'prism-react-renderer';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
import type {NavbarItem} from '@docusaurus/theme-common'
import type { NavbarItem } from '@docusaurus/theme-common';
import type { Config } from '@docusaurus/types';
import yaml from 'js-yaml';
import { themes as prismThemes } from 'prism-react-renderer';
import docuservix from './plugins/docuservix';
interface DocsConfig {
title: string;
@@ -13,24 +16,17 @@ interface DocsConfig {
const docsConfig = yaml.load(fs.readFileSync('./.docuservix.yml', 'utf8')) as DocsConfig;
const {
title,
} = docsConfig
const { title } = docsConfig;
const url = process.env.DOCUSERVIX_URL;
const url = process.env.DOCUSERVIX_URL || 'http://example.com';
const {
org,
repo
} = docsConfig.project
const { org, repo } = docsConfig.project;
const {
docs: docsDir = 'docs',
blog: blogDir
} = docsConfig.dirs || {}
const { docs: _docsDir = 'docs', blog: blogDir } = docsConfig.dirs || {};
const giteaUrl = 'https://git.jt4d.ru';
const onBrokenLinks = (process.env.DOCUSERVIX_ON_BROKEN_LINKS as Config['onBrokenLinks']) || 'throw';
const onBrokenLinks =
(process.env.DOCUSERVIX_ON_BROKEN_LINKS as Config['onBrokenLinks']) || 'throw';
const config: Config = {
title,
@@ -39,6 +35,7 @@ const config: Config = {
markdown: {
mermaid: true,
},
plugins: [docuservix()],
themes: ['@docusaurus/theme-mermaid'],
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
@@ -107,11 +104,13 @@ const config: Config = {
label: 'Документация',
position: 'left',
},
blogDir ? {
blogDir
? {
to: '/blog',
label: 'Блог',
position: 'left'
} : undefined,
position: 'left',
}
: undefined,
{
href: `${giteaUrl}/${org}/${repo}`,
label: 'Gitea',
@@ -121,12 +120,19 @@ const config: Config = {
},
footer: {
style: 'dark',
copyright: `Проект хостится на JT4D.ru, документация собрана с использованием Docuservix и Docusaurus.`,
copyright:
'Проект хостится на JT4D.ru, документация собрана с использованием Docuservix и Docusaurus.',
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
},
mermaid: {
theme: {
light: 'default',
dark: 'base',
},
},
} satisfies Preset.ThemeConfig,
};
+351
View File
@@ -0,0 +1,351 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fixupPluginRules } from '@eslint/compat';
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import etc from 'eslint-plugin-etc';
import _import from 'eslint-plugin-import';
import noOnlyTests from 'eslint-plugin-no-only-tests';
import noSkipTests from 'eslint-plugin-no-skip-tests';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import unusedImports from 'eslint-plugin-unused-imports';
import globals from 'globals';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: [
'**/.eslintrc.js',
'**/node_modules',
'**/coverage',
'**/build',
'**/.docusaurus',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
],
},
...compat.extends(
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'prettier',
'plugin:eslint-comments/recommended',
),
{
plugins: {
import: fixupPluginRules(_import),
react,
'react-hooks': fixupPluginRules(reactHooks),
'@typescript-eslint': typescriptEslint,
etc,
'no-only-tests': noOnlyTests,
'no-skip-tests': noSkipTests,
'unused-imports': unusedImports,
},
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: tsParser,
ecmaVersion: 6,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
},
},
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.ts', '.tsx', '.json'],
},
typescript: {
alwaysTryTypes: true,
},
},
react: {
version: 'detect',
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
curly: ['error', 'all'],
'max-params': 'off',
'no-console': [
'error',
{
allow: ['warn', 'error'],
},
],
'no-warning-comments': [
'error',
{
terms: ['fixme'],
location: 'anywhere',
},
],
'no-unused-vars': 'off',
'space-before-blocks': 'error',
'padding-line-between-statements': [
'error',
{
blankLine: 'always',
prev: '*',
next: ['break', 'continue', 'return'],
},
{
blankLine: 'always',
prev: ['const', 'let'],
next: '*',
},
{
blankLine: 'any',
prev: ['const', 'let'],
next: ['const', 'let'],
},
{
blankLine: 'always',
prev: 'directive',
next: '*',
},
{
blankLine: 'any',
prev: 'directive',
next: 'directive',
},
{
blankLine: 'always',
prev: 'block-like',
next: '*',
},
{
blankLine: 'always',
prev: '*',
next: 'block-like',
},
],
'import/order': [
'error',
{
pathGroups: [
{
pattern: 'react,bem-css-modules',
group: 'builtin',
position: 'before',
},
{
pattern: '@docuservix/**',
group: 'internal',
},
],
pathGroupsExcludedImportTypes: ['react'],
'newlines-between': 'always',
groups: ['builtin', 'external', 'internal', 'parent', ['sibling', 'index']],
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'react/no-direct-mutation-state': 'error',
'react/no-deprecated': 'error',
'react/no-unsafe': 'error',
'react/jsx-uses-vars': 'error',
'react/jsx-uses-react': 'error',
'react/jsx-curly-brace-presence': ['error', 'never'],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
quotes: [
'error',
'single',
{
avoidEscape: true,
},
],
'quote-props': ['warn', 'as-needed'],
'@typescript-eslint/no-explicit-any': [
'warn',
{
ignoreRestArgs: true,
},
],
'@typescript-eslint/member-ordering': [
'error',
{
default: [
'public-static-field',
'protected-static-field',
'private-static-field',
'public-instance-field',
'protected-instance-field',
'private-instance-field',
'constructor',
'public-instance-method',
'protected-instance-method',
'private-instance-method',
'public-static-method',
'protected-static-method',
'private-static-method',
'signature',
],
},
],
'etc/prefer-interface': [
'warn',
{
allowLocal: true,
},
],
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-ignore': 'allow-with-description',
'ts-nocheck': 'allow-with-description',
'ts-check': false,
'ts-expect-error': false,
},
],
'@typescript-eslint/no-empty-interface': 'warn',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-unused-vars': 'off',
// todo изучить и включить
'@typescript-eslint/no-unused-expressions': 'off',
// todo изучить и включить
'@typescript-eslint/no-empty-object-type': 'off',
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@nestjs/swagger',
importNames: ['PartialType'],
message:
"Please import 'PartialType' from '@src/server/common/nest' instead.",
},
{
name: 'react-bootstrap',
importNames: [
'Card',
'CardHeader',
'CardBody',
'CardFooter',
'Row',
'Col',
'Modal',
],
message: "Please use project's components with same name",
},
{
name: 'react-bootstrap/Modal',
message: "Please use project's components with same name",
},
{
name: '@nestjs/common',
importNames: ['Logger'],
message: "Please import 'Logger' from '@src/server/logger' instead.",
},
{
name: 'nestjs-pino',
importNames: ['Logger', 'PinoLogger'],
message: "Please import 'Logger' from '@src/server/logger' instead.",
},
],
},
],
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'error',
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: true,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'eslint-comments/require-description': [
'error',
{
ignore: ['eslint-enable'],
},
],
'eslint-comments/disable-enable-pair': [
'error',
{
allowWholeFile: true,
},
],
complexity: ['warn', 10],
eqeqeq: ['error'],
'func-style': ['warn', 'declaration'],
},
},
{
files: ['**/*.spec.{js,jsx,ts,tsx}'],
rules: {
'no-only-tests/no-only-tests': 'error',
'no-skip-tests/no-skip-tests': 'warn',
'no-console': [
'warn',
{
allow: ['warn', 'error'],
},
],
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
];
+61 -25
View File
@@ -3,36 +3,27 @@
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"deploy": "docusaurus deploy",
"docusaurus": "docusaurus",
"eslint:check": "yarn eslint",
"eslint:fix": "yarn eslint --fix",
"lint": "run-s eslint:fix prettier:fix",
"lint:check": "run-s eslint:check prettier:check",
"prepare": "husky",
"prettier:check": "prettier --check \"**/*.{ts,tsx,js,mjs,json,yml,yaml,md,mdx}\"",
"prettier:fix": "prettier --write \"**/*.{ts,tsx,js,mjs,json,yml,yaml,md,mdx}\"",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"start": "docusaurus start",
"swizzle": "docusaurus swizzle",
"typecheck": "tsc",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
"write-translations": "docusaurus write-translations"
},
"dependencies": {
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@docusaurus/theme-mermaid": "3.10.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"js-yaml": "^4.2.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.0.0",
"typescript": "~6.0.2"
"lint-staged": {
"*.{json,ts,tsx,js,jsx,js,mjs,md,mdx,yaml,yml}": "prettier --write",
"{src,e2e}/**/*.{ts,tsx}": "eslint --quiet --fix"
},
"browserslist": {
"production": [
@@ -46,6 +37,51 @@
"last 5 safari version"
]
},
"dependencies": {
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@docusaurus/theme-mermaid": "3.10.1",
"@mdx-js/react": "^3.0.0",
"bem-css-modules": "^1.4.3",
"clsx": "^2.0.0",
"js-yaml": "^4.2.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"@eslint/compat": "^1.1.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-etc": "^2.0.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-no-skip-tests": "^1.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.0.1",
"globals": "^17.6.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"npm-run-all": "^4.1.5",
"prettier": "^3.8.4",
"typescript": "~6.0.2"
},
"engines": {
"node": ">=20.0"
}
@@ -0,0 +1,59 @@
.MD p:last-child {
margin-bottom: 0;
}
.MD p:first-child {
margin-top: 0;
}
.MD code {
padding: 0.15em 0.4em;
border-radius: 4px;
background: var(--ifm-color-emphasis-200);
font-size: 0.85em;
}
.MD pre {
margin: 0.5em 0;
padding: 0.75em;
overflow-x: auto;
border-radius: 6px;
background: var(--ifm-color-emphasis-100);
}
.MD pre code {
padding: 0;
background: none;
}
.MD ul,
.MD ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.MD table {
width: 100%;
margin: 0.5em 0;
border-collapse: collapse;
font-size: 0.9em;
}
.MD th,
.MD td {
padding: 0.4em 0.75em;
border: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
}
.MD th {
background: var(--ifm-color-emphasis-100);
font-weight: var(--ifm-font-weight-semibold);
}
.MD blockquote {
margin: 0.5em 0;
padding: 0.25em 1em;
border-left: 3px solid var(--ifm-color-emphasis-300);
color: var(--ifm-color-emphasis-700);
}
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import styles from './MD.module.css';
interface MDProps {
children: string;
}
export function MD({ children }: MDProps): ReactNode {
return (
<div className={styles.MD}>
<Markdown remarkPlugins={[remarkGfm]}>{children}</Markdown>
</div>
);
}
@@ -0,0 +1 @@
export { MD } from './MD';
+88
View File
@@ -0,0 +1,88 @@
import { useLocation } from '@docusaurus/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useOptions } from '@docuservix/hooks/useOptions';
import { IChat, IChatMessage, IChatSource } from '@docuservix/models/chat';
interface UseChatResult {
dialog: IChat;
typing: boolean;
statusMessage?: string;
sendMessage: (text: string) => void;
}
function useQuery(): string {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get('q') ?? '';
}
export function useChat(): UseChatResult {
const chatEndpoint = useOptions().api + '/v1/chat';
const urlQuery = useQuery();
const [messages, setMessages] = useState<IChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialSentRef = useRef(false);
const messagesEndRef = useRef(messages);
messagesEndRef.current = messages;
const sendMessage = useCallback(
async (text: string) => {
const content = text.trim();
if (!content) {
return;
}
const userMessage: IChatMessage = { role: 'user', content };
const newHistory = [...messagesEndRef.current, userMessage];
setMessages(newHistory);
setLoading(true);
setError(null);
try {
const res = await fetch(chatEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: newHistory }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data: { answer: string; sources?: IChatSource[] } = await res.json();
setMessages((prev) => [
...prev,
{ role: 'assistant', content: data.answer, sources: data.sources },
]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при обращении к серверу');
} finally {
setLoading(false);
}
},
[chatEndpoint],
);
useEffect(() => {
if (urlQuery && !initialSentRef.current) {
initialSentRef.current = true;
sendMessage(urlQuery);
}
}, [urlQuery, sendMessage]);
return {
dialog: { messages },
typing: loading,
statusMessage: error ?? undefined,
sendMessage,
};
}
+119
View File
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useOptions } from '@docuservix/hooks/useOptions';
import { ChatStatus } from '@docuservix/models/chat';
interface UseChatStatusResult {
status: ChatStatus;
apiUrl: string;
recheck: () => void;
}
const POLL_INTERVAL_MS = 30_000;
const REQUEST_TIMEOUT_MS = 5_000;
function sameStatus(a: ChatStatus, b: ChatStatus): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === 'server' && b.kind === 'server') return a.label === b.label;
return true;
}
export function useChatStatus(): UseChatStatusResult {
const apiUrl = useOptions().api ?? '';
const statusApiUrl = apiUrl + '/v1/status';
const [status, setStatus] = useState<ChatStatus>({ kind: 'connecting' });
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateStatus = useCallback((next: ChatStatus): void => {
setStatus((prev) => (sameStatus(prev, next) ? prev : next));
}, []);
const check = useCallback(async (): Promise<void> => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, REQUEST_TIMEOUT_MS);
try {
const res = await fetch(statusApiUrl, { signal: controller.signal });
if (!res.ok) {
updateStatus({ kind: 'offline' });
return;
}
const parsed = (await res.json()) as { status?: unknown } | null | undefined;
const raw = typeof parsed?.status === 'string' ? parsed.status : null;
if (raw === null || raw.length === 0) {
updateStatus({ kind: 'offline' });
return;
}
updateStatus({
kind: 'server',
label: raw.charAt(0).toUpperCase() + raw.slice(1),
});
} catch {
if (!controller.signal.aborted || timedOut) {
updateStatus({ kind: 'offline' });
}
} finally {
clearTimeout(timeoutId);
}
}, [statusApiUrl, updateStatus]);
const scheduleNext = useCallback((): void => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(async () => {
if (document.visibilityState === 'visible') {
await check();
}
scheduleNext();
}, POLL_INTERVAL_MS);
}, [check]);
const recheck = useCallback((): void => {
updateStatus({ kind: 'connecting' });
void check();
scheduleNext();
}, [check, scheduleNext, updateStatus]);
useEffect(() => {
void check();
scheduleNext();
const onVisibilityChange = (): void => {
if (document.visibilityState === 'visible') {
void check();
scheduleNext();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return (): void => {
document.removeEventListener('visibilitychange', onVisibilityChange);
abortRef.current?.abort();
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, [check, scheduleNext]);
return { status, apiUrl, recheck };
}
+7
View File
@@ -0,0 +1,7 @@
import { usePluginData } from '@docusaurus/useGlobalData';
import { DocuservixOptions } from '@docuservix/models/docuservix';
export function useOptions(): DocuservixOptions {
return usePluginData('docuservix') as DocuservixOptions;
}
+39
View File
@@ -0,0 +1,39 @@
import path from 'path';
import type { LoadContext, Plugin } from '@docusaurus/types';
import { DocuservixOptions } from '@docuservix/models/docuservix';
export default function docuservix(options: Partial<DocuservixOptions> = {}) {
const api = process.env.DOCUSERVIX_API || options.api || '/api';
return function pluginDocuservix(_context: LoadContext): Plugin {
return {
name: 'docuservix',
configureWebpack() {
return {
resolve: {
alias: {
'@docuservix': path.resolve(__dirname),
},
},
};
},
async contentLoaded({ actions }) {
const { addRoute, setGlobalData } = actions;
setGlobalData({
api,
});
addRoute({
path: '/chat',
component: '@docuservix/pages/chat',
exact: true,
});
},
};
};
}
+25
View File
@@ -0,0 +1,25 @@
export type ChatStatus =
| { kind: 'connecting' }
| { kind: 'offline' }
| { kind: 'server'; label: string };
export interface IChat {
messages: IChatMessage[];
}
export interface IChatSource {
file: string;
heading: string;
anchor: string;
score: number;
}
export interface IChatMessage {
role: 'user' | 'assistant';
content: string;
sources?: IChatSource[];
}
export function sourceToUrl(file: string, anchor: string): string {
return `/${file}${anchor ? `#${anchor}` : ''}`;
}
+3
View File
@@ -0,0 +1,3 @@
export interface DocuservixOptions {
api?: string;
}
@@ -0,0 +1,22 @@
import Layout from '@theme/Layout';
import { ReactNode } from 'react';
import { useChat } from '@docuservix/hooks/useChat';
import { Chat } from '@docuservix/widgets/chat';
export function ChatPage(): ReactNode {
const { dialog, typing, statusMessage, sendMessage } = useChat();
return (
<Layout title="Чат">
<main className="container margin-vert--lg">
<Chat
dialog={dialog}
typing={typing}
statusMessage={statusMessage}
onSend={sendMessage}
/>
</main>
</Layout>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { ChatPage } from './ChatPage';
export default ChatPage;
@@ -0,0 +1,24 @@
.Chat {
display: flex;
flex-direction: column;
width: 100%;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: var(--ifm-global-radius);
overflow: hidden;
}
.Chat__statusMessage {
font-size: .65rem;
color: #666;
bottom: 0;
left: 0;
right: 0;
padding: .75rem 2.5rem;
background: #eee;
}
.Chat__statusMessage i {
margin-right: .25rem;
color: #667eea;
}
+37
View File
@@ -0,0 +1,37 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { IChat } from '@docuservix/models/chat';
import styles from './Chat.module.css';
import { Header } from './Header';
import { Input } from './Input';
import { Messages } from './Messages';
const b = block(styles, 'Chat');
interface ChatProps {
dialog: IChat;
typing?: boolean;
statusMessage?: string;
onSend?: (text: string) => void;
}
export function Chat({ dialog, typing, statusMessage, onSend }: ChatProps): ReactNode {
const { messages } = dialog;
return (
<div className={b()}>
<Header />
<Messages
messages={messages}
typing={typing}
/>
{statusMessage && <div className={b('statusMessage')}>{statusMessage}</div>}
<Input
disabled={typing}
onSend={onSend}
/>
</div>
);
}
@@ -0,0 +1,26 @@
.Header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.Header__avatar {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
flex-shrink: 0;
}
.Header__info h3 {
margin: 0 0 0.25rem;
color: var(--ifm-font-color-base);
font-size: 1.125rem;
}
@@ -0,0 +1,22 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import styles from './Header.module.css';
import { RobotIcon } from './icons';
import { Status } from './Status';
const b = block(styles, 'Header');
export function Header(): ReactNode {
return (
<div className={b()}>
<div className={b('avatar')}>
<RobotIcon />
</div>
<div className={b('info')}>
<h3>AI Assistant</h3>
<Status />
</div>
</div>
);
}
@@ -0,0 +1,58 @@
.Input {
display: flex;
align-items: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.Input__field {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--ifm-color-emphasis-200);
border-radius: 1.5rem;
font-size: 1rem;
font-family: inherit;
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
outline: none;
transition: border-color 0.3s;
resize: none;
min-height: 3.25rem;
max-height: 8rem;
field-sizing: content;
}
.Input__field:focus {
border-color: #667eea;
}
.Input__field:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.Input__send {
display: flex;
justify-content: center;
align-items: center;
width: 3.25rem;
height: 3.25rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 50%;
color: white;
font-size: 1.125rem;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.3s;
}
.Input__send:hover:not(:disabled) {
transform: scale(1.1);
}
.Input__send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+55
View File
@@ -0,0 +1,55 @@
import block from 'bem-css-modules';
import React, { ReactNode, useState } from 'react';
import { PaperPlaneIcon } from './icons';
import styles from './Input.module.css';
const b = block(styles, 'Input');
interface InputProps {
disabled?: boolean;
onSend?: (text: string) => void;
}
export function Input({ disabled, onSend }: InputProps): ReactNode {
const [input, setInput] = useState('');
const handleSend = () => {
const text = input.trim();
if (!text || disabled) {
return;
}
setInput('');
onSend?.(text);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className={b()}>
<textarea
className={b('field')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message here..."
rows={1}
disabled={disabled}
/>
<button
className={b('send')}
onClick={handleSend}
disabled={disabled || !input.trim()}
>
<PaperPlaneIcon />
</button>
</div>
);
}
@@ -0,0 +1,70 @@
.Message {
max-width: 90%;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.Message_role_assistant {
align-self: flex-start;
}
.Message_role_user {
align-self: flex-end;
}
.Message__content {
padding: 0.75rem 1rem;
border-radius: 1.25rem;
line-height: 1.5;
}
.Message_role_assistant .Message__content {
background: var(--ifm-color-emphasis-100);
color: var(--ifm-font-color-base);
border-top-left-radius: 0.25rem;
}
.Message_role_user .Message__content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-bottom-right-radius: 0.25rem;
}
.Message__sources {
margin-top: 8px;
padding: 8px 1rem 0;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.Message__sourcesLabel {
margin-bottom: 4px;
color: var(--ifm-color-emphasis-600);
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.Message__sourceLink {
display: block;
overflow: hidden;
color: var(--ifm-color-primary);
font-size: 0.8rem;
white-space: nowrap;
text-decoration: none;
text-overflow: ellipsis;
}
.Message__sourceLink:hover {
text-decoration: underline;
}
@media (max-width: 576px) {
.Message {
max-width: 100%;
}
}
@@ -0,0 +1,42 @@
import Link from '@docusaurus/Link';
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { MD } from '@docuservix/entities/markdown';
import { IChatSource, sourceToPath, sourceToUrl } from '@docuservix/models/chat';
import styles from './Message.module.css';
const b = block(styles, 'Message');
interface MessageProps {
role: 'user' | 'assistant';
content: string;
sources?: IChatSource[];
}
export function Message({ role, content, sources }: MessageProps): ReactNode {
return (
<div className={b({ role })}>
<div className={b('content')}>
<MD>{content}</MD>
</div>
{sources && sources.length > 0 && (
<div className={b('sources')}>
<div className={b('sourcesLabel')}>Источники:</div>
{sources.map((src, j) => (
<Link
key={j}
to={sourceToUrl(src.file, src.anchor)}
className={b('sourceLink')}
>
{src.heading}
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,65 @@
.Messages {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Typing indicator */
.Messages__typing {
display: flex;
align-items: center;
align-self: flex-start;
}
.Messages__typingIndicator {
display: flex;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: var(--ifm-color-emphasis-100);
border-radius: 1.25rem;
border-top-left-radius: 0.25rem;
}
.Messages__typingIndicator span {
width: 0.5rem;
height: 0.5rem;
background: var(--ifm-color-emphasis-500);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.Messages__typingIndicator span:nth-child(1) { animation-delay: -0.32s; }
.Messages__typingIndicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Scrollbar */
.Messages::-webkit-scrollbar {
width: 6px;
}
.Messages::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.Messages::-webkit-scrollbar-thumb {
background: var(--ifm-color-emphasis-300);
border-radius: 3px;
}
.Messages::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400);
}
@media (min-width: 576px) {
.Messages {
padding: 1.5rem;
}
}
@@ -0,0 +1,39 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { IChatMessage } from '@docuservix/models/chat';
import { Message } from './Message';
import styles from './Messages.module.css';
const b = block(styles, 'Messages');
interface MessagesProps {
messages: IChatMessage[];
typing?: boolean;
}
export function Messages({ messages, typing }: MessagesProps): ReactNode {
return (
<div className={b()}>
{messages.map((msg, i) => (
<Message
key={i}
role={msg.role}
content={msg.content}
sources={msg.sources}
/>
))}
{typing && (
<div className={b('typing')}>
<div className={b('typingIndicator')}>
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,53 @@
.Status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
background: none;
border: none;
cursor: pointer;
color: var(--ifm-color-emphasis-600);
font-size: 0.85rem;
line-height: 1;
}
.Status:hover .Status__label {
color: var(--ifm-color-emphasis-800);
}
.Status:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
border-radius: 4px;
}
.Status__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ifm-color-emphasis-400);
flex-shrink: 0;
}
.Status_kind_server .Status__dot {
background: #22c55e;
}
.Status_kind_offline .Status__dot {
background: #ef4444;
}
.Status_kind_connecting .Status__dot {
background: #eab308;
animation: Status__pulse 1s ease-in-out infinite;
}
@keyframes Status__pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@@ -0,0 +1,38 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { useChatStatus } from '@docuservix/hooks/useChatStatus';
import { ChatStatus } from '@docuservix/models/chat';
import styles from './Status.module.css';
const b = block(styles, 'Status');
function getLabel(status: ChatStatus): string {
switch (status.kind) {
case 'connecting':
return 'Подключение…';
case 'offline':
return 'Offline';
case 'server':
return status.label;
}
}
export function Status(): ReactNode {
const { status, apiUrl, recheck } = useChatStatus();
const label = getLabel(status);
return (
<button
type="button"
className={b({ kind: status.kind })}
onClick={recheck}
title={apiUrl}
aria-label={`Статус: ${label}. Нажмите для проверки`}
>
<span className={b('dot')} />
<span className={b('label')}>{label}</span>
</button>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React, { ReactNode } from 'react';
export function RobotIcon(): ReactNode {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135" />
<path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5" />
</svg>
);
}
export function PaperPlaneIcon(): ReactNode {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471z" />
</svg>
);
}
+1
View File
@@ -0,0 +1 @@
export { Chat } from './Chat';
+16 -4
View File
@@ -1,3 +1,5 @@
/* eslint-disable no-console -- logs required */
import fs from 'fs';
import path from 'path';
@@ -10,18 +12,28 @@ pinIndexToTop();
*/
function pinIndexToTop() {
const indexPath = path.join(docsDir, 'index.md');
if (!fs.existsSync(indexPath)) return;
if (!fs.existsSync(indexPath)) {
return;
}
let content = fs.readFileSync(indexPath, 'utf8');
if (content.startsWith('---\n')) {
const endIdx = content.indexOf('\n---\n', 4);
if (endIdx === -1) return;
if (endIdx === -1) {
return;
}
const frontMatter = content.slice(4, endIdx);
if (/^sidebar_position\s*:/m.test(frontMatter)) return;
content = '---\nsidebar_position: 0\n' + frontMatter + '\n---\n' + content.slice(endIdx + 5);
if (/^sidebar_position\s*:/m.test(frontMatter)) {
return;
}
content =
'---\nsidebar_position: 0\n' + frontMatter + '\n---\n' + content.slice(endIdx + 5);
} else {
content = '---\nsidebar_position: 0\n---\n' + content;
}
+30 -9
View File
@@ -17,14 +17,35 @@
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
/* Nord dark theme — https://www.nordtheme.com */
[data-theme='dark'] {
--ifm-color-primary: #25c2a0;
--ifm-color-primary-dark: #21af90;
--ifm-color-primary-darker: #1fa588;
--ifm-color-primary-darkest: #1a8870;
--ifm-color-primary-light: #29d5b0;
--ifm-color-primary-lighter: #32d8b4;
--ifm-color-primary-lightest: #4fddbf;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
/* Frost — основной акцент (nord8) */
--ifm-color-primary: #88c0d0;
--ifm-color-primary-dark: #76b5c7;
--ifm-color-primary-darker: #6dafc3;
--ifm-color-primary-darkest: #5e81ac;
--ifm-color-primary-light: #95c8d8;
--ifm-color-primary-lighter: #9ecfdd;
--ifm-color-primary-lightest: #b4dce8;
/* Polar Night — фоны */
--ifm-background-color: #2e3440;
--ifm-background-surface-color: #3b4252;
--ifm-navbar-background-color: #2e3440;
--ifm-footer-background-color: #2e3440;
/* Snow Storm — текст */
--ifm-font-color-base: #eceff4;
--ifm-font-color-secondary: #d8dee9;
/* Emphasis scale: Polar Night → Snow Storm */
--ifm-color-emphasis-100: #3b4252;
--ifm-color-emphasis-200: #434c5e;
--ifm-color-emphasis-300: #4c566a;
--ifm-color-emphasis-400: #7b8ba0;
--ifm-color-emphasis-500: #d8dee9;
--ifm-color-emphasis-600: #e5e9f0;
--ifm-color-emphasis-700: #eceff4;
--docusaurus-highlighted-code-line-bg: rgba(136, 192, 208, 0.15);
}
+15 -8
View File
@@ -1,25 +1,30 @@
import type {ReactNode} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import Layout from '@theme/Layout';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import styles from './index.module.css';
function HomepageHeader() {
const {siteConfig} = useDocusaurusContext();
const { siteConfig } = useDocusaurusContext();
return (
<header className={clsx('hero hero--primary', styles.heroBanner)}>
<div className="container">
<Heading as="h1" className="hero__title">
<Heading
as="h1"
className="hero__title"
>
{siteConfig.title}
</Heading>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs">
to="/docs"
>
Документация
</Link>
</div>
@@ -29,11 +34,13 @@ function HomepageHeader() {
}
export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext();
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />">
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
</Layout>
);
+82
View File
@@ -0,0 +1,82 @@
import ErrorBoundary from '@docusaurus/ErrorBoundary';
import { ErrorBoundaryErrorMessageFallback } from '@docusaurus/theme-common';
import { useColorMode } from '@docusaurus/theme-common';
import {
MermaidContainerClassName,
useMermaidConfig,
useMermaidRenderResult,
} from '@docusaurus/theme-mermaid/client';
import type { Props } from '@theme/Mermaid';
import type { RenderResult } from 'mermaid';
import React, { useRef, useEffect, useMemo, type ReactNode } from 'react';
import styles from './styles.module.css';
const NORD_THEME_VARIABLES = {
background: '#2e3440',
mainBkg: '#3b4252',
primaryColor: '#5e81ac',
primaryTextColor: '#eceff4',
primaryBorderColor: '#88c0d0',
secondaryColor: '#434c5e',
secondaryTextColor: '#d8dee9',
secondaryBorderColor: '#4c566a',
tertiaryColor: '#3b4252',
tertiaryTextColor: '#d8dee9',
tertiaryBorderColor: '#4c566a',
lineColor: '#88c0d0',
edgeLabelBackground: '#434c5e',
textColor: '#eceff4',
titleColor: '#88c0d0',
nodeBorder: '#4c566a',
clusterBkg: '#434c5e',
clusterBorder: '#4c566a',
};
function MermaidRenderResult({ renderResult }: { renderResult: RenderResult }): ReactNode {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const div = ref.current!;
renderResult.bindFunctions?.(div);
}, [renderResult]);
return (
<div
ref={ref}
className={`${MermaidContainerClassName} ${styles.container}`}
// eslint-disable-next-line react/no-danger -- mermaid renders trusted SVG
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
/>
);
}
function MermaidRenderer({ value }: Props): ReactNode {
const { colorMode } = useColorMode();
const baseConfig = useMermaidConfig();
const config = useMemo(
() =>
colorMode === 'dark'
? { ...baseConfig, themeVariables: NORD_THEME_VARIABLES }
: baseConfig,
[colorMode, baseConfig],
);
const renderResult = useMermaidRenderResult({ text: value, config });
if (renderResult === null) {
return null;
}
return <MermaidRenderResult renderResult={renderResult} />;
}
export default function Mermaid(props: Props): ReactNode {
return (
<ErrorBoundary fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}>
<MermaidRenderer {...props} />
</ErrorBoundary>
);
}
+7
View File
@@ -0,0 +1,7 @@
.container {
max-width: 100%;
}
.container > svg {
max-width: 100%;
}
+9 -1
View File
@@ -5,8 +5,16 @@
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@docuservix/*": [
"plugins/docuservix/*"
]
},
"ignoreDeprecations": "6.0",
"strict": true
},
"exclude": [".docusaurus", "build"]
"exclude": [
".docusaurus",
"build"
]
}
+2361 -43
View File
File diff suppressed because it is too large Load Diff