@barodoc/theme-docs 5.0.0 → 6.1.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.
@@ -0,0 +1,35 @@
1
+ import * as React from "react";
2
+ import mediumZoom from "medium-zoom";
3
+ import type { Zoom } from "medium-zoom";
4
+
5
+ interface ImageZoomProps extends React.ImgHTMLAttributes<HTMLImageElement> {
6
+ zoomSrc?: string;
7
+ }
8
+
9
+ export function ImageZoom({ zoomSrc, ...props }: ImageZoomProps) {
10
+ const imgRef = React.useRef<HTMLImageElement>(null);
11
+ const zoomRef = React.useRef<Zoom | null>(null);
12
+
13
+ React.useEffect(() => {
14
+ if (!imgRef.current) return;
15
+
16
+ zoomRef.current = mediumZoom(imgRef.current, {
17
+ margin: 24,
18
+ background: "var(--bd-bg)",
19
+ scrollOffset: 0,
20
+ });
21
+
22
+ return () => {
23
+ zoomRef.current?.detach();
24
+ };
25
+ }, []);
26
+
27
+ return (
28
+ <img
29
+ ref={imgRef}
30
+ data-zoom-src={zoomSrc}
31
+ className="bd-image-zoom"
32
+ {...props}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,71 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils.js";
3
+
4
+ interface VideoProps {
5
+ url: string;
6
+ title?: string;
7
+ caption?: string;
8
+ className?: string;
9
+ }
10
+
11
+ function parseVideoUrl(url: string): { provider: string; embedUrl: string } | null {
12
+ // YouTube: youtube.com/watch?v=ID, youtu.be/ID, youtube.com/embed/ID
13
+ const ytMatch = url.match(
14
+ /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
15
+ );
16
+ if (ytMatch) {
17
+ return {
18
+ provider: "youtube",
19
+ embedUrl: `https://www.youtube-nocookie.com/embed/${ytMatch[1]}?rel=0`,
20
+ };
21
+ }
22
+
23
+ // Vimeo: vimeo.com/ID, player.vimeo.com/video/ID
24
+ const vimeoMatch = url.match(/(?:vimeo\.com\/|player\.vimeo\.com\/video\/)(\d+)/);
25
+ if (vimeoMatch) {
26
+ return {
27
+ provider: "vimeo",
28
+ embedUrl: `https://player.vimeo.com/video/${vimeoMatch[1]}?dnt=1`,
29
+ };
30
+ }
31
+
32
+ // Loom: loom.com/share/ID, loom.com/embed/ID
33
+ const loomMatch = url.match(/loom\.com\/(?:share|embed)\/([a-f0-9]+)/);
34
+ if (loomMatch) {
35
+ return {
36
+ provider: "loom",
37
+ embedUrl: `https://www.loom.com/embed/${loomMatch[1]}`,
38
+ };
39
+ }
40
+
41
+ return null;
42
+ }
43
+
44
+ export function Video({ url, title, caption, className }: VideoProps) {
45
+ const parsed = parseVideoUrl(url);
46
+
47
+ if (!parsed) {
48
+ return (
49
+ <div className={cn("bd-video", className)} style={{ paddingTop: 0, padding: "2rem" }}>
50
+ <p style={{ color: "var(--bd-text-muted)", margin: 0, textAlign: "center" }}>
51
+ Unsupported video URL: {url}
52
+ </p>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <figure className={className}>
59
+ <div className="bd-video">
60
+ <iframe
61
+ src={parsed.embedUrl}
62
+ title={title || `${parsed.provider} video`}
63
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
64
+ allowFullScreen
65
+ loading="lazy"
66
+ />
67
+ </div>
68
+ {caption && <figcaption className="bd-video-caption">{caption}</figcaption>}
69
+ </figure>
70
+ );
71
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ import type { ThemeExport, ResolvedBarodocConfig } from "@barodoc/core";
3
3
  import mdx from "@astrojs/mdx";
4
4
  import react from "@astrojs/react";
5
5
  import tailwindcss from "@tailwindcss/vite";
6
+ import remarkMath from "remark-math";
7
+ import rehypeKatex from "rehype-katex";
6
8
 
7
9
  export interface DocsThemeOptions {
8
10
  customCss?: string[];
@@ -79,9 +81,33 @@ function createThemeIntegration(
79
81
  entrypoint: "@barodoc/theme-docs/pages/docs/[...slug].astro",
80
82
  });
81
83
 
84
+ // Blog routes
85
+ if (config?.blog?.enabled !== false) {
86
+ injectRoute({
87
+ pattern: "/blog",
88
+ entrypoint: "@barodoc/theme-docs/pages/blog/index.astro",
89
+ });
90
+ injectRoute({
91
+ pattern: "/blog/[...slug]",
92
+ entrypoint: "@barodoc/theme-docs/pages/blog/[...slug].astro",
93
+ });
94
+ }
95
+
96
+ // Changelog route
97
+ injectRoute({
98
+ pattern: "/changelog",
99
+ entrypoint: "@barodoc/theme-docs/pages/changelog/index.astro",
100
+ });
101
+
82
102
  // Update Astro config with integrations and Vite plugins
83
103
  updateConfig({
84
- integrations: [mdx(), react()],
104
+ integrations: [
105
+ mdx({
106
+ remarkPlugins: [remarkMath],
107
+ rehypePlugins: [rehypeKatex],
108
+ }),
109
+ react(),
110
+ ],
85
111
  vite: {
86
112
  plugins: [tailwindcss()],
87
113
  optimizeDeps: {
@@ -45,6 +45,9 @@ const themeCSS = config.theme?.colors ? generateThemeCSS(config.theme.colors) :
45
45
  <meta name="twitter:description" content={description} />
46
46
  {ogImageUrl && <meta name="twitter:image" content={ogImageUrl} />}
47
47
 
48
+ <!-- KaTeX math rendering -->
49
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css" crossorigin="anonymous" />
50
+
48
51
  <title>{title}</title>
49
52
  <ThemeScript />
50
53
  <ClientRouter />
@@ -0,0 +1,93 @@
1
+ ---
2
+ import BaseLayout from "./BaseLayout.astro";
3
+ import Header from "../components/Header.astro";
4
+ import Banner from "../components/Banner.astro";
5
+ import { defaultLocale } from "virtual:barodoc/i18n";
6
+ import { getLocaleFromPath } from "@barodoc/core";
7
+
8
+ interface Props {
9
+ title: string;
10
+ description?: string;
11
+ date?: Date;
12
+ author?: string;
13
+ image?: string;
14
+ tags?: string[];
15
+ readingTime?: string;
16
+ }
17
+
18
+ const { title, description, date, author, image, tags = [], readingTime } = Astro.props;
19
+ const currentPath = Astro.url.pathname;
20
+ const i18nConfig = { defaultLocale, locales: [defaultLocale] };
21
+ const currentLocale = getLocaleFromPath(currentPath, i18nConfig);
22
+ ---
23
+
24
+ <BaseLayout title={title} description={description} ogImage={image}>
25
+ <Banner />
26
+ <Header currentLocale={currentLocale} currentPath={currentPath} />
27
+
28
+ <div class="w-full min-w-0 min-h-[calc(100vh-3.5rem)] flex justify-center">
29
+ <article class="w-full max-w-[720px] px-4 py-10 sm:px-6 lg:px-8">
30
+ <!-- Blog post header -->
31
+ <header class="mb-10">
32
+ {tags.length > 0 && (
33
+ <div class="flex flex-wrap gap-2 mb-4">
34
+ {tags.map((tag) => (
35
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300">
36
+ {tag}
37
+ </span>
38
+ ))}
39
+ </div>
40
+ )}
41
+ <h1 class="text-3xl sm:text-4xl font-bold tracking-tight text-[var(--bd-text-heading)] leading-tight">
42
+ {title}
43
+ </h1>
44
+ {description && (
45
+ <p class="mt-3 text-lg text-[var(--bd-text-secondary)] leading-relaxed">
46
+ {description}
47
+ </p>
48
+ )}
49
+ <div class="mt-4 flex items-center gap-3 text-sm text-[var(--bd-text-muted)]">
50
+ {author && <span>{author}</span>}
51
+ {author && date && <span class="text-[var(--bd-border)]">&middot;</span>}
52
+ {date && (
53
+ <time datetime={date.toISOString()}>
54
+ {date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}
55
+ </time>
56
+ )}
57
+ {readingTime && (
58
+ <>
59
+ <span class="text-[var(--bd-border)]">&middot;</span>
60
+ <span>{readingTime}</span>
61
+ </>
62
+ )}
63
+ </div>
64
+ </header>
65
+
66
+ {image && (
67
+ <img
68
+ src={image}
69
+ alt={title}
70
+ class="w-full rounded-lg mb-10 bd-no-zoom"
71
+ loading="eager"
72
+ />
73
+ )}
74
+
75
+ <div class="prose prose-gray dark:prose-invert max-w-none">
76
+ <slot />
77
+ </div>
78
+
79
+ <!-- Back to blog -->
80
+ <div class="mt-12 pt-8 border-t border-[var(--bd-border)]">
81
+ <a
82
+ href="/blog"
83
+ class="inline-flex items-center gap-1.5 text-sm text-[var(--bd-text-secondary)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
84
+ >
85
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
86
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
87
+ </svg>
88
+ Back to Blog
89
+ </a>
90
+ </div>
91
+ </article>
92
+ </div>
93
+ </BaseLayout>
@@ -7,6 +7,8 @@ import MobileNav from "../components/MobileNav.astro";
7
7
  import CodeCopy from "../components/CodeCopy.astro";
8
8
  import Breadcrumb from "../components/Breadcrumb.astro";
9
9
  import Banner from "../components/Banner.astro";
10
+ import KeyboardShortcuts from "../components/KeyboardShortcuts.astro";
11
+ import Contributors from "../components/Contributors.astro";
10
12
  import { defaultLocale } from "virtual:barodoc/i18n";
11
13
  import { getLocaleFromPath } from "@barodoc/core";
12
14
  import config from "virtual:barodoc/config";
@@ -30,9 +32,11 @@ interface Props {
30
32
  editUrl?: string | null;
31
33
  lastUpdated?: Date | null;
32
34
  breadcrumbs?: BreadcrumbItem[];
35
+ readingTime?: string;
36
+ filePath?: string;
33
37
  }
34
38
 
35
- const { title, description, headings = [], prevPage, nextPage, editUrl, lastUpdated, breadcrumbs = [] } = Astro.props;
39
+ const { title, description, headings = [], prevPage, nextPage, editUrl, lastUpdated, breadcrumbs = [], readingTime, filePath } = Astro.props;
36
40
  const currentPath = Astro.url.pathname;
37
41
 
38
42
  // Get locale from path
@@ -61,6 +65,14 @@ const feedbackEndpoint = config.feedback?.endpoint;
61
65
  <main class="flex-1 min-w-0 max-w-[768px]">
62
66
  <div class="px-4 py-8 sm:px-6 sm:py-10 lg:px-10 lg:py-10">
63
67
  {breadcrumbs.length > 0 && <Breadcrumb items={breadcrumbs} />}
68
+ {readingTime && (
69
+ <div class="bd-reading-time mb-4">
70
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
71
+ <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
72
+ </svg>
73
+ <span>{readingTime}</span>
74
+ </div>
75
+ )}
64
76
  <article class="prose prose-gray dark:prose-invert max-w-none min-w-0 overflow-x-auto">
65
77
  <slot />
66
78
  </article>
@@ -111,6 +123,7 @@ const feedbackEndpoint = config.feedback?.endpoint;
111
123
 
112
124
  <!-- Page footer -->
113
125
  <footer class="mt-8 pt-6 border-t border-[var(--bd-border-subtle)] flex flex-col gap-3">
126
+ {filePath && <Contributors filePath={filePath} />}
114
127
  <div class="flex flex-wrap items-center justify-between gap-2 text-[13px] text-[var(--bd-text-muted)]">
115
128
  {lastUpdated && (
116
129
  <span>
@@ -166,6 +179,25 @@ const feedbackEndpoint = config.feedback?.endpoint;
166
179
  <!-- Code copy functionality -->
167
180
  <CodeCopy />
168
181
 
182
+ <!-- Keyboard shortcuts -->
183
+ <KeyboardShortcuts />
184
+
185
+ <!-- Image zoom for all prose images -->
186
+ <script>
187
+ async function initImageZoom() {
188
+ const images = document.querySelectorAll('.prose img:not(.bd-no-zoom):not(.medium-zoom-image)');
189
+ if (images.length === 0) return;
190
+ const { default: mediumZoom } = await import('medium-zoom');
191
+ mediumZoom(images as any, {
192
+ margin: 24,
193
+ background: 'var(--bd-bg)',
194
+ scrollOffset: 0,
195
+ });
196
+ }
197
+ initImageZoom();
198
+ document.addEventListener('astro:page-load', initImageZoom);
199
+ </script>
200
+
169
201
  <!-- Heading anchor links -->
170
202
  <script>
171
203
  function initHeadingAnchors() {
@@ -0,0 +1,39 @@
1
+ ---
2
+ import { getCollection } from "astro:content";
3
+ import BlogLayout from "../../layouts/BlogLayout.astro";
4
+ import readingTime from "reading-time";
5
+
6
+ export async function getStaticPaths() {
7
+ let posts: Awaited<ReturnType<typeof getCollection<"blog">>> = [];
8
+ try {
9
+ posts = await getCollection("blog");
10
+ } catch {
11
+ return [];
12
+ }
13
+
14
+ return posts.map((post) => ({
15
+ params: { slug: post.slug },
16
+ props: { post },
17
+ }));
18
+ }
19
+
20
+ interface Props {
21
+ post: Awaited<ReturnType<typeof getCollection<"blog">>>[number];
22
+ }
23
+
24
+ const { post } = Astro.props;
25
+ const { Content } = await post.render();
26
+ const readTime = readingTime(post.body ?? "");
27
+ ---
28
+
29
+ <BlogLayout
30
+ title={post.data.title}
31
+ description={post.data.description || post.data.excerpt}
32
+ date={post.data.date ? new Date(post.data.date) : undefined}
33
+ author={post.data.author}
34
+ image={post.data.image}
35
+ tags={post.data.tags}
36
+ readingTime={readTime.text}
37
+ >
38
+ <Content />
39
+ </BlogLayout>
@@ -0,0 +1,92 @@
1
+ ---
2
+ import { getCollection } from "astro:content";
3
+ import BaseLayout from "../../layouts/BaseLayout.astro";
4
+ import Header from "../../components/Header.astro";
5
+ import Banner from "../../components/Banner.astro";
6
+ import { defaultLocale } from "virtual:barodoc/i18n";
7
+ import { getLocaleFromPath } from "@barodoc/core";
8
+ import config from "virtual:barodoc/config";
9
+
10
+ const currentPath = Astro.url.pathname;
11
+ const i18nConfig = { defaultLocale, locales: [defaultLocale] };
12
+ const currentLocale = getLocaleFromPath(currentPath, i18nConfig);
13
+
14
+ let posts: Awaited<ReturnType<typeof getCollection<"blog">>> = [];
15
+ try {
16
+ posts = await getCollection("blog");
17
+ } catch {
18
+ // blog collection may not exist
19
+ }
20
+
21
+ const sortedPosts = posts.sort((a, b) => {
22
+ const dateA = a.data.date ? new Date(a.data.date).getTime() : 0;
23
+ const dateB = b.data.date ? new Date(b.data.date).getTime() : 0;
24
+ return dateB - dateA;
25
+ });
26
+ ---
27
+
28
+ <BaseLayout title={`Blog - ${config.name}`} description="Latest blog posts and updates">
29
+ <Banner />
30
+ <Header currentLocale={currentLocale} currentPath={currentPath} />
31
+
32
+ <div class="w-full min-w-0 min-h-[calc(100vh-3.5rem)] flex justify-center">
33
+ <div class="w-full max-w-[960px] px-4 py-10 sm:px-6 lg:px-8">
34
+ <header class="mb-10">
35
+ <h1 class="text-3xl font-bold tracking-tight text-[var(--bd-text-heading)]">Blog</h1>
36
+ <p class="mt-2 text-[var(--bd-text-secondary)]">Latest posts and updates</p>
37
+ </header>
38
+
39
+ {sortedPosts.length === 0 ? (
40
+ <p class="text-[var(--bd-text-muted)]">No posts yet.</p>
41
+ ) : (
42
+ <div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
43
+ {sortedPosts.map((post) => (
44
+ <a
45
+ href={`/blog/${post.slug}`}
46
+ class="group flex flex-col rounded-lg border border-[var(--bd-border)] hover:border-primary-400/50 dark:hover:border-primary-500/40 hover:shadow-[var(--bd-shadow-sm)] transition-all overflow-hidden"
47
+ >
48
+ {post.data.image && (
49
+ <div class="aspect-[16/9] overflow-hidden bg-[var(--bd-bg-subtle)]">
50
+ <img
51
+ src={post.data.image}
52
+ alt={post.data.title}
53
+ class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
54
+ loading="lazy"
55
+ />
56
+ </div>
57
+ )}
58
+ <div class="flex flex-col gap-2 p-5">
59
+ {post.data.tags && post.data.tags.length > 0 && (
60
+ <div class="flex flex-wrap gap-1.5">
61
+ {post.data.tags.slice(0, 3).map((tag: string) => (
62
+ <span class="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300">
63
+ {tag}
64
+ </span>
65
+ ))}
66
+ </div>
67
+ )}
68
+ <h2 class="text-base font-semibold text-[var(--bd-text)] group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors leading-snug">
69
+ {post.data.title}
70
+ </h2>
71
+ {post.data.excerpt && (
72
+ <p class="text-sm text-[var(--bd-text-muted)] line-clamp-2">
73
+ {post.data.excerpt}
74
+ </p>
75
+ )}
76
+ <div class="mt-auto pt-2 flex items-center gap-2 text-xs text-[var(--bd-text-muted)]">
77
+ {post.data.author && <span>{post.data.author}</span>}
78
+ {post.data.author && post.data.date && <span>&middot;</span>}
79
+ {post.data.date && (
80
+ <time datetime={new Date(post.data.date).toISOString()}>
81
+ {new Date(post.data.date).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}
82
+ </time>
83
+ )}
84
+ </div>
85
+ </div>
86
+ </a>
87
+ ))}
88
+ </div>
89
+ )}
90
+ </div>
91
+ </div>
92
+ </BaseLayout>
@@ -0,0 +1,72 @@
1
+ ---
2
+ import { getCollection } from "astro:content";
3
+ import BaseLayout from "../../layouts/BaseLayout.astro";
4
+ import Header from "../../components/Header.astro";
5
+ import Banner from "../../components/Banner.astro";
6
+ import { defaultLocale } from "virtual:barodoc/i18n";
7
+ import { getLocaleFromPath } from "@barodoc/core";
8
+ import config from "virtual:barodoc/config";
9
+
10
+ const currentPath = Astro.url.pathname;
11
+ const i18nConfig = { defaultLocale, locales: [defaultLocale] };
12
+ const currentLocale = getLocaleFromPath(currentPath, i18nConfig);
13
+
14
+ let entries: Awaited<ReturnType<typeof getCollection<"changelog">>> = [];
15
+ try {
16
+ entries = await getCollection("changelog");
17
+ } catch {
18
+ // changelog collection may not exist
19
+ }
20
+
21
+ const sorted = entries.sort((a, b) => {
22
+ return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
23
+ });
24
+ ---
25
+
26
+ <BaseLayout title={`Changelog - ${config.name}`} description="Release history and changelog">
27
+ <Banner />
28
+ <Header currentLocale={currentLocale} currentPath={currentPath} />
29
+
30
+ <div class="w-full min-w-0 min-h-[calc(100vh-3.5rem)] flex justify-center">
31
+ <div class="w-full max-w-[720px] px-4 py-10 sm:px-6 lg:px-8">
32
+ <header class="mb-10">
33
+ <h1 class="text-3xl font-bold tracking-tight text-[var(--bd-text-heading)]">Changelog</h1>
34
+ <p class="mt-2 text-[var(--bd-text-secondary)]">Release history and notable changes</p>
35
+ </header>
36
+
37
+ {sorted.length === 0 ? (
38
+ <p class="text-[var(--bd-text-muted)]">No changelog entries yet.</p>
39
+ ) : (
40
+ <div class="bd-changelog-timeline">
41
+ {sorted.map(async (entry) => {
42
+ const { Content } = await entry.render();
43
+ return (
44
+ <article class="bd-changelog-entry">
45
+ <div class="bd-changelog-marker">
46
+ <div class="bd-changelog-dot" />
47
+ <div class="bd-changelog-line" />
48
+ </div>
49
+ <div class="bd-changelog-content">
50
+ <div class="bd-changelog-header">
51
+ <span class="bd-changelog-version">{entry.data.version}</span>
52
+ <time class="bd-changelog-date" datetime={new Date(entry.data.date).toISOString()}>
53
+ {new Date(entry.data.date).toLocaleDateString("en-US", {
54
+ year: "numeric", month: "long", day: "numeric",
55
+ })}
56
+ </time>
57
+ </div>
58
+ {entry.data.title && (
59
+ <h2 class="bd-changelog-title">{entry.data.title}</h2>
60
+ )}
61
+ <div class="prose prose-sm prose-gray dark:prose-invert max-w-none bd-changelog-body">
62
+ <Content />
63
+ </div>
64
+ </div>
65
+ </article>
66
+ );
67
+ })}
68
+ </div>
69
+ )}
70
+ </div>
71
+ </div>
72
+ </BaseLayout>
@@ -4,6 +4,7 @@ import DocsLayout from "../../layouts/DocsLayout.astro";
4
4
  import config from "virtual:barodoc/config";
5
5
  import { defaultLocale, locales } from "virtual:barodoc/i18n";
6
6
  import { getLocalizedNavGroup } from "@barodoc/core";
7
+ import readingTime from "reading-time";
7
8
 
8
9
  export async function getStaticPaths() {
9
10
  const docs = await getCollection("docs");
@@ -41,6 +42,7 @@ interface Props {
41
42
 
42
43
  const { doc, locale, cleanSlug } = Astro.props;
43
44
  const { Content, headings } = await doc.render();
45
+ const readTime = readingTime(doc.body ?? "");
44
46
 
45
47
  // Find the category (navigation group) for this page
46
48
  function findCategory(slug: string): string | null {
@@ -122,6 +124,8 @@ breadcrumbs.push({ label: doc.data.title });
122
124
  editUrl={editUrl}
123
125
  lastUpdated={lastUpdated}
124
126
  breadcrumbs={breadcrumbs}
127
+ readingTime={readTime.text}
128
+ filePath={doc.id}
125
129
  >
126
130
  {category && (
127
131
  <p class="text-xs font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-3">