veslx 0.1.5 → 0.1.15
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/README.md +262 -55
- package/bin/lib/build.ts +65 -13
- package/bin/lib/import-config.ts +10 -9
- package/bin/lib/init.ts +21 -22
- package/bin/lib/serve.ts +66 -12
- package/bin/veslx.ts +2 -2
- package/dist/client/App.js +3 -9
- package/dist/client/App.js.map +1 -1
- package/dist/client/components/front-matter.js +11 -25
- package/dist/client/components/front-matter.js.map +1 -1
- package/dist/client/components/gallery/components/figure-caption.js +6 -4
- package/dist/client/components/gallery/components/figure-caption.js.map +1 -1
- package/dist/client/components/gallery/components/figure-header.js +3 -3
- package/dist/client/components/gallery/components/figure-header.js.map +1 -1
- package/dist/client/components/gallery/components/lightbox.js +13 -13
- package/dist/client/components/gallery/components/lightbox.js.map +1 -1
- package/dist/client/components/gallery/components/loading-image.js +11 -10
- package/dist/client/components/gallery/components/loading-image.js.map +1 -1
- package/dist/client/components/gallery/hooks/use-gallery-images.js +31 -7
- package/dist/client/components/gallery/hooks/use-gallery-images.js.map +1 -1
- package/dist/client/components/gallery/index.js +22 -15
- package/dist/client/components/gallery/index.js.map +1 -1
- package/dist/client/components/header.js +5 -3
- package/dist/client/components/header.js.map +1 -1
- package/dist/client/components/mdx-components.js +42 -8
- package/dist/client/components/mdx-components.js.map +1 -1
- package/dist/client/components/post-list.js +97 -90
- package/dist/client/components/post-list.js.map +1 -1
- package/dist/client/components/running-bar.js +1 -1
- package/dist/client/components/running-bar.js.map +1 -1
- package/dist/client/components/slide.js +18 -0
- package/dist/client/components/slide.js.map +1 -0
- package/dist/client/components/slides-renderer.js +7 -71
- package/dist/client/components/slides-renderer.js.map +1 -1
- package/dist/client/hooks/use-mdx-content.js +55 -9
- package/dist/client/hooks/use-mdx-content.js.map +1 -1
- package/dist/client/main.js +1 -0
- package/dist/client/main.js.map +1 -1
- package/dist/client/pages/content-router.js +19 -0
- package/dist/client/pages/content-router.js.map +1 -0
- package/dist/client/pages/home.js +11 -7
- package/dist/client/pages/home.js.map +1 -1
- package/dist/client/pages/post.js +8 -20
- package/dist/client/pages/post.js.map +1 -1
- package/dist/client/pages/slides.js +62 -86
- package/dist/client/pages/slides.js.map +1 -1
- package/dist/client/plugin/src/client.js +58 -96
- package/dist/client/plugin/src/client.js.map +1 -1
- package/dist/client/plugin/src/directory-tree.js +111 -0
- package/dist/client/plugin/src/directory-tree.js.map +1 -0
- package/index.html +1 -1
- package/package.json +34 -15
- package/plugin/src/client.tsx +64 -116
- package/plugin/src/directory-tree.ts +171 -0
- package/plugin/src/lib.ts +6 -249
- package/plugin/src/plugin.ts +93 -50
- package/plugin/src/remark-slides.ts +100 -0
- package/plugin/src/types.ts +22 -0
- package/src/App.tsx +3 -6
- package/src/components/front-matter.tsx +14 -29
- package/src/components/gallery/components/figure-caption.tsx +15 -7
- package/src/components/gallery/components/figure-header.tsx +3 -3
- package/src/components/gallery/components/lightbox.tsx +15 -13
- package/src/components/gallery/components/loading-image.tsx +15 -12
- package/src/components/gallery/hooks/use-gallery-images.ts +51 -10
- package/src/components/gallery/index.tsx +32 -26
- package/src/components/header.tsx +14 -9
- package/src/components/mdx-components.tsx +61 -8
- package/src/components/post-list.tsx +149 -115
- package/src/components/running-bar.tsx +1 -1
- package/src/components/slide.tsx +22 -5
- package/src/components/slides-renderer.tsx +7 -115
- package/src/components/welcome.tsx +11 -14
- package/src/hooks/use-mdx-content.ts +94 -9
- package/src/index.css +159 -0
- package/src/main.tsx +1 -0
- package/src/pages/content-router.tsx +27 -0
- package/src/pages/home.tsx +16 -2
- package/src/pages/post.tsx +10 -13
- package/src/pages/slides.tsx +75 -88
- package/src/vite-env.d.ts +7 -17
- package/vite.config.ts +25 -6
- package/dist/assets/README-NSyLDlyP.js +0 -7
- package/dist/assets/SLIDES-C12TOqNU.js +0 -10
- package/dist/assets/_virtual_content-modules-DK3Yb9K2.js +0 -2
- package/dist/assets/index-BUMwRZ7d.js +0 -468
- package/dist/assets/index-C8sJQuOZ.js +0 -1
- package/dist/assets/index-PspMxLnH.css +0 -1
- package/dist/index.html +0 -18
- package/dist/logo_dark.png +0 -0
- package/dist/logo_light.png +0 -0
- package/dist/raw/.veslx.json +0 -61
- package/dist/raw/README.md +0 -33
- package/dist/raw/test-post/Chart.tsx +0 -16
- package/dist/raw/test-post/README.mdx +0 -21
- package/dist/raw/test-slides/Counter.tsx +0 -25
- package/dist/raw/test-slides/SLIDES.mdx +0 -27
package/plugin/src/plugin.ts
CHANGED
|
@@ -1,13 +1,57 @@
|
|
|
1
1
|
import { type Plugin, type Connect } from 'vite'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import fs from 'fs'
|
|
4
|
-
import { buildAll } from './lib'
|
|
5
|
-
import chokidar, { type FSWatcher } from 'chokidar'
|
|
6
4
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
5
|
+
import { type VeslxConfig, type ResolvedSiteConfig, DEFAULT_SITE_CONFIG } from './types'
|
|
6
|
+
import matter from 'gray-matter'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract frontmatter from all MDX files in a directory
|
|
10
|
+
*/
|
|
11
|
+
function extractFrontmatters(dir: string): Record<string, { title?: string; description?: string; date?: string }> {
|
|
12
|
+
const frontmatters: Record<string, { title?: string; description?: string; date?: string }> = {};
|
|
13
|
+
|
|
14
|
+
function scanDir(currentDir: string, relativePath: string = '') {
|
|
15
|
+
if (!fs.existsSync(currentDir)) return;
|
|
16
|
+
|
|
17
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
20
|
+
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
21
|
+
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
// Skip hidden directories and node_modules
|
|
24
|
+
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
25
|
+
scanDir(fullPath, relPath);
|
|
26
|
+
}
|
|
27
|
+
} else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
|
|
28
|
+
try {
|
|
29
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
30
|
+
const { data } = matter(content);
|
|
31
|
+
// Use @content prefix to match glob keys
|
|
32
|
+
const key = `@content/${relPath}`;
|
|
33
|
+
frontmatters[key] = {
|
|
34
|
+
title: data.title,
|
|
35
|
+
description: data.description,
|
|
36
|
+
date: data.date instanceof Date ? data.date.toISOString() : data.date,
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
// Skip files that can't be parsed
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
scanDir(dir);
|
|
46
|
+
return frontmatters;
|
|
47
|
+
}
|
|
7
48
|
|
|
8
49
|
const VIRTUAL_MODULE_ID = 'virtual:content-modules'
|
|
9
50
|
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID
|
|
10
51
|
|
|
52
|
+
const VIRTUAL_CONFIG_ID = 'virtual:veslx-config'
|
|
53
|
+
const RESOLVED_VIRTUAL_CONFIG_ID = '\0' + VIRTUAL_CONFIG_ID
|
|
54
|
+
|
|
11
55
|
/**
|
|
12
56
|
* Recursively copy a directory
|
|
13
57
|
*/
|
|
@@ -27,7 +71,7 @@ function copyDirSync(src: string, dest: string) {
|
|
|
27
71
|
}
|
|
28
72
|
}
|
|
29
73
|
|
|
30
|
-
export default function contentPlugin(contentDir: string): Plugin {
|
|
74
|
+
export default function contentPlugin(contentDir: string, config?: VeslxConfig): Plugin {
|
|
31
75
|
|
|
32
76
|
if (!contentDir) {
|
|
33
77
|
throw new Error('Content directory must be specified.')
|
|
@@ -39,9 +83,11 @@ export default function contentPlugin(contentDir: string): Plugin {
|
|
|
39
83
|
|
|
40
84
|
const dir = contentDir
|
|
41
85
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
86
|
+
// Resolve site config with defaults
|
|
87
|
+
const siteConfig: ResolvedSiteConfig = {
|
|
88
|
+
...DEFAULT_SITE_CONFIG,
|
|
89
|
+
...config?.site,
|
|
90
|
+
}
|
|
45
91
|
|
|
46
92
|
// Server middleware for serving content files
|
|
47
93
|
const urlToDir = new Map<string, string>()
|
|
@@ -101,76 +147,73 @@ export default function contentPlugin(contentDir: string): Plugin {
|
|
|
101
147
|
},
|
|
102
148
|
},
|
|
103
149
|
optimizeDeps: {
|
|
104
|
-
exclude: ['virtual:content-modules'],
|
|
150
|
+
exclude: ['virtual:content-modules', 'virtual:veslx-config'],
|
|
105
151
|
},
|
|
106
152
|
}
|
|
107
153
|
},
|
|
108
154
|
|
|
109
|
-
// Virtual
|
|
155
|
+
// Virtual modules for content MDX imports and site config
|
|
110
156
|
resolveId(id) {
|
|
111
157
|
if (id === VIRTUAL_MODULE_ID) {
|
|
112
158
|
return RESOLVED_VIRTUAL_MODULE_ID
|
|
113
159
|
}
|
|
160
|
+
if (id === VIRTUAL_CONFIG_ID) {
|
|
161
|
+
return RESOLVED_VIRTUAL_CONFIG_ID
|
|
162
|
+
}
|
|
114
163
|
},
|
|
115
164
|
|
|
116
165
|
load(id) {
|
|
117
166
|
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
167
|
+
// Extract frontmatter from MDX files at build time (avoids MDX hook issues)
|
|
168
|
+
const frontmatterData = extractFrontmatters(dir);
|
|
169
|
+
|
|
118
170
|
// Generate virtual module with import.meta.glob for MDX files
|
|
119
171
|
return `
|
|
120
|
-
export const
|
|
121
|
-
|
|
122
|
-
|
|
172
|
+
export const posts = import.meta.glob('@content/**/*.mdx', {
|
|
173
|
+
import: 'default',
|
|
174
|
+
query: { skipSlides: true }
|
|
175
|
+
});
|
|
176
|
+
export const allMdx = import.meta.glob('@content/**/*.mdx');
|
|
177
|
+
export const slides = import.meta.glob(['@content/**/SLIDES.mdx', '@content/**/*.slides.mdx']);
|
|
178
|
+
|
|
179
|
+
// All files for directory tree building (web-compatible files only)
|
|
180
|
+
export const files = import.meta.glob([
|
|
181
|
+
'@content/**/*.mdx',
|
|
182
|
+
'@content/**/*.md',
|
|
183
|
+
'@content/**/*.tsx',
|
|
184
|
+
'@content/**/*.ts',
|
|
185
|
+
'@content/**/*.jsx',
|
|
186
|
+
'@content/**/*.js',
|
|
187
|
+
'@content/**/*.png',
|
|
188
|
+
'@content/**/*.jpg',
|
|
189
|
+
'@content/**/*.jpeg',
|
|
190
|
+
'@content/**/*.gif',
|
|
191
|
+
'@content/**/*.svg',
|
|
192
|
+
'@content/**/*.webp',
|
|
193
|
+
'@content/**/*.css',
|
|
194
|
+
], { eager: false });
|
|
195
|
+
|
|
196
|
+
// Frontmatter extracted at build time (no MDX execution required)
|
|
197
|
+
export const frontmatters = ${JSON.stringify(frontmatterData)};
|
|
198
|
+
|
|
199
|
+
// Legacy aliases for backwards compatibility
|
|
200
|
+
export const modules = import.meta.glob('@content/**/*.mdx');
|
|
123
201
|
`
|
|
124
202
|
}
|
|
203
|
+
if (id === RESOLVED_VIRTUAL_CONFIG_ID) {
|
|
204
|
+
// Generate virtual module with site config
|
|
205
|
+
return `export default ${JSON.stringify(siteConfig)};`
|
|
206
|
+
}
|
|
125
207
|
},
|
|
126
208
|
|
|
127
|
-
async buildStart() {
|
|
128
|
-
await buildFn()
|
|
129
|
-
},
|
|
130
209
|
configureServer(server) {
|
|
131
210
|
// Add middleware for serving content files
|
|
132
211
|
server.middlewares.use(middleware)
|
|
133
|
-
|
|
134
|
-
// Watch all content directories and rebuild on changes
|
|
135
|
-
watchers = [dir].map(dir => {
|
|
136
|
-
const watcher = chokidar.watch(dir, {
|
|
137
|
-
ignored: (path: string) => path.endsWith('.veslx.json'),
|
|
138
|
-
persistent: true,
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
watcher.on('change', async () => {
|
|
142
|
-
const runningFilePath = path.join(dir, '.running')
|
|
143
|
-
if (!fs.existsSync(runningFilePath)) {
|
|
144
|
-
await buildFn()
|
|
145
|
-
server.ws.send({
|
|
146
|
-
type: 'full-reload',
|
|
147
|
-
path: '*',
|
|
148
|
-
})
|
|
149
|
-
}
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
watcher.on('unlink', async (filePath) => {
|
|
153
|
-
if (path.basename(filePath) === '.running') {
|
|
154
|
-
await buildFn()
|
|
155
|
-
server.ws.send({
|
|
156
|
-
type: 'full-reload',
|
|
157
|
-
path: '*',
|
|
158
|
-
})
|
|
159
|
-
}
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
return watcher
|
|
163
|
-
})
|
|
164
|
-
|
|
165
212
|
},
|
|
166
213
|
configurePreviewServer(server) {
|
|
167
214
|
// Add middleware for preview server too
|
|
168
215
|
server.middlewares.use(middleware)
|
|
169
216
|
},
|
|
170
|
-
async buildEnd() {
|
|
171
|
-
await Promise.all(watchers.map(w => w.close()))
|
|
172
|
-
watchers = []
|
|
173
|
-
},
|
|
174
217
|
writeBundle(options) {
|
|
175
218
|
// Copy content directory to dist/raw during production build
|
|
176
219
|
const outDir = options.dir || 'dist'
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Root, Content } from 'mdast'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Remark plugin that transforms MDX content into slides.
|
|
5
|
+
* Splits content at thematic breaks (---) and wraps each section
|
|
6
|
+
* in a <Slide index={n}> component.
|
|
7
|
+
*
|
|
8
|
+
* Also exports `slideCount` from the MDX module.
|
|
9
|
+
*
|
|
10
|
+
* Note: This plugin should run AFTER remark-frontmatter so that
|
|
11
|
+
* YAML frontmatter is already extracted and won't be confused with
|
|
12
|
+
* slide breaks.
|
|
13
|
+
*/
|
|
14
|
+
export function remarkSlides() {
|
|
15
|
+
return (tree: Root) => {
|
|
16
|
+
const slides: Content[][] = [[]]
|
|
17
|
+
let frontmatterNode: Content | null = null
|
|
18
|
+
|
|
19
|
+
// Split children by thematic breaks (skip yaml/toml frontmatter nodes)
|
|
20
|
+
for (const node of tree.children) {
|
|
21
|
+
if (node.type === 'thematicBreak') {
|
|
22
|
+
// Start a new slide
|
|
23
|
+
slides.push([])
|
|
24
|
+
} else if (node.type === 'yaml' || node.type === 'toml') {
|
|
25
|
+
// Keep frontmatter to add back later
|
|
26
|
+
frontmatterNode = node
|
|
27
|
+
} else {
|
|
28
|
+
// Add to current slide
|
|
29
|
+
slides[slides.length - 1].push(node)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Filter out empty slides
|
|
34
|
+
const nonEmptySlides = slides.filter(slide => slide.length > 0)
|
|
35
|
+
|
|
36
|
+
// Build new tree with Slide wrappers
|
|
37
|
+
const newChildren: Content[] = []
|
|
38
|
+
|
|
39
|
+
// Preserve frontmatter at the top (for remark-mdx-frontmatter to process)
|
|
40
|
+
if (frontmatterNode) {
|
|
41
|
+
newChildren.push(frontmatterNode)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Add slideCount export
|
|
45
|
+
newChildren.push({
|
|
46
|
+
type: 'mdxjsEsm',
|
|
47
|
+
value: `export const slideCount = ${nonEmptySlides.length};`,
|
|
48
|
+
data: {
|
|
49
|
+
estree: {
|
|
50
|
+
type: 'Program',
|
|
51
|
+
sourceType: 'module',
|
|
52
|
+
body: [{
|
|
53
|
+
type: 'ExportNamedDeclaration',
|
|
54
|
+
declaration: {
|
|
55
|
+
type: 'VariableDeclaration',
|
|
56
|
+
kind: 'const',
|
|
57
|
+
declarations: [{
|
|
58
|
+
type: 'VariableDeclarator',
|
|
59
|
+
id: { type: 'Identifier', name: 'slideCount' },
|
|
60
|
+
init: { type: 'Literal', value: nonEmptySlides.length }
|
|
61
|
+
}]
|
|
62
|
+
},
|
|
63
|
+
specifiers: [],
|
|
64
|
+
source: null
|
|
65
|
+
}]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} as any)
|
|
69
|
+
|
|
70
|
+
// Wrap each slide's content in a Slide component
|
|
71
|
+
nonEmptySlides.forEach((slideContent, index) => {
|
|
72
|
+
// Opening <Slide index={n}>
|
|
73
|
+
newChildren.push({
|
|
74
|
+
type: 'mdxJsxFlowElement',
|
|
75
|
+
name: 'Slide',
|
|
76
|
+
attributes: [{
|
|
77
|
+
type: 'mdxJsxAttribute',
|
|
78
|
+
name: 'index',
|
|
79
|
+
value: {
|
|
80
|
+
type: 'mdxJsxAttributeValueExpression',
|
|
81
|
+
value: String(index),
|
|
82
|
+
data: {
|
|
83
|
+
estree: {
|
|
84
|
+
type: 'Program',
|
|
85
|
+
sourceType: 'module',
|
|
86
|
+
body: [{
|
|
87
|
+
type: 'ExpressionStatement',
|
|
88
|
+
expression: { type: 'Literal', value: index }
|
|
89
|
+
}]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}],
|
|
94
|
+
children: slideContent
|
|
95
|
+
} as any)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
tree.children = newChildren
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface SiteConfig {
|
|
2
|
+
name?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
github?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface VeslxConfig {
|
|
8
|
+
dir?: string;
|
|
9
|
+
site?: SiteConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResolvedSiteConfig {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
github: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_SITE_CONFIG: ResolvedSiteConfig = {
|
|
19
|
+
name: 'veslx',
|
|
20
|
+
description: '',
|
|
21
|
+
github: '',
|
|
22
|
+
};
|
package/src/App.tsx
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { BrowserRouter, Routes, Route } from "react-router-dom"
|
|
2
2
|
import { ThemeProvider } from "./components/theme-provider"
|
|
3
|
-
import {
|
|
4
|
-
import { Post } from "./pages/post"
|
|
5
|
-
import { SlidesPage } from "./pages/slides"
|
|
3
|
+
import { ContentRouter } from "./pages/content-router"
|
|
6
4
|
|
|
7
5
|
function App() {
|
|
8
6
|
return (
|
|
9
7
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
10
8
|
<BrowserRouter>
|
|
11
9
|
<Routes>
|
|
12
|
-
|
|
13
|
-
<Route path="
|
|
14
|
-
<Route path="/*" element={<Home />} />
|
|
10
|
+
{/* Single catch-all route - ContentRouter determines page type */}
|
|
11
|
+
<Route path="/*" element={<ContentRouter />} />
|
|
15
12
|
</Routes>
|
|
16
13
|
</BrowserRouter>
|
|
17
14
|
</ThemeProvider>
|
|
@@ -1,49 +1,34 @@
|
|
|
1
|
+
import { useMDXContent, useMDXSlides } from "@/hooks/use-mdx-content";
|
|
1
2
|
import { formatDate } from "@/lib/format-date"
|
|
2
|
-
import {
|
|
3
|
-
import { FileEntry } from "plugin/src/lib"
|
|
4
|
-
import { Link } from "react-router-dom"
|
|
3
|
+
import { useParams } from "react-router-dom"
|
|
5
4
|
|
|
6
|
-
export function FrontMatter({
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
title?: string
|
|
13
|
-
date?: string
|
|
14
|
-
description?: string
|
|
15
|
-
slides?: FileEntry | null
|
|
16
|
-
}){
|
|
5
|
+
export function FrontMatter(){
|
|
6
|
+
const { "path": path = "." } = useParams();
|
|
7
|
+
const { frontmatter: readmeFm } = useMDXContent(path);
|
|
8
|
+
const { frontmatter: slidesFm } = useMDXSlides(path);
|
|
9
|
+
|
|
10
|
+
let frontmatter = readmeFm || slidesFm;
|
|
17
11
|
|
|
18
12
|
return (
|
|
19
13
|
<div>
|
|
20
|
-
{title && (
|
|
14
|
+
{frontmatter?.title && (
|
|
21
15
|
<header className="not-prose flex flex-col gap-2 mb-8 pt-4">
|
|
22
16
|
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
|
23
|
-
{title}
|
|
17
|
+
{frontmatter?.title}
|
|
24
18
|
</h1>
|
|
25
19
|
|
|
26
20
|
{/* Meta line */}
|
|
27
21
|
<div className="flex flex-wrap items-center gap-3 text-muted-foreground">
|
|
28
|
-
{date && (
|
|
22
|
+
{frontmatter?.date && (
|
|
29
23
|
<time className="font-mono text-xs bg-muted px-2 py-0.5 rounded">
|
|
30
|
-
{formatDate(new Date(date as string))}
|
|
24
|
+
{formatDate(new Date(frontmatter.date as string))}
|
|
31
25
|
</time>
|
|
32
26
|
)}
|
|
33
|
-
{slides && (
|
|
34
|
-
<Link
|
|
35
|
-
to={`/${slides.path}`}
|
|
36
|
-
className="font-mono text-xs px-2 py-0.5 rounded flex items-center gap-1"
|
|
37
|
-
>
|
|
38
|
-
<Presentation className="h-3.5 w-3.5" />
|
|
39
|
-
<span>slides</span>
|
|
40
|
-
</Link>
|
|
41
|
-
)}
|
|
42
27
|
</div>
|
|
43
28
|
|
|
44
|
-
{description && (
|
|
29
|
+
{frontmatter?.description && (
|
|
45
30
|
<div className="flex flex-wrap text-sm items-center gap-3 text-muted-foreground">
|
|
46
|
-
{description}
|
|
31
|
+
{frontmatter?.description}
|
|
47
32
|
</div>
|
|
48
33
|
)}
|
|
49
34
|
</header>
|
|
@@ -4,12 +4,20 @@ export function FigureCaption({ caption, label }: { caption?: string; label?: st
|
|
|
4
4
|
if (!caption && !label) return null;
|
|
5
5
|
|
|
6
6
|
return (
|
|
7
|
-
<
|
|
8
|
-
<
|
|
9
|
-
{label &&
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
<figcaption className="px-[calc((var(--gallery-width)-var(--content-width))/2)] mt-4">
|
|
8
|
+
<p className="text-[13px] leading-[1.6] text-muted-foreground">
|
|
9
|
+
{label && (
|
|
10
|
+
<span className="font-semibold text-foreground tracking-tight">
|
|
11
|
+
{label}
|
|
12
|
+
{caption && <span className="font-normal mx-1.5">·</span>}
|
|
13
|
+
</span>
|
|
14
|
+
)}
|
|
15
|
+
{caption && (
|
|
16
|
+
<span className="text-muted-foreground/90">
|
|
17
|
+
{renderMathInText(caption)}
|
|
18
|
+
</span>
|
|
19
|
+
)}
|
|
20
|
+
</p>
|
|
21
|
+
</figcaption>
|
|
14
22
|
);
|
|
15
23
|
}
|
|
@@ -4,14 +4,14 @@ export function FigureHeader({ title, subtitle }: { title?: string; subtitle?: s
|
|
|
4
4
|
if (!title && !subtitle) return null;
|
|
5
5
|
|
|
6
6
|
return (
|
|
7
|
-
<div className="
|
|
7
|
+
<div className="px-[calc((var(--gallery-width)-var(--content-width))/2)] mb-4">
|
|
8
8
|
{title && (
|
|
9
|
-
<h3 className="text-
|
|
9
|
+
<h3 className="text-[15px] font-medium tracking-[-0.01em] text-foreground">
|
|
10
10
|
{renderMathInText(title)}
|
|
11
11
|
</h3>
|
|
12
12
|
)}
|
|
13
13
|
{subtitle && (
|
|
14
|
-
<p className="text-
|
|
14
|
+
<p className="text-[13px] text-muted-foreground/80 leading-relaxed mt-1">
|
|
15
15
|
{renderMathInText(subtitle)}
|
|
16
16
|
</p>
|
|
17
17
|
)}
|
|
@@ -31,25 +31,27 @@ export function Lightbox({
|
|
|
31
31
|
|
|
32
32
|
return createPortal(
|
|
33
33
|
<div
|
|
34
|
-
className="fixed inset-0 z-[9999] bg-background"
|
|
34
|
+
className="fixed inset-0 z-[9999] bg-background/98 backdrop-blur-md animate-fade-in-slow"
|
|
35
35
|
onClick={onClose}
|
|
36
36
|
{...{ [FULLSCREEN_DATA_ATTR]: "true" }}
|
|
37
37
|
style={{ top: 0, left: 0, right: 0, bottom: 0 }}
|
|
38
38
|
>
|
|
39
39
|
{/* Top bar */}
|
|
40
40
|
<div
|
|
41
|
-
className="fixed top-0 left-0 right-0 z-10 flex items-center justify-between px-
|
|
41
|
+
className="fixed top-0 left-0 right-0 z-10 flex items-center justify-between px-6 py-4"
|
|
42
42
|
onClick={(e) => e.stopPropagation()}
|
|
43
43
|
>
|
|
44
|
-
<div className="font-mono text-
|
|
45
|
-
{String(selectedIndex + 1).padStart(2, '0')}
|
|
44
|
+
<div className="font-mono text-[11px] text-muted-foreground/60 tabular-nums tracking-wider uppercase">
|
|
45
|
+
{String(selectedIndex + 1).padStart(2, '0')}
|
|
46
|
+
<span className="mx-1.5 text-muted-foreground/30">/</span>
|
|
47
|
+
{String(images.length).padStart(2, '0')}
|
|
46
48
|
</div>
|
|
47
49
|
<button
|
|
48
50
|
onClick={onClose}
|
|
49
|
-
className="p-2 text-muted-foreground hover:text-foreground transition-colors"
|
|
51
|
+
className="p-2 -m-2 text-muted-foreground/50 hover:text-foreground transition-colors duration-200"
|
|
50
52
|
aria-label="Close"
|
|
51
53
|
>
|
|
52
|
-
<X className="h-
|
|
54
|
+
<X className="h-4 w-4" strokeWidth={1.5} />
|
|
53
55
|
</button>
|
|
54
56
|
</div>
|
|
55
57
|
|
|
@@ -60,10 +62,10 @@ export function Lightbox({
|
|
|
60
62
|
e.stopPropagation();
|
|
61
63
|
onPrevious();
|
|
62
64
|
}}
|
|
63
|
-
className="fixed left-
|
|
65
|
+
className="fixed left-6 top-1/2 -translate-y-1/2 z-10 p-3 -m-3 text-muted-foreground/40 hover:text-foreground transition-colors duration-200"
|
|
64
66
|
aria-label="Previous image"
|
|
65
67
|
>
|
|
66
|
-
<ChevronLeft className="h-
|
|
68
|
+
<ChevronLeft className="h-6 w-6" strokeWidth={1.5} />
|
|
67
69
|
</button>
|
|
68
70
|
)}
|
|
69
71
|
|
|
@@ -74,10 +76,10 @@ export function Lightbox({
|
|
|
74
76
|
e.stopPropagation();
|
|
75
77
|
onNext();
|
|
76
78
|
}}
|
|
77
|
-
className="fixed right-
|
|
79
|
+
className="fixed right-6 top-1/2 -translate-y-1/2 z-10 p-3 -m-3 text-muted-foreground/40 hover:text-foreground transition-colors duration-200"
|
|
78
80
|
aria-label="Next image"
|
|
79
81
|
>
|
|
80
|
-
<ChevronRight className="h-
|
|
82
|
+
<ChevronRight className="h-6 w-6" strokeWidth={1.5} />
|
|
81
83
|
</button>
|
|
82
84
|
)}
|
|
83
85
|
|
|
@@ -86,17 +88,17 @@ export function Lightbox({
|
|
|
86
88
|
<img
|
|
87
89
|
src={current.src}
|
|
88
90
|
alt={current.label}
|
|
89
|
-
className="max-w-full max-h-full object-contain"
|
|
91
|
+
className="max-w-full max-h-full object-contain rounded-sm shadow-2xl"
|
|
90
92
|
onClick={(e) => e.stopPropagation()}
|
|
91
93
|
/>
|
|
92
94
|
</div>
|
|
93
95
|
|
|
94
96
|
{/* Caption */}
|
|
95
97
|
<div
|
|
96
|
-
className="fixed bottom-0 left-0 right-0 z-10
|
|
98
|
+
className="fixed bottom-0 left-0 right-0 z-10 px-6 py-5 text-center"
|
|
97
99
|
onClick={(e) => e.stopPropagation()}
|
|
98
100
|
>
|
|
99
|
-
<span className="font-mono text-
|
|
101
|
+
<span className="font-mono text-[11px] text-muted-foreground/50 tracking-wide">
|
|
100
102
|
{current.label}
|
|
101
103
|
</span>
|
|
102
104
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, ImgHTMLAttributes } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { ImageOff } from "lucide-react";
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
4
|
|
|
5
5
|
export function LoadingImage({
|
|
@@ -11,27 +11,30 @@ export function LoadingImage({
|
|
|
11
11
|
const [hasError, setHasError] = useState(false);
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<div className={cn("relative", wrapperClassName)}>
|
|
14
|
+
<div className={cn("relative overflow-hidden rounded-sm bg-muted/20", wrapperClassName)}>
|
|
15
15
|
{isLoading && !hasError && (
|
|
16
|
-
<div className="absolute inset-0
|
|
17
|
-
<div className="
|
|
16
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
17
|
+
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/40 to-transparent animate-shimmer" />
|
|
18
18
|
</div>
|
|
19
19
|
)}
|
|
20
20
|
{hasError && (
|
|
21
|
-
<div className="absolute inset-0 bg-muted/
|
|
22
|
-
<div className="text-center">
|
|
23
|
-
<
|
|
24
|
-
<span className="text-
|
|
21
|
+
<div className="absolute inset-0 bg-muted/10 flex items-center justify-center backdrop-blur-sm">
|
|
22
|
+
<div className="text-center space-y-1">
|
|
23
|
+
<ImageOff className="h-4 w-4 text-muted-foreground/30 mx-auto" strokeWidth={1.5} />
|
|
24
|
+
<span className="text-[10px] text-muted-foreground/30 block font-mono uppercase tracking-wider">
|
|
25
|
+
unavailable
|
|
26
|
+
</span>
|
|
25
27
|
</div>
|
|
26
28
|
</div>
|
|
27
29
|
)}
|
|
28
30
|
<img
|
|
29
31
|
{...props}
|
|
30
32
|
className={cn(
|
|
31
|
-
|
|
32
|
-
"transition-
|
|
33
|
-
isLoading
|
|
34
|
-
hasError && "opacity-0"
|
|
33
|
+
"w-full h-full",
|
|
34
|
+
"transition-all duration-500 ease-out",
|
|
35
|
+
isLoading ? "opacity-0 scale-[1.02]" : "opacity-100 scale-100",
|
|
36
|
+
hasError && "opacity-0",
|
|
37
|
+
className
|
|
35
38
|
)}
|
|
36
39
|
onLoad={(e) => {
|
|
37
40
|
setIsLoading(false);
|