@stsgs1980/fab-inspector 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # FabInspector
2
+
3
+ Визуальный инспектор элементов для Next.js dev mode. Плавающая FAB-кнопка с draggable панелью в GitHub Dark Theme, подсветкой синтаксиса и box-model визуализацией.
4
+
5
+ ![React 19](https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react)
6
+ ![Next.js 15+](https://img.shields.io/badge/Next.js-15%2B-000000?style=flat-square&logo=nextdotjs)
7
+ ![License: MIT](https://img.shields.io/badge/License-MIT-green?style=flat-square)
8
+
9
+ ## Что делает
10
+
11
+ При клике на любой DOM-элемент показывает:
12
+
13
+ - Источник: файл и строка через `data-src` атрибут
14
+ - CSS-классы, текст, CSS Path, HTML-код
15
+ - Вычисленные стили (размер, шрифт, цвет)
16
+ - Box Model: визуальная схема margin / border / padding / content
17
+ - Сниппет исходного кода с подсветкой синтаксиса
18
+ - Copy task context: структурированный промпт (файл + тег + текст)
19
+ - Copy file:line в буфер обмена
20
+ - Quick-copy в свёрнутой панели (тег + классы + текст)
21
+
22
+ Панель по умолчанию свёрнута (только заголовок), раскрывается по клику на шеврон. Перетаскивается за заголовок.
23
+
24
+ ## Установка
25
+
26
+ ### npm / bun
27
+
28
+ ```bash
29
+ bun add @stsgs1980/fab-inspector -D
30
+ # или
31
+ npm install @stsgs1980/fab-inspector -D
32
+ ```
33
+
34
+ Peer dependencies (если ещё не стоят):
35
+
36
+ ```bash
37
+ bun add framer-motion react-syntax-highlighter
38
+ ```
39
+
40
+ ### Подключение (одна команда)
41
+
42
+ ```bash
43
+ npx @stsgs1980/fab-inspector init
44
+ ```
45
+
46
+ CLI автоматически:
47
+ - Создаёт `src/app/api/source/route.ts` (re-export GET из пакета)
48
+ - Добавляет `import { SelectElementFab } from '@stsgs1980/fab-inspector'` в `src/app/layout.tsx`
49
+ - Вставляет `<SelectElementFab />` перед `</body>`
50
+ - Проверяет наличие peer dependencies
51
+
52
+ Идемпотентно — безопасно запускать сколько угодно раз.
53
+
54
+ ### Ручная установка
55
+
56
+ ```tsx
57
+ // src/app/layout.tsx:
58
+ import { SelectElementFab } from '@stsgs1980/fab-inspector';
59
+ // В JSX перед </body>:
60
+ <SelectElementFab />
61
+ ```
62
+
63
+ ```ts
64
+ // src/app/api/source/route.ts:
65
+ export { GET } from '@stsgs1980/fab-inspector/api/source';
66
+ ```
67
+
68
+ ## Обновление
69
+
70
+ ```bash
71
+ bun update @stsgs1980/fab-inspector
72
+ # или
73
+ npm update @stsgs1980/fab-inspector
74
+ ```
75
+
76
+ ## Использование
77
+
78
+ ### Режим инспекции
79
+
80
+ - FAB-кнопка в правом нижнем углу включает режим инспекции
81
+ - Клик по элементу показывает свёрнутую панель (тег, ID, файл:строка)
82
+ - Шеврон раскрывает все секции
83
+ - Esc закрывает инспектор
84
+ - Панель перетаскивается за заголовок
85
+
86
+ ### Кнопки в заголовке панели
87
+
88
+ | Кнопка | Действие | Доступна без `data-src` |
89
+ |--------|----------|-------------------------|
90
+ | Документ | Copy task context (файл, тег, текст) | нет |
91
+ | Два прямоугольника | Copy file:line | нет |
92
+ | Копировать | Quick-copy (тег + классы + текст) | **да** |
93
+ | Шеврон | Свернуть / развернуть секции | да |
94
+ | x | Закрыть панель | да |
95
+
96
+ ### data-src атрибут
97
+
98
+ Для отображения источника добавьте `data-src` на JSX-элементы:
99
+
100
+ ```tsx
101
+ <h1 data-src="src/components/sections/hero.tsx:12">Заголовок</h1>
102
+ ```
103
+
104
+ Инспектор поднимается по DOM-дереву и найдёт ближайший `data-src`.
105
+
106
+ > **Next.js 16 note:** Авто-проставление `data-src` через Turbopack plugin в Next.js 16 больше не работает (API `experimental.turbo.plugins` удалён). Проставляйте `data-src` вручную на ключевых элементах. SWC plugin для авто-разметки — в планах.
107
+
108
+ ## Конфигурация
109
+
110
+ ### Зависимости (peerDependencies)
111
+
112
+ | Пакет | Версия |
113
+ |-------|--------|
114
+ | `framer-motion` | >= 11.0 |
115
+ | `react-syntax-highlighter` | >= 15.0 |
116
+ | `next` | >= 15.0 |
117
+ | `react` | >= 19.0 |
118
+
119
+ ### Production safety
120
+
121
+ - Пакет ставится в `devDependencies` — в production-bundle не попадает
122
+ - Компонент `SelectElementFab` имеет dev-only guard: `if (process.env.NODE_ENV !== 'development') return null`
123
+ - `sideEffects: false` — tree-shaking вырезает неиспользуемый код
124
+
125
+ ### API-роут `/api/source`
126
+
127
+ Создаётся CLI `init` автоматически. Принимает `file`, `line`, `ctx` (контекст строк). Файл валидируется по белому списку директорий (`src/components/`, `src/app/`, `src/content/`, `src/hooks/`, `src/lib/`).
128
+
129
+ ## Структура модуля
130
+
131
+ - `index.ts` — Barrel export
132
+ - `types.ts` — Интерфейсы (ElementInfo, SourceInfo, BoxModel, SnippetData)
133
+ - `select-element-fab.tsx` — Корневой composer
134
+ - `inspector-fab.tsx` — FAB-кнопка
135
+ - `inspector-panel.tsx` — Draggable сворачиваемая панель
136
+ - `highlight-overlay.tsx` — Подсветка элемента
137
+ - `panel-sections.tsx` — Секции панели (Source, Classes, Text, CSS Path, HTML, Styles, Snippet)
138
+ - `box-model-section.tsx` — Box Model диаграмма
139
+ - `use-element-inspector.ts` — Хук инспекции
140
+ - `use-panel-drag.ts` — Хук перетаскивания
141
+ - `api-source-route.ts` — GET handler для `/api/source`
142
+ - `cli/init.mjs` — CLI `npx @stsgs1980/fab-inspector init`
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,60 @@
1
+ // Шаблон API-роута для установки.
2
+ // В проекте-потребителе создайте src/app/api/source/route.ts:
3
+ // export { GET } from '@stsgs1980/fab-inspector/api/source';
4
+ // Либо, если модуль подключён как локальный путь:
5
+ // export { GET } from '@/components/inspector/api-source-route';
6
+
7
+ import { NextRequest, NextResponse } from 'next/server';
8
+ import { readFile } from 'fs/promises';
9
+ import { resolve } from 'path';
10
+
11
+ // process.cwd() в Next.js = корень проекта-потребителя.
12
+ // Никаких '..' — иначе путь уходит за пределы проекта и isAllowed() всегда false.
13
+ const PROJECT_ROOT = process.cwd();
14
+ const ALLOWED_PREFIXES = [
15
+ resolve(PROJECT_ROOT, 'src/components/'),
16
+ resolve(PROJECT_ROOT, 'src/app/'),
17
+ resolve(PROJECT_ROOT, 'src/content/'),
18
+ resolve(PROJECT_ROOT, 'src/hooks/'),
19
+ resolve(PROJECT_ROOT, 'src/lib/'),
20
+ ];
21
+
22
+ function isAllowed(filePath: string): boolean {
23
+ const resolved = resolve(PROJECT_ROOT, filePath);
24
+ return ALLOWED_PREFIXES.some((prefix) => resolved.startsWith(prefix));
25
+ }
26
+
27
+ export async function GET(request: NextRequest) {
28
+ const { searchParams } = new URL(request.url);
29
+ const file = searchParams.get('file');
30
+ const line = parseInt(searchParams.get('line') || '1', 10);
31
+ const ctx = parseInt(searchParams.get('ctx') || '8', 10);
32
+
33
+ if (!file || !isAllowed(file)) {
34
+ return NextResponse.json({ error: 'File not allowed' }, { status: 403 });
35
+ }
36
+
37
+ try {
38
+ const filePath = resolve(PROJECT_ROOT, file);
39
+ const content = await readFile(filePath, 'utf-8');
40
+ const lines = content.split('\n');
41
+
42
+ const start = Math.max(0, line - 1 - ctx);
43
+ const end = Math.min(lines.length, line - 1 + ctx + 1);
44
+ const snippet = lines.slice(start, end);
45
+ const startLine = start + 1;
46
+
47
+ return NextResponse.json({
48
+ file,
49
+ line,
50
+ totalLines: lines.length,
51
+ snippet: {
52
+ startLine,
53
+ lines: snippet,
54
+ highlightLine: line,
55
+ },
56
+ });
57
+ } catch {
58
+ return NextResponse.json({ error: 'File not found' }, { status: 404 });
59
+ }
60
+ }
@@ -0,0 +1,97 @@
1
+ import type { BoxModel } from './types';
2
+
3
+ function fmt(val: string): string {
4
+ if (val === '0px') return '0';
5
+ return val;
6
+ }
7
+
8
+ function BoxRow({
9
+ top,
10
+ right,
11
+ bottom,
12
+ left,
13
+ color,
14
+ bg,
15
+ label,
16
+ inner,
17
+ }: {
18
+ top: string; right: string; bottom: string; left: string;
19
+ color: string; bg: string; label: string;
20
+ inner: React.ReactNode;
21
+ }) {
22
+ const allSame = top === right && right === bottom && bottom === left;
23
+ const display = allSame ? fmt(top) : `${fmt(top)} ${fmt(right)} ${fmt(bottom)} ${fmt(left)}`;
24
+
25
+ return (
26
+ <div className="flex items-center gap-2 text-[10px] font-mono">
27
+ <span className="w-12 text-right text-[#8B949E] flex-shrink-0">{label}</span>
28
+ <div className="flex-1">
29
+ <div
30
+ className="border text-center py-1 text-[#E6EDF3] rounded-sm"
31
+ style={{ borderColor: color, backgroundColor: bg }}
32
+ >
33
+ {allSame ? (
34
+ <span>{display}</span>
35
+ ) : (
36
+ <div className="flex justify-between px-1">
37
+ <span>{fmt(top)}</span>
38
+ <span>{fmt(right)}</span>
39
+ <span>{fmt(bottom)}</span>
40
+ <span>{fmt(left)}</span>
41
+ </div>
42
+ )}
43
+ <div className="mt-1">{inner}</div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export function BoxModelSection({ boxModel }: { boxModel: BoxModel }) {
51
+ return (
52
+ <div className="px-4 py-2.5 border-b border-[#30363D]">
53
+ <div className="text-[11px] font-semibold text-[#8B949E] uppercase tracking-wider mb-2">
54
+ Box Model
55
+ </div>
56
+ <div className="flex flex-col gap-1.5">
57
+ <BoxRow
58
+ top={boxModel.marginTop}
59
+ right={boxModel.marginRight}
60
+ bottom={boxModel.marginBottom}
61
+ left={boxModel.marginLeft}
62
+ color="#D29922"
63
+ bg="rgba(210, 153, 34, 0.06)"
64
+ label="margin"
65
+ inner={
66
+ <BoxRow
67
+ top={boxModel.borderTop}
68
+ right={boxModel.borderRight}
69
+ bottom={boxModel.borderBottom}
70
+ left={boxModel.borderLeft}
71
+ color="#58A6FF"
72
+ bg="rgba(88, 166, 255, 0.06)"
73
+ label="border"
74
+ inner={
75
+ <BoxRow
76
+ top={boxModel.paddingTop}
77
+ right={boxModel.paddingRight}
78
+ bottom={boxModel.paddingBottom}
79
+ left={boxModel.paddingLeft}
80
+ color="#3FB950"
81
+ bg="rgba(63, 185, 80, 0.06)"
82
+ label="padding"
83
+ inner={
84
+ <div className="text-center py-1 bg-[#0D1117] rounded-sm text-[#E6EDF3]">
85
+ <div className="text-[9px] text-[#6E7681]">content</div>
86
+ <div>{fmt(boxModel.width)} x {fmt(boxModel.height)}</div>
87
+ </div>
88
+ }
89
+ />
90
+ }
91
+ />
92
+ }
93
+ />
94
+ </div>
95
+ </div>
96
+ );
97
+ }
package/cli/init.mjs ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @stsgs1980/fab-inspector init
4
+ *
5
+ * Автоподключение инспектора в Next.js App Router проект.
6
+ * Создаёт API-роут /api/source и вставляет импорт + JSX в layout.tsx.
7
+ * Идемпотентно: безопасно запускать многократно.
8
+ *
9
+ * Использование:
10
+ * bun add @stsgs1980/fab-inspector -D
11
+ * npx @stsgs1980/fab-inspector init
12
+ */
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
15
+
16
+ const PKG = '@stsgs1980/fab-inspector';
17
+ const IMPORT_LINE = `import { SelectElementFab } from '${PKG}';`;
18
+ const ROUTE_REL = 'src/app/api/source/route.ts';
19
+ const ROUTE_CONTENT = `// FabInspector source endpoint.
20
+ // Handler живёт внутри npm-пакета — этот файл просто реэкспортирует его.
21
+ // При обновлении пакета ничего менять тут не надо.
22
+ export { GET } from '${PKG}/api/source';
23
+ `;
24
+
25
+ const LAYOUT_CANDIDATES = [
26
+ 'src/app/layout.tsx',
27
+ 'src/app/layout.ts',
28
+ 'src/app/page.tsx',
29
+ 'src/app/page.ts',
30
+ ];
31
+
32
+ function findLayout(cwd) {
33
+ for (const f of LAYOUT_CANDIDATES) {
34
+ const p = resolve(cwd, f);
35
+ if (existsSync(p)) return p;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function ensureImport(code) {
41
+ if (code.includes('SelectElementFab')) return { code, added: false };
42
+ const lines = code.split('\n');
43
+ let lastImportIdx = -1;
44
+ for (let i = 0; i < lines.length; i++) {
45
+ if (/^import\s/.test(lines[i])) lastImportIdx = i;
46
+ }
47
+ if (lastImportIdx === -1) {
48
+ return { code: IMPORT_LINE + '\n' + code, added: true };
49
+ }
50
+ lines.splice(lastImportIdx + 1, 0, IMPORT_LINE);
51
+ return { code: lines.join('\n'), added: true };
52
+ }
53
+
54
+ function ensureJsx(code) {
55
+ if (code.includes('<SelectElementFab')) return { code, added: false };
56
+
57
+ // </body> — стандартный случай для App Router root layout
58
+ const bodyMatch = code.match(/^(\s*)<\/body>/m);
59
+ if (bodyMatch) {
60
+ const indent = bodyMatch[1];
61
+ const insertion = `${indent} <SelectElementFab />\n${indent}</body>`;
62
+ return { code: code.replace(/^(\s*)<\/body>/m, insertion), added: true };
63
+ }
64
+
65
+ // </html> — если почему-то нет body
66
+ const htmlMatch = code.match(/^(\s*)<\/html>/m);
67
+ if (htmlMatch) {
68
+ const indent = htmlMatch[1];
69
+ const insertion = `${indent} <SelectElementFab />\n${indent}</html>`;
70
+ return { code: code.replace(/^(\s*)<\/html>/m, insertion), added: true };
71
+ }
72
+
73
+ return { code, added: false };
74
+ }
75
+
76
+ function ensureRoute(cwd) {
77
+ const p = resolve(cwd, ROUTE_REL);
78
+ if (existsSync(p)) return false;
79
+ mkdirSync(resolve(cwd, 'src/app/api/source'), { recursive: true });
80
+ writeFileSync(p, ROUTE_CONTENT);
81
+ return true;
82
+ }
83
+
84
+ function detectMissingPeers(cwd) {
85
+ const pkgPath = resolve(cwd, 'package.json');
86
+ if (!existsSync(pkgPath)) return [];
87
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
88
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
89
+ const peers = ['framer-motion', 'react-syntax-highlighter'];
90
+ return peers.filter((p) => !deps[p]);
91
+ }
92
+
93
+ function main() {
94
+ const cwd = process.cwd();
95
+ console.log('');
96
+ console.log(' @stsgs1980/fab-inspector — init');
97
+ console.log(' --------------------------------');
98
+
99
+ const layout = findLayout(cwd);
100
+ if (!layout) {
101
+ console.error(' X No src/app/layout.tsx or src/app/page.tsx found.');
102
+ console.error(' Run from the root of a Next.js App Router project.');
103
+ process.exit(1);
104
+ }
105
+
106
+ const relLayout = layout.replace(cwd + '/', '');
107
+ let code = readFileSync(layout, 'utf-8');
108
+
109
+ const importRes = ensureImport(code);
110
+ code = importRes.code;
111
+ const jsxRes = ensureJsx(code);
112
+ code = jsxRes.code;
113
+
114
+ if (importRes.added || jsxRes.added) {
115
+ writeFileSync(layout, code);
116
+ console.log(` + Updated ${relLayout}`);
117
+ } else {
118
+ console.log(` ok Already wired: ${relLayout}`);
119
+ }
120
+
121
+ const created = ensureRoute(cwd);
122
+ console.log(created
123
+ ? ` + Created ${ROUTE_REL}`
124
+ : ` ok Already exists: ${ROUTE_REL}`);
125
+
126
+ const missing = detectMissingPeers(cwd);
127
+ console.log('');
128
+ if (missing.length) {
129
+ console.log(' ! Peer dependencies missing:');
130
+ missing.forEach((p) => console.log(` - ${p}`));
131
+ console.log(' Install them:');
132
+ console.log(` bun add ${missing.join(' ')}`);
133
+ console.log(' (or: npm i ' + missing.join(' ') + ' -D)');
134
+ } else {
135
+ console.log(' ok Peer dependencies installed');
136
+ }
137
+
138
+ console.log('');
139
+ console.log(' Start dev server — FAB appears bottom-right.');
140
+ console.log(' Press Esc to close inspector.');
141
+ console.log('');
142
+ }
143
+
144
+ main();
@@ -0,0 +1,23 @@
1
+ export function HighlightOverlay({
2
+ highlightBox,
3
+ }: {
4
+ highlightBox: DOMRect;
5
+ }) {
6
+ return (
7
+ <div
8
+ data-se-highlight
9
+ className="fixed pointer-events-none z-[90]"
10
+ style={{
11
+ top: highlightBox.top,
12
+ left: highlightBox.left,
13
+ width: highlightBox.width,
14
+ height: highlightBox.height,
15
+ border: '1px dashed #58A6FF',
16
+ backgroundColor: 'rgba(56, 139, 253, 0.06)',
17
+ borderRadius: '3px',
18
+ transition: 'all 0.1s ease-out',
19
+ }}
20
+ aria-hidden="true"
21
+ />
22
+ );
23
+ }
package/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { SelectElementFab } from './select-element-fab';
2
+
3
+ export type {
4
+ SourceInfo,
5
+ ElementInfo,
6
+ SnippetData,
7
+ BoxModel,
8
+ } from './types';
9
+
10
+ export { dataSrcPlugin } from './plugins/data-src-plugin';
@@ -0,0 +1,63 @@
1
+ import { motion, AnimatePresence } from 'framer-motion';
2
+
3
+ export function InspectorFab({
4
+ active,
5
+ onToggle,
6
+ showTooltip,
7
+ }: {
8
+ active: boolean;
9
+ onToggle: () => void;
10
+ showTooltip: boolean;
11
+ }) {
12
+ return (
13
+ <>
14
+ <motion.button
15
+ data-se-fab
16
+ onClick={onToggle}
17
+ className={`
18
+ fixed bottom-6 right-6 z-[90] w-12 h-12 rounded-full
19
+ flex items-center justify-center
20
+ shadow-lg transition-all duration-200 cursor-pointer
21
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-[#58A6FF] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0D1117]
22
+ ${active
23
+ ? 'bg-[#1F6FEB] text-[#F0F6FC] shadow-[#1F6FEB]/30'
24
+ : 'bg-[#21262D] text-[#E6EDF3]/80 hover:bg-[#30363D] hover:text-[#E6EDF3] shadow-black/40'
25
+ }
26
+ `}
27
+ whileHover={{ scale: 1.08 }}
28
+ whileTap={{ scale: 0.95 }}
29
+ aria-label={active ? 'Закрыть инспектор элементов' : 'Открыть инспектор элементов'}
30
+ title={active ? 'Закрыть инспектор (Esc)' : 'Инспектор элементов'}
31
+ >
32
+ {active ? (
33
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
34
+ <path d="M18 6 6 18" />
35
+ <path d="m6 6 12 12" />
36
+ </svg>
37
+ ) : (
38
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
39
+ <path d="m19 19-14-14" />
40
+ <path d="m5 5 14 0" />
41
+ <path d="m5 5 0 14" />
42
+ </svg>
43
+ )}
44
+ <span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-[#58A6FF] text-[#0D1117] text-[9px] font-bold flex items-center justify-center leading-none select-none">
45
+ 3.4.1
46
+ </span>
47
+ </motion.button>
48
+
49
+ <AnimatePresence>
50
+ {showTooltip && (
51
+ <motion.div
52
+ initial={{ opacity: 0, y: 8 }}
53
+ animate={{ opacity: 1, y: 0 }}
54
+ exit={{ opacity: 0, y: 8 }}
55
+ className="fixed bottom-20 right-6 z-[90] bg-[#21262D] text-[#E6EDF3] text-xs px-3 py-1.5 rounded-lg shadow-lg border border-[#30363D] whitespace-nowrap pointer-events-none"
56
+ >
57
+ Кликните на элемент для инспекции
58
+ </motion.div>
59
+ )}
60
+ </AnimatePresence>
61
+ </>
62
+ );
63
+ }