@djangocfg/ui-tools 2.1.287 → 2.1.290

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.
Files changed (105) hide show
  1. package/README.md +14 -3
  2. package/dist/DocsLayout-IKH7BLSU.cjs +3464 -0
  3. package/dist/DocsLayout-IKH7BLSU.cjs.map +1 -0
  4. package/dist/DocsLayout-JPXFUKAR.mjs +3457 -0
  5. package/dist/DocsLayout-JPXFUKAR.mjs.map +1 -0
  6. package/dist/{PrettyCode.client-5GABIN2I.cjs → PrettyCode.client-RPDIE5CH.cjs} +104 -3
  7. package/dist/PrettyCode.client-RPDIE5CH.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-IZTXXYHG.mjs → PrettyCode.client-SPMTQEG4.mjs} +106 -5
  9. package/dist/PrettyCode.client-SPMTQEG4.mjs.map +1 -0
  10. package/dist/{chunk-IULI4XII.cjs → chunk-5Q4UMSWB.cjs} +355 -9
  11. package/dist/chunk-5Q4UMSWB.cjs.map +1 -0
  12. package/dist/{chunk-VZGQC3NG.mjs → chunk-EFWOJPA6.mjs} +349 -9
  13. package/dist/chunk-EFWOJPA6.mjs.map +1 -0
  14. package/dist/index.cjs +10 -10
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +34 -0
  17. package/dist/index.d.ts +34 -0
  18. package/dist/index.mjs +5 -5
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +21 -14
  21. package/src/components/markdown/MarkdownMessage.tsx +46 -0
  22. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +93 -157
  23. package/src/tools/OpenapiViewer/README.md +114 -6
  24. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +20 -6
  25. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +331 -53
  26. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/LanguageTabs.tsx +36 -0
  27. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/index.tsx +56 -0
  28. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/useCodeSnippet.ts +77 -0
  29. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +146 -0
  30. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MethodBadge.tsx +6 -0
  31. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/PathDisplay.tsx +26 -0
  32. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/index.tsx +87 -0
  33. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamGroup.tsx +30 -0
  34. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamRow.tsx +36 -0
  35. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/index.tsx +22 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/RequestBody/index.tsx +33 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +76 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseRow.tsx +80 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +32 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/index.tsx +21 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +106 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +127 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/index.tsx +31 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/types.ts +28 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +87 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/defaults.ts +27 -0
  47. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/index.tsx +45 -0
  48. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/context.tsx +56 -0
  49. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/hooks/useSectionHash.ts +63 -0
  50. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +96 -0
  51. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/index.ts +133 -0
  52. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/selectors.ts +40 -0
  53. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/types.ts +17 -0
  54. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +40 -11
  55. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/BrandHeader.tsx +48 -0
  56. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/CategoryBlock.tsx +33 -0
  57. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/EndpointRow.tsx +73 -0
  58. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/MethodChips.tsx +43 -0
  59. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SchemaSection.tsx +27 -0
  60. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SearchInput.tsx +45 -0
  61. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SidebarBody.tsx +50 -0
  62. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/Toolbar.tsx +64 -0
  63. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/buildVM.ts +126 -0
  64. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/index.tsx +112 -0
  65. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/types.ts +42 -0
  66. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/useDebouncedValue.ts +14 -0
  67. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +10 -7
  68. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +9 -6
  69. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +19 -2
  70. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
  71. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
  72. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +55 -0
  73. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PreviewView.tsx +115 -0
  74. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/RawView.tsx +24 -0
  75. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/StatusBar.tsx +63 -0
  76. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/ViewTabs.tsx +45 -0
  77. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/detectContent.ts +97 -0
  78. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/index.tsx +93 -0
  79. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/types.ts +26 -0
  80. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/useResponseView.ts +62 -0
  81. package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
  82. package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
  83. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +164 -74
  84. package/src/tools/OpenapiViewer/types.ts +46 -1
  85. package/src/tools/OpenapiViewer/utils/codeSamples.ts +287 -0
  86. package/src/tools/OpenapiViewer/utils/index.ts +3 -0
  87. package/src/tools/OpenapiViewer/utils/operationToHar.ts +119 -0
  88. package/src/tools/OpenapiViewer/utils/sampler.ts +72 -0
  89. package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
  90. package/src/tools/PrettyCode/PrettyCode.client.tsx +88 -1
  91. package/src/tools/PrettyCode/PrettyCode.story.tsx +114 -361
  92. package/src/tools/PrettyCode/index.tsx +13 -0
  93. package/src/tools/PrettyCode/lazy.tsx +5 -0
  94. package/src/tools/PrettyCode/registerPrismLanguages.ts +111 -0
  95. package/dist/DocsLayout-BCVU6TTX.cjs +0 -2027
  96. package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
  97. package/dist/DocsLayout-ERETJLLV.mjs +0 -2020
  98. package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
  99. package/dist/PrettyCode.client-5GABIN2I.cjs.map +0 -1
  100. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +0 -1
  101. package/dist/chunk-IULI4XII.cjs.map +0 -1
  102. package/dist/chunk-VZGQC3NG.mjs.map +0 -1
  103. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +0 -268
  104. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -211
  105. package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
@@ -0,0 +1,97 @@
1
+ import type { ContentKind, DetectedContent } from './types';
2
+
3
+ /** Strip Content-Type parameters (``; charset=utf-8`` etc.) and lower-case. */
4
+ function normaliseContentType(raw: string | null): string | null {
5
+ if (!raw) return null;
6
+ const semi = raw.indexOf(';');
7
+ return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase();
8
+ }
9
+
10
+ /** Pull Content-Type out of the headers bag. We accept both plain
11
+ * objects (``{ "content-type": "..." }``) and ``Headers``-like shapes
12
+ * with a ``.get`` method. The context type is ``any`` so we can't be
13
+ * stricter without plumbing a proper type through the response code
14
+ * path. */
15
+ function readContentType(headers: unknown): string | null {
16
+ if (!headers) return null;
17
+ // Headers instance — use ``.get`` (case-insensitive).
18
+ if (typeof (headers as Headers).get === 'function') {
19
+ return (headers as Headers).get('content-type');
20
+ }
21
+ // Plain object — look up with case-insensitive key match so both
22
+ // ``Content-Type`` and ``content-type`` work.
23
+ if (typeof headers === 'object') {
24
+ for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
25
+ if (k.toLowerCase() === 'content-type') {
26
+ return typeof v === 'string' ? v : null;
27
+ }
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** Map a normalised MIME type to our short ``ContentKind``. Unknown
34
+ * types fall back to ``text`` so PrettyCode still shows something
35
+ * sensible (plain text is a valid Prism language). */
36
+ function kindFromContentType(mime: string | null): ContentKind {
37
+ if (!mime) return 'text';
38
+ if (mime === 'application/json' || mime.endsWith('+json')) return 'json';
39
+ if (mime === 'text/html' || mime === 'application/xhtml+xml') return 'html';
40
+ if (
41
+ mime === 'application/xml' ||
42
+ mime === 'text/xml' ||
43
+ mime.endsWith('+xml')
44
+ ) return 'xml';
45
+ if (mime === 'text/css') return 'css';
46
+ if (
47
+ mime === 'application/javascript' ||
48
+ mime === 'text/javascript' ||
49
+ mime === 'application/x-javascript'
50
+ ) return 'javascript';
51
+ return 'text';
52
+ }
53
+
54
+ /** Heuristic fallback when ``Content-Type`` is missing or opaque.
55
+ * Returns ``null`` when nothing firm can be inferred; the caller
56
+ * then defaults to ``text``. */
57
+ function kindFromBody(body: string): ContentKind | null {
58
+ const trimmed = body.trimStart();
59
+ if (!trimmed) return null;
60
+ if (trimmed.startsWith('<!DOCTYPE') || /^<html[\s>]/i.test(trimmed)) return 'html';
61
+ if (trimmed.startsWith('<?xml')) return 'xml';
62
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
63
+ // Cheap validity check — full parse happens in ResponsePanel.
64
+ try { JSON.parse(trimmed); return 'json'; } catch { /* fall through */ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ const PRISM_BY_KIND: Record<ContentKind, DetectedContent['prism']> = {
70
+ json: 'json',
71
+ // ``markup`` is Prism's HTML/XML grammar — there isn't a separate
72
+ // ``html`` language. XML piggy-backs on the same tokeniser.
73
+ html: 'markup',
74
+ xml: 'markup',
75
+ css: 'css',
76
+ javascript: 'javascript',
77
+ text: 'markup',
78
+ };
79
+
80
+ /** Detect content kind from headers, falling back to body sniffing
81
+ * when the header is missing. Pure function — safe to call inside
82
+ * ``useMemo`` deps on the raw body and headers. */
83
+ export function detectContent(headers: unknown, rawBody: string): DetectedContent {
84
+ const contentType = normaliseContentType(readContentType(headers));
85
+ const headerKind = kindFromContentType(contentType);
86
+
87
+ // If headers say it's text/plain but the body looks like HTML or
88
+ // JSON, trust the body — a lot of framework 500 pages claim
89
+ // text/html but some setups default to text/plain for errors.
90
+ const kind = headerKind === 'text' ? (kindFromBody(rawBody) ?? 'text') : headerKind;
91
+
92
+ return {
93
+ kind,
94
+ prism: PRISM_BY_KIND[kind],
95
+ contentType,
96
+ };
97
+ }
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import { Loader2, Send, Terminal, WifiOff } from 'lucide-react';
4
+ import { useEffect, useState } from 'react';
5
+
6
+ import { usePlaygroundContext } from '../../../context/PlaygroundContext';
7
+ import { EmptyState, ScrollArea } from '../ui';
8
+ import { PreviewView } from './PreviewView';
9
+ import { PrettyView } from './PrettyView';
10
+ import { RawView } from './RawView';
11
+ import { StatusBar } from './StatusBar';
12
+ import type { ViewMode } from './types';
13
+ import { useResponseView } from './useResponseView';
14
+ import { ViewTabs } from './ViewTabs';
15
+
16
+ /** Response panel used by both the sticky ``SlideInPlayground`` and the
17
+ * modal ``TryItSheet``. Responsibilities:
18
+ * - Empty / loading / network-error gates
19
+ * - Three view modes (Pretty / Raw / Preview) with a tab bar
20
+ * - Auto-pick the best default view per response type (JSON → Pretty,
21
+ * HTML → Preview, everything else → Pretty as the widest-purpose)
22
+ *
23
+ * View mode is local component state rather than context state — it
24
+ * should reset when the selected endpoint changes and not leak between
25
+ * endpoints. */
26
+ export function ResponsePanel() {
27
+ const { state } = usePlaygroundContext();
28
+ const { response, loading, selectedEndpoint } = state;
29
+
30
+ const { treeData, rawText, detected } = useResponseView(response?.data, response?.headers);
31
+ const showPreview = detected.kind === 'html';
32
+
33
+ // Default view heuristic: show HTML pages preview-first so the
34
+ // reader sees the actual rendered server page immediately.
35
+ // Everything else lands on Pretty.
36
+ const [mode, setMode] = useState<ViewMode>(showPreview ? 'preview' : 'pretty');
37
+
38
+ // Reset the mode when the endpoint or response changes so a
39
+ // previously-selected tab from a different shape doesn't stick
40
+ // around (e.g. stuck on Preview when switching from HTML→JSON).
41
+ useEffect(() => {
42
+ setMode(showPreview ? 'preview' : 'pretty');
43
+ }, [selectedEndpoint, response, showPreview]);
44
+
45
+ if (loading) {
46
+ return (
47
+ <div className="flex items-center justify-center h-full gap-2">
48
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
49
+ <span className="text-xs text-muted-foreground">Sending…</span>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ if (!selectedEndpoint) return <EmptyState icon={Terminal} text="Response will appear here" />;
55
+ if (!response) return <EmptyState icon={Send} text='Press "Send Request" to see the response' />;
56
+
57
+ const hasError = Boolean(response.error);
58
+ const hasStatus = response.status != null;
59
+
60
+ // Pure network error (no HTTP response at all — CORS, offline, timeout)
61
+ if (hasError && !hasStatus) {
62
+ return (
63
+ <EmptyState
64
+ icon={WifiOff}
65
+ text={response.error!}
66
+ className="text-destructive [&_svg]:text-destructive"
67
+ />
68
+ );
69
+ }
70
+
71
+ return (
72
+ <>
73
+ <StatusBar response={response} rawText={rawText} contentType={detected.contentType} />
74
+
75
+ {/* HTTP-level error body (4xx/5xx — has status but also error flag) */}
76
+ {hasError && (
77
+ <div className="shrink-0 mx-4 mt-3 rounded border border-destructive/20 bg-destructive/5 px-3 py-2">
78
+ <p className="text-xs text-destructive">{response.error}</p>
79
+ </div>
80
+ )}
81
+
82
+ <ViewTabs active={mode} onChange={setMode} showPreview={showPreview} />
83
+
84
+ <ScrollArea>
85
+ {mode === 'pretty' && (
86
+ <PrettyView treeData={treeData} rawText={rawText} detected={detected} />
87
+ )}
88
+ {mode === 'raw' && <RawView rawText={rawText} />}
89
+ {mode === 'preview' && <PreviewView html={rawText} />}
90
+ </ScrollArea>
91
+ </>
92
+ );
93
+ }
@@ -0,0 +1,26 @@
1
+ import type { Language } from '../../../../PrettyCode';
2
+
3
+ /** Panels the user can switch between in the response view.
4
+ *
5
+ * - ``pretty``: structured rendering — JsonTree for JSON, syntax-
6
+ * highlighted code for HTML/XML/CSS/text.
7
+ * - ``raw``: plain ``<pre>`` dump of whatever the server returned.
8
+ * - ``preview``: sandboxed iframe for HTML responses (when you want
9
+ * to *see* an error page the server produced rather
10
+ * than read its source). Tab is hidden for non-HTML. */
11
+ export type ViewMode = 'pretty' | 'raw' | 'preview';
12
+
13
+ /** Content categories derived from the server's ``Content-Type`` header.
14
+ * We project the rich MIME type space onto a short enum so the renderer
15
+ * and tab-list only have to switch on a handful of values. */
16
+ export type ContentKind = 'json' | 'html' | 'xml' | 'css' | 'javascript' | 'text';
17
+
18
+ export interface DetectedContent {
19
+ kind: ContentKind;
20
+ /** Prism ``Language`` id matching ``kind`` — fed into PrettyCode. */
21
+ prism: Language;
22
+ /** Full Content-Type header value, stripped of parameters (e.g.
23
+ * ``application/json`` without ``; charset=utf-8``). ``null`` when
24
+ * the server didn't send one. */
25
+ contentType: string | null;
26
+ }
@@ -0,0 +1,62 @@
1
+ import { useMemo } from 'react';
2
+
3
+ import { detectContent } from './detectContent';
4
+ import type { DetectedContent } from './types';
5
+
6
+ interface UseResponseViewResult {
7
+ /** Parsed JSON value when the body is JSON, otherwise ``null``.
8
+ * JsonTree needs a live object to render properly, so we parse
9
+ * once up-front and cache. */
10
+ treeData: unknown;
11
+ /** Stringified body, always present when the server returned
12
+ * anything. Used by Raw and Pretty-for-text tabs. */
13
+ rawText: string;
14
+ /** Content-type + prism language inference. */
15
+ detected: DetectedContent;
16
+ }
17
+
18
+ /** Shape the raw response payload into the pieces each view tab
19
+ * needs. Exposed as a hook so the component stays thin and the
20
+ * normalisation is easy to test in isolation. */
21
+ export function useResponseView(
22
+ data: unknown,
23
+ headers: unknown,
24
+ ): UseResponseViewResult {
25
+ return useMemo(() => {
26
+ if (data == null) {
27
+ return {
28
+ treeData: null,
29
+ rawText: '',
30
+ detected: detectContent(headers, ''),
31
+ };
32
+ }
33
+
34
+ // String body — might be JSON-in-a-string or a plain text dump.
35
+ if (typeof data === 'string') {
36
+ try {
37
+ return {
38
+ treeData: JSON.parse(data),
39
+ rawText: data,
40
+ detected: detectContent(headers, data),
41
+ };
42
+ } catch {
43
+ return {
44
+ treeData: null,
45
+ rawText: data,
46
+ detected: detectContent(headers, data),
47
+ };
48
+ }
49
+ }
50
+
51
+ // Object body — axios already parsed it. Stringify for Raw/
52
+ // Preview tabs and treat it as JSON regardless of headers.
53
+ const stringified = (() => {
54
+ try { return JSON.stringify(data, null, 2); } catch { return String(data); }
55
+ })();
56
+ return {
57
+ treeData: data,
58
+ rawText: stringified,
59
+ detected: detectContent(headers, stringified),
60
+ };
61
+ }, [data, headers]);
62
+ }
@@ -5,4 +5,6 @@
5
5
  */
6
6
 
7
7
  export { default as useOpenApiSchema } from './useOpenApiSchema';
8
- export { useMobile } from './useMobile';
8
+ export { useMobile } from './useMobile';
9
+ export { useDocsUrlSync, parseDocsHash, buildDocsHash } from './useDocsUrlSync';
10
+ export type { ParsedHash } from './useDocsUrlSync';
@@ -0,0 +1,119 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from 'react';
4
+
5
+ /** Hash format: ``#<schemaId>/<anchor>``.
6
+ * - ``#catalog/ep-get-users`` — specific endpoint in ``catalog`` schema
7
+ * - ``#catalog`` — open ``catalog`` schema at its top
8
+ * - empty — no initial target, leave viewer at its default
9
+ *
10
+ * We intentionally keep this opinionated and stringly-typed: the host app
11
+ * already controls which schemas exist, so there is no room for ambiguity
12
+ * beyond the two segments. */
13
+ export interface ParsedHash {
14
+ schemaId: string | null;
15
+ anchor: string | null;
16
+ }
17
+
18
+ export function parseDocsHash(hash: string): ParsedHash {
19
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
20
+ if (!raw) return { schemaId: null, anchor: null };
21
+ const [schemaId = null, ...rest] = raw.split('/');
22
+ const anchor = rest.length > 0 ? rest.join('/') : null;
23
+ return {
24
+ schemaId: schemaId || null,
25
+ anchor: anchor || null,
26
+ };
27
+ }
28
+
29
+ export function buildDocsHash(schemaId: string | null, anchor: string | null): string {
30
+ if (!schemaId && !anchor) return '';
31
+ if (schemaId && anchor) return `#${schemaId}/${anchor}`;
32
+ if (schemaId) return `#${schemaId}`;
33
+ return anchor ? `#${anchor}` : '';
34
+ }
35
+
36
+ interface UseDocsUrlSyncProps {
37
+ enabled: boolean;
38
+ currentSchemaId: string | null;
39
+ activeAnchor: string | null;
40
+ /** Called on mount / ``popstate`` / ``hashchange`` with the hash state.
41
+ * The consumer is responsible for dispatching into its own handlers
42
+ * (switching schema, scrolling to endpoint) in the right order. */
43
+ onHashTarget: (target: ParsedHash) => void;
44
+ }
45
+
46
+ /** Two-way sync between browser hash and docs viewer state.
47
+ *
48
+ * - Writes use ``history.replaceState`` so scrollspy-driven updates don't
49
+ * pollute the back/forward stack. User-initiated navigation (click on
50
+ * sidebar row, schema switch) still lands in history because the click
51
+ * itself already did ``pushState`` — or will, via plain anchor hrefs.
52
+ * - Reads happen on mount (initial target) and on ``popstate`` /
53
+ * ``hashchange`` (Back/Forward / external anchor clicks).
54
+ * - When ``enabled`` is false, the hook is a no-op — the viewer stays
55
+ * hash-free so you can embed it inside a larger page. */
56
+ export function useDocsUrlSync({
57
+ enabled,
58
+ currentSchemaId,
59
+ activeAnchor,
60
+ onHashTarget,
61
+ }: UseDocsUrlSyncProps) {
62
+ // Ignore the very first write — otherwise on mount we'd clobber the
63
+ // incoming hash with the viewer's empty defaults before ``onHashTarget``
64
+ // has a chance to apply it.
65
+ const primedRef = useRef(false);
66
+ const onHashTargetRef = useRef(onHashTarget);
67
+ useEffect(() => {
68
+ onHashTargetRef.current = onHashTarget;
69
+ }, [onHashTarget]);
70
+
71
+ // Read: mount + hashchange/popstate
72
+ useEffect(() => {
73
+ if (!enabled || typeof window === 'undefined') return;
74
+
75
+ const apply = () => {
76
+ onHashTargetRef.current(parseDocsHash(window.location.hash));
77
+ };
78
+ apply();
79
+ primedRef.current = true;
80
+
81
+ window.addEventListener('hashchange', apply);
82
+ window.addEventListener('popstate', apply);
83
+ return () => {
84
+ window.removeEventListener('hashchange', apply);
85
+ window.removeEventListener('popstate', apply);
86
+ };
87
+ }, [enabled]);
88
+
89
+ // Write: whenever the viewer's state changes
90
+ useEffect(() => {
91
+ if (!enabled || typeof window === 'undefined') return;
92
+ if (!primedRef.current) return;
93
+
94
+ const next = buildDocsHash(currentSchemaId, activeAnchor);
95
+ const current = window.location.hash;
96
+ if (next === current) return;
97
+
98
+ // replaceState keeps Back/Forward meaningful — a single scroll-through
99
+ // the page shouldn't create 50 history entries.
100
+ const url = next
101
+ ? `${window.location.pathname}${window.location.search}${next}`
102
+ : `${window.location.pathname}${window.location.search}`;
103
+ window.history.replaceState(window.history.state, '', url);
104
+ }, [enabled, currentSchemaId, activeAnchor]);
105
+
106
+ const pushTarget = useCallback(
107
+ (schemaId: string | null, anchor: string | null) => {
108
+ if (!enabled || typeof window === 'undefined') return;
109
+ const next = buildDocsHash(schemaId, anchor);
110
+ const url = next
111
+ ? `${window.location.pathname}${window.location.search}${next}`
112
+ : `${window.location.pathname}${window.location.search}`;
113
+ window.history.pushState(window.history.state, '', url);
114
+ },
115
+ [enabled],
116
+ );
117
+
118
+ return { pushTarget };
119
+ }