doccupine 0.0.95 → 0.0.97
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/dist/index.js +4 -1
- package/dist/lib/layout.js +30 -10
- package/dist/lib/structures.d.ts +1 -0
- package/dist/lib/structures.js +9 -6
- package/dist/templates/app/theme.d.ts +1 -1
- package/dist/templates/app/theme.js +36 -54
- package/dist/templates/components/Chat.d.ts +1 -1
- package/dist/templates/components/Chat.js +4 -0
- package/dist/templates/components/SearchDocs.d.ts +1 -1
- package/dist/templates/components/SearchDocs.js +26 -17
- package/dist/templates/components/SearchModalContent.d.ts +1 -1
- package/dist/templates/components/SearchModalContent.js +2 -4
- package/dist/templates/components/SideBar.d.ts +1 -1
- package/dist/templates/components/SideBar.js +3 -9
- package/dist/templates/components/layout/ActionBar.d.ts +1 -1
- package/dist/templates/components/layout/ActionBar.js +22 -27
- package/dist/templates/components/layout/CherryThemeProvider.d.ts +1 -1
- package/dist/templates/components/layout/CherryThemeProvider.js +26 -6
- package/dist/templates/components/layout/Code.d.ts +1 -1
- package/dist/templates/components/layout/Code.js +25 -38
- package/dist/templates/components/layout/DemoTheme.d.ts +1 -1
- package/dist/templates/components/layout/DemoTheme.js +45 -14
- package/dist/templates/components/layout/Header.d.ts +1 -1
- package/dist/templates/components/layout/Header.js +1 -1
- package/dist/templates/components/layout/SharedStyles.d.ts +1 -1
- package/dist/templates/components/layout/SharedStyles.js +9 -0
- package/dist/templates/components/layout/SiteGate.d.ts +1 -1
- package/dist/templates/components/layout/SiteGate.js +3 -9
- package/dist/templates/components/layout/Tabs.d.ts +1 -1
- package/dist/templates/components/layout/Tabs.js +2 -0
- package/dist/templates/package.js +2 -2
- package/package.json +1 -1
|
@@ -27,6 +27,10 @@ interface SectionItem {
|
|
|
27
27
|
slug: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Stable empty array so the derived contentResults keeps the same identity
|
|
31
|
+
// across renders when there are no hits (memo deps stay stable).
|
|
32
|
+
const EMPTY_RESULTS: ContentHit[] = [];
|
|
33
|
+
|
|
30
34
|
interface ContentHit {
|
|
31
35
|
slug: string;
|
|
32
36
|
snippet: string;
|
|
@@ -91,8 +95,14 @@ function SearchProvider({
|
|
|
91
95
|
const [isClosing, setIsClosing] = useState(false);
|
|
92
96
|
const [query, setQuery] = useState("");
|
|
93
97
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
// Latest completed content search, keyed by the query that produced it.
|
|
99
|
+
// contentResults and isSearching are derived from it at render time, so
|
|
100
|
+
// the debounced-search effect never sets state synchronously (which would
|
|
101
|
+
// trigger cascading renders — react-hooks/set-state-in-effect).
|
|
102
|
+
const [fetched, setFetched] = useState<{
|
|
103
|
+
q: string;
|
|
104
|
+
results: ContentHit[];
|
|
105
|
+
} | null>(null);
|
|
96
106
|
const resultsRef = useRef<HTMLUListElement>(null);
|
|
97
107
|
const closingRef = useRef(false);
|
|
98
108
|
const abortRef = useRef<AbortController | null>(null);
|
|
@@ -127,10 +137,16 @@ function SearchProvider({
|
|
|
127
137
|
setIsClosing(false);
|
|
128
138
|
setQuery("");
|
|
129
139
|
setActiveIndex(0);
|
|
130
|
-
|
|
131
|
-
setIsSearching(false);
|
|
140
|
+
setFetched(null);
|
|
132
141
|
}, []);
|
|
133
142
|
|
|
143
|
+
const trimmedQuery = query.trim();
|
|
144
|
+
const contentResults =
|
|
145
|
+
trimmedQuery.length >= 2 && fetched?.q === trimmedQuery
|
|
146
|
+
? fetched.results
|
|
147
|
+
: EMPTY_RESULTS;
|
|
148
|
+
const isSearching = trimmedQuery.length >= 2 && fetched?.q !== trimmedQuery;
|
|
149
|
+
|
|
134
150
|
// Instant title/description filtering
|
|
135
151
|
const titleFiltered = useMemo(() => {
|
|
136
152
|
if (!query.trim()) return pages;
|
|
@@ -168,19 +184,16 @@ function SearchProvider({
|
|
|
168
184
|
return [...titleMatches, ...contentOnly];
|
|
169
185
|
}, [pages, query, titleFiltered, contentResults]);
|
|
170
186
|
|
|
171
|
-
// Debounced content search
|
|
187
|
+
// Debounced content search. State updates happen only inside the timeout's
|
|
188
|
+
// async callback; short queries need no reset because the derived values
|
|
189
|
+
// above ignore results whose query no longer matches.
|
|
172
190
|
useEffect(() => {
|
|
173
191
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
174
192
|
if (abortRef.current) abortRef.current.abort();
|
|
175
193
|
|
|
176
194
|
const q = query.trim();
|
|
177
|
-
if (q.length < 2)
|
|
178
|
-
setContentResults([]);
|
|
179
|
-
setIsSearching(false);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
195
|
+
if (q.length < 2) return;
|
|
182
196
|
|
|
183
|
-
setIsSearching(true);
|
|
184
197
|
debounceRef.current = setTimeout(async () => {
|
|
185
198
|
const controller = new AbortController();
|
|
186
199
|
abortRef.current = controller;
|
|
@@ -191,14 +204,10 @@ function SearchProvider({
|
|
|
191
204
|
);
|
|
192
205
|
if (!res.ok) throw new Error("Search failed");
|
|
193
206
|
const data = await res.json();
|
|
194
|
-
|
|
207
|
+
setFetched({ q, results: data.results ?? [] });
|
|
195
208
|
} catch (err: unknown) {
|
|
196
209
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
197
|
-
|
|
198
|
-
} finally {
|
|
199
|
-
if (!controller.signal.aborted) {
|
|
200
|
-
setIsSearching(false);
|
|
201
|
-
}
|
|
210
|
+
setFetched({ q, results: [] });
|
|
202
211
|
}
|
|
203
212
|
}, 300);
|
|
204
213
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Search } from \"lucide-react\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\n\nexport interface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\nexport interface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\nexport interface SearchModalContentProps {\n isClosing: boolean;\n closeSearch: () => void;\n onCloseAnimationEnd: () => void;\n query: string;\n setQuery: (q: string) => void;\n activeIndex: number;\n setActiveIndex: (i: number | ((prev: number) => number)) => void;\n resultsRef: React.RefObject<HTMLUListElement | null>;\n onKeyDown: (e: React.KeyboardEvent) => void;\n merged: MergedResult[];\n sectionLabels: Record<string, string>;\n isSearching: boolean;\n navigate: (slug: string) => void;\n}\n\nconst ANIMATION_MS = 150;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n /* Backdrop is intentionally near-black in both modes \u2014 covers everything\n behind the modal regardless of the user's theme. */\n background: rgba(0, 0, 0, 0.5);\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xs};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding-bottom: 8px;\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n margin: 8px 0 0 0;\n padding: 0 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n
|
|
1
|
+
export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Search } from \"lucide-react\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\n\nexport interface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\nexport interface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\nexport interface SearchModalContentProps {\n isClosing: boolean;\n closeSearch: () => void;\n onCloseAnimationEnd: () => void;\n query: string;\n setQuery: (q: string) => void;\n activeIndex: number;\n setActiveIndex: (i: number | ((prev: number) => number)) => void;\n resultsRef: React.RefObject<HTMLUListElement | null>;\n onKeyDown: (e: React.KeyboardEvent) => void;\n merged: MergedResult[];\n sectionLabels: Record<string, string>;\n isSearching: boolean;\n navigate: (slug: string) => void;\n}\n\nconst ANIMATION_MS = 150;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n /* Backdrop is intentionally near-black in both modes \u2014 covers everything\n behind the modal regardless of the user's theme. */\n background: rgba(0, 0, 0, 0.5);\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xs};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding-bottom: 8px;\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n margin: 8px 0 0 0;\n padding: 0 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n ${thinScrollbar};\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n transition: background 0.15s ease;\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n background: color-mix(\n in srgb,\n ${theme.colors.primaryLight} 20%,\n transparent\n );\n `}\n\n &:hover {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 15%, transparent)`};\n }\n`;\n\nconst StyledResultTitle = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n`;\n\nconst StyledResultMeta = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n display: block;\n margin-top: 2px;\n`;\n\nconst StyledSnippet = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.grayDark};\n display: block;\n margin-top: 4px;\n line-height: 1.4;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n & mark {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 35%, transparent)`};\n color: inherit;\n border-radius: 4px;\n padding: 0 1px;\n }\n`;\n\nconst StyledEmpty = styled.div<{ theme: Theme }>`\n padding: 20px 20px 12px;\n min-height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n`;\n\nconst StyledKbd = styled.kbd<{ theme: Theme }>`\n font-size: 11px;\n font-family: inherit;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n padding: 2px 6px;\n border-radius: 4px;\n margin-left: auto;\n font-weight: 600;\n display: none;\n\n ${mq(\"lg\")} {\n display: initial;\n }\n`;\n\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\");\n}\n\nfunction highlightMatch(snippet: string, query: string): string {\n const escaped = escapeHtml(snippet);\n if (!query.trim()) return escaped;\n const q = escapeHtml(query.trim());\n const regex = new RegExp(\n `(${q.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")})`,\n \"gi\",\n );\n return escaped.replace(regex, \"<mark>$1</mark>\");\n}\n\nexport function SearchModalContent({\n isClosing,\n closeSearch,\n onCloseAnimationEnd,\n query,\n setQuery,\n activeIndex,\n setActiveIndex,\n resultsRef,\n onKeyDown,\n merged,\n sectionLabels,\n isSearching,\n navigate,\n}: SearchModalContentProps) {\n return (\n <StyledBackdrop\n $isClosing={isClosing}\n onClick={closeSearch}\n onAnimationEnd={onCloseAnimationEnd}\n >\n <StyledModal $isClosing={isClosing} onClick={(e) => e.stopPropagation()}>\n <StyledInputWrapper>\n <Search size={18} />\n <StyledInput\n autoFocus\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={onKeyDown}\n placeholder=\"Search docs...\"\n autoComplete=\"off\"\n spellCheck={false}\n />\n <StyledKbd>Esc</StyledKbd>\n </StyledInputWrapper>\n {merged.length > 0 ? (\n <StyledResults ref={resultsRef}>\n {merged.map((result, index) => (\n <StyledResultItem\n key={result.page.slug + result.page.section}\n $isActive={index === activeIndex}\n onClick={() => navigate(result.page.slug)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n <StyledResultTitle>{result.page.title}</StyledResultTitle>\n <StyledResultMeta>\n {result.page.section\n ? `${sectionLabels[result.page.section] || result.page.section} / `\n : \"\"}\n {result.page.category}\n </StyledResultMeta>\n {result.snippet && (\n <StyledSnippet\n dangerouslySetInnerHTML={{\n __html: highlightMatch(result.snippet, query),\n }}\n />\n )}\n </StyledResultItem>\n ))}\n </StyledResults>\n ) : (\n <StyledEmpty>\n {isSearching ? <Spinner size={18} /> : \"No results found\"}\n </StyledEmpty>\n )}\n </StyledModal>\n </StyledBackdrop>\n );\n}\n";
|
|
@@ -4,6 +4,7 @@ import styled, { css, keyframes } from "styled-components";
|
|
|
4
4
|
import { Search } from "lucide-react";
|
|
5
5
|
import { mq, Theme } from "@/app/theme";
|
|
6
6
|
import { Spinner } from "@/components/Spinner";
|
|
7
|
+
import { thinScrollbar } from "@/components/layout/SharedStyled";
|
|
7
8
|
|
|
8
9
|
export interface PageItem {
|
|
9
10
|
slug: string;
|
|
@@ -133,10 +134,7 @@ const StyledResults = styled.ul<{ theme: Theme }>\`
|
|
|
133
134
|
flex: 1;
|
|
134
135
|
min-height: 0;
|
|
135
136
|
-webkit-overflow-scrolling: touch;
|
|
136
|
-
|
|
137
|
-
&::-webkit-scrollbar {
|
|
138
|
-
display: none;
|
|
139
|
-
}
|
|
137
|
+
\${thinScrollbar};
|
|
140
138
|
\`;
|
|
141
139
|
|
|
142
140
|
const StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>\`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useState
|
|
1
|
+
export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useState } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space, ThemeToggle } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyledSidebarFooter,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\n\ntype NavItem = {\n label: string;\n links: NavItemLink[];\n};\n\ntype NavItemLink = {\n slug: string;\n title: string;\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n\n useLockBodyScroll(isMobileMenuOpen);\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n aria-label={\n isMobileMenuOpen ? \"Close navigation menu\" : \"Open navigation menu\"\n }\n aria-expanded={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>{item.label}</StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => {\n return (\n <StyledSidebarListItem key={indexChild}>\n <StyledSidebarListItemLink\n href={`/${link.slug}`}\n $isActive={pathname === `/${link.slug}`}\n onClick={() => setIsMobileMenuOpen(false)}\n >\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n })}\n <li aria-hidden=\"true\">\n <Space $size={20} />\n </li>\n </StyledSidebarList>\n );\n })}\n <StyledSidebarFooter>\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <ThemeToggle />\n </Flex>\n </StyledSidebarFooter>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const sideBarTemplate = `"use client";
|
|
2
|
-
import { useContext, useState
|
|
2
|
+
import { useContext, useState } from "react";
|
|
3
3
|
import { usePathname } from "next/navigation";
|
|
4
|
-
import { Flex, Space } from "cherry-styled-components";
|
|
4
|
+
import { Flex, Space, ThemeToggle } from "cherry-styled-components";
|
|
5
5
|
import {
|
|
6
6
|
DocsSidebar,
|
|
7
7
|
SectionBarContext,
|
|
@@ -14,10 +14,6 @@ import {
|
|
|
14
14
|
StyleMobileBar,
|
|
15
15
|
StyledMobileBurger,
|
|
16
16
|
} from "@/components/layout/DocsComponents";
|
|
17
|
-
import {
|
|
18
|
-
ToggleTheme,
|
|
19
|
-
ToggleThemeLoading,
|
|
20
|
-
} from "@/components/layout/ThemeToggle";
|
|
21
17
|
import { useLockBodyScroll } from "@/components/LockBodyScroll";
|
|
22
18
|
|
|
23
19
|
type NavItem = {
|
|
@@ -90,9 +86,7 @@ function SideBar({ result }: SideBarProps) {
|
|
|
90
86
|
})}
|
|
91
87
|
<StyledSidebarFooter>
|
|
92
88
|
<Flex $xsJustifyContent="flex-start" $lgJustifyContent="flex-end">
|
|
93
|
-
<
|
|
94
|
-
<ToggleTheme />
|
|
95
|
-
</Suspense>
|
|
89
|
+
<ThemeToggle />
|
|
96
90
|
</Flex>
|
|
97
91
|
</StyledSidebarFooter>
|
|
98
92
|
</StyledSidebar>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const actionBarTemplate = "\"use client\";\nimport { useContext, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport {
|
|
1
|
+
export declare const actionBarTemplate = "\"use client\";\nimport { useContext, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport {\n interactiveStyles,\n resetButton,\n Textarea,\n} from \"cherry-styled-components\";\nimport { SectionBarContext } from \"@/components/layout/DocsComponents\";\nimport { StyledSmallButton } from \"@/components/layout/SharedStyled\";\n\ninterface ActionBarProps {\n children: React.ReactNode;\n content: string;\n}\n\nconst StyledActionBar = styled.div<{\n theme: Theme;\n $isChatOpen?: boolean;\n}>`\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n left: 0;\n padding: 12px 0;\n display: flex;\n justify-content: space-between;\n width: 100%;\n max-width: 640px;\n margin: auto;\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n padding: 12px 0;\n }\n`;\n\nconst StyledActionBarContent = styled.div`\n margin: auto 0;\n`;\n\nconst StyledCopyButton = styled(StyledSmallButton)<{\n theme: Theme;\n $copied: boolean;\n}>`\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.grayLight};\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n }\n`;\n\n// Mirrors the geometry and interaction of Cherry's ThemeToggle so the two\n// pills look identical side by side: interactiveStyles supplies the border\n// highlight + focus/active rings (no scale effect), and the space-between\n// layout with 6px padding puts each 16px icon's center exactly where the\n// 24px knob's center sits in its resting (left: 2px) and active\n// (translateX(26px)) positions.\nconst StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>`\n ${resetButton}\n ${interactiveStyles}\n width: 56px;\n height: 30px;\n border-radius: 30px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 6px;\n position: relative;\n margin: auto 0;\n background: ${({ theme }) => theme.colors.light};\n border-color: ${({ theme }) => theme.colors.grayLight};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ $isActive }) =>\n !$isActive &&\n css`\n transform: translateX(26px);\n `}\n }\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover svg[stroke] {\n stroke: ${({ theme }) => theme.colors.accent};\n }\n`;\n\nconst StyledContent = styled.div<{\n theme: Theme;\n $hasSectionBar?: boolean;\n}>`\n padding-top: 20px;\n transition: all 0.3s ease;\n\n & textarea {\n max-width: 640px;\n margin: auto;\n width: 100%;\n height: 100%;\n min-height: calc(\n 100vh - ${({ $hasSectionBar }) => ($hasSectionBar ? 202 : 160)}px\n );\n\n ${mq(\"lg\")} {\n min-height: calc(100vh - 159px);\n }\n }\n`;\n\nfunction ActionBar({ children, content }: ActionBarProps) {\n const [isView, setIsView] = useState(true);\n const [copied, setCopied] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n\n const handleCopyContent = async () => {\n try {\n await navigator.clipboard.writeText(content);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy:\", err);\n }\n };\n\n return (\n <>\n <StyledActionBar>\n <StyledCopyButton onClick={handleCopyContent} $copied={copied}>\n {copied ? (\n <>\n <Icon name=\"check\" size={16} />\n Copied!\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={16} />\n Copy content\n </>\n )}\n </StyledCopyButton>\n <StyledActionBarContent>\n <StyledToggle\n onClick={() => setIsView(!isView)}\n aria-label=\"Toggle View\"\n $isActive={isView}\n >\n <Icon name=\"Eye\" />\n <Icon name=\"CodeXml\" />\n </StyledToggle>\n </StyledActionBarContent>\n </StyledActionBar>\n {isView && (\n <StyledContent $hasSectionBar={hasSectionBar}>{children}</StyledContent>\n )}\n {!isView && (\n <StyledContent $hasSectionBar={hasSectionBar}>\n <Textarea defaultValue={content} $fullWidth />\n </StyledContent>\n )}\n </>\n );\n}\n\nexport { ActionBar };\n";
|
|
@@ -3,7 +3,11 @@ import { useContext, useState } from "react";
|
|
|
3
3
|
import styled, { css } from "styled-components";
|
|
4
4
|
import { Icon } from "@/components/layout/Icon";
|
|
5
5
|
import { mq, Theme } from "@/app/theme";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
interactiveStyles,
|
|
8
|
+
resetButton,
|
|
9
|
+
Textarea,
|
|
10
|
+
} from "cherry-styled-components";
|
|
7
11
|
import { SectionBarContext } from "@/components/layout/DocsComponents";
|
|
8
12
|
import { StyledSmallButton } from "@/components/layout/SharedStyled";
|
|
9
13
|
|
|
@@ -51,23 +55,32 @@ const StyledCopyButton = styled(StyledSmallButton)<{
|
|
|
51
55
|
}
|
|
52
56
|
\`;
|
|
53
57
|
|
|
58
|
+
// Mirrors the geometry and interaction of Cherry's ThemeToggle so the two
|
|
59
|
+
// pills look identical side by side: interactiveStyles supplies the border
|
|
60
|
+
// highlight + focus/active rings (no scale effect), and the space-between
|
|
61
|
+
// layout with 6px padding puts each 16px icon's center exactly where the
|
|
62
|
+
// 24px knob's center sits in its resting (left: 2px) and active
|
|
63
|
+
// (translateX(26px)) positions.
|
|
54
64
|
const StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>\`
|
|
55
65
|
\${resetButton}
|
|
66
|
+
\${interactiveStyles}
|
|
56
67
|
width: 56px;
|
|
57
|
-
height:
|
|
68
|
+
height: 30px;
|
|
58
69
|
border-radius: 30px;
|
|
59
70
|
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
padding: 0 6px;
|
|
60
74
|
position: relative;
|
|
61
75
|
margin: auto 0;
|
|
62
|
-
transform: scale(1);
|
|
63
76
|
background: \${({ theme }) => theme.colors.light};
|
|
64
|
-
border:
|
|
77
|
+
border-color: \${({ theme }) => theme.colors.grayLight};
|
|
65
78
|
|
|
66
79
|
&::after {
|
|
67
80
|
content: "";
|
|
68
81
|
position: absolute;
|
|
69
|
-
top:
|
|
70
|
-
left:
|
|
82
|
+
top: 2px;
|
|
83
|
+
left: 2px;
|
|
71
84
|
width: 24px;
|
|
72
85
|
height: 24px;
|
|
73
86
|
border-radius: 50%;
|
|
@@ -78,7 +91,7 @@ const StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>\`
|
|
|
78
91
|
\${({ $isActive }) =>
|
|
79
92
|
!$isActive &&
|
|
80
93
|
css\`
|
|
81
|
-
transform: translateX(
|
|
94
|
+
transform: translateX(26px);
|
|
82
95
|
\`}
|
|
83
96
|
}
|
|
84
97
|
|
|
@@ -86,35 +99,17 @@ const StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>\`
|
|
|
86
99
|
width: 16px;
|
|
87
100
|
height: 16px;
|
|
88
101
|
object-fit: contain;
|
|
89
|
-
margin: auto;
|
|
90
102
|
transition: all 0.3s ease;
|
|
91
103
|
position: relative;
|
|
92
104
|
z-index: 2;
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
& .lucide-eye {
|
|
96
|
-
transform: translateX(1px);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
& .lucide-code-xml {
|
|
100
|
-
transform: translateX(-1px);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
107
|
& svg[stroke] {
|
|
104
108
|
stroke: \${({ theme }) => theme.colors.primary};
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
&:hover {
|
|
108
|
-
|
|
109
|
-
color: \${({ theme }) => theme.colors.accent};
|
|
110
|
-
|
|
111
|
-
& svg[stroke] {
|
|
112
|
-
stroke: \${({ theme }) => theme.colors.accent};
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
&:active {
|
|
117
|
-
transform: scale(0.97);
|
|
111
|
+
&:hover svg[stroke] {
|
|
112
|
+
stroke: \${({ theme }) => theme.colors.accent};
|
|
118
113
|
}
|
|
119
114
|
\`;
|
|
120
115
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const cherryThemeProviderTemplate = "import React from \"react\";\nimport { Theme } from \"@/app/theme\";\nimport {
|
|
1
|
+
export declare const cherryThemeProviderTemplate = "import React from \"react\";\nimport { ClientThemeProvider } from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { GlobalStyles } from \"@/components/layout/GlobalStyles\";\n\n/**\n * Wraps Cherry's ClientThemeProvider, which swaps theme/themeDark on toggle,\n * persists the choice to the `theme` cookie + localStorage, and keeps the\n * \"dark\" class on <html> in sync. Deliberately reads NO request data\n * (cookies/headers) so pages stay statically renderable \u2014 the blocking\n * theme-init script in the root layout sets the \"dark\" class before paint\n * and hides the body on dark visits; the provider reconciles against the\n * cookie on mount and removes the hiding style. $globalStyles is off because\n * this app ships its own GlobalStyles; $themeColor tracks \"light\", the\n * html/body background.\n */\nfunction CherryThemeProvider({\n children,\n theme,\n themeDark,\n}: {\n children: React.ReactNode;\n theme: Theme;\n themeDark: Theme;\n}) {\n return (\n <ClientThemeProvider\n theme={theme}\n themeDark={themeDark}\n $globalStyles={false}\n $themeColor=\"light\"\n >\n <GlobalStyles />\n {children}\n </ClientThemeProvider>\n );\n}\n\nexport { CherryThemeProvider };\n";
|
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
export const cherryThemeProviderTemplate = `import React from "react";
|
|
2
|
+
import { ClientThemeProvider } from "cherry-styled-components";
|
|
2
3
|
import { Theme } from "@/app/theme";
|
|
3
|
-
import {
|
|
4
|
+
import { GlobalStyles } from "@/components/layout/GlobalStyles";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Wraps Cherry's ClientThemeProvider, which swaps theme/themeDark on toggle,
|
|
8
|
+
* persists the choice to the \`theme\` cookie + localStorage, and keeps the
|
|
9
|
+
* "dark" class on <html> in sync. Deliberately reads NO request data
|
|
10
|
+
* (cookies/headers) so pages stay statically renderable — the blocking
|
|
11
|
+
* theme-init script in the root layout sets the "dark" class before paint
|
|
12
|
+
* and hides the body on dark visits; the provider reconciles against the
|
|
13
|
+
* cookie on mount and removes the hiding style. $globalStyles is off because
|
|
14
|
+
* this app ships its own GlobalStyles; $themeColor tracks "light", the
|
|
15
|
+
* html/body background.
|
|
16
|
+
*/
|
|
9
17
|
function CherryThemeProvider({
|
|
10
18
|
children,
|
|
11
19
|
theme,
|
|
20
|
+
themeDark,
|
|
12
21
|
}: {
|
|
13
22
|
children: React.ReactNode;
|
|
14
23
|
theme: Theme;
|
|
24
|
+
themeDark: Theme;
|
|
15
25
|
}) {
|
|
16
|
-
return
|
|
26
|
+
return (
|
|
27
|
+
<ClientThemeProvider
|
|
28
|
+
theme={theme}
|
|
29
|
+
themeDark={themeDark}
|
|
30
|
+
$globalStyles={false}
|
|
31
|
+
$themeColor="light"
|
|
32
|
+
>
|
|
33
|
+
<GlobalStyles />
|
|
34
|
+
{children}
|
|
35
|
+
</ClientThemeProvider>
|
|
36
|
+
);
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
export { CherryThemeProvider };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {
|
|
1
|
+
export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n interactiveStyles,\n resetButton,\n styledCode,\n} from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n border-color: rgba(255, 255, 255, 0.2);\n }\n`;\n\n/* Code block uses a fixed GitHub-style palette in both modes. Independent of\n theme.json so syntax highlighting stays legible regardless of brand colors.\n Dark variants live in :root.dark & blocks so the swap happens via the\n active <html> class with no re-render. */\nconst TopBar = styled.div<{ theme: Theme }>`\n background: #f6f8fa;\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px rgba(0, 0, 0, 0.1);\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n\n :root.dark & {\n background: #0d1117;\n border-bottom-color: rgba(255, 255, 255, 0.1);\n }\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n background: rgba(255, 255, 255, 0.1);\n }\n`;\n\n/* Icon-only copy button. interactiveStyles supplies the border highlight on\n hover plus the focus/active rings (no scale effect); the GitHub-style\n copied/base colors stay fixed like the rest of the code block. The dark\n block re-declares the hover border because its higher-specificity\n :root.dark & border-color would otherwise override the mixin's hover. */\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n ${resetButton}\n ${interactiveStyles}\n background: ${({ $copied }) =>\n $copied ? \"rgba(45, 164, 78, 0.1)\" : \"transparent\"};\n border-color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"rgba(0, 0, 0, 0.1)\")};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"#57606a\")};\n }\n\n :root.dark & {\n background: ${({ $copied }) =>\n $copied ? \"rgba(126, 231, 135, 0.2)\" : \"transparent\"};\n border-color: ${({ $copied }) =>\n $copied ? \"#7ee787\" : \"rgba(255, 255, 255, 0.1)\"};\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#7ee787\" : \"#c9d1d9\")};\n }\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n`;\n\n/* GitHub Light syntax highlighting by default; GitHub Dark in :root.dark.\n Browser resolves which rule wins based on the active <html> class with no\n JS or React re-render involved. */\nconst lightSyntaxHighlight = css`\n & .hljs {\n color: #24292f;\n background: #ffffff;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n & .hljs-subst {\n color: #24292f;\n }\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #953800;\n }\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n`;\n\nconst darkSyntaxHighlight = css`\n & .hljs {\n color: #c9d1d9;\n background: #0d1117;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n & .hljs-subst {\n color: #c9d1d9;\n }\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #f2cc60;\n }\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: #ffffff;\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: #24292f;\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100dvh - 400px);\n ${thinScrollbar};\n ${({ theme }) => styledCode(theme)};\n ${lightSyntaxHighlight};\n\n :root.dark & {\n background: #0d1117;\n color: #ffffff;\n ${darkSyntaxHighlight};\n }\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n};\n\nconst sanitizeLanguage = (lang: string): string =>\n lang.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const safeLang = sanitizeLanguage(language);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${safeLang}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({ code, language = \"javascript\", theme, className }: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = useMemo(\n () => highlightCode(code, language),\n [code, language],\n );\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code };\n";
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
export const codeTemplate = `"use client";
|
|
2
2
|
import { useState, useCallback, useMemo } from "react";
|
|
3
3
|
import styled, { css } from "styled-components";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
interactiveStyles,
|
|
6
|
+
resetButton,
|
|
7
|
+
styledCode,
|
|
8
|
+
} from "cherry-styled-components";
|
|
5
9
|
import { Theme } from "@/app/theme";
|
|
6
10
|
import { unified } from "unified";
|
|
7
11
|
import rehypeParse from "rehype-parse";
|
|
8
12
|
import rehypeHighlight from "rehype-highlight";
|
|
9
13
|
import rehypeStringify from "rehype-stringify";
|
|
10
14
|
import { Icon } from "@/components/layout/Icon";
|
|
15
|
+
import { thinScrollbar } from "@/components/layout/SharedStyled";
|
|
11
16
|
|
|
12
17
|
interface CodeProps extends Omit<
|
|
13
18
|
React.HTMLAttributes<HTMLDivElement>,
|
|
@@ -70,54 +75,40 @@ const Dot = styled.span<{ theme: Theme }>\`
|
|
|
70
75
|
}
|
|
71
76
|
\`;
|
|
72
77
|
|
|
78
|
+
/* Icon-only copy button. interactiveStyles supplies the border highlight on
|
|
79
|
+
hover plus the focus/active rings (no scale effect); the GitHub-style
|
|
80
|
+
copied/base colors stay fixed like the rest of the code block. The dark
|
|
81
|
+
block re-declares the hover border because its higher-specificity
|
|
82
|
+
:root.dark & border-color would otherwise override the mixin's hover. */
|
|
73
83
|
const CopyButton = styled.button<{ theme: Theme; $copied: boolean }>\`
|
|
84
|
+
\${resetButton}
|
|
85
|
+
\${interactiveStyles}
|
|
74
86
|
background: \${({ $copied }) =>
|
|
75
87
|
$copied ? "rgba(45, 164, 78, 0.1)" : "transparent"};
|
|
76
|
-
border:
|
|
77
|
-
\${({ $copied }) => ($copied ? "#2da44e" : "rgba(0, 0, 0, 0.1)")};
|
|
78
|
-
color: \${({ $copied }) => ($copied ? "#2da44e" : "#57606a")};
|
|
88
|
+
border-color: \${({ $copied }) => ($copied ? "#2da44e" : "rgba(0, 0, 0, 0.1)")};
|
|
79
89
|
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
80
|
-
padding: 4px
|
|
81
|
-
font-size: 12px;
|
|
82
|
-
font-family: \${({ theme }) => theme.fonts.mono};
|
|
83
|
-
cursor: pointer;
|
|
84
|
-
transition: all 0.2s ease;
|
|
90
|
+
padding: 4px;
|
|
85
91
|
display: flex;
|
|
86
92
|
align-items: center;
|
|
87
|
-
|
|
93
|
+
justify-content: center;
|
|
88
94
|
margin-right: -6px;
|
|
89
95
|
|
|
90
96
|
& svg.lucide {
|
|
91
97
|
color: \${({ $copied }) => ($copied ? "#2da44e" : "#57606a")};
|
|
92
98
|
}
|
|
93
99
|
|
|
94
|
-
&:hover {
|
|
95
|
-
background: \${({ $copied }) =>
|
|
96
|
-
$copied ? "rgba(45, 164, 78, 0.2)" : "rgba(0, 0, 0, 0.05)"};
|
|
97
|
-
border-color: \${({ $copied }) =>
|
|
98
|
-
$copied ? "#2da44e" : "rgba(0, 0, 0, 0.2)"};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
&:active {
|
|
102
|
-
transform: scale(0.95);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
100
|
:root.dark & {
|
|
106
101
|
background: \${({ $copied }) =>
|
|
107
102
|
$copied ? "rgba(126, 231, 135, 0.2)" : "transparent"};
|
|
108
103
|
border-color: \${({ $copied }) =>
|
|
109
104
|
$copied ? "#7ee787" : "rgba(255, 255, 255, 0.1)"};
|
|
110
|
-
color: \${({ $copied }) => ($copied ? "#7ee787" : "#c9d1d9")};
|
|
111
105
|
|
|
112
106
|
& svg.lucide {
|
|
113
107
|
color: \${({ $copied }) => ($copied ? "#7ee787" : "#c9d1d9")};
|
|
114
108
|
}
|
|
115
109
|
|
|
116
110
|
&:hover {
|
|
117
|
-
|
|
118
|
-
$copied ? "rgba(126, 231, 135, 0.3)" : "rgba(255, 255, 255, 0.1)"};
|
|
119
|
-
border-color: \${({ $copied }) =>
|
|
120
|
-
$copied ? "#7ee787" : "rgba(255, 255, 255, 0.2)"};
|
|
111
|
+
border-color: \${({ theme }) => theme.colors.primary};
|
|
121
112
|
}
|
|
122
113
|
}
|
|
123
114
|
\`;
|
|
@@ -296,6 +287,7 @@ const Body = styled.div<{ theme: Theme }>\`
|
|
|
296
287
|
overflow-x: auto;
|
|
297
288
|
overflow-y: auto;
|
|
298
289
|
max-height: calc(100dvh - 400px);
|
|
290
|
+
\${thinScrollbar};
|
|
299
291
|
\${({ theme }) => styledCode(theme)};
|
|
300
292
|
\${lightSyntaxHighlight};
|
|
301
293
|
|
|
@@ -363,18 +355,13 @@ function Code({ code, language = "javascript", theme, className }: CodeProps) {
|
|
|
363
355
|
<Dot theme={theme} />
|
|
364
356
|
<Dot theme={theme} />
|
|
365
357
|
</DotsContainer>
|
|
366
|
-
<CopyButton
|
|
367
|
-
{
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
<>
|
|
374
|
-
<Icon name="copy" size={12} />
|
|
375
|
-
<span>Copy</span>
|
|
376
|
-
</>
|
|
377
|
-
)}
|
|
358
|
+
<CopyButton
|
|
359
|
+
onClick={handleCopy}
|
|
360
|
+
$copied={copied}
|
|
361
|
+
theme={theme}
|
|
362
|
+
aria-label={copied ? "Copied" : "Copy code"}
|
|
363
|
+
>
|
|
364
|
+
<Icon name={copied ? "check" : "copy"} size={12} />
|
|
378
365
|
</CopyButton>
|
|
379
366
|
</TopBar>
|
|
380
367
|
<Body
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const demoThemeTemplate = "\"use client\";\nimport {
|
|
1
|
+
export declare const demoThemeTemplate = "\"use client\";\nimport { useContext } from \"react\";\nimport { useTheme } from \"styled-components\";\nimport { ThemeContext } from \"cherry-styled-components\";\nimport {\n buildColors,\n colorsLight,\n colorsDark,\n theme,\n themeDark,\n Theme,\n} from \"@/app/theme\";\nimport { Button } from \"@/components/layout/Button\";\nimport { Columns } from \"@/components/layout/Columns\";\n\ninterface DemoThemeProps {\n variant: \"purple\" | \"green\" | \"yellow\";\n}\n\ntype Palette = Record<string, string>;\n\n// Each preset is light + dark color overrides, mirroring what a user editing\n// theme.json would see. Applying one does two things: swaps in a theme object\n// rebuilt with the overridden palette (Cherry's setTheme, which also persists\n// the mode), and mirrors the overrides onto the --color-* CSS custom\n// properties on <html> for the plain-CSS consumers that read variables\n// (e.g. Callout's and Code's :root.dark blocks). Reset clears both so the\n// defaults take over again.\nconst PRESETS: Record<\n DemoThemeProps[\"variant\"],\n { light: Palette; dark: Palette; label: string }\n> = {\n purple: {\n label: \"Purple\",\n light: {\n primaryLight: \"#c4b5fd\",\n primary: \"#8b5cf6\",\n primaryDark: \"#5b21b6\",\n },\n dark: {\n primaryLight: \"#ddd6fe\",\n primary: \"#a78bfa\",\n primaryDark: \"#7c3aed\",\n },\n },\n green: {\n label: \"Green\",\n light: {\n primaryLight: \"#86efac\",\n primary: \"#22c55e\",\n primaryDark: \"#15803d\",\n },\n dark: {\n primaryLight: \"#6ee7b7\",\n primary: \"#10b981\",\n primaryDark: \"#065f46\",\n },\n },\n yellow: {\n label: \"Yellow\",\n light: {\n primaryLight: \"#fbbf24\",\n primary: \"#f59e0b\",\n primaryDark: \"#d97706\",\n },\n dark: {\n primaryLight: \"#fed7aa\",\n primary: \"#fb923c\",\n primaryDark: \"#ea580c\",\n },\n },\n};\n\nconst PRESET_KEYS = [\"primaryLight\", \"primary\", \"primaryDark\"] as const;\n\nfunction applyPresetVars(palette: Palette) {\n const root = document.documentElement;\n for (const key of PRESET_KEYS) {\n if (palette[key]) root.style.setProperty(`--color-${key}`, palette[key]);\n }\n}\n\nfunction clearPresetVars() {\n const root = document.documentElement;\n for (const key of PRESET_KEYS) {\n root.style.removeProperty(`--color-${key}`);\n }\n}\n\n// Rebuilds a mode's theme with the preset palette merged in, so the semantic\n// tokens (accent, accentStrong, \u2026) derive from the preset colors exactly like\n// they would from a theme.json override.\nfunction presetTheme(palette: Palette, dark: boolean): Theme {\n const base = dark ? themeDark : theme;\n const paletteBase = dark ? colorsDark : colorsLight;\n return {\n ...base,\n colors: buildColors({ ...paletteBase, ...palette }, dark),\n };\n}\n\nfunction DemoTheme({ variant }: DemoThemeProps) {\n const { setTheme } = useContext(ThemeContext);\n const activeTheme = useTheme() as Theme;\n const preset = PRESETS[variant];\n\n if (!preset) {\n return (\n <Columns cols={2}>\n <Button\n onClick={() => {\n clearPresetVars();\n setTheme(activeTheme.isDark ? themeDark : theme);\n }}\n fullWidth\n >\n Reset to Default\n </Button>\n </Columns>\n );\n }\n\n return (\n <Columns cols={2}>\n <Button\n onClick={() => {\n applyPresetVars(preset.light);\n setTheme(presetTheme(preset.light, false));\n }}\n icon=\"sun\"\n fullWidth\n >\n Demo {preset.label} Light\n </Button>\n <Button\n outline\n onClick={() => {\n applyPresetVars(preset.dark);\n setTheme(presetTheme(preset.dark, true));\n }}\n icon=\"moon-star\"\n fullWidth\n >\n Demo {preset.label} Dark\n </Button>\n </Columns>\n );\n}\n\nexport { DemoTheme };\n";
|