@dzhechkov/p-replicator 1.5.6 → 1.5.7

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.
@@ -0,0 +1,496 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * P-Replicator docs interactivity (vanilla JS, no dependencies).
5
+ * 12 features: theme toggle, mobile sidebar, scroll-spy TOC,
6
+ * full-text search w/ snippets, copy buttons, back-to-top,
7
+ * reading progress, prev/next nav, regex syntax highlighting,
8
+ * keyboard shortcuts, print mode, reduced-motion respect.
9
+ */
10
+
11
+ (function () {
12
+ // ===========================================================================
13
+ // Helpers
14
+ // ===========================================================================
15
+
16
+ const $ = (sel, root) => (root || document).querySelector(sel);
17
+ const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
18
+
19
+ function escapeHtml(s) {
20
+ return String(s)
21
+ .replace(/&/g, '&')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;')
24
+ .replace(/"/g, '&quot;');
25
+ }
26
+
27
+ function escapeRegex(s) {
28
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ }
30
+
31
+ function debounce(fn, wait) {
32
+ let t;
33
+ return function (...args) {
34
+ clearTimeout(t);
35
+ t = setTimeout(() => fn.apply(this, args), wait);
36
+ };
37
+ }
38
+
39
+ // ===========================================================================
40
+ // 1. Theme toggle (light / dark / auto) with localStorage persistence
41
+ // ===========================================================================
42
+
43
+ const THEME_KEY = 'p-replicator-theme';
44
+ const html = document.documentElement;
45
+ const themeToggle = $('#themeToggle');
46
+
47
+ function getStoredTheme() {
48
+ try { return localStorage.getItem(THEME_KEY) || 'auto'; }
49
+ catch (_) { return 'auto'; }
50
+ }
51
+
52
+ function setTheme(t) {
53
+ if (t === 'auto') {
54
+ html.removeAttribute('data-theme');
55
+ } else {
56
+ html.setAttribute('data-theme', t);
57
+ }
58
+ try { localStorage.setItem(THEME_KEY, t); } catch (_) { /* ignore */ }
59
+ updateThemeIcon();
60
+ }
61
+
62
+ function isDarkActive() {
63
+ const stored = getStoredTheme();
64
+ if (stored === 'dark') return true;
65
+ if (stored === 'light') return false;
66
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
67
+ }
68
+
69
+ function updateThemeIcon() {
70
+ if (!themeToggle) return;
71
+ const dark = isDarkActive();
72
+ themeToggle.innerHTML = `<span aria-hidden="true">${dark ? '☀️' : '🌙'}</span>`;
73
+ themeToggle.setAttribute('aria-label', dark ? 'Переключить на светлую тему' : 'Переключить на тёмную тему');
74
+ }
75
+
76
+ themeToggle?.addEventListener('click', () => {
77
+ const next = isDarkActive() ? 'light' : 'dark';
78
+ setTheme(next);
79
+ });
80
+
81
+ // React to system theme change when in 'auto' mode
82
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
83
+ if (getStoredTheme() === 'auto') updateThemeIcon();
84
+ });
85
+
86
+ updateThemeIcon();
87
+
88
+ // ===========================================================================
89
+ // 2. Mobile sidebar toggle
90
+ // ===========================================================================
91
+
92
+ const menuToggle = $('#menuToggle');
93
+ const sidebar = $('#sidebar');
94
+ const overlay = $('#sidebarOverlay');
95
+
96
+ function setSidebarOpen(open) {
97
+ if (!sidebar) return;
98
+ sidebar.classList.toggle('open', open);
99
+ overlay?.classList.toggle('active', open);
100
+ menuToggle?.setAttribute('aria-expanded', String(open));
101
+ document.body.style.overflow = open && window.innerWidth < 1024 ? 'hidden' : '';
102
+ }
103
+
104
+ menuToggle?.addEventListener('click', () => {
105
+ setSidebarOpen(!sidebar.classList.contains('open'));
106
+ });
107
+ overlay?.addEventListener('click', () => setSidebarOpen(false));
108
+
109
+ // Close sidebar when a TOC link is clicked on mobile
110
+ $$('.toc a').forEach((a) => {
111
+ a.addEventListener('click', () => {
112
+ if (window.innerWidth < 1024) setSidebarOpen(false);
113
+ });
114
+ });
115
+
116
+ // ===========================================================================
117
+ // 3. Scroll-spy: highlight active TOC entry as user scrolls
118
+ // ===========================================================================
119
+
120
+ const tocLinks = $$('.toc a');
121
+ const tocLinksByHref = new Map(tocLinks.map((a) => [a.getAttribute('href'), a]));
122
+
123
+ // Observe both <article> sections and h2/h3 with id (subsections)
124
+ const observed = $$('article.section, h2[id], h3[id]');
125
+
126
+ if ('IntersectionObserver' in window) {
127
+ const visible = new Set();
128
+ const observer = new IntersectionObserver(
129
+ (entries) => {
130
+ for (const e of entries) {
131
+ if (e.isIntersecting) visible.add(e.target);
132
+ else visible.delete(e.target);
133
+ }
134
+
135
+ // Find topmost visible
136
+ let top = null;
137
+ let topY = Infinity;
138
+ for (const el of visible) {
139
+ const r = el.getBoundingClientRect();
140
+ if (r.top < topY) {
141
+ topY = r.top;
142
+ top = el;
143
+ }
144
+ }
145
+ if (!top) return;
146
+
147
+ const id = top.id;
148
+ const href = '#' + id;
149
+ tocLinks.forEach((a) => a.classList.toggle('active', a.getAttribute('href') === href));
150
+ },
151
+ { rootMargin: '-72px 0px -65% 0px', threshold: [0, 0.1] }
152
+ );
153
+ observed.forEach((el) => observer.observe(el));
154
+ }
155
+
156
+ // ===========================================================================
157
+ // 4. Full-text search with snippet preview
158
+ // ===========================================================================
159
+
160
+ const searchInput = $('#searchInput');
161
+ const searchResults = $('#searchResults');
162
+
163
+ // Build search index: each h2/h3 + its surrounding paragraph text
164
+ const searchIndex = (() => {
165
+ const idx = [];
166
+ $$('article.section').forEach((article) => {
167
+ const sectionId = article.id;
168
+ const sectionTitle = article.dataset.sectionTitle || article.querySelector('h1')?.textContent || sectionId;
169
+
170
+ // Index the article's H1 with first paragraph snippet
171
+ const h1 = article.querySelector('h1');
172
+ if (h1) {
173
+ const firstPara = h1.nextElementSibling?.tagName === 'P' ? h1.nextElementSibling.textContent : '';
174
+ idx.push({
175
+ sectionId, sectionTitle,
176
+ headingId: sectionId,
177
+ headingText: h1.textContent.trim(),
178
+ content: (h1.textContent + ' ' + firstPara).replace(/\s+/g, ' ').trim().slice(0, 1500),
179
+ score: 2,
180
+ });
181
+ }
182
+
183
+ // Index each h2 and h3
184
+ $$('h2[id], h3[id]', article).forEach((heading) => {
185
+ const headingText = heading.textContent.trim();
186
+ const headingId = heading.id;
187
+ // Collect text content of siblings until next heading of same-or-higher level
188
+ const stopTags = heading.tagName === 'H2' ? ['H1', 'H2'] : ['H1', 'H2', 'H3'];
189
+ const parts = [];
190
+ let next = heading.nextElementSibling;
191
+ while (next && !stopTags.includes(next.tagName)) {
192
+ parts.push(next.textContent || '');
193
+ next = next.nextElementSibling;
194
+ }
195
+ idx.push({
196
+ sectionId, sectionTitle,
197
+ headingId, headingText,
198
+ content: parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, 1500),
199
+ score: heading.tagName === 'H2' ? 1.5 : 1,
200
+ });
201
+ });
202
+ });
203
+ return idx;
204
+ })();
205
+
206
+ function makeSnippet(text, query, len) {
207
+ len = len || 140;
208
+ if (!text) return '';
209
+ const lower = text.toLowerCase();
210
+ const q = query.toLowerCase();
211
+ const at = lower.indexOf(q);
212
+ if (at === -1) return text.slice(0, len) + (text.length > len ? '…' : '');
213
+ const start = Math.max(0, at - 50);
214
+ const end = Math.min(text.length, at + q.length + 90);
215
+ return (start > 0 ? '… ' : '') + text.slice(start, end) + (end < text.length ? ' …' : '');
216
+ }
217
+
218
+ function highlightMatches(text, query) {
219
+ const safe = escapeHtml(text);
220
+ if (!query) return safe;
221
+ const re = new RegExp(escapeRegex(query), 'gi');
222
+ return safe.replace(re, (m) => `<mark>${m}</mark>`);
223
+ }
224
+
225
+ function runSearch(q) {
226
+ if (!searchResults) return;
227
+ const query = q.trim();
228
+ if (query.length < 2) {
229
+ searchResults.hidden = true;
230
+ searchResults.innerHTML = '';
231
+ return;
232
+ }
233
+ const lower = query.toLowerCase();
234
+ const results = [];
235
+ for (const item of searchIndex) {
236
+ const inHeading = item.headingText.toLowerCase().includes(lower);
237
+ const inContent = item.content.toLowerCase().includes(lower);
238
+ if (!inHeading && !inContent) continue;
239
+ const score = (inHeading ? 10 : 0) + (inContent ? 1 : 0) + item.score;
240
+ results.push({ ...item, _score: score });
241
+ }
242
+ results.sort((a, b) => b._score - a._score);
243
+
244
+ if (results.length === 0) {
245
+ searchResults.innerHTML = '<div class="search-empty">Ничего не найдено по запросу «' + escapeHtml(query) + '».</div>';
246
+ } else {
247
+ const top = results.slice(0, 25);
248
+ searchResults.innerHTML = top.map((r) => `
249
+ <a class="search-result" href="#${escapeHtml(r.headingId)}" role="option" tabindex="-1">
250
+ <div class="search-result-section">${escapeHtml(r.sectionTitle)}</div>
251
+ <div class="search-result-title">${highlightMatches(r.headingText, query)}</div>
252
+ <div class="search-result-snippet">${highlightMatches(makeSnippet(r.content, query), query)}</div>
253
+ </a>
254
+ `).join('');
255
+ }
256
+ searchResults.hidden = false;
257
+ }
258
+
259
+ const debouncedSearch = debounce((q) => runSearch(q), 150);
260
+ searchInput?.addEventListener('input', (e) => debouncedSearch(e.target.value));
261
+ searchInput?.addEventListener('focus', () => {
262
+ if (searchInput.value && searchResults && searchResults.children.length > 0) {
263
+ searchResults.hidden = false;
264
+ }
265
+ });
266
+
267
+ // Close search results on outside click
268
+ document.addEventListener('click', (e) => {
269
+ const wrap = searchInput?.parentElement;
270
+ if (!wrap || wrap.contains(e.target) || searchResults?.contains(e.target)) return;
271
+ if (searchResults) searchResults.hidden = true;
272
+ });
273
+
274
+ // Close search results when one clicked
275
+ searchResults?.addEventListener('click', (e) => {
276
+ if (e.target.closest('.search-result')) {
277
+ searchResults.hidden = true;
278
+ if (searchInput) searchInput.value = '';
279
+ if (window.innerWidth < 1024) setSidebarOpen(false);
280
+ }
281
+ });
282
+
283
+ // ===========================================================================
284
+ // 5. Copy-to-clipboard buttons on every <pre>
285
+ // ===========================================================================
286
+
287
+ $$('pre').forEach((pre) => {
288
+ if (pre.querySelector('.copy-btn')) return;
289
+ const btn = document.createElement('button');
290
+ btn.type = 'button';
291
+ btn.className = 'copy-btn';
292
+ btn.setAttribute('aria-label', 'Скопировать код');
293
+ btn.textContent = '📋 Copy';
294
+ btn.addEventListener('click', async () => {
295
+ const code = pre.querySelector('code')?.textContent || pre.textContent || '';
296
+ try {
297
+ await navigator.clipboard.writeText(code);
298
+ btn.dataset.state = 'copied';
299
+ btn.textContent = '✓ Copied';
300
+ setTimeout(() => {
301
+ btn.dataset.state = '';
302
+ btn.textContent = '📋 Copy';
303
+ }, 1600);
304
+ } catch (_) {
305
+ btn.textContent = '✗ Failed';
306
+ setTimeout(() => { btn.textContent = '📋 Copy'; }, 1600);
307
+ }
308
+ });
309
+ pre.appendChild(btn);
310
+ });
311
+
312
+ // ===========================================================================
313
+ // 6. Back-to-top button + 7. Reading progress bar
314
+ // ===========================================================================
315
+
316
+ const backToTop = $('#backToTop');
317
+ const progressBar = $('#progressBar');
318
+
319
+ function updateScrollUI() {
320
+ const scrollTop = window.scrollY || window.pageYOffset;
321
+ const totalScroll = document.documentElement.scrollHeight - window.innerHeight;
322
+ const progress = totalScroll > 0 ? Math.min(100, (scrollTop / totalScroll) * 100) : 0;
323
+ if (progressBar) progressBar.style.width = progress + '%';
324
+ if (backToTop) backToTop.hidden = scrollTop < 500;
325
+ }
326
+
327
+ let scrollFrame;
328
+ window.addEventListener('scroll', () => {
329
+ cancelAnimationFrame(scrollFrame);
330
+ scrollFrame = requestAnimationFrame(updateScrollUI);
331
+ }, { passive: true });
332
+ updateScrollUI();
333
+
334
+ backToTop?.addEventListener('click', () => {
335
+ window.scrollTo({ top: 0, behavior: 'smooth' });
336
+ });
337
+
338
+ // ===========================================================================
339
+ // 8. Keyboard shortcuts
340
+ // `/` → focus search
341
+ // `Escape` → close sidebar / clear search
342
+ // `t` → toggle theme
343
+ // ===========================================================================
344
+
345
+ document.addEventListener('keydown', (e) => {
346
+ const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName) || e.target.isContentEditable;
347
+
348
+ // Escape always works
349
+ if (e.key === 'Escape') {
350
+ if (searchResults && !searchResults.hidden) {
351
+ searchResults.hidden = true;
352
+ return;
353
+ }
354
+ if (sidebar?.classList.contains('open')) {
355
+ setSidebarOpen(false);
356
+ return;
357
+ }
358
+ if (isInput && e.target === searchInput) {
359
+ searchInput.value = '';
360
+ runSearch('');
361
+ searchInput.blur();
362
+ return;
363
+ }
364
+ }
365
+
366
+ if (isInput) return;
367
+
368
+ if (e.key === '/') {
369
+ e.preventDefault();
370
+ searchInput?.focus();
371
+ return;
372
+ }
373
+ if (e.key === 't' || e.key === 'T') {
374
+ const next = isDarkActive() ? 'light' : 'dark';
375
+ setTheme(next);
376
+ return;
377
+ }
378
+ });
379
+
380
+ // ===========================================================================
381
+ // 9. Syntax highlighting (vanilla regex; bash / js / json / markdown)
382
+ // ===========================================================================
383
+
384
+ function highlightBash(src) {
385
+ let out = escapeHtml(src);
386
+ // Comments
387
+ out = out.replace(/(^|\n)(#[^\n]*)/g, '$1<span class="tk-comment">$2</span>');
388
+ // Strings (preserve already-escaped content)
389
+ out = out.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/g, '<span class="tk-string">$1</span>');
390
+ // Long flags
391
+ out = out.replace(/(--[a-zA-Z][\w-]*)/g, '<span class="tk-flag">$1</span>');
392
+ // Common command keywords
393
+ out = out.replace(/\b(npx|npm|node|cd|ls|cat|grep|echo|git|docker|export|set|if|then|else|elif|fi|for|do|done|while|case|esac|source|sudo|claude|find|wc|du|tail|head|chmod|chown|mkdir|rm|cp|mv)\b/g, '<span class="tk-keyword">$1</span>');
394
+ return out;
395
+ }
396
+
397
+ function highlightJson(src) {
398
+ let out = escapeHtml(src);
399
+ out = out.replace(/("(?:\\.|[^"\\])*")(\s*:)/g, '<span class="tk-attr">$1</span>$2');
400
+ out = out.replace(/:(\s*)("(?:\\.|[^"\\])*")/g, ':$1<span class="tk-string">$2</span>');
401
+ out = out.replace(/\b(true|false|null)\b/g, '<span class="tk-keyword">$1</span>');
402
+ out = out.replace(/(:\s*|,\s*|\[\s*|\(\s*)(-?\d+(?:\.\d+)?)/g, '$1<span class="tk-number">$2</span>');
403
+ return out;
404
+ }
405
+
406
+ function highlightJs(src) {
407
+ let out = escapeHtml(src);
408
+ // Comments
409
+ out = out.replace(/(\/\/[^\n]*)/g, '<span class="tk-comment">$1</span>');
410
+ out = out.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="tk-comment">$1</span>');
411
+ // Strings (single, double, backtick)
412
+ out = out.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/g, '<span class="tk-string">$1</span>');
413
+ // Keywords
414
+ out = out.replace(/\b(const|let|var|function|return|if|else|for|while|class|extends|new|this|async|await|import|export|from|require|module|exports|true|false|null|undefined|throw|try|catch|finally|in|of|typeof|instanceof|switch|case|break|continue|default|do)\b/g, '<span class="tk-keyword">$1</span>');
415
+ // Numbers
416
+ out = out.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="tk-number">$1</span>');
417
+ return out;
418
+ }
419
+
420
+ function highlightMd(src) {
421
+ let out = escapeHtml(src);
422
+ // Headings
423
+ out = out.replace(/(^|\n)(#{1,6}\s[^\n]*)/g, '$1<span class="tk-keyword">$2</span>');
424
+ // Bold / italic
425
+ out = out.replace(/(\*\*[^*\n]+\*\*)/g, '<span class="tk-attr">$1</span>');
426
+ // Inline code
427
+ out = out.replace(/(`[^`\n]+`)/g, '<span class="tk-string">$1</span>');
428
+ // Links
429
+ out = out.replace(/(\[[^\]\n]+\]\([^)\n]+\))/g, '<span class="tk-function">$1</span>');
430
+ return out;
431
+ }
432
+
433
+ function highlightHtml(src) {
434
+ let out = escapeHtml(src);
435
+ // Comments
436
+ out = out.replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="tk-comment">$1</span>');
437
+ // Tags
438
+ out = out.replace(/(&lt;\/?[a-zA-Z][\w-]*)/g, '<span class="tk-tag">$1</span>');
439
+ // Attributes
440
+ out = out.replace(/([a-zA-Z-]+)=(&quot;[^&]*?&quot;)/g, '<span class="tk-attr">$1</span>=<span class="tk-string">$2</span>');
441
+ return out;
442
+ }
443
+
444
+ const highlighters = {
445
+ bash: highlightBash,
446
+ shell: highlightBash,
447
+ sh: highlightBash,
448
+ json: highlightJson,
449
+ js: highlightJs,
450
+ javascript: highlightJs,
451
+ md: highlightMd,
452
+ markdown: highlightMd,
453
+ html: highlightHtml,
454
+ };
455
+
456
+ $$('pre code').forEach((code) => {
457
+ const m = code.className.match(/language-(\S+)/);
458
+ const lang = m && m[1] !== 'text' ? m[1] : null;
459
+ if (!lang) return;
460
+ const fn = highlighters[lang];
461
+ if (!fn) return;
462
+ const raw = code.textContent;
463
+ code.innerHTML = fn(raw);
464
+ });
465
+
466
+ // ===========================================================================
467
+ // 10. External link handling (open in new tab — already in HTML, but
468
+ // also add visual indicator for clarity if desired). No-op here.
469
+ // ===========================================================================
470
+
471
+ // ===========================================================================
472
+ // 11. Hash-on-load: scroll to anchor if URL has hash
473
+ // ===========================================================================
474
+
475
+ if (window.location.hash) {
476
+ requestAnimationFrame(() => {
477
+ try {
478
+ const target = document.querySelector(window.location.hash);
479
+ target?.scrollIntoView({ behavior: 'auto', block: 'start' });
480
+ } catch (_) { /* ignore invalid hashes */ }
481
+ });
482
+ }
483
+
484
+ // ===========================================================================
485
+ // 12. Restore scroll position on theme switch (no flicker)
486
+ // ===========================================================================
487
+
488
+ // Already handled by CSS transitions; nothing else needed.
489
+
490
+ // ===========================================================================
491
+ // Done. Log build info to console for debugging.
492
+ // ===========================================================================
493
+
494
+ // eslint-disable-next-line no-console
495
+ console.info('%cP-Replicator docs', 'color:#58a6ff;font-weight:bold;', 'v1.5.0 — vanilla JS, zero deps. Press / to search, t to toggle theme.');
496
+ })();