@marlinjai/clearify 1.5.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/bin/clearify.js +2 -0
  4. package/dist/node/chunk-5TD7NQIW.js +25 -0
  5. package/dist/node/chunk-B2Q23JW3.js +55 -0
  6. package/dist/node/chunk-CQ4MNGBE.js +301 -0
  7. package/dist/node/chunk-GFD54GNO.js +223 -0
  8. package/dist/node/chunk-IBK35HZR.js +194 -0
  9. package/dist/node/chunk-L24ILRSX.js +125 -0
  10. package/dist/node/chunk-NXQNNLGC.js +395 -0
  11. package/dist/node/chunk-PRTER35L.js +48 -0
  12. package/dist/node/chunk-SCZZB7OE.js +9 -0
  13. package/dist/node/chunk-V7LLYIRO.js +8 -0
  14. package/dist/node/chunk-WT5W333R.js +136 -0
  15. package/dist/node/cli/index.d.ts +2 -0
  16. package/dist/node/cli/index.js +41 -0
  17. package/dist/node/core/config.d.ts +9 -0
  18. package/dist/node/core/config.js +16 -0
  19. package/dist/node/core/mermaid-renderer.d.ts +22 -0
  20. package/dist/node/core/mermaid-renderer.js +125 -0
  21. package/dist/node/core/mermaid-utils.d.ts +3 -0
  22. package/dist/node/core/mermaid-utils.js +6 -0
  23. package/dist/node/core/navigation.d.ts +2 -0
  24. package/dist/node/core/navigation.js +13 -0
  25. package/dist/node/core/openapi-parser.d.ts +14 -0
  26. package/dist/node/core/openapi-parser.js +6 -0
  27. package/dist/node/core/remark-mermaid.d.ts +10 -0
  28. package/dist/node/core/remark-mermaid.js +11 -0
  29. package/dist/node/core/search.d.ts +31 -0
  30. package/dist/node/core/search.js +6 -0
  31. package/dist/node/node/build.d.ts +3 -0
  32. package/dist/node/node/build.js +14 -0
  33. package/dist/node/node/check.d.ts +3 -0
  34. package/dist/node/node/check.js +10 -0
  35. package/dist/node/node/index.d.ts +11 -0
  36. package/dist/node/node/index.js +108 -0
  37. package/dist/node/node/init.d.ts +6 -0
  38. package/dist/node/node/init.js +6 -0
  39. package/dist/node/presets/nestjs.d.ts +15 -0
  40. package/dist/node/presets/nestjs.js +98 -0
  41. package/dist/node/types/index.d.ts +79 -0
  42. package/dist/node/types/index.js +6 -0
  43. package/dist/node/vite-plugin/index.d.ts +13 -0
  44. package/dist/node/vite-plugin/index.js +11 -0
  45. package/package.json +94 -0
  46. package/src/client/App.tsx +101 -0
  47. package/src/client/Page.tsx +15 -0
  48. package/src/client/entry-server.tsx +79 -0
  49. package/src/client/index.html +18 -0
  50. package/src/client/main.tsx +11 -0
  51. package/src/theme/CodeBlock.tsx +103 -0
  52. package/src/theme/Content.tsx +32 -0
  53. package/src/theme/Footer.tsx +53 -0
  54. package/src/theme/Head.tsx +80 -0
  55. package/src/theme/HeadContext.tsx +32 -0
  56. package/src/theme/Header.tsx +177 -0
  57. package/src/theme/Layout.tsx +44 -0
  58. package/src/theme/MDXComponents.tsx +40 -0
  59. package/src/theme/NotFound.tsx +246 -0
  60. package/src/theme/Search.tsx +359 -0
  61. package/src/theme/Sidebar.tsx +325 -0
  62. package/src/theme/TableOfContents.tsx +153 -0
  63. package/src/theme/ThemeProvider.tsx +77 -0
  64. package/src/theme/components/Accordion.tsx +109 -0
  65. package/src/theme/components/Badge.tsx +72 -0
  66. package/src/theme/components/Breadcrumbs.tsx +88 -0
  67. package/src/theme/components/Callout.tsx +115 -0
  68. package/src/theme/components/Card.tsx +103 -0
  69. package/src/theme/components/CodeGroup.tsx +79 -0
  70. package/src/theme/components/Columns.tsx +42 -0
  71. package/src/theme/components/Frame.tsx +55 -0
  72. package/src/theme/components/Mermaid.tsx +99 -0
  73. package/src/theme/components/MermaidStatic.tsx +32 -0
  74. package/src/theme/components/OpenAPI.tsx +160 -0
  75. package/src/theme/components/OpenAPIPage.tsx +16 -0
  76. package/src/theme/components/Steps.tsx +76 -0
  77. package/src/theme/components/Tabs.tsx +75 -0
  78. package/src/theme/components/Tooltip.tsx +108 -0
  79. package/src/theme/components/index.ts +14 -0
  80. package/src/theme/styles/globals.css +363 -0
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "@marlinjai/clearify",
3
+ "version": "1.5.0",
4
+ "description": "An open-source documentation site generator. Turn markdown into beautiful docs.",
5
+ "type": "module",
6
+ "bin": {
7
+ "clearify": "./bin/clearify.js"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/node/types/index.d.ts",
12
+ "import": "./dist/node/types/index.js"
13
+ },
14
+ "./config": {
15
+ "types": "./dist/node/core/config.d.ts",
16
+ "import": "./dist/node/core/config.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "dist",
22
+ "src/client",
23
+ "src/theme"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev:self": "node bin/clearify.js dev"
28
+ },
29
+ "dependencies": {
30
+ "@mdx-js/react": "^3.1.1",
31
+ "@mdx-js/rollup": "^3.1.1",
32
+ "@scalar/api-reference-react": "^0.8.52",
33
+ "@shikijs/rehype": "^3.22.0",
34
+ "@tailwindcss/typography": "^0.5.19",
35
+ "@tailwindcss/vite": "^4.1.18",
36
+ "@vitejs/plugin-react": "^4.3.4",
37
+ "autoprefixer": "^10.4.24",
38
+ "cac": "^6.7.14",
39
+ "clsx": "^2.1.1",
40
+ "flexsearch": "^0.8.212",
41
+ "globby": "^16.1.0",
42
+ "gray-matter": "^4.0.3",
43
+ "mermaid": "^11.0.0",
44
+ "postcss": "^8.5.6",
45
+ "react-router-dom": "^7.13.0",
46
+ "remark-frontmatter": "^5.0.0",
47
+ "remark-gfm": "^4.0.1",
48
+ "remark-mdx-frontmatter": "^5.2.0",
49
+ "shiki": "^3.22.0",
50
+ "tailwindcss": "^4.1.18",
51
+ "unist-util-visit": "^5.0.0",
52
+ "vite": "^6.1.0",
53
+ "zod": "^4.3.6"
54
+ },
55
+ "peerDependencies": {
56
+ "puppeteer": ">=22.0.0",
57
+ "react": "^18.0.0 || ^19.0.0",
58
+ "react-dom": "^18.0.0 || ^19.0.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "puppeteer": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "devDependencies": {
66
+ "@types/node": "^25.2.2",
67
+ "@types/react": "^18.3.18",
68
+ "@types/react-dom": "^18.3.5",
69
+ "puppeteer": "^24.37.2",
70
+ "tsup": "^8.3.6",
71
+ "typescript": "^5.7.3"
72
+ },
73
+ "license": "MIT",
74
+ "author": "MarlinJai",
75
+ "repository": {
76
+ "type": "git",
77
+ "url": "https://github.com/marlinjai/clearify.git"
78
+ },
79
+ "homepage": "https://github.com/marlinjai/clearify",
80
+ "bugs": {
81
+ "url": "https://github.com/marlinjai/clearify/issues"
82
+ },
83
+ "keywords": [
84
+ "documentation",
85
+ "docs",
86
+ "markdown",
87
+ "mdx",
88
+ "static-site-generator",
89
+ "vite",
90
+ "react",
91
+ "mermaid",
92
+ "openapi"
93
+ ]
94
+ }
@@ -0,0 +1,101 @@
1
+ import React, { useEffect, useState, useRef } from 'react';
2
+ import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
3
+ import { MDXProvider } from '@mdx-js/react';
4
+ // @ts-expect-error virtual module
5
+ import routes from 'virtual:clearify/routes';
6
+ // @ts-expect-error virtual module
7
+ import config from 'virtual:clearify/config';
8
+ // @ts-expect-error virtual module
9
+ import sections from 'virtual:clearify/navigation';
10
+ import { Layout } from '../theme/Layout.js';
11
+ import { Head } from '../theme/Head.js';
12
+ import { NotFound } from '../theme/NotFound.js';
13
+ import { mdxComponents } from '../theme/MDXComponents.js';
14
+ import '../theme/styles/globals.css';
15
+
16
+ interface RouteEntry {
17
+ path: string;
18
+ component: () => Promise<any>;
19
+ frontmatter: { title?: string; description?: string };
20
+ }
21
+
22
+ function PageWrapper({ loader, fallbackFrontmatter }: { loader: () => Promise<any>; fallbackFrontmatter: any }) {
23
+ const location = useLocation();
24
+ const [page, setPage] = useState<{ Component: React.ComponentType; fm: any } | null>(null);
25
+ const isInitialRender = useRef(true);
26
+
27
+ useEffect(() => {
28
+ let cancelled = false;
29
+
30
+ // On SSR-hydrated pages, the initial route's content is already visible.
31
+ // Still load the module so subsequent SPA navigations work, but don't
32
+ // flash "Loading..." by clearing state only on navigation changes.
33
+ if (isInitialRender.current) {
34
+ isInitialRender.current = false;
35
+ // Load eagerly but don't clear existing DOM content
36
+ loader().then((mod) => {
37
+ if (cancelled) return;
38
+ setPage({
39
+ Component: mod.default,
40
+ fm: mod.frontmatter ?? fallbackFrontmatter,
41
+ });
42
+ });
43
+ return () => { cancelled = true; };
44
+ }
45
+
46
+ setPage(null);
47
+ loader().then((mod) => {
48
+ if (cancelled) return;
49
+ setPage({
50
+ Component: mod.default,
51
+ fm: mod.frontmatter ?? fallbackFrontmatter,
52
+ });
53
+ });
54
+ return () => { cancelled = true; };
55
+ }, [location.pathname]);
56
+
57
+ if (!page) {
58
+ // If we're hydrating an SSR page, don't show loading — the HTML is already there.
59
+ // The browser will display the server-rendered content until React hydrates.
60
+ const isSSR = typeof document !== 'undefined' && document.getElementById('root')?.dataset.clearifySsr;
61
+ if (isSSR && isInitialRender.current) {
62
+ return null;
63
+ }
64
+ return <div style={{ padding: '2rem', color: 'var(--clearify-text-secondary)' }}>Loading...</div>;
65
+ }
66
+
67
+ const { Component, fm } = page;
68
+ return (
69
+ <article>
70
+ <Head title={fm?.title} description={fm?.description} url={location.pathname} />
71
+ <Component />
72
+ </article>
73
+ );
74
+ }
75
+
76
+ function AppRoutes() {
77
+ return (
78
+ <Layout config={config} sections={sections}>
79
+ <Routes>
80
+ {(routes as RouteEntry[]).map((route) => (
81
+ <Route
82
+ key={route.path}
83
+ path={route.path}
84
+ element={<PageWrapper loader={route.component} fallbackFrontmatter={route.frontmatter} />}
85
+ />
86
+ ))}
87
+ <Route path="*" element={<NotFound />} />
88
+ </Routes>
89
+ </Layout>
90
+ );
91
+ }
92
+
93
+ export function App() {
94
+ return (
95
+ <BrowserRouter>
96
+ <MDXProvider components={mdxComponents}>
97
+ <AppRoutes />
98
+ </MDXProvider>
99
+ </BrowserRouter>
100
+ );
101
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+
3
+ interface PageProps {
4
+ content: React.ComponentType;
5
+ frontmatter: { title?: string; description?: string };
6
+ }
7
+
8
+ export function Page({ content: Content, frontmatter }: PageProps) {
9
+ return (
10
+ <article>
11
+ {frontmatter.title && <h1>{frontmatter.title}</h1>}
12
+ <Content />
13
+ </article>
14
+ );
15
+ }
@@ -0,0 +1,79 @@
1
+ import React from 'react';
2
+ import { renderToString } from 'react-dom/server';
3
+ import { MemoryRouter, Routes, Route } from 'react-router';
4
+ import { MDXProvider } from '@mdx-js/react';
5
+ // @ts-expect-error virtual module
6
+ import routes from 'virtual:clearify/routes';
7
+ // @ts-expect-error virtual module
8
+ import config from 'virtual:clearify/config';
9
+ // @ts-expect-error virtual module
10
+ import sections from 'virtual:clearify/navigation';
11
+ import { Layout } from '../theme/Layout.js';
12
+ import { Head } from '../theme/Head.js';
13
+ import { mdxComponents } from '../theme/MDXComponents.js';
14
+ import { HeadProvider, type HeadData } from '../theme/HeadContext.js';
15
+ import '../theme/styles/globals.css';
16
+
17
+ interface RouteEntry {
18
+ path: string;
19
+ component: () => Promise<any>;
20
+ frontmatter: { title?: string; description?: string };
21
+ }
22
+
23
+ export async function render(url: string): Promise<{ html: string; head: HeadData }> {
24
+ // Find the matching route and pre-load its module
25
+ const allRoutes = routes as RouteEntry[];
26
+ // Prefer exact match over catch-all
27
+ const matchedRoute =
28
+ allRoutes.find((r) => r.path === url) ??
29
+ allRoutes.find((r) => {
30
+ if (!r.path.endsWith('/*')) return false;
31
+ const base = r.path.slice(0, -2);
32
+ return url === base || url.startsWith(base + '/');
33
+ });
34
+
35
+ let Component: React.ComponentType = () => null;
36
+ let fm: { title?: string; description?: string } = {};
37
+
38
+ if (matchedRoute) {
39
+ const mod = await matchedRoute.component();
40
+ Component = mod.default;
41
+ fm = mod.frontmatter ?? matchedRoute.frontmatter;
42
+ }
43
+
44
+ // Collect head data during render
45
+ let headData: HeadData = {
46
+ title: config.name,
47
+ description: `${config.name} documentation`,
48
+ url,
49
+ siteName: config.name,
50
+ };
51
+
52
+ const onCollect = (data: HeadData) => {
53
+ headData = data;
54
+ };
55
+
56
+ const html = renderToString(
57
+ <HeadProvider onCollect={onCollect}>
58
+ <MemoryRouter initialEntries={[url]}>
59
+ <MDXProvider components={mdxComponents}>
60
+ <Layout config={config} sections={sections}>
61
+ <Routes>
62
+ <Route
63
+ path={matchedRoute?.path ?? url}
64
+ element={
65
+ <article>
66
+ <Head title={fm.title} description={fm.description} url={url} />
67
+ <Component />
68
+ </article>
69
+ }
70
+ />
71
+ </Routes>
72
+ </Layout>
73
+ </MDXProvider>
74
+ </MemoryRouter>
75
+ </HeadProvider>
76
+ );
77
+
78
+ return { html, head: headData };
79
+ }
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="generator" content="Clearify" />
7
+ <!--clearify-head-->
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400;1,500&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
11
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>" />
12
+ <title>Documentation</title>
13
+ </head>
14
+ <body>
15
+ <div id="root"><!--clearify-outlet--></div>
16
+ <script type="module" src="./main.tsx"></script>
17
+ </body>
18
+ </html>
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import { hydrateRoot, createRoot } from 'react-dom/client';
3
+ import { App } from './App.js';
4
+
5
+ const container = document.getElementById('root')!;
6
+
7
+ if (container.dataset.clearifySsr) {
8
+ hydrateRoot(container, <App />);
9
+ } else {
10
+ createRoot(container).render(<App />);
11
+ }
@@ -0,0 +1,103 @@
1
+ import React, { useState, useRef } from 'react';
2
+
3
+ export function CodeBlock({ children, className, ...props }: React.HTMLAttributes<HTMLPreElement>) {
4
+ const [copied, setCopied] = useState(false);
5
+ const preRef = useRef<HTMLPreElement>(null);
6
+
7
+ const lang = className
8
+ ?.split(' ')
9
+ .find((c) => c.startsWith('language-'))
10
+ ?.replace('language-', '') ?? '';
11
+
12
+ const handleCopy = async () => {
13
+ const text = preRef.current?.textContent ?? '';
14
+ await navigator.clipboard.writeText(text);
15
+ setCopied(true);
16
+ setTimeout(() => setCopied(false), 2000);
17
+ };
18
+
19
+ return (
20
+ <div style={{ position: 'relative', marginBottom: '1.25rem' }} className="clearify-codeblock">
21
+ {lang && (
22
+ <div
23
+ style={{
24
+ position: 'absolute',
25
+ top: '0.625rem',
26
+ right: '3.5rem',
27
+ fontSize: '0.6875rem',
28
+ color: 'var(--clearify-text-tertiary)',
29
+ textTransform: 'uppercase',
30
+ letterSpacing: '0.05em',
31
+ userSelect: 'none',
32
+ fontFamily: 'var(--font-mono)',
33
+ fontWeight: 500,
34
+ }}
35
+ >
36
+ {lang}
37
+ </div>
38
+ )}
39
+ <button
40
+ onClick={handleCopy}
41
+ style={{
42
+ position: 'absolute',
43
+ top: '0.5rem',
44
+ right: '0.5rem',
45
+ background: 'var(--clearify-bg)',
46
+ border: '1px solid var(--clearify-border)',
47
+ borderRadius: 'var(--clearify-radius-sm)',
48
+ padding: '0.25rem 0.625rem',
49
+ cursor: 'pointer',
50
+ fontSize: '0.6875rem',
51
+ fontWeight: 500,
52
+ color: copied ? 'var(--clearify-primary)' : 'var(--clearify-text-tertiary)',
53
+ transition: 'all 0.15s ease',
54
+ fontFamily: 'var(--font-sans)',
55
+ }}
56
+ className="clearify-copy-btn"
57
+ aria-label="Copy code"
58
+ >
59
+ {copied ? (
60
+ <span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
61
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
62
+ <polyline points="20 6 9 17 4 12" />
63
+ </svg>
64
+ Copied
65
+ </span>
66
+ ) : (
67
+ <span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
68
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
69
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
70
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
71
+ </svg>
72
+ Copy
73
+ </span>
74
+ )}
75
+ </button>
76
+ <pre
77
+ ref={preRef}
78
+ className={className}
79
+ style={{
80
+ backgroundColor: 'var(--clearify-bg-secondary)',
81
+ border: '1px solid var(--clearify-border)',
82
+ borderRadius: 'var(--clearify-radius)',
83
+ padding: '1rem 1.25rem',
84
+ overflow: 'auto',
85
+ fontSize: '0.8125rem',
86
+ lineHeight: 1.75,
87
+ fontFamily: 'var(--font-mono)',
88
+ }}
89
+ {...props}
90
+ >
91
+ {children}
92
+ </pre>
93
+
94
+ <style>{`
95
+ .clearify-copy-btn:hover {
96
+ border-color: var(--clearify-border-strong) !important;
97
+ color: var(--clearify-text) !important;
98
+ background: var(--clearify-bg-secondary) !important;
99
+ }
100
+ `}</style>
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { Breadcrumbs } from './components/Breadcrumbs.js';
3
+
4
+ interface ContentProps {
5
+ children: React.ReactNode;
6
+ }
7
+
8
+ export function Content({ children }: ContentProps) {
9
+ return (
10
+ <div
11
+ className="clearify-prose"
12
+ style={{
13
+ flex: 1,
14
+ minWidth: 0,
15
+ padding: '2.5rem 3rem',
16
+ maxWidth: 'var(--clearify-content-max)',
17
+ animation: 'clearify-fade-in 0.3s ease-out',
18
+ }}
19
+ >
20
+ <Breadcrumbs />
21
+ {children}
22
+
23
+ <style>{`
24
+ @media (max-width: 768px) {
25
+ .clearify-prose {
26
+ padding: 1.5rem 1.25rem !important;
27
+ }
28
+ }
29
+ `}</style>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+
3
+ interface FooterProps {
4
+ name: string;
5
+ links?: { github?: string; [key: string]: string | undefined };
6
+ }
7
+
8
+ export function Footer({ name, links }: FooterProps) {
9
+ return (
10
+ <footer
11
+ style={{
12
+ borderTop: '1px solid var(--clearify-border)',
13
+ padding: '2rem 2.5rem',
14
+ fontSize: '0.8125rem',
15
+ color: 'var(--clearify-text-tertiary)',
16
+ display: 'flex',
17
+ justifyContent: 'space-between',
18
+ alignItems: 'center',
19
+ flexWrap: 'wrap',
20
+ gap: '1rem',
21
+ }}
22
+ >
23
+ <div style={{ letterSpacing: '-0.01em' }}>
24
+ &copy; {new Date().getFullYear()} {name}.{' '}
25
+ <span style={{ opacity: 0.6 }}>Built with Clearify.</span>
26
+ </div>
27
+ {links && (
28
+ <div style={{ display: 'flex', gap: '1.25rem' }}>
29
+ {Object.entries(links).map(([key, url]) =>
30
+ url ? (
31
+ <a
32
+ key={key}
33
+ href={url}
34
+ target="_blank"
35
+ rel="noopener noreferrer"
36
+ style={{
37
+ color: 'var(--clearify-text-tertiary)',
38
+ textDecoration: 'none',
39
+ transition: 'color 0.15s',
40
+ fontSize: '0.8125rem',
41
+ }}
42
+ onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--clearify-text-secondary)')}
43
+ onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--clearify-text-tertiary)')}
44
+ >
45
+ {key.charAt(0).toUpperCase() + key.slice(1)}
46
+ </a>
47
+ ) : null
48
+ )}
49
+ </div>
50
+ )}
51
+ </footer>
52
+ );
53
+ }
@@ -0,0 +1,80 @@
1
+ import { useEffect } from 'react';
2
+ // @ts-expect-error virtual module
3
+ import config from 'virtual:clearify/config';
4
+ import { useHeadContext } from './HeadContext.js';
5
+
6
+ interface HeadProps {
7
+ title?: string;
8
+ description?: string;
9
+ url?: string;
10
+ }
11
+
12
+ export function Head({ title, description, url }: HeadProps) {
13
+ const headCtx = useHeadContext();
14
+
15
+ // SSR mode: push data to collector, no DOM access
16
+ if (headCtx) {
17
+ const pageTitle = title ? `${title} | ${config.name}` : config.name;
18
+ const desc = description ?? `${config.name} documentation`;
19
+ headCtx.collect({
20
+ title: pageTitle,
21
+ description: desc,
22
+ url: url ?? '',
23
+ siteName: config.name,
24
+ });
25
+ return null;
26
+ }
27
+
28
+ // Client mode: useEffect DOM mutations (existing behavior)
29
+ useEffect(() => {
30
+ const pageTitle = title ? `${title} | ${config.name}` : config.name;
31
+ document.title = pageTitle;
32
+
33
+ const desc = description ?? `${config.name} documentation`;
34
+
35
+ // Update meta description
36
+ let metaDesc = document.querySelector('meta[name="description"]');
37
+ if (!metaDesc) {
38
+ metaDesc = document.createElement('meta');
39
+ metaDesc.setAttribute('name', 'description');
40
+ document.head.appendChild(metaDesc);
41
+ }
42
+ metaDesc.setAttribute('content', desc);
43
+
44
+ // Update OG tags
45
+ setMeta('og:title', pageTitle);
46
+ setMeta('og:description', desc);
47
+ setMeta('og:type', 'article');
48
+ setMeta('og:site_name', config.name);
49
+
50
+ // Update Twitter tags
51
+ setMeta('twitter:card', 'summary', 'name');
52
+ setMeta('twitter:title', pageTitle, 'name');
53
+ setMeta('twitter:description', desc, 'name');
54
+
55
+ // Update canonical link
56
+ if (url) {
57
+ const siteUrl = config.siteUrl?.replace(/\/$/, '') ?? '';
58
+ const canonical = siteUrl ? `${siteUrl}${url}` : url;
59
+ let link = document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null;
60
+ if (!link) {
61
+ link = document.createElement('link');
62
+ link.setAttribute('rel', 'canonical');
63
+ document.head.appendChild(link);
64
+ }
65
+ link.setAttribute('href', canonical);
66
+ }
67
+ }, [title, description, url]);
68
+
69
+ return null;
70
+ }
71
+
72
+ function setMeta(key: string, content: string, attr: 'property' | 'name' = 'property') {
73
+ let el = document.querySelector(`meta[${attr}="${key}"]`);
74
+ if (!el) {
75
+ el = document.createElement('meta');
76
+ el.setAttribute(attr, key);
77
+ document.head.appendChild(el);
78
+ }
79
+ el.setAttribute('content', content);
80
+ }
@@ -0,0 +1,32 @@
1
+ import React, { createContext, useContext } from 'react';
2
+
3
+ export interface HeadData {
4
+ title: string;
5
+ description: string;
6
+ url: string;
7
+ siteName: string;
8
+ }
9
+
10
+ interface HeadContextValue {
11
+ collect: (data: HeadData) => void;
12
+ }
13
+
14
+ const HeadContext = createContext<HeadContextValue | null>(null);
15
+
16
+ export function useHeadContext() {
17
+ return useContext(HeadContext);
18
+ }
19
+
20
+ export function HeadProvider({
21
+ children,
22
+ onCollect,
23
+ }: {
24
+ children: React.ReactNode;
25
+ onCollect: (data: HeadData) => void;
26
+ }) {
27
+ return (
28
+ <HeadContext.Provider value={{ collect: onCollect }}>
29
+ {children}
30
+ </HeadContext.Provider>
31
+ );
32
+ }