@stigmer/react 3.1.3 → 3.1.5
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/composer/SessionComposer.d.ts +31 -0
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +12 -4
- package/composer/SessionComposer.js.map +1 -1
- package/execution/ArtifactApplyButton.d.ts +25 -0
- package/execution/ArtifactApplyButton.d.ts.map +1 -0
- package/execution/ArtifactApplyButton.js +21 -0
- package/execution/ArtifactApplyButton.js.map +1 -0
- package/execution/ArtifactContentBody.d.ts +36 -0
- package/execution/ArtifactContentBody.d.ts.map +1 -0
- package/execution/ArtifactContentBody.js +62 -0
- package/execution/ArtifactContentBody.js.map +1 -0
- package/execution/ArtifactDocument.d.ts +48 -0
- package/execution/ArtifactDocument.d.ts.map +1 -0
- package/execution/ArtifactDocument.js +89 -0
- package/execution/ArtifactDocument.js.map +1 -0
- package/execution/ArtifactPreviewModal.d.ts +4 -4
- package/execution/ArtifactPreviewModal.d.ts.map +1 -1
- package/execution/ArtifactPreviewModal.js +15 -142
- package/execution/ArtifactPreviewModal.js.map +1 -1
- package/execution/ArtifactRow.d.ts +49 -0
- package/execution/ArtifactRow.d.ts.map +1 -0
- package/execution/ArtifactRow.js +56 -0
- package/execution/ArtifactRow.js.map +1 -0
- package/execution/ArtifactsWidget.d.ts +6 -9
- package/execution/ArtifactsWidget.d.ts.map +1 -1
- package/execution/ArtifactsWidget.js +15 -19
- package/execution/ArtifactsWidget.js.map +1 -1
- package/execution/FileReviewContext.d.ts +15 -1
- package/execution/FileReviewContext.d.ts.map +1 -1
- package/execution/FileReviewContext.js +20 -1
- package/execution/FileReviewContext.js.map +1 -1
- package/execution/MessageThread.d.ts +65 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +42 -23
- package/execution/MessageThread.js.map +1 -1
- package/execution/TodoCard.d.ts +8 -0
- package/execution/TodoCard.d.ts.map +1 -1
- package/execution/TodoCard.js +6 -4
- package/execution/TodoCard.js.map +1 -1
- package/execution/TodoList.d.ts +20 -1
- package/execution/TodoList.d.ts.map +1 -1
- package/execution/TodoList.js +9 -6
- package/execution/TodoList.js.map +1 -1
- package/execution/ToolCallDetail.d.ts +3 -2
- package/execution/ToolCallDetail.d.ts.map +1 -1
- package/execution/ToolCallDetail.js +49 -16
- package/execution/ToolCallDetail.js.map +1 -1
- package/execution/file-review-status.d.ts +15 -0
- package/execution/file-review-status.d.ts.map +1 -1
- package/execution/file-review-status.js +22 -0
- package/execution/file-review-status.js.map +1 -1
- package/execution/index.d.ts +13 -7
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +7 -4
- package/execution/index.js.map +1 -1
- package/execution/useArtifactContent.d.ts +6 -1
- package/execution/useArtifactContent.d.ts.map +1 -1
- package/execution/useArtifactContent.js +7 -2
- package/execution/useArtifactContent.js.map +1 -1
- package/execution/useArtifactInspection.d.ts +85 -0
- package/execution/useArtifactInspection.d.ts.map +1 -0
- package/execution/useArtifactInspection.js +143 -0
- package/execution/useArtifactInspection.js.map +1 -0
- package/execution/useCreateAgentExecution.d.ts +11 -0
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js +1 -0
- package/execution/useCreateAgentExecution.js.map +1 -1
- package/github/useGitHubFileReader.d.ts +4 -2
- package/github/useGitHubFileReader.d.ts.map +1 -1
- package/github/useGitHubFileReader.js +16 -3
- package/github/useGitHubFileReader.js.map +1 -1
- package/github/useGitHubTreeLister.d.ts +2 -1
- package/github/useGitHubTreeLister.d.ts.map +1 -1
- package/github/useGitHubTreeLister.js +7 -3
- package/github/useGitHubTreeLister.js.map +1 -1
- package/index.d.ts +4 -4
- package/index.d.ts.map +1 -1
- package/index.js +3 -3
- package/index.js.map +1 -1
- package/internal/MermaidDiagram.d.ts +47 -0
- package/internal/MermaidDiagram.d.ts.map +1 -0
- package/internal/MermaidDiagram.js +118 -0
- package/internal/MermaidDiagram.js.map +1 -0
- package/internal/VirtualizedThread.d.ts +3 -2
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +3 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/markdown-components.d.ts.map +1 -1
- package/internal/markdown-components.js +34 -0
- package/internal/markdown-components.js.map +1 -1
- package/internal/mermaid-loader.d.ts +26 -0
- package/internal/mermaid-loader.d.ts.map +1 -0
- package/internal/mermaid-loader.js +34 -0
- package/internal/mermaid-loader.js.map +1 -0
- package/internal/mermaid-theme-css.d.ts +41 -0
- package/internal/mermaid-theme-css.d.ts.map +1 -0
- package/internal/mermaid-theme-css.js +179 -0
- package/internal/mermaid-theme-css.js.map +1 -0
- package/internal/slug.d.ts +9 -5
- package/internal/slug.d.ts.map +1 -1
- package/internal/slug.js +10 -6
- package/internal/slug.js.map +1 -1
- package/package.json +5 -4
- package/session/SessionViewer.d.ts +11 -1
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +101 -21
- package/session/SessionViewer.js.map +1 -1
- package/session/artifact-document.d.ts +13 -0
- package/session/artifact-document.d.ts.map +1 -0
- package/session/artifact-document.js +22 -0
- package/session/artifact-document.js.map +1 -0
- package/session/facets/ArtifactsTab.d.ts +30 -4
- package/session/facets/ArtifactsTab.d.ts.map +1 -1
- package/session/facets/ArtifactsTab.js +32 -10
- package/session/facets/ArtifactsTab.js.map +1 -1
- package/session/index.d.ts +3 -1
- package/session/index.d.ts.map +1 -1
- package/session/index.js +5 -1
- package/session/index.js.map +1 -1
- package/session/useSessionArtifacts.d.ts +17 -0
- package/session/useSessionArtifacts.d.ts.map +1 -1
- package/session/useSessionArtifacts.js +16 -5
- package/session/useSessionArtifacts.js.map +1 -1
- package/session/useSessionConversation.d.ts +17 -1
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +32 -4
- package/session/useSessionConversation.js.map +1 -1
- package/session/useSessionPageFlow.d.ts.map +1 -1
- package/session/useSessionPageFlow.js +1 -0
- package/session/useSessionPageFlow.js.map +1 -1
- package/session/useSessionPanel.d.ts +20 -0
- package/session/useSessionPanel.d.ts.map +1 -1
- package/session/useSessionPanel.js +13 -0
- package/session/useSessionPanel.js.map +1 -1
- package/session/useSessionRailViews.d.ts +13 -1
- package/session/useSessionRailViews.d.ts.map +1 -1
- package/session/useSessionRailViews.js +5 -3
- package/session/useSessionRailViews.js.map +1 -1
- package/session/useWorkspaceReadRefs.d.ts +45 -0
- package/session/useWorkspaceReadRefs.d.ts.map +1 -0
- package/session/useWorkspaceReadRefs.js +104 -0
- package/session/useWorkspaceReadRefs.js.map +1 -0
- package/src/composer/SessionComposer.tsx +69 -1
- package/src/composer/__tests__/SessionComposer-editing.test.tsx +128 -0
- package/src/execution/ArtifactApplyButton.tsx +79 -0
- package/src/execution/ArtifactContentBody.tsx +255 -0
- package/src/execution/ArtifactDocument.tsx +344 -0
- package/src/execution/ArtifactPreviewModal.tsx +39 -405
- package/src/execution/ArtifactRow.tsx +165 -0
- package/src/execution/ArtifactsWidget.tsx +24 -33
- package/src/execution/FileReviewContext.ts +27 -1
- package/src/execution/MessageThread.tsx +110 -22
- package/src/execution/TodoCard.tsx +20 -4
- package/src/execution/TodoList.tsx +26 -4
- package/src/execution/ToolCallDetail.tsx +69 -16
- package/src/execution/__tests__/ArtifactContentBody.test.tsx +131 -0
- package/src/execution/__tests__/ArtifactDocument.test.tsx +100 -0
- package/src/execution/__tests__/ArtifactRow.test.tsx +139 -0
- package/src/execution/__tests__/ArtifactsWidget.test.tsx +78 -0
- package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
- package/src/execution/__tests__/file-review-status.test.ts +47 -0
- package/src/execution/__tests__/message-entry.test.tsx +50 -2
- package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
- package/src/execution/__tests__/useArtifactInspection.test.tsx +151 -0
- package/src/execution/__tests__/useCreateAgentExecution.test.tsx +35 -0
- package/src/execution/file-review-status.ts +24 -0
- package/src/execution/index.ts +27 -5
- package/src/execution/useArtifactContent.ts +7 -0
- package/src/execution/useArtifactInspection.ts +252 -0
- package/src/execution/useCreateAgentExecution.ts +12 -0
- package/src/github/__tests__/useGitHubFileReader.test.ts +35 -2
- package/src/github/__tests__/useGitHubTreeLister.test.ts +14 -0
- package/src/github/useGitHubFileReader.ts +19 -6
- package/src/github/useGitHubTreeLister.ts +7 -3
- package/src/index.ts +15 -2
- package/src/internal/MermaidDiagram.tsx +164 -0
- package/src/internal/VirtualizedThread.tsx +5 -1
- package/src/internal/__tests__/a11y/mermaid-theme.a11y.test.tsx +122 -0
- package/src/internal/__tests__/markdown-components.test.tsx +93 -1
- package/src/internal/__tests__/mermaid-diagram.test.tsx +282 -0
- package/src/internal/markdown-components.tsx +36 -1
- package/src/internal/mermaid-loader.ts +44 -0
- package/src/internal/mermaid-theme-css.ts +178 -0
- package/src/internal/slug.ts +10 -6
- package/src/session/SessionViewer.tsx +204 -35
- package/src/session/__tests__/SessionViewer-artifactTabs.test.tsx +210 -0
- package/src/session/__tests__/SessionViewer-editResubmit.test.tsx +233 -0
- package/src/session/__tests__/useSessionArtifacts.test.ts +57 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +172 -1
- package/src/session/__tests__/useSessionPanel.test.ts +70 -1
- package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
- package/src/session/artifact-document.ts +23 -0
- package/src/session/facets/ArtifactsTab.tsx +66 -18
- package/src/session/facets/__tests__/ArtifactsTab.test.tsx +111 -47
- package/src/session/index.ts +7 -1
- package/src/session/useSessionArtifacts.ts +16 -5
- package/src/session/useSessionConversation.ts +48 -6
- package/src/session/useSessionPageFlow.ts +1 -0
- package/src/session/useSessionPanel.ts +41 -0
- package/src/session/useSessionRailViews.tsx +21 -1
- package/src/session/useWorkspaceReadRefs.ts +118 -0
- package/src/workspace/FileViewer.tsx +144 -38
- package/src/workspace/WorkspaceFileReader.ts +21 -0
- package/src/workspace/__tests__/FileViewer.test.tsx +138 -3
- package/src/workspace/__tests__/useWorkspaceFileContent.test.tsx +20 -0
- package/src/workspace/__tests__/workspaceListingCache.test.ts +114 -0
- package/src/workspace/index.ts +4 -1
- package/src/workspace/useWorkspaceEntries.ts +11 -0
- package/src/workspace/useWorkspaceFileContent.ts +4 -1
- package/src/workspace/useWorkspaceFileSearch.ts +8 -3
- package/src/workspace/useWorkspaceFiles.ts +3 -2
- package/src/workspace/workspaceListingCache.ts +21 -7
- package/styles.css +1 -1
- package/workspace/FileViewer.d.ts +1 -1
- package/workspace/FileViewer.d.ts.map +1 -1
- package/workspace/FileViewer.js +55 -5
- package/workspace/FileViewer.js.map +1 -1
- package/workspace/WorkspaceFileReader.d.ts +17 -0
- package/workspace/WorkspaceFileReader.d.ts.map +1 -1
- package/workspace/WorkspaceFileReader.js +18 -0
- package/workspace/WorkspaceFileReader.js.map +1 -1
- package/workspace/index.d.ts +1 -1
- package/workspace/index.d.ts.map +1 -1
- package/workspace/index.js +1 -1
- package/workspace/index.js.map +1 -1
- package/workspace/useWorkspaceEntries.d.ts +11 -0
- package/workspace/useWorkspaceEntries.d.ts.map +1 -1
- package/workspace/useWorkspaceEntries.js.map +1 -1
- package/workspace/useWorkspaceFileContent.d.ts.map +1 -1
- package/workspace/useWorkspaceFileContent.js +4 -1
- package/workspace/useWorkspaceFileContent.js.map +1 -1
- package/workspace/useWorkspaceFileSearch.d.ts.map +1 -1
- package/workspace/useWorkspaceFileSearch.js +8 -3
- package/workspace/useWorkspaceFileSearch.js.map +1 -1
- package/workspace/useWorkspaceFiles.d.ts +2 -1
- package/workspace/useWorkspaceFiles.d.ts.map +1 -1
- package/workspace/useWorkspaceFiles.js +3 -2
- package/workspace/useWorkspaceFiles.js.map +1 -1
- package/workspace/workspaceListingCache.d.ts +1 -1
- package/workspace/workspaceListingCache.d.ts.map +1 -1
- package/workspace/workspaceListingCache.js +20 -7
- package/workspace/workspaceListingCache.js.map +1 -1
- package/execution/ArtifactCard.d.ts +0 -81
- package/execution/ArtifactCard.d.ts.map +0 -1
- package/execution/ArtifactCard.js +0 -130
- package/execution/ArtifactCard.js.map +0 -1
- package/src/execution/ArtifactCard.tsx +0 -320
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { render, cleanup, waitFor, act } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { MermaidDiagram } from "../MermaidDiagram";
|
|
5
|
+
import { loadMermaid, type MermaidModule } from "../mermaid-loader";
|
|
6
|
+
import { MERMAID_THEME_CSS } from "../mermaid-theme-css";
|
|
7
|
+
import { ColorModeContext, type ResolvedColorMode } from "../../color-mode";
|
|
8
|
+
|
|
9
|
+
// The loader is the component's only side-effectful dependency; mocking it
|
|
10
|
+
// (not the `mermaid` package) exercises the real degradation contract:
|
|
11
|
+
// a rejected load must look exactly like "library not installed".
|
|
12
|
+
vi.mock("../mermaid-loader", () => ({
|
|
13
|
+
loadMermaid: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Partial-mock streamdown so tests can drive the incomplete-fence signal
|
|
17
|
+
// without spinning up a full Streamdown render.
|
|
18
|
+
vi.mock("streamdown", async (importOriginal) => ({
|
|
19
|
+
...(await importOriginal<typeof import("streamdown")>()),
|
|
20
|
+
useIsCodeFenceIncomplete: vi.fn(() => false),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { useIsCodeFenceIncomplete } = await import("streamdown");
|
|
24
|
+
|
|
25
|
+
const loadMermaidMock = vi.mocked(loadMermaid);
|
|
26
|
+
const incompleteFenceMock = vi.mocked(useIsCodeFenceIncomplete);
|
|
27
|
+
|
|
28
|
+
/** Builds a mermaid module stub whose render resolves with the given SVG. */
|
|
29
|
+
function stubMermaid(svg = "<svg><g>diagram</g></svg>") {
|
|
30
|
+
const mermaid = {
|
|
31
|
+
initialize: vi.fn(),
|
|
32
|
+
render: vi.fn().mockResolvedValue({ svg }),
|
|
33
|
+
};
|
|
34
|
+
loadMermaidMock.mockResolvedValue(mermaid as unknown as MermaidModule);
|
|
35
|
+
return mermaid;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderDiagram(
|
|
39
|
+
chart: string,
|
|
40
|
+
{ colorMode = "light" as ResolvedColorMode } = {},
|
|
41
|
+
) {
|
|
42
|
+
return render(
|
|
43
|
+
<ColorModeContext.Provider value={colorMode}>
|
|
44
|
+
<MermaidDiagram chart={chart} />
|
|
45
|
+
</ColorModeContext.Provider>,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function queryDiagram(container: HTMLElement) {
|
|
50
|
+
return container.querySelector('[role="img"][aria-label="Mermaid diagram"]');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
loadMermaidMock.mockReset();
|
|
55
|
+
incompleteFenceMock.mockReset();
|
|
56
|
+
incompleteFenceMock.mockReturnValue(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(cleanup);
|
|
60
|
+
|
|
61
|
+
const CHART = "flowchart LR\n A --> B";
|
|
62
|
+
|
|
63
|
+
describe("MermaidDiagram — successful render", () => {
|
|
64
|
+
it("renders the SVG in an accessible container", async () => {
|
|
65
|
+
stubMermaid("<svg data-diagram><g>ok</g></svg>");
|
|
66
|
+
const { container } = renderDiagram(CHART);
|
|
67
|
+
|
|
68
|
+
await waitFor(() => expect(queryDiagram(container)).not.toBeNull());
|
|
69
|
+
expect(queryDiagram(container)!.querySelector("svg[data-diagram]")).not.toBeNull();
|
|
70
|
+
// The source fallback is gone once the diagram is up.
|
|
71
|
+
expect(container.querySelector("pre")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("shows the source as a code block while the render is pending", () => {
|
|
75
|
+
// A load that never settles = the loading window.
|
|
76
|
+
loadMermaidMock.mockReturnValue(new Promise(() => {}));
|
|
77
|
+
const { container } = renderDiagram(CHART);
|
|
78
|
+
|
|
79
|
+
const code = container.querySelector("pre code");
|
|
80
|
+
expect(code).not.toBeNull();
|
|
81
|
+
expect(code!.textContent).toBe(CHART);
|
|
82
|
+
expect(queryDiagram(container)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("trims the trailing newline the fence body carries before rendering", async () => {
|
|
86
|
+
const mermaid = stubMermaid();
|
|
87
|
+
renderDiagram("flowchart LR\n A --> B\n");
|
|
88
|
+
|
|
89
|
+
await waitFor(() => expect(mermaid.render).toHaveBeenCalled());
|
|
90
|
+
expect(mermaid.render).toHaveBeenCalledWith(
|
|
91
|
+
expect.any(String),
|
|
92
|
+
"flowchart LR\n A --> B",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("MermaidDiagram — security and theming configuration", () => {
|
|
98
|
+
it("configures mermaid with securityLevel strict (agent output is untrusted)", async () => {
|
|
99
|
+
const mermaid = stubMermaid();
|
|
100
|
+
renderDiagram(CHART);
|
|
101
|
+
|
|
102
|
+
await waitFor(() => expect(mermaid.initialize).toHaveBeenCalled());
|
|
103
|
+
expect(mermaid.initialize).toHaveBeenCalledWith(
|
|
104
|
+
expect.objectContaining({ securityLevel: "strict", startOnLoad: false }),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("injects the token-driven themeCSS so interiors track --stgm-* tokens", async () => {
|
|
109
|
+
const mermaid = stubMermaid();
|
|
110
|
+
renderDiagram(CHART);
|
|
111
|
+
|
|
112
|
+
await waitFor(() => expect(mermaid.initialize).toHaveBeenCalled());
|
|
113
|
+
// The interior palette rides on themeCSS (var(--stgm-*)), layered over the
|
|
114
|
+
// built-in default/dark base theme. The browser-mode a11y suite verifies
|
|
115
|
+
// the CSS actually resolves; here we lock the wiring itself.
|
|
116
|
+
expect(mermaid.initialize).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({ themeCSS: MERMAID_THEME_CSS }),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each([
|
|
122
|
+
["light", "default"],
|
|
123
|
+
["dark", "dark"],
|
|
124
|
+
] as const)("selects the %s theme for %s color mode", async (mode, theme) => {
|
|
125
|
+
const mermaid = stubMermaid();
|
|
126
|
+
renderDiagram(CHART, { colorMode: mode });
|
|
127
|
+
|
|
128
|
+
await waitFor(() => expect(mermaid.initialize).toHaveBeenCalled());
|
|
129
|
+
expect(mermaid.initialize).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({ theme }),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("re-renders the diagram when the color mode changes", async () => {
|
|
135
|
+
const mermaid = stubMermaid();
|
|
136
|
+
const { container, rerender } = render(
|
|
137
|
+
<ColorModeContext.Provider value="light">
|
|
138
|
+
<MermaidDiagram chart={CHART} />
|
|
139
|
+
</ColorModeContext.Provider>,
|
|
140
|
+
);
|
|
141
|
+
await waitFor(() => expect(queryDiagram(container)).not.toBeNull());
|
|
142
|
+
|
|
143
|
+
rerender(
|
|
144
|
+
<ColorModeContext.Provider value="dark">
|
|
145
|
+
<MermaidDiagram chart={CHART} />
|
|
146
|
+
</ColorModeContext.Provider>,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(2));
|
|
150
|
+
expect(mermaid.initialize).toHaveBeenLastCalledWith(
|
|
151
|
+
expect.objectContaining({ theme: "dark" }),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("MermaidDiagram — failure degrades to the code block", () => {
|
|
157
|
+
it("falls back with an announced note when the diagram source is invalid", async () => {
|
|
158
|
+
loadMermaidMock.mockResolvedValue({
|
|
159
|
+
initialize: vi.fn(),
|
|
160
|
+
render: vi.fn().mockRejectedValue(new Error("Parse error on line 2")),
|
|
161
|
+
} as unknown as MermaidModule);
|
|
162
|
+
const { container } = renderDiagram("flowchart LR\n A --> ");
|
|
163
|
+
|
|
164
|
+
await waitFor(() =>
|
|
165
|
+
expect(container.querySelector('[role="status"]')).not.toBeNull(),
|
|
166
|
+
);
|
|
167
|
+
expect(container.querySelector("pre code")!.textContent).toContain(
|
|
168
|
+
"A -->",
|
|
169
|
+
);
|
|
170
|
+
expect(container.querySelector('[role="status"]')!.textContent).toContain(
|
|
171
|
+
"Parse error on line 2",
|
|
172
|
+
);
|
|
173
|
+
expect(queryDiagram(container)).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("falls back when the mermaid library cannot be loaded (optional peer absent)", async () => {
|
|
177
|
+
loadMermaidMock.mockRejectedValue(
|
|
178
|
+
new Error("Cannot find module 'mermaid'"),
|
|
179
|
+
);
|
|
180
|
+
const { container } = renderDiagram(CHART);
|
|
181
|
+
|
|
182
|
+
await waitFor(() =>
|
|
183
|
+
expect(container.querySelector('[role="status"]')).not.toBeNull(),
|
|
184
|
+
);
|
|
185
|
+
expect(container.querySelector("pre code")!.textContent).toBe(CHART);
|
|
186
|
+
expect(queryDiagram(container)).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("does not attempt to render an empty fence", () => {
|
|
190
|
+
stubMermaid();
|
|
191
|
+
const { container } = renderDiagram(" \n ");
|
|
192
|
+
|
|
193
|
+
expect(loadMermaidMock).not.toHaveBeenCalled();
|
|
194
|
+
expect(queryDiagram(container)).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("MermaidDiagram — streaming deferral", () => {
|
|
199
|
+
it("defers rendering while the fence is still streaming", () => {
|
|
200
|
+
stubMermaid();
|
|
201
|
+
incompleteFenceMock.mockReturnValue(true);
|
|
202
|
+
const { container } = renderDiagram(CHART);
|
|
203
|
+
|
|
204
|
+
expect(loadMermaidMock).not.toHaveBeenCalled();
|
|
205
|
+
const code = container.querySelector("pre code");
|
|
206
|
+
expect(code).not.toBeNull();
|
|
207
|
+
expect(code!.textContent).toBe(CHART);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("renders the diagram once the fence closes", async () => {
|
|
211
|
+
stubMermaid();
|
|
212
|
+
incompleteFenceMock.mockReturnValue(true);
|
|
213
|
+
const { container, rerender } = renderDiagram(CHART);
|
|
214
|
+
expect(queryDiagram(container)).toBeNull();
|
|
215
|
+
|
|
216
|
+
incompleteFenceMock.mockReturnValue(false);
|
|
217
|
+
rerender(
|
|
218
|
+
<ColorModeContext.Provider value="light">
|
|
219
|
+
<MermaidDiagram chart={CHART} />
|
|
220
|
+
</ColorModeContext.Provider>,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await waitFor(() => expect(queryDiagram(container)).not.toBeNull());
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("MermaidDiagram — stale-render races", () => {
|
|
228
|
+
it("discards a render that resolves after the chart has changed", async () => {
|
|
229
|
+
const initialize = vi.fn();
|
|
230
|
+
let resolveFirst!: (result: { svg: string }) => void;
|
|
231
|
+
const renderMock = vi
|
|
232
|
+
.fn()
|
|
233
|
+
.mockImplementationOnce(
|
|
234
|
+
() => new Promise((resolve) => (resolveFirst = resolve)),
|
|
235
|
+
)
|
|
236
|
+
.mockResolvedValue({ svg: "<svg data-chart='second'></svg>" });
|
|
237
|
+
loadMermaidMock.mockResolvedValue({
|
|
238
|
+
initialize,
|
|
239
|
+
render: renderMock,
|
|
240
|
+
} as unknown as MermaidModule);
|
|
241
|
+
|
|
242
|
+
const { container, rerender } = renderDiagram("flowchart LR\n A --> B");
|
|
243
|
+
await waitFor(() => expect(renderMock).toHaveBeenCalledTimes(1));
|
|
244
|
+
|
|
245
|
+
rerender(
|
|
246
|
+
<ColorModeContext.Provider value="light">
|
|
247
|
+
<MermaidDiagram chart={"flowchart LR\n C --> D"} />
|
|
248
|
+
</ColorModeContext.Provider>,
|
|
249
|
+
);
|
|
250
|
+
await waitFor(() =>
|
|
251
|
+
expect(
|
|
252
|
+
container.querySelector("svg[data-chart='second']"),
|
|
253
|
+
).not.toBeNull(),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// The first (now stale) render settles late — it must not clobber the
|
|
257
|
+
// second chart's SVG.
|
|
258
|
+
act(() => resolveFirst({ svg: "<svg data-chart='first'></svg>" }));
|
|
259
|
+
expect(container.querySelector("svg[data-chart='first']")).toBeNull();
|
|
260
|
+
expect(container.querySelector("svg[data-chart='second']")).not.toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("ignores a render that settles after unmount", async () => {
|
|
264
|
+
let resolveRender!: (result: { svg: string }) => void;
|
|
265
|
+
loadMermaidMock.mockResolvedValue({
|
|
266
|
+
initialize: vi.fn(),
|
|
267
|
+
render: vi.fn().mockReturnValue(
|
|
268
|
+
new Promise((resolve) => (resolveRender = resolve)),
|
|
269
|
+
),
|
|
270
|
+
} as unknown as MermaidModule);
|
|
271
|
+
|
|
272
|
+
const { unmount } = renderDiagram(CHART);
|
|
273
|
+
await waitFor(() => expect(loadMermaidMock).toHaveBeenCalled());
|
|
274
|
+
unmount();
|
|
275
|
+
|
|
276
|
+
// Settling after unmount must not warn or throw (React state update on
|
|
277
|
+
// an unmounted component).
|
|
278
|
+
expect(() =>
|
|
279
|
+
act(() => resolveRender({ svg: "<svg></svg>" })),
|
|
280
|
+
).not.toThrow();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { isValidElement, type ComponentProps, type JSX, type ReactNode } from "react";
|
|
2
2
|
import type { Components } from "react-markdown";
|
|
3
3
|
import remarkGfm from "remark-gfm";
|
|
4
4
|
import { cn } from "@stigmer/theme";
|
|
5
5
|
import { highlightToReact } from "./code-highlight.js";
|
|
6
|
+
import { MermaidDiagram } from "./MermaidDiagram.js";
|
|
6
7
|
|
|
7
8
|
const LANGUAGE_CLASS_PREFIX = "language-";
|
|
9
|
+
const MERMAID_LANGUAGE_CLASS = "language-mermaid";
|
|
8
10
|
|
|
9
11
|
type MdProps<T extends keyof JSX.IntrinsicElements> = ComponentProps<T>;
|
|
10
12
|
|
|
@@ -119,6 +121,30 @@ export function extractLeadingH1(markdown: string): {
|
|
|
119
121
|
return { title: match[1], body: trimmed.slice(match[0].length) };
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Returns the mermaid source when a `pre` element's children are a single
|
|
126
|
+
* fenced code block explicitly tagged `mermaid`, or `null` otherwise.
|
|
127
|
+
*
|
|
128
|
+
* Both renderers (Streamdown for chat, react-markdown for artifacts/skills)
|
|
129
|
+
* hand `pre` exactly one `<code>` child whose `className` carries the fence's
|
|
130
|
+
* info string as `language-*` and whose children are the raw source text.
|
|
131
|
+
* Anything that deviates from that shape — a different language, a missing
|
|
132
|
+
* tag, non-string children — is not a mermaid fence and keeps the ordinary
|
|
133
|
+
* code-block rendering. Only the explicit `mermaid` tag qualifies:
|
|
134
|
+
* inspecting fence bodies to guess diagram intent is the kind of fuzzy
|
|
135
|
+
* heuristic this codebase avoids.
|
|
136
|
+
*/
|
|
137
|
+
function extractMermaidSource(children: ReactNode): string | null {
|
|
138
|
+
if (!isValidElement(children)) return null;
|
|
139
|
+
const { className, children: code } = children.props as {
|
|
140
|
+
className?: unknown;
|
|
141
|
+
children?: unknown;
|
|
142
|
+
};
|
|
143
|
+
if (typeof className !== "string") return null;
|
|
144
|
+
if (!className.split(/\s+/).includes(MERMAID_LANGUAGE_CLASS)) return null;
|
|
145
|
+
return typeof code === "string" ? code : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
/**
|
|
123
149
|
* Styled react-markdown component overrides for SDK markdown surfaces.
|
|
124
150
|
*
|
|
@@ -208,6 +234,15 @@ export const MARKDOWN_COMPONENTS: Components = {
|
|
|
208
234
|
},
|
|
209
235
|
|
|
210
236
|
pre({ children, ...props }: MdProps<"pre">) {
|
|
237
|
+
// A ```mermaid fence renders as a diagram, not a code block. The check
|
|
238
|
+
// lives here (not in the `code` override) because the diagram must
|
|
239
|
+
// replace the <pre> wrapper too — a block-level diagram container inside
|
|
240
|
+
// <pre> is invalid HTML and would inherit code-block chrome.
|
|
241
|
+
const mermaidSource = extractMermaidSource(children);
|
|
242
|
+
if (mermaidSource !== null) {
|
|
243
|
+
return <MermaidDiagram chart={mermaidSource} />;
|
|
244
|
+
}
|
|
245
|
+
|
|
211
246
|
return (
|
|
212
247
|
<pre
|
|
213
248
|
className="mb-3 last:mb-0 overflow-x-auto rounded-md bg-muted p-3"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy loader for the `mermaid` library, isolated in its own module — the
|
|
3
|
+
* same seam pattern as `workflow/layout/elk-layout-engine.ts` — so
|
|
4
|
+
* {@link file://./MermaidDiagram.tsx} stays a pure presentation component and
|
|
5
|
+
* tests can mock the load outcome (resolve, reject) without touching module
|
|
6
|
+
* registries.
|
|
7
|
+
*
|
|
8
|
+
* `mermaid` is an optional peer dependency (DD-013): it is large, so it must
|
|
9
|
+
* never sit on the synchronous bundle path, and a host that has not installed
|
|
10
|
+
* it must degrade gracefully (the caller falls back to a code block).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The mermaid API surface the SDK consumes, typed from the package itself so
|
|
15
|
+
* it stays in lockstep with the installed version.
|
|
16
|
+
*/
|
|
17
|
+
export type MermaidModule = typeof import("mermaid").default;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Holds the in-flight import promise so concurrent diagrams share one load.
|
|
21
|
+
* A failed import clears the cache: rejections are often transient (a
|
|
22
|
+
* chunk-load hiccup mid-deploy), and caching one would break every diagram
|
|
23
|
+
* for the rest of the session.
|
|
24
|
+
*/
|
|
25
|
+
let mermaidImport: Promise<MermaidModule> | null = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Dynamically imports `mermaid`, sharing one in-flight import across callers.
|
|
29
|
+
*
|
|
30
|
+
* @returns The mermaid default export.
|
|
31
|
+
* @throws If `mermaid` cannot be imported (not installed, or the async chunk
|
|
32
|
+
* failed to load). Callers treat this as "diagrams unavailable" and render
|
|
33
|
+
* the fenced source as a plain code block.
|
|
34
|
+
*/
|
|
35
|
+
export function loadMermaid(): Promise<MermaidModule> {
|
|
36
|
+
mermaidImport ??= import("mermaid").then(
|
|
37
|
+
(module) => module.default,
|
|
38
|
+
(error: unknown) => {
|
|
39
|
+
mermaidImport = null;
|
|
40
|
+
throw error;
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
return mermaidImport;
|
|
44
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-driven `themeCSS` for Mermaid diagram interiors.
|
|
3
|
+
*
|
|
4
|
+
* Mermaid's built-in `default`/`dark` themes color a diagram's SVG interior
|
|
5
|
+
* with a fixed palette that ignores the host's `--stgm-*` preset (Corporate,
|
|
6
|
+
* Fintech, ...). The obvious fix — feeding resolved tokens into Mermaid's
|
|
7
|
+
* `themeVariables` — is blocked: our tokens are authored in `oklch()`, and
|
|
8
|
+
* Mermaid's color engine (`khroma`) parses only hex/rgb/hsl/keyword, so it
|
|
9
|
+
* throws or NaNs on `oklch(...)`. (Mermaid also gates every `themeVariables`
|
|
10
|
+
* value through the allowlist `^[\d "#%(),.;A-Za-z]+$`, which excludes `-` and
|
|
11
|
+
* would blank any `var(--...)`.)
|
|
12
|
+
*
|
|
13
|
+
* Instead we hand Mermaid this CSS via `initialize({ themeCSS })`. Mermaid
|
|
14
|
+
* injects it into the diagram's own `<style>` block, scoped under the render
|
|
15
|
+
* id (`#stgm-mermaid-N`), *after* the theme palette it computes. Because every
|
|
16
|
+
* selector in that block is id-prefixed, our rules and the palette's rules sit
|
|
17
|
+
* at equal specificity, so ours win by source order — and `!important` makes
|
|
18
|
+
* that bulletproof against the few palette rules that are themselves important
|
|
19
|
+
* or inlined. Crucially, `themeCSS` never passes through khroma, so the
|
|
20
|
+
* `oklch()`-backed `var(--stgm-*)` references flow straight to the browser,
|
|
21
|
+
* which resolves them against the `.stgm` scope the SVG lives in. That is why
|
|
22
|
+
* interiors track the active preset *and* color mode live, with no JS color
|
|
23
|
+
* reading and no re-render on theme change.
|
|
24
|
+
*
|
|
25
|
+
* `initialize({ theme })` still selects the built-in `default`/`dark` theme as
|
|
26
|
+
* the base, so any element or diagram type we do not target here degrades to
|
|
27
|
+
* Mermaid's stock palette — never a blank or mis-colored diagram.
|
|
28
|
+
*
|
|
29
|
+
* Palette intent (subtle surfaces + brand accents — legibility over brand
|
|
30
|
+
* saturation): node/actor bodies sit on a muted surface with quiet
|
|
31
|
+
* token-colored borders and edges; labels use the standard foreground; the
|
|
32
|
+
* brand `--stgm-primary` is reserved for structural headers (subgraph/cluster
|
|
33
|
+
* titles, sequence loop/section titles, the sequence-number badge).
|
|
34
|
+
*
|
|
35
|
+
* Coverage is the two families agents emit overwhelmingly — flowchart and
|
|
36
|
+
* sequence — plus the shared marker/edge-label chrome. Both label render paths
|
|
37
|
+
* are handled: SVG `text`/`tspan` (via `fill`) and HTML labels inside
|
|
38
|
+
* `foreignObject` (via `color`).
|
|
39
|
+
*/
|
|
40
|
+
export const MERMAID_THEME_CSS = `
|
|
41
|
+
/* Shared: edge arrowheads / markers */
|
|
42
|
+
.marker {
|
|
43
|
+
fill: var(--stgm-border) !important;
|
|
44
|
+
stroke: var(--stgm-border) !important;
|
|
45
|
+
}
|
|
46
|
+
.marker.cross {
|
|
47
|
+
stroke: var(--stgm-border) !important;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Flowchart: node bodies */
|
|
51
|
+
.node rect,
|
|
52
|
+
.node circle,
|
|
53
|
+
.node ellipse,
|
|
54
|
+
.node polygon,
|
|
55
|
+
.node path {
|
|
56
|
+
fill: var(--stgm-muted) !important;
|
|
57
|
+
stroke: var(--stgm-border) !important;
|
|
58
|
+
}
|
|
59
|
+
.node .neo-node {
|
|
60
|
+
stroke: var(--stgm-border) !important;
|
|
61
|
+
}
|
|
62
|
+
.node .katex path {
|
|
63
|
+
fill: var(--stgm-foreground) !important;
|
|
64
|
+
stroke: var(--stgm-foreground) !important;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Flowchart: node labels (SVG text and HTML spans) */
|
|
68
|
+
.label {
|
|
69
|
+
color: var(--stgm-foreground) !important;
|
|
70
|
+
}
|
|
71
|
+
.label text,
|
|
72
|
+
.label span {
|
|
73
|
+
fill: var(--stgm-foreground) !important;
|
|
74
|
+
color: var(--stgm-foreground) !important;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Flowchart: edges */
|
|
78
|
+
.edgePath .path,
|
|
79
|
+
.flowchart-link {
|
|
80
|
+
stroke: var(--stgm-border) !important;
|
|
81
|
+
}
|
|
82
|
+
.arrowheadPath,
|
|
83
|
+
.root .anchor path {
|
|
84
|
+
fill: var(--stgm-border) !important;
|
|
85
|
+
stroke: var(--stgm-border) !important;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Flowchart: edge labels sit on the diagram surface for legibility */
|
|
89
|
+
.edgeLabel,
|
|
90
|
+
.edgeLabel p,
|
|
91
|
+
.edgeLabel rect,
|
|
92
|
+
.labelBkg {
|
|
93
|
+
background-color: var(--stgm-card) !important;
|
|
94
|
+
fill: var(--stgm-card) !important;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Flowchart: subgraph clusters — muted surface, brand-accent title */
|
|
98
|
+
.cluster rect {
|
|
99
|
+
fill: var(--stgm-muted-subtle) !important;
|
|
100
|
+
stroke: var(--stgm-border) !important;
|
|
101
|
+
}
|
|
102
|
+
.cluster-label text,
|
|
103
|
+
.cluster text {
|
|
104
|
+
fill: var(--stgm-primary) !important;
|
|
105
|
+
}
|
|
106
|
+
.cluster-label span,
|
|
107
|
+
.cluster span {
|
|
108
|
+
color: var(--stgm-primary) !important;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Sequence: actors and lifelines */
|
|
112
|
+
.actor {
|
|
113
|
+
fill: var(--stgm-muted) !important;
|
|
114
|
+
stroke: var(--stgm-border) !important;
|
|
115
|
+
}
|
|
116
|
+
text.actor > tspan {
|
|
117
|
+
fill: var(--stgm-foreground) !important;
|
|
118
|
+
}
|
|
119
|
+
.actor-line {
|
|
120
|
+
stroke: var(--stgm-border) !important;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Sequence: messages */
|
|
124
|
+
.messageLine0,
|
|
125
|
+
.messageLine1 {
|
|
126
|
+
stroke: var(--stgm-border) !important;
|
|
127
|
+
}
|
|
128
|
+
[id$="-arrowhead"] path,
|
|
129
|
+
[id$="-crosshead"] path {
|
|
130
|
+
fill: var(--stgm-border) !important;
|
|
131
|
+
stroke: var(--stgm-border) !important;
|
|
132
|
+
}
|
|
133
|
+
.messageText {
|
|
134
|
+
fill: var(--stgm-foreground) !important;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Sequence: number badge — brand accent with its paired foreground */
|
|
138
|
+
[id$="-sequencenumber"] {
|
|
139
|
+
fill: var(--stgm-primary) !important;
|
|
140
|
+
}
|
|
141
|
+
.sequenceNumber {
|
|
142
|
+
fill: var(--stgm-primary-foreground) !important;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Sequence: alt/opt/loop boxes and labels */
|
|
146
|
+
.labelBox {
|
|
147
|
+
fill: var(--stgm-muted-subtle) !important;
|
|
148
|
+
stroke: var(--stgm-border) !important;
|
|
149
|
+
}
|
|
150
|
+
.labelText,
|
|
151
|
+
.labelText > tspan {
|
|
152
|
+
fill: var(--stgm-foreground) !important;
|
|
153
|
+
}
|
|
154
|
+
.loopText,
|
|
155
|
+
.loopText > tspan,
|
|
156
|
+
.sectionTitle,
|
|
157
|
+
.sectionTitle > tspan {
|
|
158
|
+
fill: var(--stgm-primary) !important;
|
|
159
|
+
}
|
|
160
|
+
.loopLine {
|
|
161
|
+
stroke: var(--stgm-border) !important;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Sequence: notes and activations */
|
|
165
|
+
.note {
|
|
166
|
+
fill: var(--stgm-muted-subtle) !important;
|
|
167
|
+
stroke: var(--stgm-border) !important;
|
|
168
|
+
}
|
|
169
|
+
.noteText,
|
|
170
|
+
.noteText > tspan {
|
|
171
|
+
fill: var(--stgm-foreground) !important;
|
|
172
|
+
}
|
|
173
|
+
.activation0,
|
|
174
|
+
.activation1 {
|
|
175
|
+
fill: var(--stgm-muted) !important;
|
|
176
|
+
stroke: var(--stgm-border) !important;
|
|
177
|
+
}
|
|
178
|
+
`;
|
package/src/internal/slug.ts
CHANGED
|
@@ -7,23 +7,27 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Rules (applied in order):
|
|
9
9
|
* 1. Convert to lowercase
|
|
10
|
-
* 2. Replace spaces with hyphens
|
|
10
|
+
* 2. Replace spaces and dots (namespace separators) with hyphens
|
|
11
11
|
* 3. Strip everything except `a-z`, `0-9`, and `-`
|
|
12
12
|
* 4. Collapse consecutive hyphens into one
|
|
13
13
|
* 5. Trim leading / trailing hyphens
|
|
14
14
|
*
|
|
15
|
+
* Dots are treated as namespace separators (e.g. `platform.sara` → `platform-sara`),
|
|
16
|
+
* matching the backend generators, so namespace-scoped names stay readable.
|
|
17
|
+
*
|
|
15
18
|
* @example
|
|
16
19
|
* ```ts
|
|
17
|
-
* generateSlug("My Cool Agent")
|
|
18
|
-
* generateSlug("Acme Corp!")
|
|
19
|
-
* generateSlug(" --hello-- ")
|
|
20
|
-
* generateSlug("")
|
|
20
|
+
* generateSlug("My Cool Agent") // "my-cool-agent"
|
|
21
|
+
* generateSlug("Acme Corp!") // "acme-corp"
|
|
22
|
+
* generateSlug(" --hello-- ") // "hello"
|
|
23
|
+
* generateSlug("platform.planton-arch") // "platform-planton-arch"
|
|
24
|
+
* generateSlug("") // ""
|
|
21
25
|
* ```
|
|
22
26
|
*/
|
|
23
27
|
export function generateSlug(name: string): string {
|
|
24
28
|
return name
|
|
25
29
|
.toLowerCase()
|
|
26
|
-
.replace(/ /g, "-")
|
|
30
|
+
.replace(/[ .]/g, "-")
|
|
27
31
|
.replace(/[^a-z0-9-]/g, "")
|
|
28
32
|
.replace(/-{2,}/g, "-")
|
|
29
33
|
.replace(/^-+|-+$/g, "");
|