@eventcatalog/core 4.10.14 → 4.11.0

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 (136) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-ONDFEA5R.js → chunk-DWC5GRFX.js} +1 -1
  6. package/dist/{chunk-LITOQH56.js → chunk-H3324DNT.js} +1 -1
  7. package/dist/{chunk-V2SKOQYF.js → chunk-SW7SJZIX.js} +1 -1
  8. package/dist/{chunk-HERAHLM2.js → chunk-W4LDIMUI.js} +1 -1
  9. package/dist/{chunk-LUW6RDA7.js → chunk-XJOKK5ZO.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/docs/api/02-config.md +6 -6
  13. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/02-configuration.md +18 -11
  14. package/dist/docs/development/components/components/13-mermaid-file-loader.md +12 -0
  15. package/dist/docs/development/components/diagram-syntax/06-mermaid.md +45 -6
  16. package/dist/docs/development/customization/05-resource-sidebar.md +1 -1
  17. package/dist/eventcatalog.cjs +1 -1
  18. package/dist/eventcatalog.js +5 -5
  19. package/dist/generate.cjs +1 -1
  20. package/dist/generate.js +3 -3
  21. package/dist/utils/cli-logger.cjs +1 -1
  22. package/dist/utils/cli-logger.js +2 -2
  23. package/eventcatalog/astro.config.mjs +2 -1
  24. package/eventcatalog/public/icons/languages/android.svg +1 -0
  25. package/eventcatalog/public/icons/languages/angular.svg +1 -0
  26. package/eventcatalog/public/icons/languages/apple.svg +1 -0
  27. package/eventcatalog/public/icons/languages/arduino.svg +1 -0
  28. package/eventcatalog/public/icons/languages/bash.svg +1 -0
  29. package/eventcatalog/public/icons/languages/bun.svg +1 -0
  30. package/eventcatalog/public/icons/languages/c.svg +1 -0
  31. package/eventcatalog/public/icons/languages/clojure.svg +1 -0
  32. package/eventcatalog/public/icons/languages/cpp.svg +1 -0
  33. package/eventcatalog/public/icons/languages/crystal.svg +1 -0
  34. package/eventcatalog/public/icons/languages/csharp.svg +1 -0
  35. package/eventcatalog/public/icons/languages/css.svg +1 -0
  36. package/eventcatalog/public/icons/languages/curl.svg +1 -0
  37. package/eventcatalog/public/icons/languages/dart.svg +1 -0
  38. package/eventcatalog/public/icons/languages/deno.svg +1 -0
  39. package/eventcatalog/public/icons/languages/docker.svg +1 -0
  40. package/eventcatalog/public/icons/languages/elm.svg +1 -0
  41. package/eventcatalog/public/icons/languages/erlang.svg +1 -0
  42. package/eventcatalog/public/icons/languages/flutter.svg +1 -0
  43. package/eventcatalog/public/icons/languages/fortran.svg +1 -0
  44. package/eventcatalog/public/icons/languages/fsharp.svg +1 -0
  45. package/eventcatalog/public/icons/languages/git.svg +1 -0
  46. package/eventcatalog/public/icons/languages/gradle.svg +1 -0
  47. package/eventcatalog/public/icons/languages/graphql.svg +1 -0
  48. package/eventcatalog/public/icons/languages/grpc.svg +1 -0
  49. package/eventcatalog/public/icons/languages/haskell.svg +1 -0
  50. package/eventcatalog/public/icons/languages/hasura.svg +1 -0
  51. package/eventcatalog/public/icons/languages/html.svg +1 -0
  52. package/eventcatalog/public/icons/languages/httpie.svg +1 -0
  53. package/eventcatalog/public/icons/languages/javascript.svg +1 -0
  54. package/eventcatalog/public/icons/languages/json-schema.svg +1 -0
  55. package/eventcatalog/public/icons/languages/json.svg +1 -0
  56. package/eventcatalog/public/icons/languages/julia.svg +1 -0
  57. package/eventcatalog/public/icons/languages/jupyter.svg +1 -0
  58. package/eventcatalog/public/icons/languages/kafka.svg +1 -0
  59. package/eventcatalog/public/icons/languages/kotlin.svg +1 -0
  60. package/eventcatalog/public/icons/languages/lua.svg +1 -0
  61. package/eventcatalog/public/icons/languages/markdown.svg +1 -0
  62. package/eventcatalog/public/icons/languages/maven.svg +1 -0
  63. package/eventcatalog/public/icons/languages/mongodb.svg +1 -0
  64. package/eventcatalog/public/icons/languages/mysql.svg +1 -0
  65. package/eventcatalog/public/icons/languages/nginx.svg +1 -0
  66. package/eventcatalog/public/icons/languages/nim.svg +1 -0
  67. package/eventcatalog/public/icons/languages/ocaml.svg +1 -0
  68. package/eventcatalog/public/icons/languages/perl.svg +1 -0
  69. package/eventcatalog/public/icons/languages/php.svg +1 -0
  70. package/eventcatalog/public/icons/languages/postgresql.svg +1 -0
  71. package/eventcatalog/public/icons/languages/prisma.svg +1 -0
  72. package/eventcatalog/public/icons/languages/purescript.svg +1 -0
  73. package/eventcatalog/public/icons/languages/python.svg +1 -0
  74. package/eventcatalog/public/icons/languages/r.svg +1 -0
  75. package/eventcatalog/public/icons/languages/react.svg +1 -0
  76. package/eventcatalog/public/icons/languages/reasonml.svg +1 -0
  77. package/eventcatalog/public/icons/languages/redis.svg +1 -0
  78. package/eventcatalog/public/icons/languages/ruby.svg +1 -0
  79. package/eventcatalog/public/icons/languages/rust.svg +1 -0
  80. package/eventcatalog/public/icons/languages/sass.svg +1 -0
  81. package/eventcatalog/public/icons/languages/scala.svg +1 -0
  82. package/eventcatalog/public/icons/languages/solidity.svg +1 -0
  83. package/eventcatalog/public/icons/languages/spring.svg +1 -0
  84. package/eventcatalog/public/icons/languages/sqlite.svg +1 -0
  85. package/eventcatalog/public/icons/languages/svelte.svg +1 -0
  86. package/eventcatalog/public/icons/languages/swagger.svg +1 -0
  87. package/eventcatalog/public/icons/languages/swift.svg +1 -0
  88. package/eventcatalog/public/icons/languages/tailwind.svg +1 -0
  89. package/eventcatalog/public/icons/languages/terraform.svg +1 -0
  90. package/eventcatalog/public/icons/languages/toml.svg +1 -0
  91. package/eventcatalog/public/icons/languages/typescript.svg +1 -0
  92. package/eventcatalog/public/icons/languages/unity.svg +1 -0
  93. package/eventcatalog/public/icons/languages/vim.svg +1 -0
  94. package/eventcatalog/public/icons/languages/vue.svg +1 -0
  95. package/eventcatalog/public/icons/languages/webassembly.svg +1 -0
  96. package/eventcatalog/public/icons/languages/yaml.svg +1 -0
  97. package/eventcatalog/public/icons/languages/zig.svg +1 -0
  98. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.astro +12 -0
  99. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +176 -0
  100. package/eventcatalog/src/components/MDX/CodeGroup/MarkdownCodeGroup.tsx +22 -0
  101. package/eventcatalog/src/components/MDX/CodeGroup/code-group-icons.ts +345 -0
  102. package/eventcatalog/src/components/MDX/CodeGroup/code-group.css +22 -0
  103. package/eventcatalog/src/components/MDX/CodeGroup/code-group.spec.tsx +89 -0
  104. package/eventcatalog/src/components/MDX/CodeGroup/parse-panels.ts +11 -0
  105. package/eventcatalog/src/components/MDX/Columns/Column.astro +10 -0
  106. package/eventcatalog/src/components/MDX/Columns/Columns.astro +14 -0
  107. package/eventcatalog/src/components/MDX/Columns/MarkdownColumns.tsx +37 -0
  108. package/eventcatalog/src/components/MDX/Columns/columns.spec.tsx +66 -0
  109. package/eventcatalog/src/components/MDX/Columns/layout.ts +16 -0
  110. package/eventcatalog/src/components/MDX/components.tsx +6 -0
  111. package/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx +1 -1
  112. package/eventcatalog/src/components/SchemaExplorer/ExamplesViewer.tsx +151 -114
  113. package/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.test.tsx +114 -0
  114. package/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.tsx +28 -9
  115. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +510 -248
  116. package/eventcatalog/src/components/SchemaExplorer/SchemaFlows.tsx +76 -0
  117. package/eventcatalog/src/components/SchemaExplorer/SchemaGraph.tsx +47 -0
  118. package/eventcatalog/src/components/SchemaExplorer/SchemaPageViewer.tsx +11 -0
  119. package/eventcatalog/src/components/SchemaExplorer/SchemaUsage.tsx +17 -0
  120. package/eventcatalog/src/components/SchemaExplorer/types.ts +48 -3
  121. package/eventcatalog/src/components/SchemaExplorer/utils.ts +28 -1
  122. package/eventcatalog/src/content.config.ts +28 -0
  123. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/[...filename].astro +38 -7
  124. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +144 -37
  125. package/eventcatalog/src/pages/schemas/explorer/index.astro +5 -0
  126. package/eventcatalog/src/remark-plugins/code-group.ts +39 -0
  127. package/eventcatalog/src/remark-plugins/markdown-code-group.ts +59 -0
  128. package/eventcatalog/src/remark-plugins/markdown-columns.ts +66 -0
  129. package/eventcatalog/src/utils/collections/examples.ts +133 -41
  130. package/eventcatalog/src/utils/collections/flows.ts +43 -0
  131. package/eventcatalog/src/utils/collections/glob-loader.ts +2 -2
  132. package/eventcatalog/src/utils/collections/messages.ts +2 -2
  133. package/eventcatalog/src/utils/collections/schemas.ts +15 -0
  134. package/eventcatalog/src/utils/schema-explorer.ts +73 -7
  135. package/eventcatalog/src/utils/schema-usage-graph.ts +92 -0
  136. package/package.json +8 -8
@@ -0,0 +1,37 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+ import { getColumnsLayout, columnsClass, gridClass, columnClass, stickyClass } from './layout';
3
+
4
+ export function MarkdownColumns({
5
+ children,
6
+ cols = 2,
7
+ ratio,
8
+ className = '',
9
+ }: {
10
+ children?: ReactNode;
11
+ cols?: number;
12
+ ratio?: string;
13
+ className?: string;
14
+ }) {
15
+ const { template } = getColumnsLayout(cols, ratio);
16
+ return (
17
+ <div className={`${columnsClass} ${className}`} style={{ '--ec-columns-template': template } as CSSProperties}>
18
+ <div className={gridClass}>{children}</div>
19
+ </div>
20
+ );
21
+ }
22
+
23
+ export function MarkdownColumn({
24
+ children,
25
+ sticky = false,
26
+ className = '',
27
+ }: {
28
+ children?: ReactNode;
29
+ sticky?: boolean;
30
+ className?: string;
31
+ }) {
32
+ return (
33
+ <div className={`${columnClass} ${sticky ? stickyClass : ''} ${className}`} data-sticky={String(sticky)}>
34
+ {children}
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import ReactMarkdown from 'react-markdown';
4
+ import { createRequire } from 'node:module';
5
+ import { pathToFileURL } from 'node:url';
6
+ import * as runtime from 'react/jsx-runtime';
7
+ import { getColumnsLayout } from './layout';
8
+ import { MarkdownColumns, MarkdownColumn } from './MarkdownColumns';
9
+ import { remarkMarkdownColumns } from '../../../remark-plugins/markdown-columns';
10
+ import { remarkMarkdownCodeGroup } from '../../../remark-plugins/markdown-code-group';
11
+
12
+ const source =
13
+ '<Columns cols={2} ratio="2:1">\n <Column>\n\n## Publish an order\n\nSome **explanation**.\n\n </Column>\n <Column sticky>\n\n<CodeGroup>\n\n```json OrderCreated\n{"id":"123"}\n```\n\n```js JavaScript\npublish();\n```\n\n</CodeGroup>\n\n </Column>\n</Columns>';
14
+
15
+ describe('Columns', () => {
16
+ it('defaults to two columns and supports one through four', () => {
17
+ expect(getColumnsLayout().template).toBe('repeat(2, minmax(0, 1fr))');
18
+ for (const count of [1, 2, 3, 4]) expect(getColumnsLayout(count).count).toBe(count);
19
+ });
20
+ it('uses valid ratios and safely falls back for invalid options', () => {
21
+ expect(getColumnsLayout(2, '2:1').template).toBe('minmax(0, 2fr) minmax(0, 1fr)');
22
+ for (const ratio of ['0:1', '-1:2', 'NaN:1', '1:2:3', '1fr:2fr', 'Infinity:1']) {
23
+ expect(getColumnsLayout(2, ratio).template).toBe('repeat(2, minmax(0, 1fr))');
24
+ }
25
+ for (const count of [0, 5, 1.5, NaN]) expect(getColumnsLayout(count).count).toBe(2);
26
+ });
27
+ it('renders the same API as real MDX with rich content and nested components', async () => {
28
+ const require = createRequire(import.meta.url);
29
+ const mdxRequire = createRequire(require.resolve('@astrojs/mdx'));
30
+ const { evaluate } = await import(pathToFileURL(mdxRequire.resolve('@mdx-js/mdx')).href);
31
+ const result = await evaluate(source, runtime);
32
+ const html = renderToStaticMarkup(
33
+ runtime.jsx(result.default, {
34
+ components: {
35
+ Columns: MarkdownColumns,
36
+ Column: MarkdownColumn,
37
+ CodeGroup: 'section',
38
+ },
39
+ })
40
+ );
41
+ expect(html).toContain('--ec-columns-template:minmax(0, 2fr) minmax(0, 1fr)');
42
+ expect(html).toContain('data-sticky="true"');
43
+ expect(html).toContain('<strong>explanation</strong>');
44
+ expect(html).toContain('language-json');
45
+ });
46
+ it('recognizes adjacent layout tags in Markdown and lets CodeGroup transform within columns', () => {
47
+ const html = renderToStaticMarkup(
48
+ <ReactMarkdown remarkPlugins={[remarkMarkdownColumns, remarkMarkdownCodeGroup]}>{source}</ReactMarkdown>
49
+ );
50
+ expect(html).toContain('data-columns="true"');
51
+ expect(html.match(/data-column="true"/g)).toHaveLength(2);
52
+ expect(html).toContain('data-cols="2"');
53
+ expect(html).toContain('data-ratio="2:1"');
54
+ expect(html).toContain('data-sticky="true"');
55
+ expect(html).toContain('data-code-panel="OrderCreated"');
56
+ expect(html).toContain('<strong>explanation</strong>');
57
+ expect(html).not.toContain('&lt;Column');
58
+ });
59
+ it('preserves nested column layouts and leaves unrelated HTML escaped', () => {
60
+ const nested =
61
+ '<Columns>\n\n<Column>\n\n<Columns cols={1}>\n\n<Column>\n\nInner\n\n</Column>\n\n</Columns>\n\n</Column>\n\n</Columns>\n\n<script>alert(1)</script>';
62
+ const html = renderToStaticMarkup(<ReactMarkdown remarkPlugins={[remarkMarkdownColumns]}>{nested}</ReactMarkdown>);
63
+ expect(html.match(/data-columns="true"/g)).toHaveLength(2);
64
+ expect(html).not.toContain('<script>');
65
+ });
66
+ });
@@ -0,0 +1,16 @@
1
+ export function getColumnsLayout(cols: number = 2, ratio?: string) {
2
+ const count = Number.isInteger(cols) && cols >= 1 && cols <= 4 ? cols : 2;
3
+ const weights = ratio?.split(':').map(Number);
4
+ const validRatio = weights?.length === count && weights.every((weight) => Number.isFinite(weight) && weight > 0);
5
+ return {
6
+ count,
7
+ template: validRatio ? weights.map((weight) => `minmax(0, ${weight}fr)`).join(' ') : `repeat(${count}, minmax(0, 1fr))`,
8
+ };
9
+ }
10
+
11
+ export const columnsClass = 'ec-columns @container/ec-columns my-6';
12
+ export const gridClass =
13
+ 'ec-columns-grid grid grid-cols-1 items-start gap-8 [&>*]:min-w-0 @min-[640px]/ec-columns:grid-cols-[var(--ec-columns-template)]';
14
+ export const columnClass = 'ec-column min-w-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0';
15
+ export const stickyClass =
16
+ '@min-[640px]/ec-columns:sticky @min-[640px]/ec-columns:top-[var(--ec-column-sticky-top,5rem)] print:static';
@@ -22,6 +22,9 @@ import ResourceGroupTable from '@components/MDX/ResourceGroupTable/ResourceGroup
22
22
  import EntityPropertiesTable from '@components/MDX/EntityPropertiesTable/EntityPropertiesTable.astro';
23
23
  import Tabs from '@components/MDX/Tabs/Tabs.astro';
24
24
  import TabItem from '@components/MDX/Tabs/TabItem.astro';
25
+ import CodeGroup from '@components/MDX/CodeGroup/CodeGroup.astro';
26
+ import Columns from '@components/MDX/Columns/Columns.astro';
27
+ import Column from '@components/MDX/Columns/Column.astro';
25
28
  import ResourceLink from '@components/MDX/ResourceLink/ResourceLink.astro';
26
29
  import ResourceRef from '@components/MDX/ResourceRef/ResourceRef.astro';
27
30
  import Link from '@components/MDX/Link/Link.astro';
@@ -85,6 +88,9 @@ const components = (props: any) => {
85
88
  Step,
86
89
  Steps,
87
90
  TabItem,
91
+ CodeGroup,
92
+ Columns,
93
+ Column,
88
94
  Tabs,
89
95
  Tile,
90
96
  Tiles,
@@ -134,7 +134,7 @@ export default function ApiContentViewer({ message, onCopy, copiedId, apiAccessE
134
134
  className="rounded-md border bg-[rgb(var(--ec-content-hover)/0.45)] px-2.5 py-1 text-xs font-medium text-[rgb(var(--ec-page-text-muted))]"
135
135
  style={{ borderColor: boxBorderColor }}
136
136
  >
137
- application/json
137
+ text/plain
138
138
  </code>
139
139
  </div>
140
140
  <div className="flex items-center justify-between gap-4 border-t px-4 py-3" style={{ borderColor: dividerColor }}>
@@ -1,146 +1,183 @@
1
- import { useState, useEffect } from 'react';
2
- import { ClipboardDocumentIcon } from '@heroicons/react/24/outline';
1
+ import { createContext, useContext, useState, type ReactNode } from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { CheckIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
3
5
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
4
6
  import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
5
7
  import { oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism';
6
- import { getLanguageForHighlight } from './utils';
7
8
  import { copyToClipboard } from './utils';
8
9
  import type { MessageExample } from './types';
9
10
  import { useDarkMode } from './useDarkMode';
11
+ import { remarkMarkdownCodeGroup } from '../../remark-plugins/markdown-code-group';
12
+ import { MarkdownCodeGroup } from '../MDX/CodeGroup/MarkdownCodeGroup';
13
+ import { MarkdownColumns, MarkdownColumn } from '../MDX/Columns/MarkdownColumns';
14
+ import { remarkMarkdownColumns } from '../../remark-plugins/markdown-columns';
15
+ import { getLanguageForHighlight } from './utils';
10
16
 
11
17
  interface ExamplesViewerProps {
12
18
  examples: MessageExample[];
13
19
  }
14
20
 
15
- export default function ExamplesViewer({ examples }: ExamplesViewerProps) {
16
- const [selectedIndex, setSelectedIndex] = useState(0);
17
- const [copiedId, setCopiedId] = useState<string | null>(null);
18
- const isDarkMode = useDarkMode();
21
+ const PROSE_CLASS = [
22
+ 'prose prose-sm max-w-none',
23
+ 'prose-headings:text-[rgb(var(--ec-page-text))] prose-h1:mt-0 prose-h1:text-4xl prose-h1:font-bold prose-h1:tracking-tight',
24
+ 'prose-p:text-[rgb(var(--ec-page-text-muted))] prose-li:text-[rgb(var(--ec-page-text-muted))] prose-strong:text-[rgb(var(--ec-page-text))]',
25
+ 'prose-a:text-[rgb(var(--ec-accent))] prose-table:text-[rgb(var(--ec-page-text-muted))] prose-th:text-[rgb(var(--ec-page-text))]',
26
+ 'prose-blockquote:border-[rgb(var(--ec-page-border))] prose-blockquote:text-[rgb(var(--ec-page-text-muted))] prose-hr:border-[rgb(var(--ec-page-border))]',
27
+ ].join(' ');
19
28
 
20
- useEffect(() => {
21
- setSelectedIndex(0);
22
- }, [examples]);
29
+ const getCodeText = (children: ReactNode): string =>
30
+ Array.isArray(children) ? children.map(getCodeText).join('') : typeof children === 'string' ? children : '';
23
31
 
24
- if (examples.length === 0) {
32
+ /** True while rendering inside a CodeGroup panel, where the group supplies the header and copy button. */
33
+ const CodeGroupPanelContext = createContext(false);
34
+
35
+ /** Fenced code block rendered as the same themed code panel used across the schema page. */
36
+ function CodeBlock({ language, code }: { language?: string; code: string }) {
37
+ const isDarkMode = useDarkMode();
38
+ const [isCopied, setIsCopied] = useState(false);
39
+ const inCodeGroup = useContext(CodeGroupPanelContext);
40
+ const content = code.replace(/\s+$/, '');
41
+ const highlighter = (
42
+ <SyntaxHighlighter
43
+ language={language || 'text'}
44
+ style={isDarkMode ? oneDark : oneLight}
45
+ customStyle={{
46
+ margin: 0,
47
+ padding: '1rem 1.25rem',
48
+ borderRadius: 0,
49
+ fontSize: '0.8125rem',
50
+ lineHeight: '1.65',
51
+ background: 'transparent',
52
+ }}
53
+ showLineNumbers={content.includes('\n')}
54
+ wrapLines={true}
55
+ wrapLongLines={true}
56
+ >
57
+ {content}
58
+ </SyntaxHighlighter>
59
+ );
60
+
61
+ // Inside a group the panel chrome (file name, language picker, copy) belongs to the group.
62
+ // Keep the raw source on a data attribute so the group's copy button gets clean text.
63
+ if (inCodeGroup) {
25
64
  return (
26
- <div className="flex items-center justify-center h-full text-[rgb(var(--ec-page-text-muted))]">
27
- <p className="text-sm">No examples available</p>
65
+ <div data-code={content} className="not-prose max-h-[70vh] overflow-auto">
66
+ {highlighter}
28
67
  </div>
29
68
  );
30
69
  }
31
70
 
32
- const selected = examples[selectedIndex];
33
- const lang = getLanguageForHighlight(selected.extension);
34
-
35
- const handleCopy = async (content: string, id: string) => {
36
- const success = await copyToClipboard(content);
37
- if (success) {
38
- setCopiedId(id);
39
- setTimeout(() => setCopiedId(null), 2000);
71
+ const handleCopy = async () => {
72
+ if (await copyToClipboard(content)) {
73
+ setIsCopied(true);
74
+ setTimeout(() => setIsCopied(false), 2000);
40
75
  }
41
76
  };
42
77
 
43
78
  return (
44
- <div className="h-full flex gap-4 overflow-hidden">
45
- {/* Left: example list */}
46
- <div className="flex-shrink-0 w-56 overflow-y-auto space-y-1">
47
- {examples.map((ex, idx) => (
48
- <button
49
- key={ex.fileName}
50
- onClick={() => setSelectedIndex(idx)}
51
- className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
52
- idx === selectedIndex
53
- ? 'bg-[rgb(var(--ec-accent-subtle))] border border-[rgb(var(--ec-accent)/0.3)]'
54
- : 'hover:bg-[rgb(var(--ec-content-hover))] border border-transparent'
55
- }`}
56
- >
57
- <span
58
- className={`block text-xs font-medium truncate ${
59
- idx === selectedIndex ? 'text-[rgb(var(--ec-page-text))]' : 'text-[rgb(var(--ec-page-text-muted))]'
60
- }`}
61
- >
62
- {ex.title}
63
- </span>
64
- {ex.summary && (
65
- <span className="block text-[11px] text-[rgb(var(--ec-page-text-muted))] mt-0.5 line-clamp-2 leading-relaxed">
66
- {ex.summary}
67
- </span>
68
- )}
69
- </button>
70
- ))}
79
+ <div
80
+ data-example-code-block
81
+ className="not-prose my-4 overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-code-bg))] shadow-sm"
82
+ >
83
+ <div className="flex items-center justify-between gap-3 border-b border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-content-hover)/0.45)] px-3 py-1.5">
84
+ <span className="rounded-md bg-[rgb(var(--ec-content-hover))] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]">
85
+ {language || 'text'}
86
+ </span>
87
+ <button
88
+ type="button"
89
+ onClick={handleCopy}
90
+ className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
91
+ isCopied
92
+ ? 'text-emerald-500'
93
+ : 'text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-content-hover))] hover:text-[rgb(var(--ec-page-text))]'
94
+ }`}
95
+ title="Copy code"
96
+ >
97
+ {isCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <ClipboardDocumentIcon className="h-3.5 w-3.5" />}
98
+ {isCopied ? 'Copied' : 'Copy'}
99
+ </button>
71
100
  </div>
101
+ <div className="max-h-[70vh] overflow-auto">{highlighter}</div>
102
+ </div>
103
+ );
104
+ }
72
105
 
73
- {/* Right: code + usage */}
74
- <div className="flex-1 flex flex-col min-w-0 overflow-auto">
75
- {/* Code window */}
76
- <div className="flex flex-col rounded-lg border border-[rgb(var(--ec-page-border))] overflow-hidden bg-[rgb(var(--ec-dropdown-bg)/0.66)]">
77
- {/* Window title bar */}
78
- <div className="flex items-center justify-between border-b border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-content-hover)/0.45)] px-4 py-2.5">
79
- <div className="flex items-center gap-3">
80
- <div className="flex items-center gap-1.5">
81
- <div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
82
- <div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
83
- <div className="w-3 h-3 rounded-full bg-[#27C93F]" />
84
- </div>
85
- <span className="text-xs font-mono text-[rgb(var(--ec-page-text-muted))]">{selected.fileName}</span>
86
- </div>
87
-
88
- <button
89
- onClick={() => handleCopy(selected.content, selected.fileName)}
90
- className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-bg)/0.5)] hover:text-[rgb(var(--ec-page-text))] rounded transition-colors"
91
- title="Copy code"
92
- >
93
- <ClipboardDocumentIcon className="h-3 w-3" />
94
- {copiedId === selected.fileName ? 'Copied!' : 'Copy'}
95
- </button>
96
- </div>
106
+ const markdownComponents = {
107
+ div: ({ node: _node, children, ...props }: any) =>
108
+ props['data-columns'] ? (
109
+ <MarkdownColumns cols={Number(props['data-cols'])} ratio={props['data-ratio']} className={props.className}>
110
+ {children}
111
+ </MarkdownColumns>
112
+ ) : props['data-column'] ? (
113
+ <MarkdownColumn sticky={props['data-sticky'] === 'true'} className={props.className}>
114
+ {children}
115
+ </MarkdownColumn>
116
+ ) : props['data-code-group'] ? (
117
+ <MarkdownCodeGroup dropdown={props['data-dropdown'] === 'true'} className={props.className}>
118
+ {children}
119
+ </MarkdownCodeGroup>
120
+ ) : props['data-code-panel'] ? (
121
+ <div {...props}>
122
+ <CodeGroupPanelContext.Provider value={true}>{children}</CodeGroupPanelContext.Provider>
123
+ </div>
124
+ ) : (
125
+ <div {...props}>{children}</div>
126
+ ),
127
+ // Fenced blocks arrive as <pre><code class="language-x">. Render the panel from `code` and drop the <pre>.
128
+ pre: ({ children }: { children?: ReactNode }) => <>{children}</>,
129
+ code: ({ className, children }: { className?: string; children?: ReactNode }) => {
130
+ const language = /language-(\w+)/.exec(className || '')?.[1];
131
+ const text = getCodeText(children);
132
+ if (language || text.includes('\n')) return <CodeBlock language={language} code={text} />;
133
+ return (
134
+ <code className="rounded bg-[rgb(var(--ec-content-hover)/0.6)] px-1.5 py-0.5 font-mono text-[12px] font-normal text-[rgb(var(--ec-page-text))] before:content-none after:content-none">
135
+ {children}
136
+ </code>
137
+ );
138
+ },
139
+ };
97
140
 
98
- {/* Code content */}
99
- <div className="overflow-auto">
100
- <SyntaxHighlighter
101
- language={lang}
102
- style={isDarkMode ? oneDark : oneLight}
103
- customStyle={{
104
- margin: 0,
105
- padding: '1rem 1.25rem',
106
- borderRadius: 0,
107
- fontSize: '0.8125rem',
108
- lineHeight: '1.625',
109
- overflow: 'auto',
110
- background: 'transparent',
111
- }}
112
- showLineNumbers={true}
113
- wrapLines={true}
114
- wrapLongLines={true}
115
- >
116
- {selected.content}
117
- </SyntaxHighlighter>
118
- </div>
119
- </div>
141
+ /** Lists every usage example, rendering Markdown as prose and other formats as source. */
142
+ export default function ExamplesViewer({ examples }: ExamplesViewerProps) {
143
+ if (examples.length === 0) {
144
+ return (
145
+ <div className="flex h-full items-center justify-center text-[rgb(var(--ec-page-text-muted))]">
146
+ <p className="text-sm">No examples available</p>
147
+ </div>
148
+ );
149
+ }
120
150
 
121
- {/* Usage section */}
122
- {selected.usage && (
123
- <div className="flex-shrink-0 mt-3">
124
- <div className="rounded-lg border border-[rgb(var(--ec-page-border))] overflow-hidden bg-[rgb(var(--ec-dropdown-bg)/0.66)]">
125
- <div className="flex items-center justify-between border-b border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-content-hover)/0.45)] px-4 py-2">
126
- <span className="text-xs font-medium text-[rgb(var(--ec-page-text-muted))]">Usage</span>
127
- <button
128
- onClick={() => handleCopy(selected.usage!, `usage-${selected.fileName}`)}
129
- className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-bg)/0.5)] hover:text-[rgb(var(--ec-page-text))] rounded transition-colors"
130
- title="Copy usage"
151
+ return (
152
+ <div className="h-full overflow-auto pr-1">
153
+ <div className="divide-y divide-[rgb(var(--ec-page-border))]">
154
+ {examples.map((example) => (
155
+ <section
156
+ key={example.fileName}
157
+ id={`example-${example.fileName.replace(/[^a-zA-Z0-9]+/g, '-')}`}
158
+ className="w-full py-8 pr-8 first:pt-0 last:pb-0"
159
+ >
160
+ {example.summary && <p className="mb-4 text-sm text-[rgb(var(--ec-page-text-muted))]">{example.summary}</p>}
161
+ {example.renderMode === 'markdown' ? (
162
+ <div className={PROSE_CLASS}>
163
+ <ReactMarkdown
164
+ remarkPlugins={[remarkGfm, remarkMarkdownColumns, remarkMarkdownCodeGroup]}
165
+ components={markdownComponents}
131
166
  >
132
- <ClipboardDocumentIcon className="h-3 w-3" />
133
- {copiedId === `usage-${selected.fileName}` ? 'Copied!' : 'Copy'}
134
- </button>
167
+ {example.content}
168
+ </ReactMarkdown>
135
169
  </div>
136
- <div className="overflow-auto max-h-32">
137
- <pre className="m-0 px-5 py-3 text-[0.75rem] leading-[1.5] whitespace-pre-wrap break-words text-[rgb(var(--ec-page-text-muted))] bg-transparent font-mono">
138
- <code>{selected.usage}</code>
139
- </pre>
170
+ ) : (
171
+ <CodeBlock language={getLanguageForHighlight(example.extension)} code={example.content} />
172
+ )}
173
+ {example.usage && (
174
+ <div className="mt-5">
175
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]">Usage</h3>
176
+ <CodeBlock language="bash" code={example.usage} />
140
177
  </div>
141
- </div>
142
- </div>
143
- )}
178
+ )}
179
+ </section>
180
+ ))}
144
181
  </div>
145
182
  </div>
146
183
  );
@@ -0,0 +1,114 @@
1
+ // @vitest-environment jsdom
2
+ import { act, createElement } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import JSONSchemaViewer from './JSONSchemaViewer';
6
+
7
+ const constraintOnlyVariantSchema = {
8
+ type: 'object',
9
+ properties: {
10
+ detail: { $ref: '#/$defs/detail' },
11
+ },
12
+ $defs: {
13
+ detail: {
14
+ type: 'object',
15
+ properties: {
16
+ data: { $ref: '#/$defs/data' },
17
+ },
18
+ },
19
+ data: {
20
+ type: 'object',
21
+ properties: {
22
+ userId: { type: 'string' },
23
+ customerId: { type: 'string' },
24
+ correlationId: { type: 'string' },
25
+ },
26
+ required: ['correlationId'],
27
+ oneOf: [
28
+ {
29
+ title: 'Authenticated user',
30
+ required: ['userId'],
31
+ not: { required: ['customerId'] },
32
+ },
33
+ {
34
+ title: 'Customer account',
35
+ required: ['customerId'],
36
+ not: { required: ['userId'] },
37
+ },
38
+ ],
39
+ },
40
+ },
41
+ };
42
+
43
+ describe('JSONSchemaViewer variants', () => {
44
+ let container: HTMLDivElement;
45
+ let root: Root;
46
+
47
+ beforeEach(() => {
48
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
49
+ container = document.createElement('div');
50
+ document.body.appendChild(container);
51
+ root = createRoot(container);
52
+ });
53
+
54
+ afterEach(() => {
55
+ act(() => root.unmount());
56
+ container.remove();
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ it('renders parent properties selected by constraint-only oneOf branches', () => {
61
+ act(() => {
62
+ root.render(createElement(JSONSchemaViewer, { schema: constraintOnlyVariantSchema, expand: true, search: false }));
63
+ });
64
+
65
+ expect(container.textContent).toContain('Authenticated user');
66
+ expect(container.textContent).toContain('userId');
67
+ expect(container.textContent).not.toContain('customerId');
68
+ expect(container.textContent).toContain('correlationId');
69
+
70
+ const selector = container.querySelector<HTMLSelectElement>('select');
71
+ expect(selector).not.toBeNull();
72
+
73
+ act(() => {
74
+ selector!.value = '1';
75
+ selector!.dispatchEvent(new Event('change', { bubbles: true }));
76
+ });
77
+
78
+ expect(container.textContent).toContain('customerId');
79
+ expect(container.textContent).not.toContain('userId');
80
+ expect(container.textContent).toContain('correlationId');
81
+ });
82
+
83
+ it('applies constraint-only oneOf branches at the schema root', () => {
84
+ const rootSchema = constraintOnlyVariantSchema.$defs.data;
85
+
86
+ act(() => {
87
+ root.render(createElement(JSONSchemaViewer, { schema: rootSchema, expand: true, search: false }));
88
+ });
89
+
90
+ expect(container.textContent).toContain('userId');
91
+ expect(container.textContent).not.toContain('customerId');
92
+ expect(container.textContent).toContain('correlationId');
93
+ });
94
+
95
+ it('does not render array item parent properties twice when variants are present', () => {
96
+ const arraySchema = {
97
+ type: 'object',
98
+ properties: {
99
+ identities: {
100
+ type: 'array',
101
+ items: constraintOnlyVariantSchema.$defs.data,
102
+ },
103
+ },
104
+ };
105
+
106
+ act(() => {
107
+ root.render(createElement(JSONSchemaViewer, { schema: arraySchema, expand: true, search: false }));
108
+ });
109
+
110
+ expect(container.textContent?.match(/userId/g)).toHaveLength(1);
111
+ expect(container.textContent).not.toContain('customerId');
112
+ expect(container.textContent?.match(/correlationId/g)).toHaveLength(1);
113
+ });
114
+ });
@@ -31,6 +31,15 @@ function formatType(type: any): string {
31
31
  return type ?? '';
32
32
  }
33
33
 
34
+ function getForbiddenVariantProperties(variant: any): string[] {
35
+ const forbiddenRequired = variant?.not?.required;
36
+
37
+ // `not: { required: ['field'] }` forbids that field from being present. When
38
+ // multiple fields are listed, JSON Schema only forbids them being present
39
+ // together, so none of them can safely be hidden individually.
40
+ return Array.isArray(forbiddenRequired) && forbiddenRequired.length === 1 ? forbiddenRequired : [];
41
+ }
42
+
34
43
  // Helper function to count properties recursively
35
44
  function countProperties(obj: any): number {
36
45
  if (!obj || typeof obj !== 'object') return 0;
@@ -173,26 +182,35 @@ function processSchema(schema: any, rootSchema?: any): any {
173
182
  const variantsArray = schema.oneOf || schema.anyOf;
174
183
  const variantType = schema.oneOf ? 'oneOf' : 'anyOf';
175
184
 
185
+ const processedProperties = schema.properties
186
+ ? Object.fromEntries(
187
+ Object.entries(schema.properties).map(([key, prop]: [string, any]) => [key, processSchema(prop, root)])
188
+ )
189
+ : {};
190
+
176
191
  const processedVariants = variantsArray.map((variant: any, index: number) => {
177
192
  const processedVariant = processSchema(variant, root);
193
+ const forbiddenProperties = new Set(getForbiddenVariantProperties(processedVariant));
194
+ const properties = Object.fromEntries(
195
+ Object.entries({ ...processedProperties, ...(processedVariant.properties || {}) }).filter(
196
+ ([propertyName]) => !forbiddenProperties.has(propertyName)
197
+ )
198
+ );
199
+
178
200
  return {
201
+ ...processedVariant,
179
202
  title: processedVariant.title || variant.title || processedVariant.$id || `Option ${index + 1}`,
180
203
  description: processedVariant.description || variant.description,
181
- required: processedVariant.required || variant.required || [],
182
- properties: processedVariant.properties || {},
204
+ required: [...new Set([...(schema.required || []), ...(processedVariant.required || variant.required || [])])],
205
+ properties,
183
206
  type: processedVariant.type || variant.type || 'object',
184
- ...processedVariant,
185
207
  };
186
208
  });
187
209
 
188
210
  return {
189
211
  ...schema,
190
212
  type: schema.type || 'object',
191
- properties: schema.properties
192
- ? Object.fromEntries(
193
- Object.entries(schema.properties).map(([key, prop]: [string, any]) => [key, processSchema(prop, root)])
194
- )
195
- : {},
213
+ properties: processedProperties,
196
214
  variants: processedVariants,
197
215
  variantType,
198
216
  };
@@ -462,6 +480,7 @@ const SchemaProperty = ({ name, details, isRequired, level, isListItem = false,
462
480
  <div className="mt-1 border-l border-dashed border-[rgb(var(--ec-page-border))] pl-3 ml-1.5">
463
481
  <span className="text-xs italic text-[rgb(var(--ec-page-text-muted))] block mb-1">Item Details:</span>
464
482
  {details.items.properties &&
483
+ !details.items.variants &&
465
484
  Object.entries(details.items.properties).map(([itemPropName, itemPropDetails]: [string, any]) => (
466
485
  <SchemaProperty
467
486
  key={itemPropName}
@@ -823,7 +842,7 @@ export default function JSONSchemaViewer({
823
842
  // Determine which properties to display
824
843
  const propsToDisplay =
825
844
  variants && variants.length > 0 && variants[selectedVariantIndex]?.properties
826
- ? { ...properties, ...variants[selectedVariantIndex].properties }
845
+ ? variants[selectedVariantIndex].properties
827
846
  : properties;
828
847
  const requiredProps = variants && variants.length > 0 ? variants[selectedVariantIndex]?.required || [] : required;
829
848