Initial commit with stock files
40
.assetsignore.template
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Cloudflare Workers Assets Ignore File
|
||||
# This file is copied to dist/.assetsignore during build
|
||||
# See: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
|
||||
|
||||
# Dependencies (shouldn't be in dist, but exclude for safety)
|
||||
**/node_modules
|
||||
**/.pnpm-store
|
||||
|
||||
# OS files
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/ehthumbs.db
|
||||
|
||||
# Git files (shouldn't be in dist, but exclude for safety)
|
||||
**/.git
|
||||
**/.gitignore
|
||||
|
||||
# Environment files
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.production
|
||||
|
||||
# Editor files
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.suo
|
||||
**/*.ntvs*
|
||||
**/*.njsproj
|
||||
**/*.sln
|
||||
**/*.sw?
|
||||
|
||||
# Temporary files
|
||||
**/*.tmp
|
||||
**/*.temp
|
||||
**/.temp
|
||||
|
||||
# Logs
|
||||
**/*.log
|
||||
**/*.log.*
|
||||
|
||||
7
.config/astro/config.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"notifiedAt": "1756406630228",
|
||||
"anonymousId": "82b7175d26b171e9f07d9a80e388260f202e2356cbfbe75a95e2377580689494"
|
||||
}
|
||||
}
|
||||
3
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
cli/ export-ignore
|
||||
.github/ export-ignore
|
||||
AGENTS.md export-ignore
|
||||
50
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
cli/node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.temp/
|
||||
.ref/
|
||||
|
||||
# OS generated files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Obsidian workspace files
|
||||
src/content/.obsidian/workspace.json
|
||||
src/content/.obsidian/workspace-mobile.json
|
||||
obsidian_askpass.sh
|
||||
|
||||
# Generated deployment platform files (GitHub Pages + Cloudflare Workers only)
|
||||
public/_redirects
|
||||
public/_headers
|
||||
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 David V. Kimball
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
176
README.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Astro Modular
|
||||
|
||||
A flexible [Astro](https://astro.build) blog theme designed for [Obsidian](https://obsidian.md) users.
|
||||
|
||||
[](https://astro.build/themes/details/astro-modular/)
|
||||
|
||||
## Made with Vault CMS
|
||||
|
||||
Use Obsidian as a CMS with [Vault CMS](https://github.com/davidvkimball/vault-cms).
|
||||
|
||||

|
||||
|
||||
## Stats
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
### Status
|
||||
[](https://app.netlify.com/sites/astro-modular/deploys)
|
||||
[](https://github.com/davidvkimball/astro-modular)
|
||||
[](https://github.com/davidvkimball/astro-modular)
|
||||
|
||||
### Tech Stack
|
||||
[](https://astro.build/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://tailwindcss.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://pnpm.io/)
|
||||
[](https://obsidian.md/)
|
||||
|
||||
## Why Astro Modular?
|
||||
|
||||
- **Obsidian As A CMS**: Works with Obsidian out of the box (built upon [Vault CMS](https://github.com/davidvkimball/vault-cms)), write and publish instantly
|
||||
- **Highly Customizable**: Every feature can be enabled/disabled independently
|
||||
- **Performance-Focused**: Assets are highly optimized for lightning-fast loading
|
||||
- **SEO-Ready**: Automatic sitemap, RSS feed, and Open Graph image generation
|
||||
|
||||
## Features
|
||||
|
||||
- [x] **Custom Themes**
|
||||
- [x] **Feature Toggle Control**
|
||||
- [x] **Dark/Light Mode**
|
||||
- [x] **Search & Command Palette**
|
||||
- [x] **Wikilinks & Linked Mentions**
|
||||
- [x] **Obsidian-Style Callouts**
|
||||
- [x] **Obsidian Embeds Files**
|
||||
- [x] **Folder-Based Posts**
|
||||
- [x] **Multiple Content Types**
|
||||
- [x] **Image Optimization**
|
||||
- [x] **Automatic Feed Generation**
|
||||
- [x] **Table of Contents**
|
||||
- [x] **Smooth Scroll & Page Transitions**
|
||||
- [x] **Image Gallery & Lightbox**
|
||||
- [x] **Reading Time & Word Count**
|
||||
- [x] **Tagging**
|
||||
- [x] **Graph View**
|
||||
- [x] **Mermaid Diagrams**
|
||||
- [x] **LaTeX & Math Rendering**
|
||||
- [x] **Custom Typography**
|
||||
- [x] **Profile Picture**
|
||||
- [x] **Comments**
|
||||
|
||||
## Quick Start
|
||||
|
||||
[](https://app.netlify.com/start/deploy?repository=https://github.com/davidvkimball/astro-modular)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/davidvkimball/astro-modular)
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/davidvkimball/astro-modular)
|
||||
|
||||
### Get the Code
|
||||
|
||||
Choose one of the following methods to get started:
|
||||
|
||||
#### Option 1: CLI (Recommended)
|
||||
|
||||
```bash
|
||||
pnpm create astro-modular my-blog
|
||||
cd my-blog
|
||||
```
|
||||
|
||||
[](https://www.npmjs.com/package/create-astro-modular)
|
||||
|
||||
This downloads the latest template, removes dev-only files, and installs dependencies automatically. Also works with `npm create astro-modular my-blog`.
|
||||
|
||||
#### Option 2: Use GitHub Template
|
||||
|
||||
1. Click the **"Use this template"** button on the [repository page](https://github.com/davidvkimball/astro-modular)
|
||||
2. Select **"Create a new repository"**
|
||||
3. Choose your repository name and settings
|
||||
4. Click **"Create repository from template"**
|
||||
5. Clone your new repository:
|
||||
```bash
|
||||
git clone https://github.com/your-username/your-repo-name.git
|
||||
cd your-repo-name
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 24.13.0+
|
||||
- pnpm 10.29.3+ or npm 9.6.5+
|
||||
|
||||
> [!NOTE]
|
||||
> While this theme works great with any markdown editor, it's specifically optimized for Obsidian use. See the [Astro Suite Vault Guide](src/content/posts/vault-cms-guide.md) for Obsidian-specific features.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install pnpm (if you don't have it):**
|
||||
```bash
|
||||
npm install -g pnpm
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Preview:**
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
Your blog will be available at `http://localhost:5000`
|
||||
|
||||
4. **Build for production:**
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Updating the theme
|
||||
|
||||
To pull the latest theme release (framework files, Astro, config) while keeping your content and assets:
|
||||
|
||||
```bash
|
||||
pnpm run update
|
||||
```
|
||||
|
||||
This downloads the latest release from GitHub, replaces framework files, restores your `src/content` and profile/favicon assets, then runs `pnpm install`. After updating, open Obsidian and use **Astro Modular Settings → Apply all settings** so your saved settings are written to the new `config.ts` if needed.
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `src/config.ts` to customize your site - change the theme, enable/disable features, and configure all settings.
|
||||
|
||||
## Deployment
|
||||
|
||||
Set your deployment platform once in `src/config.ts` under `deployment.platform` ("netlify", "vercel", "github-pages", or "cloudflare-workers"). The build process automatically generates the correct configuration files for your chosen platform - no environment variables needed!
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed guides, see the included blog posts:
|
||||
- **[Getting Started](src/content/posts/getting-started.md)** - complete setup and workflow guide
|
||||
- **[Formatting Reference](src/content/posts/formatting-reference.md)** - comprehensive formatting reference
|
||||
- **[Vault CMS Guide](src/content/posts/vault-cms-guide.md)** - Obsidian vault walkthrough
|
||||
- **[Obsidian Embeds Demo](src/content/posts/obsidian-embeds-demo.md)** - Obsidian embed examples
|
||||
|
||||
**For AI Agents & Developers:** See [AGENTS.md](AGENTS.md) for comprehensive technical documentation.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#davidvkimball/astro-modular&type=date&legend=top-left)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
[Spaceship Astro theme](https://github.com/aitorllj93/astro-theme-spaceship) for the graph view functionality upon which this theme's is based.
|
||||
|
||||
[ogImage.click](https://ogimage.click/) to generate the open graph image used for this theme.
|
||||
|
||||
## Contributing
|
||||
|
||||
This is an open-source project. Feel free to submit feature requests, report bugs, or contribute improvements.
|
||||
|
||||
If you are contributing code back to the theme itself, please fork the repository and submit a Pull Request. **Do not fork the repository if you are just building your own site from this template.**
|
||||
|
||||
## License
|
||||
|
||||
[MIT License](https://github.com/davidvkimball/astro-modular?tab=MIT-1-ov-file)
|
||||
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.8.6
|
||||
225
astro.config.mjs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import { remarkInternalLinks, remarkFolderImages, remarkImageCaptions } from './src/utils/internallinks.ts';
|
||||
import remarkCallouts from './src/utils/remark-callouts.ts';
|
||||
import remarkImageGrids from './src/utils/remark-image-grids.ts';
|
||||
import remarkMermaid from './src/utils/remark-mermaid.ts';
|
||||
import { remarkObsidianEmbeds } from './src/utils/remark-obsidian-embeds.ts';
|
||||
import remarkBases from './src/utils/remark-bases.ts';
|
||||
import remarkInlineTags from './src/utils/remark-inline-tags.ts';
|
||||
import { remarkObsidianComments } from './src/utils/remark-obsidian-comments.ts';
|
||||
import remarkObsidianImageSize from './src/utils/remark-obsidian-image-size.ts';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkReadingTime from 'remark-reading-time';
|
||||
import remarkToc from 'remark-toc';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeMark from './src/utils/rehype-mark.ts';
|
||||
import rehypeImageAttributes from './src/utils/rehype-image-attributes.ts';
|
||||
import { rehypeNormalizeAnchors } from './src/utils/rehype-normalize-anchors.ts';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import { siteConfig } from './src/config.ts';
|
||||
import swup from '@swup/astro';
|
||||
import refreshContentOnChange from './src/integrations/refresh-content-on-change.ts';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Deployment platform configuration
|
||||
const DEPLOYMENT_PLATFORM = process.env.DEPLOYMENT_PLATFORM || 'netlify';
|
||||
|
||||
export default defineConfig({
|
||||
site: siteConfig.site,
|
||||
deployment: {
|
||||
platform: DEPLOYMENT_PLATFORM
|
||||
},
|
||||
csp: {
|
||||
scriptDirective: {
|
||||
resources: [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"https://unpkg.com",
|
||||
"https://cdnjs.cloudflare.com",
|
||||
"https://cdn.jsdelivr.net",
|
||||
"https://giscus.app",
|
||||
"https://platform.twitter.com"
|
||||
]
|
||||
},
|
||||
styleDirective: {
|
||||
resources: [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"https://fonts.googleapis.com",
|
||||
"https://cdnjs.cloudflare.com"
|
||||
]
|
||||
},
|
||||
fontDirective: {
|
||||
resources: [
|
||||
"'self'",
|
||||
"data:",
|
||||
"https://fonts.gstatic.com",
|
||||
"https://cdnjs.cloudflare.com"
|
||||
]
|
||||
},
|
||||
imgDirective: {
|
||||
resources: ["'self'", "data:", "https:"]
|
||||
},
|
||||
connectDirective: {
|
||||
resources: ["'self'", "https://giscus.app"]
|
||||
},
|
||||
frameDirective: {
|
||||
resources: [
|
||||
"'self'",
|
||||
"https://www.youtube.com",
|
||||
"https://giscus.app",
|
||||
"https://platform.twitter.com"
|
||||
]
|
||||
}
|
||||
},
|
||||
devToolbar: {
|
||||
enabled: true
|
||||
},
|
||||
redirects: (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'build') ? {
|
||||
'/about-me': '/about',
|
||||
'/about-us': '/about',
|
||||
'/contact-me': '/contact',
|
||||
'/contact-us': '/contact',
|
||||
'/privacy': '/privacy-policy',
|
||||
'/posts/mermaid-test': '/posts/obsidian-embeds-demo',
|
||||
'/posts/mermaid-diagram-test': '/posts/obsidian-embeds-demo',
|
||||
'/posts/mermaid-diagrams': '/posts/obsidian-embeds-demo',
|
||||
'/posts/astro-suite-vault-modular-guide': '/posts/vault-cms-guide',
|
||||
'/posts/astro-suite-obsidian-vault-guide-astro-modular': '/posts/vault-cms-guide',
|
||||
'/posts/obsidian-vault-guide': '/posts/vault-cms-guide',
|
||||
'/projects/obsidian-astro-composer': '/projects/astro-composer',
|
||||
'/projects/obsidian-astro-suite': '/projects/vault-cms',
|
||||
'/docs/api-reference': '/docs/api',
|
||||
'/docs/astro-modular-configuration': '/docs/configuration',
|
||||
'/docs/sourcetree-and-git': '/docs/sourcetree-and-git-setup'
|
||||
} : {},
|
||||
image: {
|
||||
service: {
|
||||
entrypoint: 'astro/assets/services/sharp',
|
||||
config: {
|
||||
limitInputPixels: false,
|
||||
}
|
||||
},
|
||||
remotePatterns: [{
|
||||
protocol: 'https'
|
||||
}]
|
||||
},
|
||||
integrations: [
|
||||
refreshContentOnChange(),
|
||||
tailwind(),
|
||||
sitemap(),
|
||||
mdx(),
|
||||
swup({
|
||||
theme: false,
|
||||
animationClass: 'transition-swup-',
|
||||
containers: ['#swup-container'],
|
||||
smoothScrolling: false,
|
||||
cache: process.env.NODE_ENV === 'production', // off in dev so post edits show immediately
|
||||
preload: true,
|
||||
accessibility: false,
|
||||
updateHead: true,
|
||||
updateBodyClass: false,
|
||||
globalInstance: true,
|
||||
plugins: [], // Disable all plugins including scroll
|
||||
skipPopStateHandling: (event) => {
|
||||
// ALWAYS skip Swup handling for back/forward navigation
|
||||
// Let the browser handle it naturally
|
||||
return true;
|
||||
},
|
||||
// Simplified link selector for better compatibility
|
||||
linkSelector: 'a[href]:not([data-no-swup]):not([href^="mailto:"]):not([href^="tel:"])'
|
||||
})
|
||||
],
|
||||
markdown: {
|
||||
remarkPlugins: [
|
||||
remarkObsidianImageSize, // Parse Obsidian image size syntax first
|
||||
remarkInternalLinks,
|
||||
remarkInlineTags,
|
||||
remarkObsidianComments, // Remove Obsidian comments (%%...%%) early in processing
|
||||
remarkFolderImages,
|
||||
remarkObsidianEmbeds,
|
||||
// Bases directive (table-only v1)
|
||||
remarkBases,
|
||||
remarkImageCaptions,
|
||||
remarkMath,
|
||||
remarkCallouts,
|
||||
remarkBreaks,
|
||||
remarkImageGrids,
|
||||
remarkMermaid,
|
||||
[remarkReadingTime, {}],
|
||||
[remarkToc, {
|
||||
tight: true,
|
||||
ordered: false,
|
||||
maxDepth: 3,
|
||||
heading: 'contents|table[ -]of[ -]contents?|toc'
|
||||
}],
|
||||
],
|
||||
rehypePlugins: [
|
||||
rehypeKatex,
|
||||
rehypeMark,
|
||||
rehypeImageAttributes,
|
||||
[rehypeSlug, {
|
||||
test: (node) => node.tagName !== 'h1'
|
||||
}],
|
||||
[rehypeAutolinkHeadings, {
|
||||
behavior: 'wrap',
|
||||
test: (node) => node.tagName !== 'h1',
|
||||
properties: {
|
||||
className: ['anchor-link'],
|
||||
ariaLabel: 'Link to this section'
|
||||
}
|
||||
}],
|
||||
rehypeNormalizeAnchors, // Run LAST to ensure className and href fixes aren't overridden
|
||||
],
|
||||
shikiConfig: {
|
||||
theme: 'github-dark',
|
||||
wrap: true
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
assetsInclude: ['**/*.base', '**/*.home', '**/*.base'],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@/components': fileURLToPath(new URL('./src/components', import.meta.url)),
|
||||
'@/layouts': fileURLToPath(new URL('./src/layouts', import.meta.url)),
|
||||
'@/utils': fileURLToPath(new URL('./src/utils', import.meta.url)),
|
||||
'@/types': fileURLToPath(new URL('./src/types.ts', import.meta.url)),
|
||||
'@/config': fileURLToPath(new URL('./src/config.ts', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
strictPort: false, // Allow fallback to 5001 if 5000 is occupied (e.g., AirPlay on macOS)
|
||||
allowedHosts: [],
|
||||
middlewareMode: false,
|
||||
hmr: true,
|
||||
watch: {
|
||||
ignored: ['**/.obsidian/**', '**/_bases/**', '**/bases/**'],
|
||||
usePolling: process.platform === 'win32',
|
||||
interval: 1000
|
||||
},
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
// CSP headers are handled by src/middleware.ts for all routes
|
||||
}
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
'process.env.ASTRO_CONTENT_COLLECTION_CACHE': 'false'
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['astro:content']
|
||||
},
|
||||
exclude: ['**/_redirects']
|
||||
},
|
||||
build: {
|
||||
assets: '_assets'
|
||||
}
|
||||
});
|
||||
247
netlify.toml
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
[build]
|
||||
publish = "dist"
|
||||
command = "pnpm run build"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "22"
|
||||
PNPM_VERSION = "8"
|
||||
|
||||
# Enable compression for all text-based files
|
||||
# Note: Netlify automatically handles gzip/brotli compression based on client support
|
||||
[[headers]]
|
||||
for = "/*.html"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=3600, s-maxage=86400"
|
||||
|
||||
[[headers]]
|
||||
for = "/*.css"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "/*.js"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "/*.json"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=3600"
|
||||
|
||||
[[headers]]
|
||||
for = "/*.xml"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=3600"
|
||||
|
||||
[[headers]]
|
||||
for = "/*.txt"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=3600"
|
||||
|
||||
# Handle pre-compressed files
|
||||
[[headers]]
|
||||
for = "/*.gz"
|
||||
[headers.values]
|
||||
Content-Encoding = "gzip"
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "/*.br"
|
||||
[headers.values]
|
||||
Content-Encoding = "br"
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
|
||||
Cross-Origin-Embedder-Policy = "unsafe-none"
|
||||
Cross-Origin-Opener-Policy = "same-origin"
|
||||
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://giscus.app https://platform.twitter.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://giscus.app; frame-src 'self' https://www.youtube.com https://giscus.app https://platform.twitter.com; object-src 'none'; base-uri 'self';"
|
||||
|
||||
# Static assets with long-term caching
|
||||
[[headers]]
|
||||
for = "/_assets/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.webp"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.woff2"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.woff"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.ttf"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.eot"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
# Images with long-term caching
|
||||
[[headers]]
|
||||
for = "*.jpg"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.jpeg"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.png"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.gif"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.svg"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "*.ico"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
# PDF files - allow iframe embedding
|
||||
[[headers]]
|
||||
for = "*.pdf"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=3600"
|
||||
X-Frame-Options = "SAMEORIGIN"
|
||||
|
||||
# Favicon files
|
||||
[[headers]]
|
||||
for = "/favicon*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[dev]
|
||||
command = "pnpm run dev"
|
||||
port = 5000
|
||||
publish = "dist"
|
||||
|
||||
# Generated redirects from content aliases
|
||||
|
||||
[[redirects]]
|
||||
from = "/about-me"
|
||||
to = "/about"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/about-us"
|
||||
to = "/about"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/contact-me"
|
||||
to = "/contact"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/contact-us"
|
||||
to = "/contact"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/privacy"
|
||||
to = "/privacy-policy"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/posts/mermaid-test"
|
||||
to = "/posts/obsidian-embeds-demo"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/posts/mermaid-diagram-test"
|
||||
to = "/posts/obsidian-embeds-demo"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/posts/mermaid-diagrams"
|
||||
to = "/posts/obsidian-embeds-demo"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/posts/astro-suite-vault-modular-guide"
|
||||
to = "/posts/vault-cms-guide"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/posts/astro-suite-obsidian-vault-guide-astro-modular"
|
||||
to = "/posts/vault-cms-guide"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/posts/obsidian-vault-guide"
|
||||
to = "/posts/vault-cms-guide"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/projects/obsidian-astro-composer"
|
||||
to = "/projects/astro-composer"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/projects/obsidian-astro-suite"
|
||||
to = "/projects/vault-cms"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/docs/api-reference"
|
||||
to = "/docs/api"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/docs/astro-modular-configuration"
|
||||
to = "/docs/configuration"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/docs/sourcetree-and-git"
|
||||
to = "/docs/sourcetree-and-git-setup"
|
||||
status = 301
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/404"
|
||||
status = 404
|
||||
71
package.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"name": "astro-modular",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "A flexible blog theme designed for Obsidian users.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "cross-env ASTRO_CONTENT_COLLECTION_CACHE=false node scripts/setup-dev.mjs && node scripts/sync-images.js && node scripts/process-aliases.js && node scripts/generate-deployment-config.js && node scripts/generate-graph-data.js && node scripts/dev-with-port.js",
|
||||
"build": "node scripts/sync-images.js && node scripts/process-aliases.js && node scripts/generate-deployment-config.js && node scripts/generate-graph-data.js --production && astro build",
|
||||
"preview": "pnpm run build && node scripts/dev-with-port.js preview",
|
||||
"sync-images": "node scripts/sync-images.js",
|
||||
"process-aliases": "node scripts/process-aliases.js",
|
||||
"generate-deployment-config": "node scripts/generate-deployment-config.js",
|
||||
"generate-deployment-config:dry-run": "node scripts/generate-deployment-config.js --dry-run",
|
||||
"generate-deployment-config:validate": "node scripts/generate-deployment-config.js --validate",
|
||||
"generate-graph-data": "node scripts/generate-graph-data.js",
|
||||
"check-images": "node scripts/check-missing-images.js",
|
||||
"update": "node scripts/update.mjs",
|
||||
"version": "node scripts/get-version.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"astro",
|
||||
"blog",
|
||||
"theme",
|
||||
"obsidian"
|
||||
],
|
||||
"author": "David V. Kimball",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.8",
|
||||
"@astrojs/mdx": "5.0.2",
|
||||
"@astrojs/rss": "4.0.17",
|
||||
"@astrojs/sitemap": "3.7.1",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||
"@swup/astro": "^1.8.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/d3": "^7.4.3",
|
||||
"astro": "6.0.8",
|
||||
"astro-redirect-from": "^1.3.5",
|
||||
"d3": "^7.9.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"katex": "^0.16.38",
|
||||
"lucide": "^0.542.0",
|
||||
"mermaid": "^11.13.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-reading-time": "^2.1.0",
|
||||
"remark-toc": "^9.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"swup": "^4.8.3",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/unist": "^3.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"vite": "^7.3.1",
|
||||
"wrangler": "^4.71.0"
|
||||
}
|
||||
}
|
||||
10997
pnpm-lock.yaml
generated
Normal file
BIN
public/docs/api-reference/code.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
public/docs/attachments/astro-modular-configuration.png
Normal file
|
After Width: | Height: | Size: 829 KiB |
BIN
public/docs/attachments/astro-modular-configuration.webp
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/docs/attachments/obsidian-cover.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/docs/attachments/obsidian-cover.webp
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
public/docs/configuration/astro-modular-configuration.webp
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
public/docs/sourcetree-and-git-setup/sourcetree.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/docs/sourcetree-and-git-setup/sourcetree.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/docs/sourcetree-and-git/sourcetree.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/favicon-dark.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/favicon-light.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
127
public/graph/graph-data.json
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "formatting-reference",
|
||||
"type": "post",
|
||||
"title": "Formatting Reference",
|
||||
"slug": "formatting-reference",
|
||||
"connections": 13
|
||||
},
|
||||
{
|
||||
"id": "getting-started",
|
||||
"type": "post",
|
||||
"title": "Getting Started",
|
||||
"slug": "getting-started",
|
||||
"connections": 6
|
||||
},
|
||||
{
|
||||
"id": "obsidian-embeds-demo",
|
||||
"type": "post",
|
||||
"title": "Obsidian Embeds Demo",
|
||||
"slug": "obsidian-embeds-demo",
|
||||
"connections": 0
|
||||
},
|
||||
{
|
||||
"id": "sample-folder-based-post",
|
||||
"type": "post",
|
||||
"title": "Sample Folder-Based Post",
|
||||
"slug": "sample-folder-based-post",
|
||||
"connections": 0
|
||||
},
|
||||
{
|
||||
"id": "vault-cms-guide",
|
||||
"type": "post",
|
||||
"title": "Vault CMS Guide",
|
||||
"slug": "vault-cms-guide",
|
||||
"connections": 0
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "getting-started",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "getting-started",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "sample-folder-based-post",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "vault-cms-guide",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "obsidian-embeds-demo",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "getting-started",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "getting-started",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "getting-started",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "obsidian-embeds-demo",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "formatting-reference",
|
||||
"target": "obsidian-embeds-demo",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "getting-started",
|
||||
"target": "vault-cms-guide",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "getting-started",
|
||||
"target": "formatting-reference",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "getting-started",
|
||||
"target": "vault-cms-guide",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "getting-started",
|
||||
"target": "formatting-reference",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "getting-started",
|
||||
"target": "vault-cms-guide",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"source": "getting-started",
|
||||
"target": "formatting-reference",
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"totalPosts": 5,
|
||||
"totalConnections": 16,
|
||||
"maxNodesApplied": false,
|
||||
"originalNodeCount": 5
|
||||
}
|
||||
}
|
||||
BIN
public/open-graph.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/pages/attachments/water.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
public/pages/attachments/water.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/posts/advanced-folder-example/gallery-1.jpg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/posts/advanced-folder-example/gallery-2.jpg
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
public/posts/advanced-folder-example/hero-image.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/posts/another-folder-based-test/cover.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/posts/another-folder-based-test/cover.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/posts/attachments/autumn-leaves.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/posts/attachments/city-skyline.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
public/posts/attachments/desert-sunset.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/posts/attachments/document.pdf
Normal file
BIN
public/posts/attachments/forest-path.webp
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
public/posts/attachments/galaxy.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
public/posts/attachments/galaxy.webp
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
public/posts/attachments/mountain-landscape.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/posts/attachments/mountains-clouds.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/posts/attachments/mountains-clouds.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/posts/attachments/mountains.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/posts/attachments/mountains.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/posts/attachments/ocean-view.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/posts/attachments/sound.wav
Normal file
BIN
public/posts/attachments/spring-flowers.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/posts/attachments/summer-beach.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/posts/attachments/sunset.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/posts/attachments/vault-cms-cover.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/posts/attachments/video.mp4
Normal file
BIN
public/posts/attachments/winter-landscape.webp
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
public/posts/sample-folder-based-post/cover.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
public/posts/sample-folder-based-post/cover.webp
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
public/posts/sample-folder-based-post/forest.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
public/posts/sample-folder-based-post/forest.webp
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
public/posts/sample-folder-based-post/lush-forest.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
public/posts/sample-folder-post/cover.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/posts/sample-folder-post/cover.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
public/posts/sample-folder-post/forest.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
public/posts/sample-folder-post/sample-image.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/profile.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/projects/another-project/hero.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
public/projects/attachments/rock.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
public/projects/attachments/rock.webp
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
public/projects/obsidian-astro-suite/glacier.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/projects/obsidian-astro-suite/glacier.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/projects/vault-cms/glacier.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
99
public/scripts/category-filtering.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Category Filtering Script for Projects Page
|
||||
* Handles clickable category pills to filter project cards
|
||||
*/
|
||||
|
||||
function initializeCategoryFiltering() {
|
||||
const categoryItems = document.querySelectorAll('.category-item');
|
||||
const projectCards = document.querySelectorAll('[data-project-categories]');
|
||||
let currentActiveCategory = null;
|
||||
|
||||
if (categoryItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing event listeners and add new ones
|
||||
categoryItems.forEach((item, index) => {
|
||||
// Clone the element to remove all event listeners
|
||||
const newItem = item.cloneNode(true);
|
||||
item.parentNode?.replaceChild(newItem, item);
|
||||
|
||||
newItem.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const category = newItem.getAttribute('data-category');
|
||||
|
||||
// Visual feedback
|
||||
newItem.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => {
|
||||
newItem.style.transform = '';
|
||||
}, 150);
|
||||
|
||||
if (!category) return;
|
||||
|
||||
// Check if this category is already active
|
||||
const isCurrentlyActive = currentActiveCategory === category;
|
||||
|
||||
// Reset all buttons to inactive state
|
||||
categoryItems.forEach(btn => {
|
||||
btn.classList.remove('bg-highlight-100', 'dark:bg-highlight-900/40', 'text-highlight-800', 'dark:text-highlight-200', 'ring-1', 'ring-highlight-300', 'dark:ring-highlight-700');
|
||||
btn.classList.add('bg-primary-100', 'dark:bg-primary-800', 'text-primary-700', 'dark:text-primary-300');
|
||||
});
|
||||
|
||||
if (isCurrentlyActive) {
|
||||
// If clicking the same category, show all projects
|
||||
projectCards.forEach(card => {
|
||||
card.style.display = 'block';
|
||||
});
|
||||
currentActiveCategory = null;
|
||||
} else {
|
||||
// Filter projects by category
|
||||
projectCards.forEach((card, cardIndex) => {
|
||||
const cardCategories = card.getAttribute('data-project-categories') || '';
|
||||
|
||||
if (cardCategories.includes(category)) {
|
||||
card.style.display = 'block';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Make clicked button active
|
||||
newItem.classList.remove('bg-primary-100', 'dark:bg-primary-800', 'text-primary-700', 'dark:text-primary-300');
|
||||
newItem.classList.add('bg-highlight-100', 'dark:bg-highlight-900/40', 'text-highlight-800', 'dark:text-highlight-200', 'ring-1', 'ring-highlight-300', 'dark:ring-highlight-700');
|
||||
currentActiveCategory = category;
|
||||
}
|
||||
|
||||
// Dispatch custom event for analytics
|
||||
window.dispatchEvent(new CustomEvent('categoryClicked', {
|
||||
detail: { category, isActive: currentActiveCategory === category }
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize with multiple strategies to ensure it works
|
||||
function tryInitialize() {
|
||||
initializeCategoryFiltering();
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', tryInitialize);
|
||||
|
||||
// Re-initialize after Swup page transitions
|
||||
document.addEventListener('swup:contentReplaced', tryInitialize);
|
||||
|
||||
// Also try to initialize immediately in case DOM is already ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', tryInitialize);
|
||||
} else {
|
||||
// Use setTimeout to ensure DOM is fully ready
|
||||
setTimeout(tryInitialize, 100);
|
||||
}
|
||||
|
||||
// Fallback: try again after a short delay
|
||||
setTimeout(tryInitialize, 500);
|
||||
|
||||
// Export for manual initialization if needed
|
||||
window.initializeCategoryFiltering = initializeCategoryFiltering;
|
||||
233
scripts/check-missing-images.js
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to check for missing images in content
|
||||
* Run with: node scripts/check-missing-images.js
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
|
||||
// Get all markdown files in content directory
|
||||
function getAllMarkdownFiles(dir) {
|
||||
const files = [];
|
||||
const items = fs.readdirSync(dir);
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...getAllMarkdownFiles(fullPath));
|
||||
} else if (item.endsWith('.md')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// Extract image references from markdown content
|
||||
function extractImageReferences(content) {
|
||||
const images = [];
|
||||
|
||||
// Match markdown images 
|
||||
const markdownImages = content.match(/!\[([^\]]*)\]\(([^)]+)\)/g) || [];
|
||||
for (const match of markdownImages) {
|
||||
const src = match.match(/!\[[^\]]*\]\(([^)]+)\)/)[1];
|
||||
images.push({ type: 'markdown', src, line: content.substring(0, content.indexOf(match)).split('\n').length });
|
||||
}
|
||||
|
||||
// Match wikilink images ![[src]]
|
||||
const wikilinkImages = content.match(/!\[\[([^\]]+)\]\]/g) || [];
|
||||
for (const match of wikilinkImages) {
|
||||
const src = match.match(/!\[\[([^\]]+)\]\]/)[1];
|
||||
images.push({ type: 'wikilink', src, line: content.substring(0, content.indexOf(match)).split('\n').length });
|
||||
}
|
||||
|
||||
// Match frontmatter images
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (frontmatterMatch) {
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const imageMatch = frontmatter.match(/^image:\s*["']?([^"'\n]+)["']?/m);
|
||||
if (imageMatch) {
|
||||
images.push({ type: 'frontmatter', src: imageMatch[1], line: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
// Check if image exists
|
||||
function checkImageExists(imageSrc, filePath) {
|
||||
// Handle different image path formats
|
||||
let imagePath = imageSrc;
|
||||
|
||||
// Remove Obsidian brackets
|
||||
if (imagePath.startsWith('[[') && imagePath.endsWith(']]')) {
|
||||
imagePath = imagePath.slice(2, -2);
|
||||
}
|
||||
|
||||
// Determine content type and folder structure
|
||||
const isPostsFile = filePath.includes('posts');
|
||||
const isPagesFile = filePath.includes('pages');
|
||||
const isProjectsFile = filePath.includes('projects');
|
||||
const isDocsFile = filePath.includes('docs');
|
||||
const isFolderBasedPost = filePath.endsWith('index.md') && isPostsFile;
|
||||
const isFolderBasedProject = filePath.endsWith('index.md') && isProjectsFile;
|
||||
const isFolderBasedDoc = filePath.endsWith('index.md') && isDocsFile;
|
||||
|
||||
// 1. Check in the same folder as the markdown file (for folder-based content)
|
||||
if (isFolderBasedPost || isFolderBasedProject || isFolderBasedDoc) {
|
||||
const contentDir = path.dirname(filePath);
|
||||
const sameFolderPath = path.join(contentDir, imagePath);
|
||||
if (fs.existsSync(sameFolderPath)) {
|
||||
return { exists: true, path: sameFolderPath };
|
||||
}
|
||||
|
||||
// Check in /attachments/ subfolder within the content folder
|
||||
const imagesSubfolderPath = path.join(contentDir, 'images', imagePath);
|
||||
if (fs.existsSync(imagesSubfolderPath)) {
|
||||
return { exists: true, path: imagesSubfolderPath };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check in general images directory for each content type
|
||||
if (isPostsFile) {
|
||||
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'posts', 'images', imagePath);
|
||||
if (fs.existsSync(generalImagesPath)) {
|
||||
return { exists: true, path: generalImagesPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isPagesFile) {
|
||||
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'pages', 'images', imagePath);
|
||||
if (fs.existsSync(generalImagesPath)) {
|
||||
return { exists: true, path: generalImagesPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isProjectsFile) {
|
||||
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'projects', 'images', imagePath);
|
||||
if (fs.existsSync(generalImagesPath)) {
|
||||
return { exists: true, path: generalImagesPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isDocsFile) {
|
||||
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'docs', 'images', imagePath);
|
||||
if (fs.existsSync(generalImagesPath)) {
|
||||
return { exists: true, path: generalImagesPath };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check in public directory (for synced images)
|
||||
if (isFolderBasedPost) {
|
||||
const postSlug = path.basename(path.dirname(filePath));
|
||||
const publicPath = path.join(projectRoot, 'public', 'posts', postSlug, imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isFolderBasedProject) {
|
||||
const projectSlug = path.basename(path.dirname(filePath));
|
||||
const publicPath = path.join(projectRoot, 'public', 'projects', projectSlug, imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isFolderBasedDoc) {
|
||||
const docSlug = path.basename(path.dirname(filePath));
|
||||
const publicPath = path.join(projectRoot, 'public', 'docs', docSlug, imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle absolute paths (starting with /)
|
||||
if (imagePath.startsWith('/')) {
|
||||
const publicPath = path.join(projectRoot, 'public', imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle relative paths from public directory
|
||||
const publicPath = path.join(projectRoot, 'public', imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
|
||||
// Handle external URLs (don't check these)
|
||||
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||
return { exists: true, path: imagePath };
|
||||
}
|
||||
|
||||
return { exists: false, path: imagePath };
|
||||
}
|
||||
|
||||
// Main function
|
||||
function main() {
|
||||
console.log('🔍 Checking for missing images...\n');
|
||||
|
||||
const contentDir = path.join(projectRoot, 'src', 'content');
|
||||
const markdownFiles = getAllMarkdownFiles(contentDir);
|
||||
|
||||
let totalImages = 0;
|
||||
let missingImages = 0;
|
||||
const missingImageDetails = [];
|
||||
|
||||
for (const filePath of markdownFiles) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const images = extractImageReferences(content);
|
||||
|
||||
for (const image of images) {
|
||||
totalImages++;
|
||||
const result = checkImageExists(image.src, filePath);
|
||||
|
||||
if (!result.exists) {
|
||||
missingImages++;
|
||||
const relativePath = path.relative(projectRoot, filePath);
|
||||
missingImageDetails.push({
|
||||
file: relativePath,
|
||||
line: image.line,
|
||||
type: image.type,
|
||||
src: image.src,
|
||||
expectedPath: result.path
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
console.log(`📊 Summary:`);
|
||||
console.log(` Total images: ${totalImages}`);
|
||||
console.log(` Missing images: ${missingImages}`);
|
||||
console.log(` Found images: ${totalImages - missingImages}\n`);
|
||||
|
||||
if (missingImages > 0) {
|
||||
console.log('❌ Missing images:');
|
||||
for (const detail of missingImageDetails) {
|
||||
console.log(` ${detail.file}:${detail.line} (${detail.type})`);
|
||||
console.log(` ${detail.src}`);
|
||||
console.log(` Expected: ${detail.expectedPath}\n`);
|
||||
}
|
||||
|
||||
console.log('💡 Tips:');
|
||||
console.log(' - In development mode, missing images will show placeholder images');
|
||||
console.log(' - Check if images are in the correct folder for folder-based posts');
|
||||
console.log(' - Verify image paths match exactly (case-sensitive)');
|
||||
console.log(' - For Obsidian wikilinks, ensure the image exists in the same folder');
|
||||
} else {
|
||||
console.log('✅ All images found!');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
80
scripts/dev-with-port.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Astro server launcher with automatic port detection
|
||||
* Checks if port 5000 is available, falls back to 5001 if occupied
|
||||
* Supports both 'dev' and 'preview' commands
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function checkPort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.once('close', () => {
|
||||
resolve(true); // Port is available
|
||||
});
|
||||
server.close();
|
||||
});
|
||||
|
||||
server.on('error', () => {
|
||||
resolve(false); // Port is occupied
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getAvailablePort() {
|
||||
const port5000Available = await checkPort(5000);
|
||||
|
||||
if (port5000Available) {
|
||||
return 5000;
|
||||
} else {
|
||||
// Port 5000 is occupied (likely AirPlay on macOS)
|
||||
console.log('⚠️ Port 5000 is occupied, using port 5001 instead');
|
||||
return 5001;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get command from first argument (defaults to 'dev')
|
||||
const command = process.argv[2] || 'dev';
|
||||
|
||||
if (!['dev', 'preview'].includes(command)) {
|
||||
console.error(`Invalid command: ${command}. Must be 'dev' or 'preview'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = await getAvailablePort();
|
||||
|
||||
// Use pnpm exec to ensure we use the local Astro installation
|
||||
// On Windows, use 'pnpm.cmd', on Unix use 'pnpm'
|
||||
const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
||||
|
||||
// Spawn astro with the detected port
|
||||
const astroProcess = spawn(pnpmCmd, ['exec', 'astro', command, '--host', '0.0.0.0', '--port', port.toString()], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
});
|
||||
|
||||
astroProcess.on('error', (error) => {
|
||||
console.error(`Failed to start Astro ${command} server:`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
astroProcess.on('exit', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
955
scripts/generate-deployment-config.js
Normal file
|
|
@ -0,0 +1,955 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Simple logging utility
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const log = {
|
||||
info: (...args) => isDev && console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
warn: (...args) => console.warn(...args)
|
||||
};
|
||||
|
||||
// Import deployment platform helper
|
||||
import getDeploymentPlatform from './get-deployment-platform.js';
|
||||
|
||||
// Deployment platform configuration - use config if no env var is set
|
||||
const DEPLOYMENT_PLATFORM = process.env.DEPLOYMENT_PLATFORM || getDeploymentPlatform();
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
const VALIDATE_ONLY = process.argv.includes('--validate');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Define content directories to process
|
||||
const CONTENT_DIRS = [
|
||||
'src/content/pages',
|
||||
'src/content/posts',
|
||||
'src/content/projects',
|
||||
'src/content/docs'
|
||||
];
|
||||
|
||||
// Function to parse frontmatter from markdown content
|
||||
function parseFrontmatter(content) {
|
||||
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: null, content: content };
|
||||
}
|
||||
|
||||
const frontmatterText = match[1];
|
||||
const body = match[2];
|
||||
|
||||
// Parse YAML-like frontmatter (simple parser)
|
||||
const frontmatter = {};
|
||||
const lines = frontmatterText.split('\n');
|
||||
let currentKey = null;
|
||||
let currentValue = [];
|
||||
let inArray = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.includes(':') && (!inArray || !trimmed.startsWith(' '))) {
|
||||
// Save previous key-value pair
|
||||
if (currentKey) {
|
||||
if (currentValue.length === 1) {
|
||||
frontmatter[currentKey] = currentValue[0];
|
||||
} else {
|
||||
frontmatter[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Start new key-value pair
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
currentKey = trimmed.substring(0, colonIndex).trim();
|
||||
const value = trimmed.substring(colonIndex + 1).trim();
|
||||
|
||||
// Reset array state when starting a new key
|
||||
inArray = false;
|
||||
|
||||
if (value.startsWith('[')) {
|
||||
// Array value
|
||||
inArray = true;
|
||||
currentValue = [];
|
||||
if (value !== '[') {
|
||||
// Single line array
|
||||
const arrayContent = value.substring(1, value.endsWith(']') ? value.length - 1 : value.length);
|
||||
if (arrayContent.trim()) {
|
||||
currentValue = arrayContent.split(',').map(item => item.trim().replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
inArray = false;
|
||||
}
|
||||
} else if (value) {
|
||||
// Single value
|
||||
currentValue = [value.replace(/^["']|["']$/g, '')];
|
||||
} else {
|
||||
// Empty value, might be start of array
|
||||
currentValue = [];
|
||||
inArray = true;
|
||||
}
|
||||
} else if (inArray && trimmed.startsWith('-')) {
|
||||
// Array item
|
||||
const item = trimmed.substring(1).trim().replace(/^["']|["']$/g, '');
|
||||
currentValue.push(item);
|
||||
} else if (inArray && trimmed === ']') {
|
||||
// End of array
|
||||
inArray = false;
|
||||
} else if (currentKey && !inArray) {
|
||||
// Continuation of single value
|
||||
currentValue = [currentValue[0] + ' ' + trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
// Save last key-value pair
|
||||
if (currentKey) {
|
||||
if (currentValue.length === 1) {
|
||||
frontmatter[currentKey] = currentValue[0];
|
||||
} else {
|
||||
frontmatter[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, content: body };
|
||||
}
|
||||
|
||||
// Function to get the final URL for a content file
|
||||
function getContentUrl(filePath, isPost = false) {
|
||||
// Normalize path separators and extract the relevant part
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
|
||||
if (isPost) {
|
||||
// For posts: extract path after 'src/content/posts/' and remove '.md'
|
||||
let postPath = normalizedPath.replace(/^.*src\/content\/posts\//, '').replace(/\.md$/, '');
|
||||
// Handle folder-based content: remove '/index' suffix
|
||||
if (postPath.endsWith('/index')) {
|
||||
postPath = postPath.replace('/index', '');
|
||||
}
|
||||
return `/posts/${postPath}`;
|
||||
} else if (normalizedPath.includes('src/content/projects/')) {
|
||||
// For projects: extract path after 'src/content/projects/' and remove '.md'
|
||||
let projectPath = normalizedPath.replace(/^.*src\/content\/projects\//, '').replace(/\.md$/, '');
|
||||
// Handle folder-based content: remove '/index' suffix
|
||||
if (projectPath.endsWith('/index')) {
|
||||
projectPath = projectPath.replace('/index', '');
|
||||
}
|
||||
return `/projects/${projectPath}`;
|
||||
} else if (normalizedPath.includes('src/content/docs/')) {
|
||||
// For docs: extract path after 'src/content/docs/' and remove '.md'
|
||||
let docPath = normalizedPath.replace(/^.*src\/content\/docs\//, '').replace(/\.md$/, '');
|
||||
// Handle folder-based content: remove '/index' suffix
|
||||
if (docPath.endsWith('/index')) {
|
||||
docPath = docPath.replace('/index', '');
|
||||
}
|
||||
return `/docs/${docPath}`;
|
||||
} else {
|
||||
// For pages: extract path after 'src/content/pages/' and remove '.md'
|
||||
let pagePath = normalizedPath.replace(/^.*src\/content\/pages\//, '').replace(/\.md$/, '');
|
||||
// Handle folder-based content: remove '/index' suffix
|
||||
if (pagePath.endsWith('/index')) {
|
||||
pagePath = pagePath.replace('/index', '');
|
||||
}
|
||||
if (pagePath === 'index') {
|
||||
return '/';
|
||||
}
|
||||
return `/${pagePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Function to process a single markdown file
|
||||
async function processMarkdownFile(filePath, isPost = false) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatter || !frontmatter.aliases) {
|
||||
return []; // No redirects to process
|
||||
}
|
||||
|
||||
// Ensure aliases is an array
|
||||
const aliasesArray = Array.isArray(frontmatter.aliases)
|
||||
? frontmatter.aliases
|
||||
: [frontmatter.aliases];
|
||||
|
||||
const targetUrl = getContentUrl(filePath, isPost);
|
||||
const redirects = [];
|
||||
|
||||
for (const alias of aliasesArray) {
|
||||
const cleanAlias = alias.startsWith('/') ? alias.substring(1) : alias;
|
||||
|
||||
// Normalize path separators for consistent checking
|
||||
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Determine redirect pattern based on content type
|
||||
let redirectFrom;
|
||||
if (isPost) {
|
||||
// Posts: /posts/alias → /posts/actual-slug
|
||||
redirectFrom = `/posts/${cleanAlias}`;
|
||||
} else if (normalizedFilePath.includes('src/content/projects/')) {
|
||||
// Projects: /projects/alias → /projects/actual-slug
|
||||
redirectFrom = `/projects/${cleanAlias}`;
|
||||
} else if (normalizedFilePath.includes('src/content/docs/')) {
|
||||
// Docs: /docs/alias → /docs/actual-slug
|
||||
redirectFrom = `/docs/${cleanAlias}`;
|
||||
} else {
|
||||
// Pages: /alias → /actual-slug
|
||||
redirectFrom = `/${cleanAlias}`;
|
||||
}
|
||||
|
||||
// Skip self-redirects (redirecting to the same URL causes infinite loops)
|
||||
if (redirectFrom !== targetUrl) {
|
||||
redirects.push({
|
||||
from: redirectFrom,
|
||||
to: targetUrl,
|
||||
alias: cleanAlias
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return redirects;
|
||||
} catch (error) {
|
||||
log.error(`❌ Error processing ${filePath}:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Function to process all markdown files in a directory
|
||||
async function processDirectory(dirPath, isPost = false) {
|
||||
try {
|
||||
const files = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
let allRedirects = [];
|
||||
let processedFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isDirectory()) {
|
||||
// Handle folder-based content (e.g., folder-name/index.md)
|
||||
const folderPath = path.join(dirPath, file.name);
|
||||
try {
|
||||
const indexPath = path.join(folderPath, 'index.md');
|
||||
await fs.access(indexPath);
|
||||
const redirects = await processMarkdownFile(indexPath, isPost);
|
||||
allRedirects = allRedirects.concat(redirects);
|
||||
if (redirects.length > 0) {
|
||||
processedFiles++;
|
||||
}
|
||||
} catch (error) {
|
||||
// index.md doesn't exist in this folder, skip
|
||||
}
|
||||
} else if (file.isFile() && file.name.endsWith('.md')) {
|
||||
// Handle single-file content
|
||||
const filePath = path.join(dirPath, file.name);
|
||||
const redirects = await processMarkdownFile(filePath, isPost);
|
||||
allRedirects = allRedirects.concat(redirects);
|
||||
if (redirects.length > 0) {
|
||||
processedFiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { redirects: allRedirects, processedFiles };
|
||||
} catch (error) {
|
||||
log.error(`❌ Error processing directory ${dirPath}:`, error.message);
|
||||
return { redirects: [], processedFiles: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Function to update astro.config.mjs with redirects (dev-only)
|
||||
async function updateAstroConfig(redirects) {
|
||||
const astroConfigPath = 'astro.config.mjs';
|
||||
|
||||
try {
|
||||
let astroContent = await fs.readFile(astroConfigPath, 'utf-8');
|
||||
|
||||
// Create redirects object
|
||||
const redirectsObj = {};
|
||||
for (const redirect of redirects) {
|
||||
redirectsObj[redirect.from] = redirect.to;
|
||||
}
|
||||
|
||||
// Format redirects with conditional dev-only check
|
||||
// Using process.env.NODE_ENV for reliable environment detection at config load time
|
||||
const redirectsString = JSON.stringify(redirectsObj, null, 2).replace(/"/g, "'");
|
||||
const newRedirectsSection = `redirects: (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'build') ? ${redirectsString} : {}`;
|
||||
|
||||
// Remove ALL existing redirects entries (including any comments before them)
|
||||
// This handles multiple redirects entries that may have been duplicated
|
||||
// Match from optional comments + redirects: through the entire conditional expression
|
||||
// Pattern: redirects: (condition) ? { object } : {},
|
||||
// Use [\s\S]*? to match any characters including newlines (non-greedy)
|
||||
const redirectsRegex = /(\s*\/\/[^\n]*\n)*\s*redirects:\s*\([^)]+\)\s*\?\s*\{[\s\S]*?\}\s*:\s*\{\},\s*/g;
|
||||
|
||||
// Remove all existing redirects entries (run multiple times to catch all duplicates)
|
||||
let previousContent = '';
|
||||
while (previousContent !== astroContent) {
|
||||
previousContent = astroContent;
|
||||
astroContent = astroContent.replace(redirectsRegex, '');
|
||||
}
|
||||
|
||||
// Add redirects after devToolbar config
|
||||
const devToolbarRegex = /(devToolbar:\s*\{[^}]*\},)/;
|
||||
if (devToolbarRegex.test(astroContent)) {
|
||||
astroContent = astroContent.replace(devToolbarRegex, `$1\n ${newRedirectsSection},\n`);
|
||||
} else {
|
||||
// Fallback: add after deployment config if devToolbar not found
|
||||
const deploymentRegex = /(deployment:\s*\{[^}]*\},)/;
|
||||
if (deploymentRegex.test(astroContent)) {
|
||||
astroContent = astroContent.replace(deploymentRegex, `$1\n ${newRedirectsSection},\n`);
|
||||
} else {
|
||||
// Last resort: add after site config
|
||||
const siteRegex = /(site:\s*[^,]+),/;
|
||||
astroContent = astroContent.replace(siteRegex, `$1,\n ${newRedirectsSection},\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(astroConfigPath, astroContent, 'utf-8');
|
||||
log.info(`📝 Updated astro.config.mjs with ${redirects.length} redirects (dev-only)`);
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating astro.config.mjs:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Platform-specific configuration generators
|
||||
function generateVercelConfig(redirects) {
|
||||
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
|
||||
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
|
||||
|
||||
const config = {
|
||||
redirects: validRedirects.map(redirect => ({
|
||||
source: redirect.from,
|
||||
destination: redirect.to,
|
||||
permanent: (redirect.status || 301) === 301
|
||||
})),
|
||||
headers: [
|
||||
{
|
||||
source: "/_assets/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: "/(.*\\.(webp|jpg|jpeg|png|gif|svg))",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: "/(.*\\.pdf)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=3600"
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://giscus.app https://platform.twitter.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://giscus.app; frame-src 'self' https://www.youtube.com https://giscus.app https://platform.twitter.com; object-src 'none'; base-uri 'self';"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
function generateGitHubPagesConfig(redirects) {
|
||||
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
|
||||
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
|
||||
|
||||
// Note: Removed '!' suffix to avoid interstitial page - direct redirects like Netlify
|
||||
const redirectLines = validRedirects.map(redirect =>
|
||||
`${redirect.from} ${redirect.to} ${redirect.status || 301}`
|
||||
);
|
||||
|
||||
return redirectLines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function generateNetlifyConfig(redirects) {
|
||||
const redirectLines = [];
|
||||
|
||||
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
|
||||
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
|
||||
|
||||
for (const redirect of validRedirects) {
|
||||
redirectLines.push('[[redirects]]');
|
||||
redirectLines.push(` from = "${redirect.from}"`);
|
||||
redirectLines.push(` to = "${redirect.to}"`);
|
||||
redirectLines.push(` status = ${redirect.status || 301}`);
|
||||
redirectLines.push(' force = true');
|
||||
redirectLines.push('');
|
||||
}
|
||||
|
||||
// Always add the catch-all 404 redirect at the end
|
||||
redirectLines.push('[[redirects]]');
|
||||
redirectLines.push(' from = "/*"');
|
||||
redirectLines.push(' to = "/404"');
|
||||
redirectLines.push(' status = 404');
|
||||
|
||||
return redirectLines.join('\n');
|
||||
}
|
||||
|
||||
function generateCloudflareWorkersConfig(projectName) {
|
||||
// Get current date for compatibility_date
|
||||
const today = new Date();
|
||||
const compatibilityDate = today.toISOString().split('T')[0];
|
||||
|
||||
// Generate Workers-compatible config (recommended by Cloudflare)
|
||||
// Workers uses assets.directory instead of pages_build_output_dir
|
||||
// See: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
|
||||
const configLines = [];
|
||||
configLines.push(`name = "${projectName}"`);
|
||||
configLines.push(`compatibility_date = "${compatibilityDate}"`);
|
||||
configLines.push(``);
|
||||
configLines.push(`[assets]`);
|
||||
configLines.push(` directory = "./dist"`);
|
||||
configLines.push(` not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
|
||||
|
||||
return configLines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// Clean up platform-specific files that don't match the selected platform
|
||||
async function cleanupOtherPlatformFiles(currentPlatform) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
|
||||
// Clean up GitHub Pages/Cloudflare Workers files if not using those platforms
|
||||
// (Both platforms use the same _redirects and _headers format)
|
||||
// These files should ONLY exist for github-pages and cloudflare-workers
|
||||
if (currentPlatform !== 'github-pages' && currentPlatform !== 'cloudflare-workers') {
|
||||
const sharedFiles = [
|
||||
path.join(projectRoot, 'public', '_redirects'),
|
||||
path.join(projectRoot, 'public', '_headers')
|
||||
];
|
||||
|
||||
for (const file of sharedFiles) {
|
||||
try {
|
||||
await fs.access(file);
|
||||
await fs.unlink(file);
|
||||
log.info(`🧹 Removed ${path.basename(file)} (not needed for ${currentPlatform})`);
|
||||
} catch (error) {
|
||||
// File doesn't exist, nothing to clean up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't remove vercel.json, netlify.toml, or wrangler.toml as they may contain
|
||||
// custom configuration (serverless functions, environment variables, bindings, etc.)
|
||||
// Only _redirects and _headers are platform-specific and should be cleaned up
|
||||
}
|
||||
|
||||
// Platform-specific file writers
|
||||
async function writeVercelConfig(redirects) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const vercelJsonPath = path.join(projectRoot, 'vercel.json');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would generate vercel.json:');
|
||||
console.log(generateVercelConfig(redirects));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing vercel.json to preserve custom settings
|
||||
let existingConfig = {};
|
||||
try {
|
||||
const existingContent = await fs.readFile(vercelJsonPath, 'utf-8');
|
||||
existingConfig = JSON.parse(existingContent);
|
||||
} catch (error) {
|
||||
// File doesn't exist or is invalid JSON, create new one
|
||||
}
|
||||
|
||||
// Generate new redirects and headers
|
||||
const newConfig = JSON.parse(generateVercelConfig(redirects));
|
||||
|
||||
// Merge configs - new redirects/headers override existing ones
|
||||
const mergedConfig = {
|
||||
...existingConfig,
|
||||
redirects: newConfig.redirects,
|
||||
headers: newConfig.headers
|
||||
};
|
||||
|
||||
await fs.writeFile(vercelJsonPath, JSON.stringify(mergedConfig, null, 2), 'utf-8');
|
||||
log.info(`📝 Updated vercel.json with ${redirects.length} redirects`);
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating vercel.json:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeGitHubPagesConfig(redirects) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const redirectsPath = path.join(projectRoot, 'public', '_redirects');
|
||||
const headersPath = path.join(projectRoot, 'public', '_headers');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would generate public/_redirects:');
|
||||
console.log(generateGitHubPagesConfig(redirects));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Write redirects file
|
||||
const config = generateGitHubPagesConfig(redirects);
|
||||
await fs.writeFile(redirectsPath, config, 'utf-8');
|
||||
log.info(`📝 Updated public/_redirects with ${redirects.length} redirects`);
|
||||
|
||||
// Write headers file (for GitHub Pages and Cloudflare Pages)
|
||||
// Note: Custom headers require GitHub Pages on a paid plan or GitHub Enterprise
|
||||
// Cloudflare Pages supports custom headers on all plans (including free tier)
|
||||
const headersContent = `# Custom Headers for GitHub Pages / Cloudflare Pages
|
||||
# Note: GitHub Pages requires paid plan for custom headers
|
||||
# Cloudflare Pages supports custom headers on all plans
|
||||
|
||||
# HTML files
|
||||
/*.html
|
||||
Cache-Control: public, max-age=3600, s-maxage=86400
|
||||
|
||||
# CSS files
|
||||
/*.css
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# JavaScript files
|
||||
/*.js
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# JSON files
|
||||
/*.json
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
# XML files
|
||||
/*.xml
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
# Text files
|
||||
/*.txt
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
# Pre-compressed files
|
||||
/*.gz
|
||||
Content-Encoding: gzip
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.br
|
||||
Content-Encoding: br
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Static assets with long-term caching
|
||||
/_assets/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# WebP images
|
||||
/*.webp
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Font files
|
||||
/*.woff2
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.woff
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.ttf
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.eot
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Image files
|
||||
/*.jpg
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.jpeg
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.png
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.gif
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.svg
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.ico
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Favicon files
|
||||
/favicon*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# All pages - Security headers and Content Security Policy
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
Cross-Origin-Embedder-Policy: unsafe-none
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://giscus.app https://platform.twitter.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://giscus.app; frame-src 'self' https://www.youtube.com https://giscus.app https://platform.twitter.com; object-src 'none'; base-uri 'self';
|
||||
|
||||
# PDF files - allow iframe embedding (override X-Frame-Options for PDFs)
|
||||
/*.pdf
|
||||
Cache-Control: public, max-age=3600
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
`;
|
||||
await fs.writeFile(headersPath, headersContent, 'utf-8');
|
||||
log.info(`📝 Created public/_headers for GitHub Pages / Cloudflare Pages`);
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating GitHub Pages config:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeNetlifyConfig(redirects) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const netlifyTomlPath = path.join(projectRoot, 'netlify.toml');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would generate netlify.toml:');
|
||||
console.log(generateNetlifyConfig(redirects));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing netlify.toml to preserve other settings
|
||||
let existingContent = '';
|
||||
try {
|
||||
existingContent = await fs.readFile(netlifyTomlPath, 'utf-8');
|
||||
} catch (error) {
|
||||
// File doesn't exist, create new one
|
||||
}
|
||||
|
||||
// Split content at redirects section
|
||||
const configLines = existingContent.split('[[redirects]]')[0].trim();
|
||||
const redirectConfig = generateNetlifyConfig(redirects);
|
||||
|
||||
const newContent = configLines + '\n\n' + redirectConfig;
|
||||
await fs.writeFile(netlifyTomlPath, newContent, 'utf-8');
|
||||
|
||||
log.info(`📝 Updated netlify.toml with ${redirects.length} redirects`);
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating netlify.toml:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAssetsIgnoreFile() {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const templatePath = path.join(projectRoot, '.assetsignore.template');
|
||||
const distPath = path.join(projectRoot, 'dist');
|
||||
const assetsIgnorePath = path.join(distPath, '.assetsignore');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would copy .assetsignore.template to dist/.assetsignore');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if template exists
|
||||
try {
|
||||
await fs.access(templatePath);
|
||||
} catch (error) {
|
||||
// Template doesn't exist, that's okay - skip copying (optional file)
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if dist directory exists
|
||||
// Note: This script runs before Astro build, so dist may not exist yet
|
||||
// That's okay - the file will be copied if dist exists, or can be added manually
|
||||
try {
|
||||
await fs.access(distPath);
|
||||
|
||||
// dist exists, copy the file
|
||||
const templateContent = await fs.readFile(templatePath, 'utf-8');
|
||||
await fs.writeFile(assetsIgnorePath, templateContent, 'utf-8');
|
||||
log.info('📝 Copied .assetsignore to dist/');
|
||||
} catch (error) {
|
||||
// dist doesn't exist yet - that's fine, it will be created during Astro build
|
||||
// The .assetsignore file is optional and mainly for safety
|
||||
// Users can manually copy it to dist/ if needed, or it will be there on subsequent builds
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-critical error - just log it
|
||||
log.warn(`⚠️ Could not copy .assetsignore file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCloudflareWorkersConfig(projectName) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const wranglerTomlPath = path.join(projectRoot, 'wrangler.toml');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would generate wrangler.toml:');
|
||||
console.log(generateCloudflareWorkersConfig(projectName));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing wrangler.toml to preserve custom settings
|
||||
let existingContent = '';
|
||||
try {
|
||||
existingContent = await fs.readFile(wranglerTomlPath, 'utf-8');
|
||||
} catch (error) {
|
||||
// File doesn't exist, create new one
|
||||
}
|
||||
|
||||
// Generate new config with managed fields (Workers format)
|
||||
const newConfig = generateCloudflareWorkersConfig(projectName);
|
||||
|
||||
if (existingContent) {
|
||||
// Update only the fields we manage while preserving everything else
|
||||
// Migrate from Pages format (pages_build_output_dir) to Workers format (assets.directory)
|
||||
let updatedContent = existingContent;
|
||||
|
||||
// Update name field
|
||||
updatedContent = updatedContent.replace(/^name\s*=\s*["'][^"']*["']/m, `name = "${projectName}"`);
|
||||
if (!updatedContent.match(/^name\s*=/m)) {
|
||||
// name doesn't exist, add it at the beginning
|
||||
updatedContent = `name = "${projectName}"\n${updatedContent}`;
|
||||
}
|
||||
|
||||
// Migrate from Pages format to Workers format
|
||||
// Remove old pages_build_output_dir if it exists
|
||||
updatedContent = updatedContent.replace(/^pages_build_output_dir\s*=\s*["'][^"']*["']\s*\n?/m, '');
|
||||
|
||||
// Update or add assets.directory (Workers format)
|
||||
if (updatedContent.match(/^\[assets\]/m)) {
|
||||
// [assets] section exists, update directory
|
||||
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n\s*)directory\s*=\s*["'][^"']*["']/m, `$1directory = "./dist"`);
|
||||
if (!updatedContent.match(/^\[assets\]\s*\n\s*directory\s*=/m)) {
|
||||
// directory doesn't exist in [assets], add it
|
||||
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n)/m, `$1 directory = "./dist"\n`);
|
||||
}
|
||||
// Ensure not_found_handling is set
|
||||
if (!updatedContent.match(/not_found_handling\s*=/m)) {
|
||||
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n\s*directory\s*=[^\n]+)/m, `$1\n not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
|
||||
}
|
||||
} else {
|
||||
// [assets] section doesn't exist, add it after compatibility_date
|
||||
const today = new Date();
|
||||
const compatibilityDate = today.toISOString().split('T')[0];
|
||||
updatedContent = updatedContent.replace(/^compatibility_date\s*=\s*["'][^"']*["']/m, `compatibility_date = "${compatibilityDate}"`);
|
||||
if (!updatedContent.match(/^compatibility_date\s*=/m)) {
|
||||
// compatibility_date doesn't exist, add both
|
||||
updatedContent = updatedContent.replace(/^(name\s*=[^\n]+)/m, `$1\ncompatibility_date = "${compatibilityDate}"`);
|
||||
}
|
||||
// Add [assets] section
|
||||
updatedContent = updatedContent.replace(/^(compatibility_date\s*=[^\n]+)/m, `$1\n\n[assets]\n directory = "./dist"\n not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
|
||||
}
|
||||
|
||||
// Update compatibility_date field
|
||||
const today = new Date();
|
||||
const compatibilityDate = today.toISOString().split('T')[0];
|
||||
updatedContent = updatedContent.replace(/^compatibility_date\s*=\s*["'][^"']*["']/m, `compatibility_date = "${compatibilityDate}"`);
|
||||
|
||||
// Remove old [build] section if it exists (not needed for Workers)
|
||||
updatedContent = updatedContent.replace(/\n*\[build\]\s*\n\s*command\s*=\s*["'][^"']*["']\s*\n*/g, '\n');
|
||||
|
||||
await fs.writeFile(wranglerTomlPath, updatedContent, 'utf-8');
|
||||
log.info(`📝 Updated wrangler.toml (Workers format, preserved custom settings)`);
|
||||
} else {
|
||||
// File doesn't exist, create new one with Workers format
|
||||
await fs.writeFile(wranglerTomlPath, newConfig, 'utf-8');
|
||||
log.info(`📝 Created wrangler.toml (Workers format)`);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating wrangler.toml:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Validation functions
|
||||
function validateVercelConfig(redirects) {
|
||||
const config = JSON.parse(generateVercelConfig(redirects));
|
||||
|
||||
// Validate redirects
|
||||
for (const redirect of config.redirects) {
|
||||
if (!redirect.source || !redirect.destination) {
|
||||
throw new Error(`Invalid Vercel redirect: ${JSON.stringify(redirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info('✅ Vercel configuration is valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateGitHubPagesConfig(redirects) {
|
||||
const config = generateGitHubPagesConfig(redirects);
|
||||
|
||||
// Basic validation - check format (no '!' suffix - direct redirects)
|
||||
const lines = config.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim() && !line.match(/^[^\s]+\s+[^\s]+\s+\d+$/)) {
|
||||
throw new Error(`Invalid GitHub Pages redirect format: ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info('✅ GitHub Pages configuration is valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateNetlifyConfig(redirects) {
|
||||
const config = generateNetlifyConfig(redirects);
|
||||
|
||||
// Basic validation - check TOML-like format
|
||||
if (!config.includes('[[redirects]]')) {
|
||||
throw new Error('Invalid Netlify configuration format');
|
||||
}
|
||||
|
||||
log.info('✅ Netlify configuration is valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function generateRedirects() {
|
||||
log.info(`🔄 Generating redirects from content aliases for ${DEPLOYMENT_PLATFORM}...`);
|
||||
|
||||
if (VALIDATE_ONLY) {
|
||||
log.info('🔍 Validation mode - checking all platform configurations...');
|
||||
}
|
||||
|
||||
// Get project name from package.json for Cloudflare Workers
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
let projectName = 'astro-modular';
|
||||
try {
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
|
||||
projectName = packageJson.name || 'astro-modular';
|
||||
} catch (error) {
|
||||
// Use default if package.json can't be read
|
||||
}
|
||||
|
||||
let allRedirects = [];
|
||||
let totalProcessedFiles = 0;
|
||||
|
||||
// Process pages
|
||||
const pagesPath = path.join(projectRoot, 'src/content/pages');
|
||||
try {
|
||||
await fs.access(pagesPath);
|
||||
const pageResult = await processDirectory(pagesPath, false);
|
||||
allRedirects = allRedirects.concat(pageResult.redirects);
|
||||
totalProcessedFiles += pageResult.processedFiles;
|
||||
} catch (error) {
|
||||
// Pages directory doesn't exist, skipping
|
||||
}
|
||||
|
||||
// Process posts
|
||||
const postsPath = path.join(projectRoot, 'src/content/posts');
|
||||
try {
|
||||
await fs.access(postsPath);
|
||||
const postResult = await processDirectory(postsPath, true);
|
||||
allRedirects = allRedirects.concat(postResult.redirects);
|
||||
totalProcessedFiles += postResult.processedFiles;
|
||||
} catch (error) {
|
||||
// Posts directory doesn't exist, skipping
|
||||
}
|
||||
|
||||
// Process projects
|
||||
const projectsPath = path.join(projectRoot, 'src/content/projects');
|
||||
try {
|
||||
await fs.access(projectsPath);
|
||||
const projectResult = await processDirectory(projectsPath, false);
|
||||
allRedirects = allRedirects.concat(projectResult.redirects);
|
||||
totalProcessedFiles += projectResult.processedFiles;
|
||||
} catch (error) {
|
||||
// Projects directory doesn't exist, skipping
|
||||
}
|
||||
|
||||
// Process docs
|
||||
const docsPath = path.join(projectRoot, 'src/content/docs');
|
||||
try {
|
||||
await fs.access(docsPath);
|
||||
const docResult = await processDirectory(docsPath, false);
|
||||
allRedirects = allRedirects.concat(docResult.redirects);
|
||||
totalProcessedFiles += docResult.processedFiles;
|
||||
} catch (error) {
|
||||
// Docs directory doesn't exist, skipping
|
||||
}
|
||||
|
||||
if (allRedirects.length > 0) {
|
||||
log.info(`📁 Processing pages directory...`);
|
||||
log.info(`📁 Processing posts directory...`);
|
||||
log.info(`📁 Processing projects directory...`);
|
||||
log.info(`📁 Processing docs directory...`);
|
||||
log.info(` Processed ${totalProcessedFiles} files with redirects`);
|
||||
|
||||
// Update Astro config with redirects (only used in dev mode - instant HTTP redirects)
|
||||
// In production builds, redirects are set to {} to prevent HTML meta refresh files
|
||||
await updateAstroConfig(allRedirects);
|
||||
|
||||
// Generate platform-specific configs
|
||||
if (VALIDATE_ONLY) {
|
||||
// Validate all platforms
|
||||
validateVercelConfig(allRedirects);
|
||||
validateGitHubPagesConfig(allRedirects);
|
||||
validateNetlifyConfig(allRedirects);
|
||||
log.info('🎉 All platform configurations are valid!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up files from other platforms before generating new config
|
||||
// This ensures we only have config files for the selected platform
|
||||
await cleanupOtherPlatformFiles(DEPLOYMENT_PLATFORM);
|
||||
|
||||
// Write platform-specific config - only generate files needed for the selected platform
|
||||
// IMPORTANT: We use platform-specific redirect files (HTTP redirects) for production
|
||||
// Astro config redirects are only active in dev mode (instant HTTP redirects)
|
||||
// In production builds, Astro redirects are set to {} to prevent HTML meta refresh files
|
||||
switch (DEPLOYMENT_PLATFORM) {
|
||||
case 'vercel':
|
||||
// Vercel: Uses vercel.json for instant HTTP redirects
|
||||
// No Astro config needed - Vercel handles redirects at edge level
|
||||
await writeVercelConfig(allRedirects);
|
||||
break;
|
||||
case 'github-pages':
|
||||
// GitHub Pages: Uses _redirects file for instant HTTP redirects
|
||||
// No Astro config needed - would create slow meta refresh HTML files
|
||||
await writeGitHubPagesConfig(allRedirects);
|
||||
break;
|
||||
case 'cloudflare-workers':
|
||||
// Cloudflare Workers: Uses _redirects file for instant HTTP redirects
|
||||
// See migration guide: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
|
||||
// No Astro config needed - would create slow meta refresh HTML files
|
||||
// Generate Workers-compatible wrangler.toml (uses assets.directory instead of pages_build_output_dir)
|
||||
await writeGitHubPagesConfig(allRedirects); // Uses same _redirects/_headers format
|
||||
await writeCloudflareWorkersConfig(projectName); // Generate Workers-compatible config
|
||||
await copyAssetsIgnoreFile(); // Copy .assetsignore to dist/ for Workers
|
||||
break;
|
||||
case 'netlify':
|
||||
default:
|
||||
// Netlify: Uses netlify.toml for instant HTTP redirects
|
||||
// No Astro config needed - Netlify handles redirects at edge level
|
||||
await writeNetlifyConfig(allRedirects);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('🎉 [DRY RUN] Redirect generation complete! No files were modified.');
|
||||
} else {
|
||||
log.info(`🎉 Redirect generation complete! Created ${allRedirects.length} redirects for ${DEPLOYMENT_PLATFORM}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
generateRedirects();
|
||||
537
scripts/generate-graph-data.js
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Graph Data Generation Script
|
||||
*
|
||||
* This script generates graph data for the local graph feature by analyzing
|
||||
* post connections (both wikilinks and standard links) and tag relationships.
|
||||
*
|
||||
* The generated data includes:
|
||||
* - Post nodes with metadata (title, slug, date, tags)
|
||||
* - Tag nodes with metadata (name, color)
|
||||
* - Connections between posts (direct links)
|
||||
* - Connections between posts and tags (shared tags)
|
||||
*
|
||||
* This data is used by the LocalGraph component to render an Obsidian-like graph view.
|
||||
*
|
||||
* ID Generation Strategy:
|
||||
* - Uses path-based IDs (no frontmatter required)
|
||||
* - Single files: "my-post.md" → ID: "my-post"
|
||||
* - Folder-based: "my-folder/index.md" → ID: "my-folder"
|
||||
* - Nested content: "category/my-post.md" → ID: "category-my-post"
|
||||
*/
|
||||
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const projectRoot = join(__dirname, "..");
|
||||
|
||||
// Configuration
|
||||
const OUTPUT_DIR = join(projectRoot, "public", "graph");
|
||||
const OUTPUT_FILE = join(OUTPUT_DIR, "graph-data.json");
|
||||
|
||||
/**
|
||||
* Read maxNodes from config file
|
||||
*/
|
||||
function getMaxNodesFromConfig() {
|
||||
try {
|
||||
const configPath = join(projectRoot, "src", "config.ts");
|
||||
const configContent = readFileSync(configPath, "utf-8");
|
||||
|
||||
// Extract maxNodes value from config
|
||||
const maxNodesMatch = configContent.match(/maxNodes:\s*(\d+)/);
|
||||
if (maxNodesMatch) {
|
||||
return parseInt(maxNodesMatch[1], 10);
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 100;
|
||||
} catch (error) {
|
||||
log.warn("Could not read config file, using default maxNodes: 100");
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple logging utility
|
||||
const isDev = process.env.NODE_ENV !== "production" && !process.argv.includes("--production");
|
||||
const log = {
|
||||
info: (...args) => isDev && console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
};
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!existsSync(OUTPUT_DIR)) {
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable ID from file path
|
||||
* @param {string} filePath - The file path
|
||||
* @param {string} collectionType - The collection type (e.g., 'posts')
|
||||
* @returns {string} - The generated ID
|
||||
*/
|
||||
function generateNodeId(filePath, collectionType) {
|
||||
// Remove collection prefix and extension
|
||||
let id = filePath.replace(`src/content/${collectionType}/`, "");
|
||||
id = id.replace(".md", "");
|
||||
id = id.replace("/index", ""); // Handle folder-based posts
|
||||
|
||||
// Clean up the ID: lowercase, replace spaces/special chars with hyphens
|
||||
id = id.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
|
||||
// Remove multiple consecutive hyphens
|
||||
id = id.replace(/-+/g, "-");
|
||||
|
||||
// Remove leading/trailing hyphens
|
||||
id = id.replace(/^-+|-+$/g, "");
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract wikilinks from content (Obsidian-style)
|
||||
*/
|
||||
function extractWikilinks(content) {
|
||||
const matches = [];
|
||||
const wikilinkRegex = /!?\[\[([^\]]+)\]\]/g;
|
||||
let match;
|
||||
|
||||
while ((match = wikilinkRegex.exec(content)) !== null) {
|
||||
const [fullMatch, linkContent] = match;
|
||||
const isImageWikilink = fullMatch.startsWith("!");
|
||||
|
||||
// Skip image wikilinks, only process link wikilinks
|
||||
if (!isImageWikilink) {
|
||||
const [link, displayText] = linkContent.includes("|")
|
||||
? linkContent.split("|", 2)
|
||||
: [linkContent, linkContent];
|
||||
|
||||
// Parse anchor if present
|
||||
const anchorIndex = link.indexOf("#");
|
||||
const baseLink =
|
||||
anchorIndex === -1 ? link : link.substring(0, anchorIndex);
|
||||
|
||||
// Generate target ID from the link
|
||||
const targetId = generateNodeId(baseLink, "posts");
|
||||
|
||||
matches.push({
|
||||
link: baseLink,
|
||||
display: displayText.trim(),
|
||||
slug: targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract standard markdown links from content
|
||||
*/
|
||||
function extractStandardLinks(content) {
|
||||
const matches = [];
|
||||
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
|
||||
while ((match = markdownLinkRegex.exec(content)) !== null) {
|
||||
const [fullMatch, displayText, url] = match;
|
||||
|
||||
// Check if this is an internal link
|
||||
if (isInternalLink(url)) {
|
||||
const { linkText } = extractLinkTextFromUrl(url);
|
||||
if (linkText) {
|
||||
// Only include posts in graph data - this includes:
|
||||
// - posts/ prefixed links
|
||||
// - /posts/ relative links
|
||||
// - .md files (assumed to be posts)
|
||||
// - Simple slugs (assumed to be posts for backward compatibility)
|
||||
const isPostLink =
|
||||
linkText.startsWith("posts/") ||
|
||||
url.startsWith("/posts/") ||
|
||||
url.startsWith("posts/") ||
|
||||
url.endsWith(".md") ||
|
||||
(!linkText.includes("/") && !url.startsWith("/"));
|
||||
|
||||
if (isPostLink) {
|
||||
// Generate target ID from the link
|
||||
const targetId = generateNodeId(linkText, "posts");
|
||||
|
||||
matches.push({
|
||||
link: linkText,
|
||||
display: displayText.trim(),
|
||||
slug: targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is an internal link
|
||||
*/
|
||||
function isInternalLink(url) {
|
||||
url = url.trim();
|
||||
|
||||
// Skip external URLs
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip email links
|
||||
if (url.startsWith("mailto:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip anchors only
|
||||
if (url.startsWith("#")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's an internal link:
|
||||
// - Ends with .md (markdown files)
|
||||
// - Starts with /posts/ or posts/ (post relative URLs)
|
||||
// - Is just a slug (no slashes) - assumes posts for backward compatibility
|
||||
const isInternal =
|
||||
url.endsWith(".md") ||
|
||||
url.startsWith("/posts/") ||
|
||||
url.startsWith("posts/") ||
|
||||
!url.includes("/");
|
||||
|
||||
return isInternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract link text from URL
|
||||
*/
|
||||
function extractLinkTextFromUrl(url) {
|
||||
url = url.trim();
|
||||
|
||||
// Parse anchor if present
|
||||
const anchorIndex = url.indexOf("#");
|
||||
const link = anchorIndex === -1 ? url : url.substring(0, anchorIndex);
|
||||
const anchor = anchorIndex === -1 ? null : url.substring(anchorIndex + 1);
|
||||
|
||||
// Handle posts/ prefixed links
|
||||
if (link.startsWith("posts/")) {
|
||||
let linkText = link.replace("posts/", "").replace(/\.md$/, "");
|
||||
// Remove /index for folder-based posts
|
||||
if (linkText.endsWith("/index") && linkText.split("/").length === 2) {
|
||||
linkText = linkText.replace("/index", "");
|
||||
}
|
||||
return {
|
||||
linkText: linkText,
|
||||
anchor: anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle /posts/ URLs (relative links)
|
||||
if (link.startsWith("/posts/")) {
|
||||
let linkText = link.replace("/posts/", "").replace(/\.md$/, "");
|
||||
// Remove /index for folder-based posts
|
||||
if (linkText.endsWith("/index") && linkText.split("/").length === 2) {
|
||||
linkText = linkText.replace("/index", "");
|
||||
}
|
||||
return {
|
||||
linkText: linkText,
|
||||
anchor: anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle .md files
|
||||
if (link.endsWith(".md")) {
|
||||
let linkText = link.replace(/\.md$/, "");
|
||||
// Remove /index for folder-based posts
|
||||
if (linkText.endsWith("/index") && linkText.split("/").length === 1) {
|
||||
linkText = linkText.replace("/index", "");
|
||||
}
|
||||
return {
|
||||
linkText: linkText,
|
||||
anchor: anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// If it's just a slug (no slashes), use it directly
|
||||
if (!link.includes("/")) {
|
||||
return {
|
||||
linkText: link,
|
||||
anchor: anchor,
|
||||
};
|
||||
}
|
||||
|
||||
return { linkText: null, anchor: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse markdown files from content directory
|
||||
*/
|
||||
function readContentFiles(dirPath) {
|
||||
const posts = [];
|
||||
|
||||
try {
|
||||
const items = readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = join(dirPath, item);
|
||||
const stat = statSync(itemPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Handle folder-based posts
|
||||
const indexPath = join(itemPath, "index.md");
|
||||
if (existsSync(indexPath)) {
|
||||
const content = readFileSync(indexPath, "utf-8");
|
||||
const parsed = parseMarkdownFile(content, item);
|
||||
if (parsed) {
|
||||
posts.push(parsed);
|
||||
}
|
||||
}
|
||||
} else if (item.endsWith(".md")) {
|
||||
// Handle single-file posts
|
||||
const content = readFileSync(itemPath, "utf-8");
|
||||
const slug = item.replace(".md", "");
|
||||
const parsed = parseMarkdownFile(content, slug);
|
||||
if (parsed) {
|
||||
posts.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Error reading content directory:", error);
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown file and extract frontmatter and content
|
||||
*/
|
||||
function parseMarkdownFile(content, slug) {
|
||||
try {
|
||||
// Extract frontmatter (handle both \n and \r\n line endings)
|
||||
const frontmatterMatch = content.match(
|
||||
/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
|
||||
);
|
||||
if (!frontmatterMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, frontmatter, body] = frontmatterMatch;
|
||||
const lines = frontmatter.split(/\r?\n/);
|
||||
const data = {};
|
||||
|
||||
// Parse frontmatter (improved YAML parser)
|
||||
let currentKey = null;
|
||||
let currentArray = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
// Check if this is a key-value pair
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0 && !line.startsWith(" ")) {
|
||||
// Save previous array if we have one
|
||||
if (currentKey && currentArray.length > 0) {
|
||||
data[currentKey] = [...currentArray];
|
||||
currentArray = [];
|
||||
}
|
||||
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
let value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Check if this is an array key (next line starts with dash)
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("- ")) {
|
||||
currentKey = key;
|
||||
currentArray = [];
|
||||
} else {
|
||||
// Single value
|
||||
if (key === "date") {
|
||||
data[key] = new Date(value);
|
||||
} else if (key === "draft") {
|
||||
data[key] = value === "true";
|
||||
} else if (
|
||||
key === "imageOG" ||
|
||||
key === "hideCoverImage" ||
|
||||
key === "noIndex" ||
|
||||
key === "featured"
|
||||
) {
|
||||
data[key] = value === "true";
|
||||
} else {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
} else if (trimmedLine.startsWith("- ")) {
|
||||
// This is an array item
|
||||
const item = trimmedLine.substring(2).trim();
|
||||
currentArray.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Save final array if we have one
|
||||
if (currentKey && currentArray.length > 0) {
|
||||
data[currentKey] = [...currentArray];
|
||||
}
|
||||
|
||||
return {
|
||||
id: slug,
|
||||
data,
|
||||
body,
|
||||
};
|
||||
} catch (error) {
|
||||
log.warn(`Error parsing file ${slug}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate graph data from posts
|
||||
*/
|
||||
async function generateGraphData() {
|
||||
log.info("🔍 Analyzing post connections...");
|
||||
|
||||
try {
|
||||
// Get configuration values
|
||||
const maxNodes = getMaxNodesFromConfig();
|
||||
|
||||
// Read all posts from the content directory
|
||||
const postsDir = join(projectRoot, "src", "content", "posts");
|
||||
log.info("📁 Reading posts from:", postsDir);
|
||||
|
||||
const posts = readContentFiles(postsDir);
|
||||
log.info(`📄 Found ${posts.length} posts`);
|
||||
|
||||
// Filter out draft posts in production
|
||||
const isDev = process.env.NODE_ENV !== "production" && !process.argv.includes("--production");
|
||||
const visiblePosts = posts.filter((post) => isDev || !post.data.draft);
|
||||
|
||||
log.info(`📄 Processing ${visiblePosts.length} visible posts`);
|
||||
|
||||
// Generate nodes and connections
|
||||
const nodes = [];
|
||||
const connections = [];
|
||||
|
||||
// Process each post
|
||||
for (const post of visiblePosts) {
|
||||
// Add post node
|
||||
const postNode = {
|
||||
id: post.id,
|
||||
type: "post",
|
||||
title: post.data.title,
|
||||
slug: post.id,
|
||||
date: post.data.date
|
||||
? post.data.date.toISOString()
|
||||
: new Date().toISOString(),
|
||||
connections: 0,
|
||||
};
|
||||
nodes.push(postNode);
|
||||
|
||||
// Extract links from post content
|
||||
const wikilinks = extractWikilinks(post.body);
|
||||
const standardLinks = extractStandardLinks(post.body);
|
||||
const allLinks = [...wikilinks, ...standardLinks];
|
||||
|
||||
// Process links to other posts
|
||||
for (const link of allLinks) {
|
||||
const targetPost = visiblePosts.find((p) => p.id === link.slug);
|
||||
if (targetPost && targetPost.id !== post.id) {
|
||||
// Add post-to-post connection
|
||||
connections.push({
|
||||
source: post.id,
|
||||
target: targetPost.id,
|
||||
type: "link",
|
||||
});
|
||||
|
||||
// Update connection counts
|
||||
postNode.connections++;
|
||||
const targetNode = nodes.find((n) => n.id === targetPost.id);
|
||||
if (targetNode) {
|
||||
targetNode.connections++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply maxNodes filtering if configured
|
||||
let filteredNodes = nodes;
|
||||
let filteredConnections = connections;
|
||||
|
||||
if (maxNodes && nodes.length > maxNodes) {
|
||||
// Sort posts by connection count (descending), then by date (descending)
|
||||
const sortedPosts = nodes.sort((a, b) => {
|
||||
if (b.connections !== a.connections) {
|
||||
return b.connections - a.connections;
|
||||
}
|
||||
return new Date(b.date) - new Date(a.date);
|
||||
});
|
||||
|
||||
filteredNodes = sortedPosts.slice(0, maxNodes);
|
||||
|
||||
// Filter connections to only include those between selected nodes
|
||||
const selectedNodeIds = new Set(filteredNodes.map((n) => n.id));
|
||||
filteredConnections = connections.filter(
|
||||
(conn) =>
|
||||
selectedNodeIds.has(conn.source) && selectedNodeIds.has(conn.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Remove date field from nodes before exporting (only needed for sorting)
|
||||
const nodesForExport = filteredNodes.map(({ date, ...node }) => node);
|
||||
|
||||
// Generate graph data
|
||||
const graphData = {
|
||||
nodes: nodesForExport,
|
||||
connections: filteredConnections,
|
||||
metadata: {
|
||||
totalPosts: filteredNodes.length,
|
||||
totalConnections: filteredConnections.length,
|
||||
maxNodesApplied: maxNodes && nodes.length > maxNodes,
|
||||
originalNodeCount: nodes.length,
|
||||
},
|
||||
};
|
||||
|
||||
// Write graph data to file
|
||||
writeFileSync(OUTPUT_FILE, JSON.stringify(graphData, null, 2));
|
||||
|
||||
log.info("✅ Graph data generated successfully!");
|
||||
if (graphData.metadata.maxNodesApplied) {
|
||||
log.info(
|
||||
`📊 Stats: ${graphData.metadata.totalPosts} posts, ${graphData.metadata.totalConnections} connections (filtered from ${graphData.metadata.originalNodeCount} total nodes)`
|
||||
);
|
||||
} else {
|
||||
log.info(
|
||||
`📊 Stats: ${graphData.metadata.totalPosts} posts, ${graphData.metadata.totalConnections} connections`
|
||||
);
|
||||
}
|
||||
log.info(`💾 Saved to: ${OUTPUT_FILE}`);
|
||||
} catch (error) {
|
||||
log.error("❌ Error generating graph data:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
generateGraphData();
|
||||
37
scripts/get-deployment-platform.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Get deployment platform from config
|
||||
* This script reads the deployment platform from src/config.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
function getDeploymentPlatform() {
|
||||
try {
|
||||
const configPath = join(process.cwd(), 'src', 'config.ts');
|
||||
const configContent = readFileSync(configPath, 'utf8');
|
||||
|
||||
// Extract platform from config
|
||||
const platformMatch = configContent.match(/platform:\s*["']([^"']+)["']/);
|
||||
|
||||
if (platformMatch) {
|
||||
return platformMatch[1];
|
||||
}
|
||||
|
||||
// Fallback to environment variable
|
||||
return process.env.DEPLOYMENT_PLATFORM || 'netlify';
|
||||
} catch (error) {
|
||||
console.error('Error reading config:', error.message);
|
||||
return process.env.DEPLOYMENT_PLATFORM || 'netlify';
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
export default getDeploymentPlatform;
|
||||
|
||||
// If run directly, output the platform
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
console.log(getDeploymentPlatform());
|
||||
}
|
||||
73
scripts/get-version.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Get version information for Astro Modular theme
|
||||
* This script provides version information for use in other scripts
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Get the current theme version
|
||||
*/
|
||||
export function getThemeVersion() {
|
||||
try {
|
||||
// Try to read from VERSION file first
|
||||
const versionFile = join(process.cwd(), 'VERSION');
|
||||
const version = readFileSync(versionFile, 'utf8').trim();
|
||||
return version;
|
||||
} catch (error) {
|
||||
try {
|
||||
// Fallback to package.json
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
return packageJson.version || '0.1.0';
|
||||
} catch (error) {
|
||||
return '0.1.0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme name
|
||||
*/
|
||||
export function getThemeName() {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
return packageJson.name || 'astro-modular';
|
||||
} catch (error) {
|
||||
return 'astro-modular';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full theme identifier (name@version)
|
||||
*/
|
||||
export function getThemeIdentifier() {
|
||||
return `${getThemeName()}@${getThemeVersion()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the version in both VERSION file and package.json
|
||||
*/
|
||||
export function updateVersion(newVersion) {
|
||||
try {
|
||||
// Update VERSION file
|
||||
writeFileSync('VERSION', newVersion + '\n');
|
||||
|
||||
// Update package.json
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
packageJson.version = newVersion;
|
||||
writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update version:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If run directly, output the version
|
||||
if (process.argv[1] && process.argv[1].endsWith('get-version.js')) {
|
||||
console.log(getThemeVersion());
|
||||
}
|
||||
337
scripts/process-aliases.js
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Simple logging utility
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const log = {
|
||||
info: (...args) => isDev && console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
warn: (...args) => console.warn(...args)
|
||||
};
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Define content directories to process
|
||||
const CONTENT_DIRS = [
|
||||
'src/content/pages',
|
||||
'src/content/posts',
|
||||
'src/content/projects',
|
||||
'src/content/docs'
|
||||
];
|
||||
|
||||
// Function to parse frontmatter from markdown content
|
||||
function parseFrontmatter(content) {
|
||||
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: null, content: content };
|
||||
}
|
||||
|
||||
const frontmatterText = match[1];
|
||||
const body = match[2];
|
||||
|
||||
// Parse YAML-like frontmatter (simple parser)
|
||||
const frontmatter = {};
|
||||
const lines = frontmatterText.split('\n');
|
||||
let currentKey = null;
|
||||
let currentValue = [];
|
||||
let inArray = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.includes(':') && (!inArray || !trimmed.startsWith(' '))) {
|
||||
// Save previous key-value pair
|
||||
if (currentKey) {
|
||||
if (currentValue.length === 1) {
|
||||
frontmatter[currentKey] = currentValue[0];
|
||||
} else {
|
||||
frontmatter[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Start new key-value pair
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
currentKey = trimmed.substring(0, colonIndex).trim();
|
||||
const value = trimmed.substring(colonIndex + 1).trim();
|
||||
|
||||
// Reset array state when starting a new key
|
||||
inArray = false;
|
||||
|
||||
if (value.startsWith('[')) {
|
||||
// Array value
|
||||
inArray = true;
|
||||
currentValue = [];
|
||||
if (value !== '[') {
|
||||
// Single line array
|
||||
const arrayContent = value.substring(1, value.endsWith(']') ? value.length - 1 : value.length);
|
||||
if (arrayContent.trim()) {
|
||||
currentValue = arrayContent.split(',').map(item => {
|
||||
const trimmed = item.trim();
|
||||
if (trimmed.startsWith('"[[') && trimmed.endsWith(']]"')) {
|
||||
// Keep quotes for Obsidian bracket syntax
|
||||
return trimmed;
|
||||
} else {
|
||||
// Remove quotes for regular values
|
||||
return trimmed.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
});
|
||||
}
|
||||
inArray = false;
|
||||
}
|
||||
} else if (value) {
|
||||
// Single value - preserve quotes for Obsidian bracket syntax
|
||||
if (value.startsWith('"[[') && value.endsWith(']]"')) {
|
||||
// Keep quotes for Obsidian bracket syntax
|
||||
currentValue = [value];
|
||||
} else {
|
||||
// Remove quotes for regular values
|
||||
currentValue = [value.replace(/^["']|["']$/g, '')];
|
||||
}
|
||||
} else {
|
||||
// Empty value, might be start of array
|
||||
currentValue = [];
|
||||
inArray = true;
|
||||
}
|
||||
} else if (inArray && trimmed.startsWith('-')) {
|
||||
// Array item - preserve quotes for Obsidian bracket syntax
|
||||
const item = trimmed.substring(1).trim();
|
||||
if (item.startsWith('"[[') && item.endsWith(']]"')) {
|
||||
// Keep quotes for Obsidian bracket syntax
|
||||
currentValue.push(item);
|
||||
} else {
|
||||
// Remove quotes for regular values
|
||||
currentValue.push(item.replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
} else if (inArray && trimmed === ']') {
|
||||
// End of array
|
||||
inArray = false;
|
||||
} else if (currentKey && !inArray) {
|
||||
// Continuation of single value
|
||||
currentValue = [currentValue[0] + ' ' + trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
// Save last key-value pair
|
||||
if (currentKey) {
|
||||
if (currentValue.length === 1) {
|
||||
frontmatter[currentKey] = currentValue[0];
|
||||
} else {
|
||||
frontmatter[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, content: body };
|
||||
}
|
||||
|
||||
// Function to convert frontmatter back to YAML string
|
||||
function frontmatterToString(frontmatter) {
|
||||
const lines = ['---'];
|
||||
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (Array.isArray(value)) {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
// Preserve quotes for Obsidian bracket syntax in arrays
|
||||
if (typeof item === 'string' && item.startsWith('"[[') && item.endsWith(']]"')) {
|
||||
lines.push(` - ${item}`);
|
||||
} else {
|
||||
lines.push(` - ${item}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Preserve quotes for Obsidian bracket syntax in single values
|
||||
if (typeof value === 'string' && value.startsWith('"[[') && value.endsWith(']]"')) {
|
||||
lines.push(`${key}: ${value}`);
|
||||
} else {
|
||||
lines.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('---');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Function to get the current slug from a file path
|
||||
function getCurrentSlug(filePath) {
|
||||
// Normalize path separators
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Extract slug based on content type
|
||||
if (normalizedPath.includes('/posts/')) {
|
||||
let postPath = normalizedPath.replace(/^.*\/posts\//, '').replace(/\.md$/, '');
|
||||
if (postPath.endsWith('/index')) {
|
||||
postPath = postPath.replace('/index', '');
|
||||
}
|
||||
return postPath;
|
||||
} else if (normalizedPath.includes('/projects/')) {
|
||||
let projectPath = normalizedPath.replace(/^.*\/projects\//, '').replace(/\.md$/, '');
|
||||
if (projectPath.endsWith('/index')) {
|
||||
projectPath = projectPath.replace('/index', '');
|
||||
}
|
||||
return projectPath;
|
||||
} else if (normalizedPath.includes('/docs/')) {
|
||||
let docPath = normalizedPath.replace(/^.*\/docs\//, '').replace(/\.md$/, '');
|
||||
if (docPath.endsWith('/index')) {
|
||||
docPath = docPath.replace('/index', '');
|
||||
}
|
||||
return docPath;
|
||||
} else if (normalizedPath.includes('/pages/')) {
|
||||
let pagePath = normalizedPath.replace(/^.*\/pages\//, '').replace(/\.md$/, '');
|
||||
if (pagePath.endsWith('/index')) {
|
||||
pagePath = pagePath.replace('/index', '');
|
||||
}
|
||||
if (pagePath === 'index') {
|
||||
return 'index';
|
||||
}
|
||||
return pagePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function to process a single markdown file
|
||||
async function processMarkdownFile(filePath) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const { frontmatter, content: body } = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatter || !frontmatter.aliases) {
|
||||
return false; // No aliases to process
|
||||
}
|
||||
|
||||
// Get the current slug for this file
|
||||
const currentSlug = getCurrentSlug(filePath);
|
||||
|
||||
// Ensure aliases is an array
|
||||
const aliasesArray = Array.isArray(frontmatter.aliases)
|
||||
? frontmatter.aliases
|
||||
: [frontmatter.aliases];
|
||||
|
||||
// Ensure aliases are in the correct format (no leading slash) and filter out self-aliases
|
||||
const cleanAliases = aliasesArray
|
||||
.map(alias => alias.startsWith('/') ? alias.substring(1) : alias)
|
||||
.filter(alias => {
|
||||
// Remove aliases that match the current slug (prevents self-redirects)
|
||||
if (currentSlug && alias === currentSlug) {
|
||||
log.warn(`⚠️ Removing self-alias "${alias}" from ${filePath} (matches current slug)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no valid aliases remain, remove the aliases field entirely
|
||||
if (cleanAliases.length === 0) {
|
||||
delete frontmatter.aliases;
|
||||
} else {
|
||||
// Update frontmatter with cleaned aliases
|
||||
frontmatter.aliases = cleanAliases.length === 1 ? cleanAliases[0] : cleanAliases;
|
||||
}
|
||||
|
||||
// Only write back if we made changes (removed aliases or cleaned them)
|
||||
const originalAliasesCount = aliasesArray.length;
|
||||
const hadChanges = cleanAliases.length !== originalAliasesCount ||
|
||||
(cleanAliases.length === 0 && originalAliasesCount > 0);
|
||||
|
||||
if (hadChanges) {
|
||||
// Rebuild the file content
|
||||
const newFrontmatter = frontmatterToString(frontmatter);
|
||||
const newContent = `${newFrontmatter}\n${body}`;
|
||||
|
||||
// Write back to file
|
||||
await fs.writeFile(filePath, newContent, 'utf-8');
|
||||
}
|
||||
|
||||
return { processed: hadChanges, aliases: cleanAliases.length };
|
||||
} catch (error) {
|
||||
log.error(`❌ Error processing ${filePath}:`, error.message);
|
||||
return { processed: false, aliases: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Function to process all markdown files in a directory (including folder-based content)
|
||||
async function processDirectory(dirPath) {
|
||||
try {
|
||||
const files = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
let processedCount = 0;
|
||||
let totalAliases = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isDirectory()) {
|
||||
// Handle folder-based content (e.g., folder-name/index.md)
|
||||
const folderPath = path.join(dirPath, file.name);
|
||||
try {
|
||||
const indexPath = path.join(folderPath, 'index.md');
|
||||
await fs.access(indexPath);
|
||||
const result = await processMarkdownFile(indexPath);
|
||||
if (result.processed) {
|
||||
processedCount++;
|
||||
totalAliases += result.aliases;
|
||||
}
|
||||
} catch (error) {
|
||||
// index.md doesn't exist in this folder, skip
|
||||
}
|
||||
} else if (file.isFile() && file.name.endsWith('.md')) {
|
||||
// Handle single-file content
|
||||
const filePath = path.join(dirPath, file.name);
|
||||
const result = await processMarkdownFile(filePath);
|
||||
if (result.processed) {
|
||||
processedCount++;
|
||||
totalAliases += result.aliases;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { processedCount, totalAliases };
|
||||
} catch (error) {
|
||||
log.error(`❌ Error processing directory ${dirPath}:`, error.message);
|
||||
return { processedCount: 0, totalAliases: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function processAllAliases() {
|
||||
log.info('🔄 Processing aliases and converting to redirect_from...');
|
||||
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
let totalProcessed = 0;
|
||||
let totalAliases = 0;
|
||||
|
||||
for (const dir of CONTENT_DIRS) {
|
||||
const fullPath = path.join(projectRoot, dir);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
const result = await processDirectory(fullPath);
|
||||
totalProcessed += result.processedCount;
|
||||
totalAliases += result.totalAliases;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
log.error(`❌ Error accessing directory ${dir}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalProcessed > 0) {
|
||||
log.info(`📁 Processing pages directory...`);
|
||||
log.info(`📁 Processing posts directory...`);
|
||||
log.info(`📁 Processing projects directory...`);
|
||||
log.info(`📁 Processing docs directory...`);
|
||||
log.info(` Processed ${totalProcessed} files with ${totalAliases} aliases`);
|
||||
}
|
||||
|
||||
log.info(`🎉 Alias processing complete! Processed ${totalProcessed} files.`);
|
||||
}
|
||||
|
||||
// Run the script
|
||||
processAllAliases();
|
||||
38
scripts/setup-dev.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { existsSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const astroCacheDir = path.join(projectRoot, '.astro');
|
||||
const viteCacheDir = path.join(projectRoot, 'node_modules', '.vite');
|
||||
|
||||
console.log('⚙️ Setting up development environment...');
|
||||
|
||||
// Wipe .astro completely so content layer and route cache are fresh every dev run
|
||||
try {
|
||||
if (existsSync(astroCacheDir)) {
|
||||
console.log(`Cleaning .astro cache: ${astroCacheDir}`);
|
||||
rmSync(astroCacheDir, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(astroCacheDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(`Failed to clean .astro: ${error.message}`);
|
||||
}
|
||||
|
||||
// Wipe Vite deps cache so modules are re-resolved
|
||||
try {
|
||||
const viteDeps = path.join(viteCacheDir, 'deps');
|
||||
if (existsSync(viteDeps)) {
|
||||
console.log(`Cleaning Vite deps cache: ${viteDeps}`);
|
||||
rmSync(viteDeps, { recursive: true, force: true });
|
||||
}
|
||||
if (!existsSync(viteCacheDir)) {
|
||||
mkdirSync(viteCacheDir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to clean Vite cache: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('✅ Development environment setup complete.');
|
||||
378
scripts/sync-images.js
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Simple logging utility
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const log = {
|
||||
info: (...args) => isDev && console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
warn: (...args) => console.warn(...args)
|
||||
};
|
||||
|
||||
// Define source and target directories for posts, pages, projects, docs, and special
|
||||
const IMAGE_SYNC_CONFIGS = [
|
||||
{
|
||||
source: 'src/content/posts/attachments',
|
||||
target: 'public/posts/attachments',
|
||||
name: 'posts'
|
||||
},
|
||||
{
|
||||
source: 'src/content/pages/attachments',
|
||||
target: 'public/pages/attachments',
|
||||
name: 'pages'
|
||||
},
|
||||
{
|
||||
source: 'src/content/projects/attachments',
|
||||
target: 'public/projects/attachments',
|
||||
name: 'projects'
|
||||
},
|
||||
{
|
||||
source: 'src/content/docs/attachments',
|
||||
target: 'public/docs/attachments',
|
||||
name: 'docs'
|
||||
},
|
||||
{
|
||||
source: 'src/content/special/attachments',
|
||||
target: 'public/special/attachments',
|
||||
name: 'special'
|
||||
}
|
||||
];
|
||||
|
||||
// Recursively find all media files in a directory (images, audio, video, PDF)
|
||||
async function findImageFiles(dir, relativePath = '') {
|
||||
const imageFiles = [];
|
||||
|
||||
try {
|
||||
const items = await fs.readdir(dir);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dir, item);
|
||||
const itemRelativePath = path.join(relativePath, item);
|
||||
const stat = await fs.stat(itemPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Recursively search subdirectories
|
||||
const subImages = await findImageFiles(itemPath, itemRelativePath);
|
||||
imageFiles.push(...subImages);
|
||||
} else if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|tif|ico|mp3|wav|ogg|m4a|3gp|flac|aac|mp4|webm|ogv|mov|mkv|avi|pdf)$/i.test(item)) {
|
||||
// Skip WebP files if we already have the original (to avoid duplicate processing)
|
||||
// We'll generate WebP from originals
|
||||
if (item.toLowerCase().endsWith('.webp')) {
|
||||
const originalName = item.replace(/\.webp$/i, '');
|
||||
const hasOriginal = items.some(i => {
|
||||
const nameWithoutExt = i.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '');
|
||||
return nameWithoutExt === originalName && /\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(i);
|
||||
});
|
||||
if (hasOriginal) {
|
||||
// Skip WebP if we have the original - we'll generate it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
imageFiles.push({
|
||||
sourcePath: itemPath,
|
||||
relativePath: itemRelativePath
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might not exist or be readable, continue
|
||||
if (error.code !== 'ENOENT') {
|
||||
log.warn(`Warning: Could not read directory ${dir}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return imageFiles;
|
||||
}
|
||||
|
||||
// Function to find folder-based content and sync their images
|
||||
async function syncFolderBasedImages(contentType) {
|
||||
const contentDir = `src/content/${contentType}`;
|
||||
const publicContentDir = `public/${contentType}`;
|
||||
|
||||
try {
|
||||
const items = await fs.readdir(contentDir);
|
||||
let totalSynced = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(contentDir, item);
|
||||
const stat = await fs.stat(itemPath);
|
||||
|
||||
// Check if it's a directory (folder-based content)
|
||||
if (stat.isDirectory()) {
|
||||
const targetDir = path.join(publicContentDir, item);
|
||||
|
||||
// Find all media files recursively
|
||||
const imageFiles = await findImageFiles(itemPath);
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
// Handle attachments subfolder within folder-based content
|
||||
// Convert src/content/posts/post-name/attachments/image.png -> public/posts/post-name/image.png
|
||||
let targetRelativePath = imageFile.relativePath;
|
||||
// Handle both forward and backward slashes for cross-platform compatibility
|
||||
if (targetRelativePath.startsWith('attachments/') || targetRelativePath.startsWith('attachments\\')) {
|
||||
targetRelativePath = targetRelativePath.replace(/^attachments[/\\]/, '');
|
||||
}
|
||||
|
||||
const targetPath = path.join(targetDir, targetRelativePath);
|
||||
const targetDirForFile = path.dirname(targetPath);
|
||||
|
||||
// Ensure target directory exists
|
||||
await ensureDir(targetDirForFile);
|
||||
|
||||
// Check if file needs updating - check both original and WebP versions
|
||||
let needsUpdate = true;
|
||||
|
||||
// Check if this is an image that would be converted to WebP
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
||||
const webpPath = path.join(targetDir, targetRelativePath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp'));
|
||||
try {
|
||||
const sourceStats = await fs.stat(imageFile.sourcePath);
|
||||
const webpStats = await fs.stat(webpPath);
|
||||
// Only update if source is newer than WebP
|
||||
needsUpdate = sourceStats.mtime > webpStats.mtime;
|
||||
} catch {
|
||||
// WebP doesn't exist, needs update
|
||||
needsUpdate = true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const sourceStats = await fs.stat(imageFile.sourcePath);
|
||||
const targetStats = await fs.stat(targetPath);
|
||||
// Only update if source is newer or different size
|
||||
needsUpdate = sourceStats.mtime > targetStats.mtime || sourceStats.size !== targetStats.size;
|
||||
} catch {
|
||||
// Target doesn't exist, definitely needs update
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
// Optimize image if it's an image format (not audio, video, or PDF)
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
||||
try {
|
||||
// Convert to WebP and optimize
|
||||
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
||||
await sharp(imageFile.sourcePath)
|
||||
.webp({ quality: 85 })
|
||||
.toFile(webpPath);
|
||||
totalSynced++;
|
||||
} catch (error) {
|
||||
// If Sharp fails, fall back to copying original
|
||||
log.warn(`⚠️ Could not optimize ${imageFile.relativePath}, copying original:`, error.message);
|
||||
await fs.copyFile(imageFile.sourcePath, targetPath);
|
||||
totalSynced++;
|
||||
}
|
||||
} else {
|
||||
// Non-image files (audio, video, PDF) - just copy as-is
|
||||
await fs.copyFile(imageFile.sourcePath, targetPath);
|
||||
totalSynced++;
|
||||
}
|
||||
} else {
|
||||
totalSkipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSynced > 0 || totalSkipped > 0) {
|
||||
log.info(`📁 Syncing folder-based ${contentType} images...`);
|
||||
if (totalSynced > 0) log.info(` Synced ${totalSynced} files`);
|
||||
if (totalSkipped > 0) log.info(` Skipped ${totalSkipped} files that were unchanged`);
|
||||
}
|
||||
|
||||
return { synced: totalSynced, skipped: totalSkipped };
|
||||
} catch (error) {
|
||||
log.error(`❌ Error syncing folder-based ${contentType} images:`, error);
|
||||
return { synced: 0, skipped: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
try {
|
||||
await fs.access(dir);
|
||||
} catch {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function syncImagesForConfig(config) {
|
||||
// Ensure target directory exists
|
||||
await ensureDir(config.target);
|
||||
|
||||
try {
|
||||
// Check if source directory exists
|
||||
let sourceFiles = [];
|
||||
try {
|
||||
sourceFiles = await fs.readdir(config.source);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return { synced: 0, skipped: 0, removed: 0 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Find all image files recursively (including subdirectories)
|
||||
const imageFiles = await findImageFiles(config.source);
|
||||
|
||||
let synced = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
const targetPath = path.join(config.target, imageFile.relativePath);
|
||||
const targetDirForFile = path.dirname(targetPath);
|
||||
|
||||
// Ensure target directory exists (including subdirectories)
|
||||
await ensureDir(targetDirForFile);
|
||||
|
||||
// Check if file needs updating - check both original and WebP versions
|
||||
let needsUpdate = true;
|
||||
|
||||
// Check if this is an image that would be converted to WebP
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
||||
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
||||
try {
|
||||
const sourceStats = await fs.stat(imageFile.sourcePath);
|
||||
const webpStats = await fs.stat(webpPath);
|
||||
// Only update if source is newer than WebP
|
||||
needsUpdate = sourceStats.mtime > webpStats.mtime;
|
||||
} catch {
|
||||
// WebP doesn't exist, needs update
|
||||
needsUpdate = true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const sourceStats = await fs.stat(imageFile.sourcePath);
|
||||
const targetStats = await fs.stat(targetPath);
|
||||
// Only update if source is newer or different size
|
||||
needsUpdate = sourceStats.mtime > targetStats.mtime || sourceStats.size !== targetStats.size;
|
||||
} catch {
|
||||
// Target doesn't exist, definitely needs update
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
// Optimize image if it's an image format (not audio, video, or PDF)
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
||||
try {
|
||||
// Convert to WebP and optimize
|
||||
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
||||
await sharp(imageFile.sourcePath)
|
||||
.webp({ quality: 85 })
|
||||
.toFile(webpPath);
|
||||
synced++;
|
||||
} catch (error) {
|
||||
// If Sharp fails, fall back to copying original
|
||||
log.warn(`⚠️ Could not optimize ${imageFile.relativePath}, copying original:`, error.message);
|
||||
await fs.copyFile(imageFile.sourcePath, targetPath);
|
||||
synced++;
|
||||
}
|
||||
} else {
|
||||
// Non-image files (audio, video, PDF) - just copy as-is
|
||||
await fs.copyFile(imageFile.sourcePath, targetPath);
|
||||
synced++;
|
||||
}
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: Remove files from target that no longer exist in source
|
||||
// We need to recursively clean up the target directory
|
||||
await cleanupTargetDirectory(config.target, imageFiles);
|
||||
|
||||
return { synced, skipped, removed: 0 }; // removed count handled in cleanup function
|
||||
} catch (error) {
|
||||
log.error(`❌ Error syncing ${config.name} images:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively clean up target directory, removing files that no longer exist in source
|
||||
async function cleanupTargetDirectory(targetDir, sourceImageFiles) {
|
||||
// Create a set that maps both original paths and attachments subfolder paths
|
||||
const sourceFileSet = new Set();
|
||||
sourceImageFiles.forEach(f => {
|
||||
sourceFileSet.add(f.relativePath);
|
||||
// Also add the path without attachments/ prefix for cleanup
|
||||
if (f.relativePath.startsWith('attachments/') || f.relativePath.startsWith('attachments\\')) {
|
||||
sourceFileSet.add(f.relativePath.replace(/^attachments[/\\]/, ''));
|
||||
}
|
||||
// Add WebP version of image paths (since we generate WebP from originals)
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(f.relativePath)) {
|
||||
const webpPath = f.relativePath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
||||
sourceFileSet.add(webpPath);
|
||||
if (f.relativePath.startsWith('attachments/') || f.relativePath.startsWith('attachments\\')) {
|
||||
const webpPathNoAttachments = webpPath.replace(/^attachments[/\\]/, '');
|
||||
sourceFileSet.add(webpPathNoAttachments);
|
||||
}
|
||||
}
|
||||
});
|
||||
let removed = 0;
|
||||
|
||||
try {
|
||||
const items = await fs.readdir(targetDir);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(targetDir, item);
|
||||
const stat = await fs.stat(itemPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Recursively clean subdirectories
|
||||
const subRemoved = await cleanupTargetDirectory(itemPath, sourceImageFiles);
|
||||
removed += subRemoved;
|
||||
|
||||
// Remove empty directories
|
||||
try {
|
||||
const remainingItems = await fs.readdir(itemPath);
|
||||
if (remainingItems.length === 0) {
|
||||
await fs.rmdir(itemPath);
|
||||
}
|
||||
} catch {
|
||||
// Directory might not be empty or might have been removed already
|
||||
}
|
||||
} else {
|
||||
// Check if this file exists in source
|
||||
const relativePath = path.relative(targetDir, itemPath).replace(/\\/g, '/');
|
||||
if (!sourceFileSet.has(relativePath)) {
|
||||
await fs.unlink(itemPath);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might not exist or be readable
|
||||
if (error.code !== 'ENOENT') {
|
||||
log.warn(`Warning: Could not clean directory ${targetDir}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function syncAllImages() {
|
||||
log.info('🖼️ Syncing images from content to public directory...');
|
||||
|
||||
for (const config of IMAGE_SYNC_CONFIGS) {
|
||||
const result = await syncImagesForConfig(config);
|
||||
if (result.synced > 0 || result.skipped > 0) {
|
||||
log.info(`📁 Syncing ${config.name} images...`);
|
||||
if (result.synced > 0) log.info(` Synced ${result.synced} files`);
|
||||
if (result.skipped > 0) log.info(` Skipped ${result.skipped} files that were unchanged`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync folder-based images for all content types
|
||||
const contentTypes = ['posts', 'pages', 'projects', 'docs', 'special'];
|
||||
for (const contentType of contentTypes) {
|
||||
await syncFolderBasedImages(contentType);
|
||||
}
|
||||
|
||||
log.info('🎉 Image sync complete!');
|
||||
}
|
||||
|
||||
syncAllImages();
|
||||
317
scripts/update.mjs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Astro Modular Update Script
|
||||
*
|
||||
* Downloads the latest release from GitHub, replaces framework files,
|
||||
* and preserves user content and assets. After updating, open Obsidian
|
||||
* and click "Apply all settings" in the Astro Modular Settings plugin
|
||||
* to rewrite your config.ts with your saved settings.
|
||||
*
|
||||
* Usage: pnpm run update
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { createWriteStream, existsSync, mkdirSync, cpSync, rmSync, readFileSync, readdirSync, statSync, renameSync, writeFileSync } from 'fs';
|
||||
import { join, resolve, basename } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { get as httpsGet } from 'https';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const REPO = 'davidvkimball/astro-modular';
|
||||
|
||||
// Files and directories that belong to the USER and must be preserved
|
||||
const USER_PATHS = [
|
||||
'src/content', // All user content + .obsidian vault (plugin data.json files)
|
||||
'public/profile.jpg',
|
||||
'public/profile.png',
|
||||
'public/profile.webp',
|
||||
'public/favicon.png',
|
||||
'public/favicon-dark.png',
|
||||
'public/favicon-light.png',
|
||||
'public/open-graph.png',
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.production',
|
||||
];
|
||||
|
||||
// Directories that should never be touched
|
||||
const SKIP_PATHS = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.astro',
|
||||
'.netlify',
|
||||
];
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function log(msg) { console.log(` ${msg}`); }
|
||||
function logStep(msg) { console.log(`\n> ${msg}`); }
|
||||
function logError(msg) { console.error(`\n ERROR: ${msg}`); }
|
||||
|
||||
function getCurrentVersion() {
|
||||
const versionFile = join(ROOT, 'VERSION');
|
||||
if (existsSync(versionFile)) {
|
||||
return readFileSync(versionFile, 'utf-8').trim();
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
async function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpsGet(url, { headers: { 'User-Agent': 'astro-modular-updater' } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return fetchJSON(res.headers.location).then(resolve, reject);
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { reject(new Error(`Invalid JSON from ${url}`)); }
|
||||
});
|
||||
res.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpsGet(url, { headers: { 'User-Agent': 'astro-modular-updater' } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return downloadFile(res.headers.location, dest).then(resolve, reject);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
||||
}
|
||||
const ws = createWriteStream(dest);
|
||||
pipeline(res, ws).then(resolve, reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function copyIfExists(src, dest) {
|
||||
if (!existsSync(src)) return false;
|
||||
const stat = statSync(src);
|
||||
if (stat.isDirectory()) {
|
||||
cpSync(src, dest, { recursive: true });
|
||||
} else {
|
||||
const dir = join(dest, '..');
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
cpSync(src, dest);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('\nAstro Modular Updater');
|
||||
console.log('====================');
|
||||
|
||||
// 1. Get current version
|
||||
const currentVersion = getCurrentVersion();
|
||||
log(`Current version: ${currentVersion}`);
|
||||
|
||||
// 2. Check latest release
|
||||
logStep('Checking for updates...');
|
||||
let release;
|
||||
try {
|
||||
release = await fetchJSON(`https://api.github.com/repos/${REPO}/releases/latest`);
|
||||
} catch (err) {
|
||||
logError(`Could not reach GitHub: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const latestVersion = release.tag_name.replace(/^v/, '');
|
||||
log(`Latest version: ${latestVersion}`);
|
||||
|
||||
if (currentVersion === latestVersion) {
|
||||
log('Already up to date!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Download release archive
|
||||
logStep(`Downloading v${latestVersion}...`);
|
||||
const isWindows = process.platform === 'win32';
|
||||
const archiveUrl = isWindows ? release.zipball_url : release.tarball_url;
|
||||
const tempDir = join(tmpdir(), `astro-modular-update-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const archivePath = join(tempDir, isWindows ? 'release.zip' : 'release.tar.gz');
|
||||
|
||||
try {
|
||||
await downloadFile(archiveUrl, archivePath);
|
||||
} catch (err) {
|
||||
logError(`Download failed: ${err.message}`);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
log(`Downloaded to temp directory.`);
|
||||
|
||||
// 4. Extract archive
|
||||
logStep('Extracting...');
|
||||
const extractDir = join(tempDir, 'extracted');
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
try {
|
||||
if (isWindows) {
|
||||
execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`, { stdio: 'pipe' });
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, { stdio: 'pipe' });
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`Extraction failed: ${err.message}`);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// GitHub tarballs extract into a directory like "owner-repo-hash/"
|
||||
const extractedContents = readdirSync(extractDir);
|
||||
if (extractedContents.length !== 1) {
|
||||
logError('Unexpected archive structure.');
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
const sourceDir = join(extractDir, extractedContents[0]);
|
||||
log('Extracted successfully.');
|
||||
|
||||
// 5. Back up user files
|
||||
logStep('Backing up user content...');
|
||||
const backupDir = join(tempDir, 'backup');
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
let backedUp = 0;
|
||||
for (const userPath of USER_PATHS) {
|
||||
const src = join(ROOT, userPath);
|
||||
const dest = join(backupDir, userPath);
|
||||
if (copyIfExists(src, dest)) {
|
||||
log(` Backed up: ${userPath}`);
|
||||
backedUp++;
|
||||
}
|
||||
}
|
||||
|
||||
// Also back up any user-added public/ files not in the new release
|
||||
const publicDir = join(ROOT, 'public');
|
||||
const newPublicDir = join(sourceDir, 'public');
|
||||
if (existsSync(publicDir)) {
|
||||
for (const file of readdirSync(publicDir)) {
|
||||
const fullPath = join(publicDir, file);
|
||||
if (statSync(fullPath).isFile()) {
|
||||
const alreadyBacked = USER_PATHS.some(p => p === `public/${file}`);
|
||||
if (!alreadyBacked) {
|
||||
// Check if this file does NOT exist in the new release (user-added)
|
||||
if (!existsSync(join(newPublicDir, file))) {
|
||||
const dest = join(backupDir, 'public', file);
|
||||
mkdirSync(join(backupDir, 'public'), { recursive: true });
|
||||
cpSync(fullPath, dest);
|
||||
log(` Backed up (user asset): public/${file}`);
|
||||
backedUp++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`${backedUp} item(s) backed up.`);
|
||||
|
||||
// 6. Replace framework files
|
||||
logStep('Updating framework files...');
|
||||
|
||||
// Get list of all paths in the new release (excluding user/skip paths)
|
||||
function listPaths(dir, base = '') {
|
||||
const results = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const rel = base ? `${base}/${entry}` : entry;
|
||||
const full = join(dir, entry);
|
||||
|
||||
// Skip paths we should never touch
|
||||
if (SKIP_PATHS.includes(rel)) continue;
|
||||
// Skip user content paths (we'll restore from backup)
|
||||
if (USER_PATHS.includes(rel)) continue;
|
||||
|
||||
if (statSync(full).isDirectory()) {
|
||||
// If it's a user directory root, skip recursing
|
||||
if (USER_PATHS.some(p => p.startsWith(rel + '/'))) {
|
||||
// Only skip exact user-path prefixes like src/content
|
||||
if (USER_PATHS.includes(rel)) continue;
|
||||
results.push(...listPaths(full, rel));
|
||||
} else {
|
||||
results.push(...listPaths(full, rel));
|
||||
}
|
||||
} else {
|
||||
results.push(rel);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const newFiles = listPaths(sourceDir);
|
||||
let updated = 0;
|
||||
|
||||
for (const relPath of newFiles) {
|
||||
const src = join(sourceDir, relPath);
|
||||
const dest = join(ROOT, relPath);
|
||||
const destDir = join(dest, '..');
|
||||
|
||||
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
||||
cpSync(src, dest);
|
||||
updated++;
|
||||
}
|
||||
log(`${updated} framework file(s) updated.`);
|
||||
|
||||
// 7. Restore user files from backup
|
||||
logStep('Restoring user content...');
|
||||
let restored = 0;
|
||||
|
||||
for (const userPath of USER_PATHS) {
|
||||
const src = join(backupDir, userPath);
|
||||
const dest = join(ROOT, userPath);
|
||||
if (copyIfExists(src, dest)) {
|
||||
log(` Restored: ${userPath}`);
|
||||
restored++;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore user-added public/ files
|
||||
const backupPublicDir = join(backupDir, 'public');
|
||||
if (existsSync(backupPublicDir)) {
|
||||
for (const file of readdirSync(backupPublicDir)) {
|
||||
const alreadyRestored = USER_PATHS.some(p => p === `public/${file}`);
|
||||
if (!alreadyRestored) {
|
||||
cpSync(join(backupPublicDir, file), join(ROOT, 'public', file));
|
||||
log(` Restored (user asset): public/${file}`);
|
||||
restored++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`${restored} item(s) restored.`);
|
||||
|
||||
// 8. Install dependencies
|
||||
logStep('Installing dependencies...');
|
||||
try {
|
||||
execSync('pnpm install', { cwd: ROOT, stdio: 'inherit' });
|
||||
} catch {
|
||||
logError('pnpm install failed. You may need to run it manually.');
|
||||
}
|
||||
|
||||
// 9. Clean up temp directory
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
// 10. Done
|
||||
console.log('\n====================================');
|
||||
console.log(` Updated to v${latestVersion}!`);
|
||||
console.log('====================================');
|
||||
console.log('\n Next steps:');
|
||||
console.log(' 1. Open your vault in Obsidian');
|
||||
console.log(' 2. Go to Astro Modular Settings > Advanced tab');
|
||||
console.log(' 3. Click "Apply to config.ts" under "Apply all settings"');
|
||||
console.log(' to write your saved settings to the new config.ts');
|
||||
console.log(' 4. Run `pnpm dev` to verify everything works');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
logError(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
8
selhostedbg-web.code-workspace
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
29
src/components/BaseTable.astro
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
export interface Props {
|
||||
columns: string[];
|
||||
rows: Record<string, any>[];
|
||||
}
|
||||
const { columns, rows } = Astro.props as Props;
|
||||
---
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-primary-200 dark:border-primary-600">
|
||||
{columns.map((col) => (
|
||||
<th class="py-2 pr-4 text-primary-600 dark:text-primary-300 whitespace-nowrap">{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr class="border-b border-primary-200/60 dark:border-primary-600/60 last:border-0">
|
||||
{columns.map((col) => (
|
||||
<td class="py-2 pr-4 text-primary-900 dark:text-primary-100 whitespace-nowrap">{String(row[col] ?? '')}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
105
src/components/Button.astro
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
|
||||
---
|
||||
export interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
type = 'button',
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
...rest
|
||||
} = Astro.props;
|
||||
|
||||
// Base button classes
|
||||
const baseClasses = 'btn inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background cursor-pointer border no-underline';
|
||||
|
||||
// Size variations
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
};
|
||||
|
||||
// Variant classes that use your existing design tokens
|
||||
const variantClasses = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
outline: 'btn-outline',
|
||||
ghost: 'btn-ghost'
|
||||
};
|
||||
|
||||
const allClasses = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${className}`;
|
||||
|
||||
// Determine if this should be a link or button
|
||||
const Element = href ? 'a' : 'button';
|
||||
---
|
||||
|
||||
{Element === 'a' ? (
|
||||
<a
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
class={allClasses}
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
class={allClasses}
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<style>
|
||||
/* Ensure buttons work well in prose content */
|
||||
.btn {
|
||||
text-decoration: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Special handling for buttons in prose/markdown content */
|
||||
.prose .btn {
|
||||
margin: 0.25rem 0.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Ensure proper focus states */
|
||||
.btn:focus-visible {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
111
src/components/CategoryFilter.astro
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
import { siteConfig } from '../config';
|
||||
---
|
||||
|
||||
<!-- Category Filter Component -->
|
||||
<!-- This component handles category filtering functionality for projects -->
|
||||
|
||||
<script>
|
||||
// Declare global function for Swup compatibility
|
||||
declare global {
|
||||
interface Window {
|
||||
initializeCategoryFiltering: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Global category filtering function - Swup compatible
|
||||
function initializeCategoryFiltering() {
|
||||
// Skip entirely if we're on the projects page (ProjectCategories handles it with multi-select)
|
||||
// Check multiple ways to ensure we catch it
|
||||
const pathname = window.location.pathname;
|
||||
const hasCategoriesContainer = document.querySelector('.categories-container') !== null;
|
||||
const isProjectsPage = pathname.startsWith('/projects') ||
|
||||
pathname === '/projects' ||
|
||||
pathname.match(/^\/projects\/?$/) !== null ||
|
||||
hasCategoriesContainer;
|
||||
|
||||
if (isProjectsPage) {
|
||||
// Completely skip - ProjectCategories will handle it
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryItems = document.querySelectorAll('.category-item');
|
||||
const projectCards = document.querySelectorAll('[data-project-categories]');
|
||||
let currentActiveCategory: string | null = null;
|
||||
|
||||
// If no category items found, skip
|
||||
if (categoryItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing event listeners to prevent duplicates
|
||||
categoryItems.forEach(item => {
|
||||
const newItem = item.cloneNode(true);
|
||||
item.parentNode?.replaceChild(newItem, item);
|
||||
});
|
||||
|
||||
// Re-query after cloning to get fresh elements
|
||||
const freshCategoryItems = document.querySelectorAll('.category-item');
|
||||
|
||||
// Add click functionality for filtering
|
||||
freshCategoryItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const category = item.getAttribute('data-category');
|
||||
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this category is already active
|
||||
const isCurrentlyActive = currentActiveCategory === category;
|
||||
|
||||
// Reset all buttons to inactive state
|
||||
freshCategoryItems.forEach(btn => {
|
||||
btn.classList.remove('bg-highlight-100', 'dark:bg-highlight-900/40', 'text-highlight-800', 'dark:text-highlight-200', 'ring-1', 'ring-highlight-300', 'dark:ring-highlight-700');
|
||||
btn.classList.add('bg-primary-100', 'dark:bg-primary-800', 'text-primary-700', 'dark:text-primary-300');
|
||||
});
|
||||
|
||||
if (isCurrentlyActive) {
|
||||
// If clicking the same category, show all projects
|
||||
projectCards.forEach(card => {
|
||||
(card as HTMLElement).style.display = 'block';
|
||||
});
|
||||
currentActiveCategory = null;
|
||||
} else {
|
||||
// Filter projects by category
|
||||
projectCards.forEach(card => {
|
||||
const cardCategories = card.getAttribute('data-project-categories') || '';
|
||||
|
||||
if (cardCategories.includes(category)) {
|
||||
(card as HTMLElement).style.display = 'block';
|
||||
} else {
|
||||
(card as HTMLElement).style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Make clicked button active
|
||||
item.classList.remove('bg-primary-100', 'dark:bg-primary-800', 'text-primary-700', 'dark:text-primary-300');
|
||||
item.classList.add('bg-highlight-100', 'dark:bg-highlight-900/40', 'text-highlight-800', 'dark:text-highlight-200', 'ring-1', 'ring-highlight-300', 'dark:ring-highlight-700');
|
||||
currentActiveCategory = category;
|
||||
}
|
||||
|
||||
// Dispatch custom event for analytics
|
||||
window.dispatchEvent(new CustomEvent('categoryClicked', {
|
||||
detail: { category, isActive: currentActiveCategory === category }
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize immediately
|
||||
initializeCategoryFiltering();
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', initializeCategoryFiltering);
|
||||
|
||||
// Make function available globally for Swup
|
||||
window['initializeCategoryFiltering'] = initializeCategoryFiltering;
|
||||
</script>
|
||||
1444
src/components/CommandPalette.astro
Normal file
183
src/components/DocsSidebar.astro
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
export interface Props {
|
||||
currentDocId: string;
|
||||
currentCategory: string | null;
|
||||
allDocs: CollectionEntry<'docs'>[];
|
||||
}
|
||||
|
||||
const { currentDocId, currentCategory, allDocs } = Astro.props;
|
||||
|
||||
// Filter docs by category
|
||||
// If current page has no category (or "General"), show all docs
|
||||
// Otherwise, show only docs matching the current category
|
||||
let filteredDocs: CollectionEntry<'docs'>[] = [];
|
||||
|
||||
if (!currentCategory || currentCategory.trim() === '' || currentCategory === 'General') {
|
||||
// Show all docs if no category
|
||||
filteredDocs = allDocs;
|
||||
} else {
|
||||
// Filter by category - handle "Unsorted" specially
|
||||
if (currentCategory === 'Unsorted') {
|
||||
filteredDocs = allDocs.filter(doc =>
|
||||
!doc.data.category ||
|
||||
doc.data.category.trim() === '' ||
|
||||
doc.data.category === 'General'
|
||||
);
|
||||
} else {
|
||||
filteredDocs = allDocs.filter(doc =>
|
||||
doc.data.category &&
|
||||
doc.data.category.trim() !== '' &&
|
||||
doc.data.category !== 'General' &&
|
||||
doc.data.category === currentCategory
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by order, then by title
|
||||
filteredDocs.sort((a, b) => {
|
||||
if (a.data.order !== b.data.order) {
|
||||
return a.data.order - b.data.order;
|
||||
}
|
||||
return a.data.title.localeCompare(b.data.title);
|
||||
});
|
||||
|
||||
// Determine sidebar header text
|
||||
const sidebarHeader = currentCategory && currentCategory !== 'General' ? currentCategory : 'Documentation';
|
||||
---
|
||||
|
||||
<!-- Desktop Sidebar (visible at xl breakpoint and above, hidden on mobile - same behavior as TOC) -->
|
||||
<aside
|
||||
id="docs-sidebar-desktop"
|
||||
class="hidden xl:block absolute top-0 right-full w-64 mr-4 h-full pointer-events-auto"
|
||||
aria-label="Documentation navigation"
|
||||
>
|
||||
<div class="sticky top-24 pb-6">
|
||||
<div class="bg-primary-50 dark:bg-primary-900 rounded-lg border border-primary-200 dark:border-primary-700 shadow-sm p-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Icon name="book-open" class="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
<h2 class="text-sm font-semibold text-primary-900 dark:text-primary-50">
|
||||
{sidebarHeader}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="docs-sidebar-content overflow-x-hidden overflow-y-auto">
|
||||
<nav class="space-y-1">
|
||||
{filteredDocs.map((doc) => {
|
||||
const isActive = doc.id === currentDocId;
|
||||
return (
|
||||
<a
|
||||
href={`/docs/${doc.id}`}
|
||||
class={`block px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-highlight-50 dark:bg-highlight-900/20 text-highlight-600 dark:text-highlight-400 font-semibold'
|
||||
: 'text-primary-600 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-800 hover:text-highlight-600 dark:hover:text-highlight-400'
|
||||
}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{doc.data.title}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
// Constants for docs sidebar height calculation
|
||||
const BOTTOM_SPACING = 96; // 6rem = 96px - fixed spacing from bottom of viewport
|
||||
const MIN_SIDEBAR_HEIGHT = 200; // Minimum height for sidebar to be useful
|
||||
|
||||
// Simplified height management - just ensure scrolling works
|
||||
function updateDocsSidebarHeight() {
|
||||
const sidebarContent = document.querySelector('.docs-sidebar-content') as HTMLElement;
|
||||
|
||||
if (!sidebarContent) return;
|
||||
|
||||
// Set a reasonable max height using viewport units - no dynamic calculation
|
||||
sidebarContent.style.maxHeight = '60vh'; // 60% of viewport height
|
||||
sidebarContent.style.overflowY = 'auto';
|
||||
}
|
||||
|
||||
// Debounce function for performance
|
||||
function debounce(func: Function, wait: number) {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return function executedFunction(...args: any[]) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Global function to initialize docs sidebar
|
||||
function initializeDocsSidebar() {
|
||||
const sidebarContainer = document.querySelector('#docs-sidebar-desktop') as HTMLElement;
|
||||
|
||||
if (!sidebarContainer) return;
|
||||
|
||||
// Calculate initial sidebar height immediately
|
||||
updateDocsSidebarHeight();
|
||||
|
||||
// No complex observers needed with simplified height management
|
||||
}
|
||||
|
||||
// Make functions globally available
|
||||
(window as any).initializeDocsSidebar = initializeDocsSidebar;
|
||||
(window as any).updateDocsSidebarHeight = updateDocsSidebarHeight;
|
||||
|
||||
// Initialize on DOMContentLoaded
|
||||
document.addEventListener('DOMContentLoaded', initializeDocsSidebar);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Docs sidebar content area - scrollable */
|
||||
.docs-sidebar-content {
|
||||
/* Simple viewport-based max height */
|
||||
max-height: 60vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--color-primary-300)) transparent;
|
||||
}
|
||||
|
||||
/* Webkit scrollbar styling for Chrome/Safari/Edge */
|
||||
.docs-sidebar-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.docs-sidebar-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.docs-sidebar-content::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--color-primary-300));
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.docs-sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--color-primary-400));
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar for docs sidebar */
|
||||
.dark .docs-sidebar-content {
|
||||
scrollbar-color: rgb(var(--color-primary-600)) transparent;
|
||||
}
|
||||
|
||||
.dark .docs-sidebar-content::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--color-primary-600));
|
||||
}
|
||||
|
||||
.dark .docs-sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--color-primary-500));
|
||||
}
|
||||
</style>
|
||||
163
src/components/DocumentationCard.astro
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { formatDate, processMarkdown, isValidDate } from '../utils/markdown';
|
||||
import { siteConfig } from '../config';
|
||||
import ImageWrapper from './ImageWrapper.astro';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
interface Props {
|
||||
documentation: CollectionEntry<'docs'>;
|
||||
featured?: boolean;
|
||||
eager?: boolean;
|
||||
context?: 'featured' | 'recent' | 'docs' | 'tags' | 'home';
|
||||
}
|
||||
|
||||
const { documentation, featured = false, eager = false, context = 'docs' } = Astro.props;
|
||||
|
||||
// Get excerpt from markdown processing
|
||||
const { excerpt } = processMarkdown(documentation.body);
|
||||
|
||||
const { title, description: rawDescription, lastModified, image: rawImage, imageAlt, category, version } = documentation.data;
|
||||
|
||||
// Process Obsidian bracket syntax in image field (copy from PostCard)
|
||||
const image = (() => {
|
||||
if (!rawImage) return rawImage;
|
||||
|
||||
// Handle case where rawImage is an array (unquoted YAML syntax)
|
||||
let imageValue = rawImage;
|
||||
if (Array.isArray(rawImage)) {
|
||||
// If it's an array, take the first element
|
||||
imageValue = rawImage[0];
|
||||
}
|
||||
|
||||
// Ensure imageValue is a string before calling string methods
|
||||
if (typeof imageValue !== 'string') {
|
||||
console.warn('DocumentationCard: imageValue is not a string:', imageValue);
|
||||
return imageValue;
|
||||
}
|
||||
|
||||
// Check if it's Obsidian double bracket syntax
|
||||
if (imageValue.startsWith('[[') && imageValue.endsWith(']]')) {
|
||||
// Extract the content inside the double brackets
|
||||
return imageValue.slice(2, -2);
|
||||
}
|
||||
|
||||
return imageValue;
|
||||
})();
|
||||
|
||||
const description = rawDescription || excerpt;
|
||||
|
||||
// Determine if we should show the cover image
|
||||
// Documentation should always show images when available (not controlled by postOptions)
|
||||
const shouldShowCoverImage = true;
|
||||
|
||||
// Hide images for docs on homepage for cleaner look
|
||||
const showCoverImage = shouldShowCoverImage && image && context !== 'home';
|
||||
---
|
||||
|
||||
<div class="group relative bg-white dark:bg-primary-800 rounded-lg border border-primary-200 dark:border-primary-700 hover:shadow-lg transition-all duration-300 h-full flex flex-col">
|
||||
<a href={`/docs/${documentation.id}`} class="block flex flex-col h-full" aria-label={`View documentation: ${title}`}>
|
||||
<!-- Header with icon and version -->
|
||||
<div class="flex items-center justify-between p-4 pb-2">
|
||||
<Icon name="book-text" class="w-4 h-4 text-primary-500 dark:text-primary-400" />
|
||||
{version && (
|
||||
<span class="text-xs text-primary-500 dark:text-primary-400 font-mono bg-primary-100 dark:bg-primary-700 px-2 py-1 rounded">
|
||||
v{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Cover Image (if exists) -->
|
||||
{image && showCoverImage && (
|
||||
<div class="px-4 pb-2">
|
||||
<div class="relative overflow-hidden rounded-md">
|
||||
<ImageWrapper
|
||||
src={(() => {
|
||||
// Clean the image path by removing Obsidian brackets
|
||||
// Remove Obsidian brackets to get the actual image path
|
||||
let cleanImagePath = image;
|
||||
if (typeof cleanImagePath === 'string' && cleanImagePath.startsWith('[[') && cleanImagePath.endsWith(']]')) {
|
||||
cleanImagePath = cleanImagePath.slice(2, -2);
|
||||
}
|
||||
|
||||
// Remove Obsidian subfolder prefixes (images/, attachments/)
|
||||
if (typeof cleanImagePath === 'string') {
|
||||
// Remove images/ prefix if present (Obsidian subfolder setting)
|
||||
if (cleanImagePath.startsWith('images/')) {
|
||||
return cleanImagePath.replace('images/', '');
|
||||
}
|
||||
// Remove attachments/ prefix - basePath will add it back
|
||||
if (cleanImagePath.startsWith('attachments/')) {
|
||||
return cleanImagePath.replace('attachments/', '');
|
||||
}
|
||||
}
|
||||
|
||||
return cleanImagePath || '';
|
||||
})()}
|
||||
basePath={(() => {
|
||||
// 1. External URLs - no basePath needed
|
||||
if (typeof image === 'string' && image.startsWith('http')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 2. Clean the image path to check for attachments/ prefix
|
||||
let cleanImagePath = image;
|
||||
if (typeof cleanImagePath === 'string' && cleanImagePath.startsWith('[[') && cleanImagePath.endsWith(']]')) {
|
||||
cleanImagePath = cleanImagePath.slice(2, -2);
|
||||
}
|
||||
|
||||
// 3. Detect folder-based vs file-based: if image path starts with 'attachments/',
|
||||
// it's a single-file doc (shared attachments folder)
|
||||
if (typeof cleanImagePath === 'string' && cleanImagePath.startsWith('attachments/')) {
|
||||
// Single-file doc with image in attachments folder
|
||||
// The sync script copies these to /docs/attachments/
|
||||
return "/docs/attachments/";
|
||||
}
|
||||
|
||||
// 4. Folder-based doc - sync script copies images to doc folder root
|
||||
// Safety check: ensure documentation.id exists before using it
|
||||
if (documentation.id) {
|
||||
return `/docs/${documentation.id}/`;
|
||||
} else {
|
||||
// Fallback to attachments directory if documentation.id is undefined
|
||||
return '/docs/attachments/';
|
||||
}
|
||||
})()}
|
||||
alt={imageAlt || `Cover image for documentation: ${title}`}
|
||||
width={400}
|
||||
height={200}
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
fetchpriority={eager ? 'high' : 'auto'}
|
||||
class="w-full h-32 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 pt-2 flex-grow flex flex-col">
|
||||
<!-- Title -->
|
||||
<h3 class="text-lg font-semibold text-primary-900 dark:text-primary-50 mb-2 group-hover:text-highlight-600 dark:group-hover:text-highlight-400 transition-colors line-clamp-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
{description && (
|
||||
<p class="text-sm text-primary-600 dark:text-primary-300 mb-3 line-clamp-3">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<!-- Footer -->
|
||||
{lastModified && (
|
||||
<div class="pt-2 border-t border-primary-200 dark:border-primary-700 mt-auto">
|
||||
{isValidDate(lastModified) && (
|
||||
<time datetime={lastModified.toISOString()} class="text-xs text-primary-500 dark:text-primary-400">
|
||||
Updated {formatDate(lastModified)}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
154
src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
import { siteConfig } from '@/config';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const socialLinks = (siteConfig.navigation.social || []).filter((social) => social && social.url && social.icon);
|
||||
const showSocialIcons = siteConfig.footer.showSocialIconsInFooter && socialLinks.length > 0;
|
||||
const showProfilePicture = siteConfig.profilePicture.enabled && siteConfig.profilePicture.placement === 'footer';
|
||||
const profilePicture = siteConfig.profilePicture;
|
||||
const footerEnabled = siteConfig.footer.enabled;
|
||||
|
||||
// Replace placeholder with actual author name
|
||||
const footerContent = siteConfig.footer.content.replace('{author}', siteConfig.author);
|
||||
|
||||
// Size and style classes for profile picture
|
||||
const getProfilePictureClasses = (size: string, style: string) => {
|
||||
let sizeClasses = '';
|
||||
switch (size) {
|
||||
case 'sm': sizeClasses = 'h-8'; break;
|
||||
case 'lg': sizeClasses = 'h-16'; break;
|
||||
default: sizeClasses = 'h-12'; // md
|
||||
}
|
||||
|
||||
let styleClasses = '';
|
||||
switch (style) {
|
||||
case 'circle': styleClasses = 'rounded-full object-cover border-2 border-primary-200 dark:border-primary-700'; break;
|
||||
case 'square': styleClasses = 'rounded-lg object-cover border-2 border-primary-200 dark:border-primary-700'; break;
|
||||
default: styleClasses = 'object-contain'; // none
|
||||
}
|
||||
|
||||
return `${sizeClasses} ${styleClasses}`;
|
||||
};
|
||||
---
|
||||
|
||||
<footer class="bg-white dark:bg-primary-950 mt-auto">
|
||||
<div class="mx-auto px-4 sm:px-6 lg:px-8 py-8" style={`max-width: ${siteConfig.layout.contentWidth}`}>
|
||||
<!-- Profile picture above footer content when footer is enabled -->
|
||||
{showProfilePicture && footerEnabled && (
|
||||
<div class="mb-4 flex justify-start">
|
||||
{profilePicture.url ? (
|
||||
<a
|
||||
href={profilePicture.url}
|
||||
class="inline-block hover:opacity-80 transition-opacity"
|
||||
target={profilePicture.url.startsWith('http') ? '_blank' : undefined}
|
||||
rel={undefined}
|
||||
>
|
||||
<img
|
||||
src={profilePicture.image}
|
||||
alt={profilePicture.alt}
|
||||
class={getProfilePictureClasses(profilePicture.size, profilePicture.style)}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={profilePicture.image}
|
||||
alt={profilePicture.alt}
|
||||
class={getProfilePictureClasses(profilePicture.size, profilePicture.style)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Mobile: Social icons above footer content, centered -->
|
||||
{showSocialIcons && (
|
||||
<div class="flex justify-center md:hidden mb-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
href={social.url}
|
||||
title={social.title}
|
||||
class="text-primary-500 hover:text-highlight-600 dark:text-primary-400 dark:hover:text-highlight-400 transition-colors duration-200"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name={social.icon} class="w-5 h-5" />
|
||||
<span class="sr-only">{social.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Footer content and social icons on same line -->
|
||||
{footerEnabled && (
|
||||
<div class={`text-center ${showSocialIcons ? 'md:flex md:justify-between md:items-center' : ''}`}>
|
||||
<p class="text-xs text-primary-500 dark:text-primary-400" set:html={footerContent}>
|
||||
</p>
|
||||
|
||||
<!-- Desktop: Social icons on same line as footer content -->
|
||||
{showSocialIcons && (
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
href={social.url}
|
||||
title={social.title}
|
||||
class="text-primary-500 hover:text-highlight-600 dark:text-primary-400 dark:hover:text-highlight-400 transition-colors duration-200"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name={social.icon} class="w-5 h-5" />
|
||||
<span class="sr-only">{social.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- When footer is disabled: Profile picture aligned with social icons OR just centered social icons -->
|
||||
{!footerEnabled && (
|
||||
<div class={`text-center ${showProfilePicture && showSocialIcons ? 'md:flex md:justify-between md:items-center' : ''}`}>
|
||||
{showProfilePicture ? (
|
||||
<div class="flex justify-start">
|
||||
{profilePicture.url ? (
|
||||
<a
|
||||
href={profilePicture.url}
|
||||
class="inline-block hover:opacity-80 transition-opacity"
|
||||
target={profilePicture.url.startsWith('http') ? '_blank' : undefined}
|
||||
rel={undefined}
|
||||
>
|
||||
<img
|
||||
src={profilePicture.image}
|
||||
alt={profilePicture.alt}
|
||||
class={getProfilePictureClasses(profilePicture.size, profilePicture.style)}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={profilePicture.image}
|
||||
alt={profilePicture.alt}
|
||||
class={getProfilePictureClasses(profilePicture.size, profilePicture.style)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<!-- Social icons: centered when no profile picture, or aligned when profile picture exists -->
|
||||
{showSocialIcons && (
|
||||
<div class={`flex items-center space-x-4 ${showProfilePicture ? 'hidden md:flex' : 'justify-center'}`}>
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
href={social.url}
|
||||
title={social.title}
|
||||
class="text-primary-500 hover:text-highlight-600 dark:text-primary-400 dark:hover:text-highlight-400 transition-colors duration-200"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name={social.icon} class="w-5 h-5" />
|
||||
<span class="sr-only">{social.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
109
src/components/GiscusComments.astro
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
import { siteConfig } from '@/config';
|
||||
|
||||
// Only render if comments are enabled
|
||||
if (!siteConfig.postOptions.comments.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { comments } = siteConfig.postOptions;
|
||||
|
||||
---
|
||||
|
||||
<div class="giscus-comments-container">
|
||||
<div
|
||||
id="giscus-comments"
|
||||
class="giscus-comments-widget"
|
||||
data-repo={comments.repo}
|
||||
data-repo-id={comments.repoId}
|
||||
data-category={comments.category}
|
||||
data-category-id={comments.categoryId}
|
||||
data-mapping={comments.mapping}
|
||||
data-strict={comments.strict}
|
||||
data-reactions-enabled={comments.reactions}
|
||||
data-emit-metadata={comments.metadata}
|
||||
data-input-position={comments.inputPosition}
|
||||
data-theme={comments.theme}
|
||||
data-lang={comments.lang}
|
||||
data-loading={comments.loading}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Comments are now handled by BaseLayout -->
|
||||
|
||||
<style>
|
||||
.giscus-comments-container {
|
||||
margin-bottom: 2rem;
|
||||
/* Ensure container is outside prose constraints */
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.giscus-comments-widget {
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
/* Reset any prose styles */
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.giscus-comments-widget:empty::before {
|
||||
content: "";
|
||||
display: none;
|
||||
}
|
||||
|
||||
.giscus-comments-widget[data-error]::before {
|
||||
content: "Unable to load comments. Please check your Giscus configuration.";
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: var(--color-primary-400);
|
||||
font-style: italic;
|
||||
padding: 2rem;
|
||||
border: 1px dashed var(--color-primary-300);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Ensure Giscus iframe renders properly with transparent background */
|
||||
:global(.giscus) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
display: block !important;
|
||||
background: transparent !important;
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:global(.giscus-frame) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
border: none !important;
|
||||
min-height: 400px;
|
||||
display: block !important;
|
||||
background: transparent !important;
|
||||
/* Support transparent themes */
|
||||
color-scheme: light dark;
|
||||
/* Remove all outlines and focus states */
|
||||
outline: none !important;
|
||||
outline-offset: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Remove focus outline from iframe */
|
||||
:global(.giscus-frame:focus),
|
||||
:global(.giscus-frame:focus-visible),
|
||||
:global(.giscus-frame:active) {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure Giscus content is visible and properly styled */
|
||||
:global(.giscus-comments-widget) {
|
||||
width: 100%;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Exclude Giscus from prose styling */
|
||||
:global(.prose .giscus-comments-container) {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
725
src/components/GraphModal.astro
Normal file
|
|
@ -0,0 +1,725 @@
|
|||
---
|
||||
import { siteConfig } from '@/config';
|
||||
import Icon from './Icon.astro';
|
||||
---
|
||||
|
||||
<!-- Graph Modal Overlay -->
|
||||
<div
|
||||
id="graph-modal-overlay"
|
||||
class="fixed inset-0 z-50 hidden bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-6xl h-[80vh] bg-white dark:bg-primary-900 rounded-xl shadow-2xl border border-primary-200 dark:border-primary-700 animate-scale-in">
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
id="graph-modal-close"
|
||||
class="absolute top-4 right-4 z-10 p-2 text-primary-500 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-200 transition-colors rounded-lg hover:bg-primary-100 dark:hover:bg-primary-800"
|
||||
aria-label="Close graph modal"
|
||||
>
|
||||
<Icon name="x" class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Graph Container -->
|
||||
<div id="graph-modal-container" class="w-full h-full bg-primary-50 dark:bg-primary-800 rounded-xl overflow-hidden" style="touch-action: none;">
|
||||
<!-- Graph will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import * as d3 from "d3";
|
||||
import { getGraphThemeColors } from '@/utils/graph-theme-colors';
|
||||
|
||||
// Graph Modal Functionality
|
||||
let graphModalInitialized = false;
|
||||
let svg: d3.Selection<SVGSVGElement, undefined, null, undefined> | null = null;
|
||||
let simulation: d3.Simulation<any, any> | null = null;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let hasFittedToGraph = false; // Prevent multiple fitToGraph calls
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
radius: number;
|
||||
group?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
fx?: number | null;
|
||||
fy?: number | null;
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
source: string | GraphNode;
|
||||
target: string | GraphNode;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
function createGraphModalHTML() {
|
||||
// Check if modal already exists
|
||||
if (document.getElementById('graph-modal-overlay')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'graph-modal-overlay';
|
||||
modal.className = 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm animate-fade-in hidden';
|
||||
modal.innerHTML = `
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-7xl h-[90vh] bg-primary-50 dark:bg-primary-800 rounded-xl shadow-2xl border border-primary-200 dark:border-primary-700 animate-scale-in">
|
||||
<button
|
||||
id="graph-modal-close"
|
||||
class="absolute top-4 right-4 z-10 p-2 text-primary-500 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-200 transition-colors rounded-lg hover:bg-primary-100 dark:hover:bg-primary-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18"/>
|
||||
<path d="M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="graph-modal-container" class="w-full h-full bg-primary-50 dark:bg-primary-800 rounded-xl overflow-hidden" style="touch-action: none;">
|
||||
<div class="flex items-center justify-center h-full text-primary-500 dark:text-primary-400">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin w-8 h-8 mx-auto mb-4 opacity-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm">Loading graph...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Global modal functions that can be called from anywhere
|
||||
function openModal() {
|
||||
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
|
||||
if (!overlay) {
|
||||
createGraphModalHTML();
|
||||
// Try again after creating the HTML
|
||||
setTimeout(() => {
|
||||
openModal();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.style.display = 'block';
|
||||
overlay.style.visibility = 'visible';
|
||||
overlay.style.opacity = '1';
|
||||
overlay.style.zIndex = '9999';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100vw';
|
||||
overlay.style.height = '100vh';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Reset the fitToGraph flag when opening
|
||||
hasFittedToGraph = false;
|
||||
|
||||
loadGraphData();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
|
||||
if (!overlay) return;
|
||||
|
||||
overlay.classList.add('hidden');
|
||||
// Reset all inline styles
|
||||
overlay.style.display = '';
|
||||
overlay.style.visibility = '';
|
||||
overlay.style.opacity = '';
|
||||
overlay.style.zIndex = '';
|
||||
overlay.style.position = '';
|
||||
overlay.style.top = '';
|
||||
overlay.style.left = '';
|
||||
overlay.style.width = '';
|
||||
overlay.style.height = '';
|
||||
overlay.style.backgroundColor = '';
|
||||
document.body.style.overflow = '';
|
||||
|
||||
// Reset fitToGraph flag
|
||||
hasFittedToGraph = false;
|
||||
|
||||
// Clean up D3 graph
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleGraphContainerClick(e: Event) {
|
||||
// Prevent clicks on the graph container from bubbling up to the overlay
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function attachEventListeners() {
|
||||
// Remove existing listeners to prevent duplicates
|
||||
const closeButton = document.getElementById('graph-modal-close') as HTMLButtonElement;
|
||||
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
|
||||
const graphContainer = document.getElementById('graph-modal-container') as HTMLElement;
|
||||
|
||||
if (closeButton) {
|
||||
// Remove existing listeners by removing and re-adding the event listener
|
||||
closeButton.removeEventListener('click', closeModal);
|
||||
closeButton.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
// Remove existing listeners by removing and re-adding the event listener
|
||||
overlay.removeEventListener('click', handleOverlayClick);
|
||||
overlay.addEventListener('click', handleOverlayClick);
|
||||
}
|
||||
|
||||
if (graphContainer) {
|
||||
// Prevent clicks on the graph container from bubbling up to the overlay
|
||||
graphContainer.removeEventListener('click', handleGraphContainerClick);
|
||||
graphContainer.addEventListener('click', handleGraphContainerClick);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayClick(e: Event) {
|
||||
const overlay = e.target as HTMLElement;
|
||||
const modalContent = overlay.querySelector('.relative');
|
||||
const graphContainer = overlay.querySelector('#graph-modal-container');
|
||||
|
||||
// Only close if clicking on the overlay itself, not on the modal content or graph
|
||||
if (e.target === overlay && !modalContent?.contains(e.target as Node) && !graphContainer?.contains(e.target as Node)) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function loadGraphData() {
|
||||
const container = document.getElementById('graph-modal-container') as HTMLElement;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state immediately
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;text-align:center;" class="text-primary-500 dark:text-primary-400">
|
||||
<div>
|
||||
<div class="animate-spin" style="width:2rem;height:2rem;margin:0 auto 1rem;opacity:0.5;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p style="font-size:0.875rem;">Loading graph...</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
fetch('/graph/graph-data.json')
|
||||
.then(response => response.json())
|
||||
.then((data: any) => {
|
||||
// Transform data to match D3 format
|
||||
const graphData: GraphData = {
|
||||
nodes: data.nodes.map((node: any) => ({
|
||||
id: node.id,
|
||||
name: node.title || node.name,
|
||||
slug: node.slug,
|
||||
radius: Math.max(5, Math.min(15, (node.connections || 0) + 5)),
|
||||
group: node.type
|
||||
})),
|
||||
links: data.connections.map((conn: any) => ({
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
value: 1
|
||||
}))
|
||||
};
|
||||
renderGraph(container, graphData);
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Could not load graph data:', error);
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;text-align:center;" class="text-primary-500 dark:text-primary-400">
|
||||
<div>
|
||||
<svg style="width:3rem;height:3rem;margin:0 auto 1rem;opacity:0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
<p style="font-size:1.125rem;font-weight:500;">Graph data not available</p>
|
||||
<p style="font-size:0.875rem;margin-top:0.5rem;">Make sure to run the build process to generate graph data</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderGraph(container: HTMLElement, data: GraphData) {
|
||||
const colors = getGraphThemeColors();
|
||||
const width = container.offsetWidth;
|
||||
const height = container.offsetHeight;
|
||||
const radiusForce = 300;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
const CLICK_DISTANCE = 5; // px threshold to distinguish click from drag
|
||||
let isDraggingNode = false;
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
const links = data.links.map((d) => ({ ...d }));
|
||||
const nodes = data.nodes.map((d) => ({ ...d }));
|
||||
|
||||
// Create force simulation
|
||||
simulation = d3
|
||||
.forceSimulation<GraphNode>(nodes)
|
||||
.force(
|
||||
"link",
|
||||
d3.forceLink(links).id((d: any) => d.id).distance(100)
|
||||
)
|
||||
.force("charge", d3.forceManyBody().strength(-radiusForce))
|
||||
.force("x", d3.forceX(width / 2).strength(0.05))
|
||||
.force("y", d3.forceY(height / 2).strength(0.05))
|
||||
.force("collision", d3.forceCollide().radius((d: any) => d.radius + 5));
|
||||
|
||||
// Create SVG
|
||||
let svg = d3
|
||||
.select(container)
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.style("background", colors.background);
|
||||
|
||||
const containerGroup = svg.append("g");
|
||||
|
||||
// Create zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 8]) // Extended zoom range for more flexibility
|
||||
.wheelDelta((event) => {
|
||||
// Conservative wheel delta
|
||||
return -event.deltaY * 0.001;
|
||||
})
|
||||
.filter((event) => {
|
||||
if (isDraggingNode) return false;
|
||||
if (event.type === 'wheel') return true;
|
||||
if (event.type === 'mousedown') {
|
||||
return !event.target.closest('g[data-id]');
|
||||
}
|
||||
if (event.type === 'touchstart' || event.type === 'touchmove') {
|
||||
if (event.touches && event.touches.length >= 2) return true;
|
||||
return !event.target.closest('g[data-id]');
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.on("start", function(event) {
|
||||
// Show grabbing cursor only when starting to pan on empty space
|
||||
if (event.sourceEvent && event.sourceEvent.type === 'mousedown') {
|
||||
const isOnNode = !!(event.sourceEvent.target as Element)?.closest('g[data-id]');
|
||||
if (!isOnNode && svg) svg.style('cursor', 'grabbing');
|
||||
}
|
||||
})
|
||||
.on("end", function(event) {
|
||||
// Always restore default cursor when zoom behavior ends
|
||||
if (svg) svg.style('cursor', 'default');
|
||||
})
|
||||
.on("zoom", (event) => {
|
||||
// Direct transform - no throttling or delays
|
||||
containerGroup.attr("transform", event.transform);
|
||||
|
||||
// Hide/show labels based on zoom level
|
||||
const scale = event.transform.k;
|
||||
const labelThreshold = 0.6; // Hide labels when zoomed out beyond this threshold
|
||||
|
||||
if (scale < labelThreshold) {
|
||||
// Hide all labels when zoomed out
|
||||
labels.style("opacity", 0);
|
||||
} else {
|
||||
// Show all labels when zoomed in
|
||||
labels.style("opacity", 1);
|
||||
}
|
||||
});
|
||||
|
||||
svg.call(zoom as any);
|
||||
if (svg) svg.style('cursor', 'default');
|
||||
|
||||
// Create links
|
||||
const link = containerGroup
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", colors.linkStroke)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke-opacity", 0.6);
|
||||
|
||||
// Create node groups
|
||||
const nodeGroup = containerGroup
|
||||
.selectAll("g")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("data-id", (d) => d.id)
|
||||
.call(
|
||||
d3
|
||||
.drag<SVGGElement, GraphNode>()
|
||||
.on("start", function (event) {
|
||||
dragStartX = event.x;
|
||||
dragStartY = event.y;
|
||||
isDraggingNode = true;
|
||||
if (svg) svg.style('cursor', 'default');
|
||||
if (!event.active) simulation!.alphaTarget(0.1).restart();
|
||||
event.subject.fx = event.subject.x;
|
||||
event.subject.fy = event.subject.y;
|
||||
})
|
||||
.on("drag", function (event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", function (event, d) {
|
||||
isDraggingNode = false;
|
||||
if (svg) svg.style('cursor', 'default');
|
||||
if (!event.active) simulation!.alphaTarget(0);
|
||||
event.subject.fx = null;
|
||||
event.subject.fy = null;
|
||||
|
||||
// Navigate on click (no drag movement). Uses distance threshold
|
||||
// because D3 drag fires for even 1px of movement. Handled here
|
||||
// instead of a "click" listener because D3 drag calls
|
||||
// stopImmediatePropagation on mouseup in Chrome.
|
||||
const dx = event.x - dragStartX;
|
||||
const dy = event.y - dragStartY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < CLICK_DISTANCE && d.slug) {
|
||||
closeModal();
|
||||
|
||||
if ((window as any).swup) {
|
||||
if ((window as any).resetLocalGraph) {
|
||||
(window as any).resetLocalGraph();
|
||||
}
|
||||
(window as any).swup.navigate(`/posts/${d.slug}`);
|
||||
} else {
|
||||
window.location.href = `/posts/${d.slug}`;
|
||||
}
|
||||
}
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// Build neighbor map for hover effects
|
||||
const neighbors = new Map<string, Set<string>>();
|
||||
for (const link of links) {
|
||||
const src = typeof link.source === "object" ? link.source.id : link.source;
|
||||
const tgt = typeof link.target === "object" ? link.target.id : link.target;
|
||||
if (!neighbors.has(src)) neighbors.set(src, new Set());
|
||||
if (!neighbors.has(tgt)) neighbors.set(tgt, new Set());
|
||||
|
||||
neighbors.get(src)!.add(tgt);
|
||||
neighbors.get(tgt)!.add(src);
|
||||
}
|
||||
|
||||
// Add hover effects
|
||||
nodeGroup
|
||||
.on("mouseover", function (_event, d) {
|
||||
const connected = neighbors.get(d.id) ?? new Set();
|
||||
connected.add(d.id);
|
||||
|
||||
link.transition().duration(150)
|
||||
.attr("stroke", (l: any) =>
|
||||
(typeof l.source === 'object' ? l.source.id : l.source) === d.id ||
|
||||
(typeof l.target === 'object' ? l.target.id : l.target) === d.id
|
||||
? colors.tagFill : colors.linkStroke)
|
||||
.attr("stroke-opacity", (l: any) =>
|
||||
(typeof l.source === 'object' ? l.source.id : l.source) === d.id ||
|
||||
(typeof l.target === 'object' ? l.target.id : l.target) === d.id ? 0.8 : 0.1);
|
||||
|
||||
nodeGroup.selectAll<SVGCircleElement, GraphNode>("circle")
|
||||
.attr("fill-opacity", (n) => connected.has(n.id) ? 1 : 0.3)
|
||||
.attr("fill", (n) => n.id === d.id ? colors.tagFill : colors.linkStroke);
|
||||
|
||||
labels.transition().duration(150)
|
||||
.style("opacity", (n) => connected.has(n.id) ? 1 : 0.1);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
link.transition().duration(150)
|
||||
.attr("stroke", colors.linkStroke)
|
||||
.attr("stroke-opacity", 0.6);
|
||||
|
||||
nodeGroup.selectAll<SVGCircleElement, GraphNode>("circle")
|
||||
.attr("fill", colors.linkStroke)
|
||||
.attr("fill-opacity", 1);
|
||||
|
||||
const currentTransform = d3.zoomTransform(svg.node() as Element);
|
||||
labels.transition().duration(150)
|
||||
.style("opacity", currentTransform.k < 0.6 ? 0 : 0.6);
|
||||
});
|
||||
|
||||
// Create circles
|
||||
const node = nodeGroup
|
||||
.append("circle")
|
||||
.attr("r", (d) => d.radius)
|
||||
.attr("fill", colors.linkStroke)
|
||||
.attr("cursor", "pointer");
|
||||
|
||||
// Create labels
|
||||
const labels = nodeGroup
|
||||
.append("text")
|
||||
.text((d) => d.name)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", (d) => d.radius + 8) // Position below the node
|
||||
.attr("font-size", "12px")
|
||||
.attr("font-weight", "500")
|
||||
.attr("fill", colors.postText)
|
||||
.attr("pointer-events", "none")
|
||||
.style("user-select", "none")
|
||||
.style("opacity", 0.6); // Start with slightly faded labels
|
||||
|
||||
// Update positions on simulation tick
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", (d: any) => d.source.x)
|
||||
.attr("y1", (d: any) => d.source.y)
|
||||
.attr("x2", (d: any) => d.target.x)
|
||||
.attr("y2", (d: any) => d.target.y);
|
||||
|
||||
nodeGroup.attr("transform", (d: any) => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
simulation.alpha(1).restart();
|
||||
|
||||
// Don't auto-fit to graph - let user control the view
|
||||
// Only fit if explicitly requested via keyboard shortcut
|
||||
|
||||
// Set up cleanup function
|
||||
cleanup = () => {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
simulation = null;
|
||||
}
|
||||
if (svg) {
|
||||
svg.remove();
|
||||
}
|
||||
};
|
||||
|
||||
// Add keyboard shortcuts
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
|
||||
if (overlay && !overlay.classList.contains('hidden')) {
|
||||
switch (e.key) {
|
||||
case 'r':
|
||||
if (svg) {
|
||||
svg.transition().duration(300).call(
|
||||
zoom.transform as any,
|
||||
d3.zoomIdentity.scale(1).translate(0, 0)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'c':
|
||||
if (svg) {
|
||||
// Get the SVG dimensions
|
||||
const svgNode = svg.node();
|
||||
if (!svgNode) return;
|
||||
const svgRect = svgNode.getBoundingClientRect();
|
||||
const width = svgRect.width;
|
||||
const height = svgRect.height;
|
||||
|
||||
// Get all nodes to calculate bounds
|
||||
const nodes = svg.selectAll('g[data-id]').nodes();
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
// Calculate bounding box of all nodes
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
nodes.forEach((node) => {
|
||||
if (node && node instanceof Element) {
|
||||
const transform = node.getAttribute('transform');
|
||||
if (transform) {
|
||||
const match = transform.match(/translate\(([^,]+),\s*([^)]+)\)/);
|
||||
if (match) {
|
||||
const x = parseFloat(match[1]);
|
||||
const y = parseFloat(match[2]);
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add padding
|
||||
const padding = 50;
|
||||
const boundsWidth = maxX - minX + padding * 2;
|
||||
const boundsHeight = maxY - minY + padding * 2;
|
||||
|
||||
// Calculate scale to fit viewport
|
||||
const scaleX = width / boundsWidth;
|
||||
const scaleY = height / boundsHeight;
|
||||
const scale = Math.min(scaleX, scaleY); // Allow zoom out (scale < 1)
|
||||
|
||||
// Constrain scale to reasonable bounds (0.1 to 3)
|
||||
const constrainedScale = Math.max(0.1, Math.min(3, scale));
|
||||
|
||||
// Calculate translation to center
|
||||
const graphCenterX = (minX + maxX) / 2;
|
||||
const graphCenterY = (minY + maxY) / 2;
|
||||
const centerX = (width / 2) / constrainedScale - graphCenterX;
|
||||
const centerY = (height / 2) / constrainedScale - graphCenterY;
|
||||
|
||||
svg.transition().duration(300).call(
|
||||
zoom.transform as any,
|
||||
d3.zoomIdentity.scale(constrainedScale).translate(centerX, centerY)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Update cleanup function
|
||||
const originalCleanup = cleanup;
|
||||
cleanup = () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
if (originalCleanup) originalCleanup();
|
||||
};
|
||||
}
|
||||
|
||||
function fitToGraph(svgElement: any, nodes: GraphNode[], width: number, height: number, zoomBehavior: any) {
|
||||
if (!svgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Instead of fitting nodes, zoom OUT to show the entire graph area
|
||||
// Use a small scale to zoom OUT and show everything
|
||||
const scale = 0.3; // Zoom OUT to 30% (much smaller)
|
||||
|
||||
// Center the graph in the viewport
|
||||
const x = (width / 2) - (width / 2 * scale);
|
||||
const y = (height / 2) - (height / 2 * scale);
|
||||
|
||||
// Use the existing zoom behavior instead of creating a new one
|
||||
svgElement
|
||||
.transition()
|
||||
.duration(750)
|
||||
.call(
|
||||
zoomBehavior.transform as any,
|
||||
d3.zoomIdentity.scale(scale).translate(x, y),
|
||||
);
|
||||
}
|
||||
|
||||
function getGraphBounds(nodes: GraphNode[]) {
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const n of nodes) {
|
||||
if (n.x !== undefined && n.y !== undefined) {
|
||||
x0 = Math.min(x0, n.x);
|
||||
y0 = Math.min(y0, n.y);
|
||||
x1 = Math.max(x1, n.x);
|
||||
y1 = Math.max(y1, n.y);
|
||||
}
|
||||
}
|
||||
return { x0, y0, x1, y1 };
|
||||
}
|
||||
|
||||
function initializeGraphModal() {
|
||||
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
|
||||
const closeButton = document.getElementById('graph-modal-close') as HTMLButtonElement;
|
||||
const container = document.getElementById('graph-modal-container') as HTMLElement;
|
||||
|
||||
if (!overlay || !closeButton || !container) {
|
||||
// Create the modal HTML if it doesn't exist
|
||||
createGraphModalHTML();
|
||||
// Try again after creating the HTML
|
||||
setTimeout(() => {
|
||||
initializeGraphModal();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always ensure the modal functions are available, even if already initialized
|
||||
if (graphModalInitialized) {
|
||||
// Make sure the functions are still available
|
||||
(window as any).openGraphModal = openModal;
|
||||
(window as any).closeGraphModal = closeModal;
|
||||
// Re-attach event listeners to ensure they work
|
||||
attachEventListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
graphModalInitialized = true;
|
||||
|
||||
// Attach event listeners
|
||||
attachEventListeners();
|
||||
|
||||
function getGraphBounds(nodes: GraphNode[]) {
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const n of nodes) {
|
||||
if (!isNaN(n.x!) && !isNaN(n.y!)) {
|
||||
x0 = Math.min(x0, n.x!);
|
||||
y0 = Math.min(y0, n.y!);
|
||||
x1 = Math.max(x1, n.x!);
|
||||
y1 = Math.max(y1, n.y!);
|
||||
}
|
||||
}
|
||||
return { x0, y0, x1, y1 };
|
||||
}
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !overlay.classList.contains('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Make functions globally accessible
|
||||
(window as any).openGraphModal = openModal;
|
||||
(window as any).closeGraphModal = closeModal;
|
||||
(window as any).initializeGraphModal = initializeGraphModal;
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeGraphModal);
|
||||
} else {
|
||||
initializeGraphModal();
|
||||
}
|
||||
|
||||
// Swup integration - reinitialize after page transitions
|
||||
if ((window as any).swup) {
|
||||
(window as any).swup.hooks.on('page:view', () => {
|
||||
// Always try to reinitialize to ensure it's available
|
||||
initializeGraphModal();
|
||||
});
|
||||
|
||||
(window as any).swup.hooks.on('visit:end', () => {
|
||||
// Ensure the global function is available after page transitions
|
||||
if (!(window as any).openGraphModal) {
|
||||
initializeGraphModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for theme changes to update graph colors
|
||||
window.addEventListener('themechange', () => {
|
||||
// Re-render the graph with new colors if it's currently open
|
||||
const overlay = document.getElementById('graph-modal-overlay');
|
||||
if (overlay && !overlay.classList.contains('hidden') && svg) {
|
||||
// Get fresh data and re-render
|
||||
fetch('/graph/graph-data.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const container = document.getElementById('graph-modal-content');
|
||||
if (container) {
|
||||
renderGraph(container, data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('GraphModal: Error fetching graph data for theme update:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
601
src/components/Header.astro
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
---
|
||||
import { siteConfig } from '../config';
|
||||
import ThemeToggle from './ThemeToggle.astro';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
const showProfilePicture = siteConfig.profilePicture.enabled && siteConfig.profilePicture.placement === 'header';
|
||||
const profilePicture = siteConfig.profilePicture;
|
||||
|
||||
// Style classes for profile picture in header
|
||||
const getHeaderProfilePictureClasses = (style: string) => {
|
||||
switch (style) {
|
||||
case 'circle': return 'w-8 h-8 rounded-full object-cover border-2 border-primary-200 dark:border-primary-700';
|
||||
case 'square': return 'w-8 h-8 rounded-lg object-cover border-2 border-primary-200 dark:border-primary-700';
|
||||
default: return 'h-8 object-contain'; // none - height constrained, width flexible
|
||||
}
|
||||
};
|
||||
|
||||
// Check if shortcut is defined and not empty
|
||||
const hasShortcut = siteConfig.commandPalette.shortcut && siteConfig.commandPalette.shortcut.trim();
|
||||
|
||||
// Extract the key from the shortcut (e.g., 'ctrl+k' -> 'K')
|
||||
const shortcutKey = hasShortcut ? siteConfig.commandPalette.shortcut.split('+').pop()?.toUpperCase() || 'K' : '';
|
||||
|
||||
// Check if all command palette sections are disabled
|
||||
const allSectionsDisabled = !siteConfig.commandPalette.sections.quickActions &&
|
||||
!siteConfig.commandPalette.sections.pages &&
|
||||
!siteConfig.commandPalette.sections.social;
|
||||
|
||||
// Choose icon based on whether all sections are disabled (for no shortcut case)
|
||||
const paletteIcon = allSectionsDisabled ? 'search' : 'command';
|
||||
---
|
||||
|
||||
<header id="main-header" class="sticky top-0 z-40 w-full bg-white dark:bg-primary-950 transition-shadow duration-200">
|
||||
<div class="mx-auto px-4 sm:px-6 lg:px-8" style={`max-width: ${siteConfig.layout.contentWidth}`}>
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo / Site Title / Profile Picture -->
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="hover:opacity-75 transition-opacity">
|
||||
{showProfilePicture ? (
|
||||
<img
|
||||
src={profilePicture.image}
|
||||
alt={profilePicture.alt}
|
||||
class={getHeaderProfilePictureClasses(profilePicture.style)}
|
||||
/>
|
||||
) : (
|
||||
<span class="font-semibold text-primary-900 dark:text-primary-50">
|
||||
{siteConfig.title}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center area with smart navigation -->
|
||||
<div class={`flex-1 flex items-center ${ siteConfig.navigation.style === 'minimal' ? 'justify-end' : 'justify-center' }`}>
|
||||
<!-- Smart Navigation (single system with style variants) -->
|
||||
{siteConfig.navigation.showNavigation && (
|
||||
<nav class={`nav-desktop-audit-visible flex items-center ${
|
||||
siteConfig.navigation.style === 'minimal' ? `space-x-5 ${siteConfig.featureButton === 'none' && !siteConfig.commandPalette.enabled ? '' : 'mr-4'}` : 'space-x-5' }`} aria-label="Main navigation">
|
||||
{siteConfig.navigation.pages.map((page, index) => {
|
||||
const hasChildren = page.children && page.children.length > 0;
|
||||
const hasUrl = page.url && page.url.trim() !== '';
|
||||
|
||||
if (hasChildren) {
|
||||
// Parent item with children - dropdown menu
|
||||
return (
|
||||
<div class="relative nav-dropdown-container" data-nav-index={index}>
|
||||
{hasUrl ? (
|
||||
// Clickable parent with dropdown
|
||||
<a
|
||||
href={page.url}
|
||||
class={
|
||||
siteConfig.navigation.style === 'minimal'
|
||||
? `flex items-center space-x-1 text-xs font-normal transition-colors hover:text-highlight-600 dark:hover:text-highlight-400 text-primary-500 dark:text-primary-400`
|
||||
: `flex items-center space-x-1 text-sm font-medium transition-colors hover:text-highlight-600 dark:hover:text-highlight-400 text-primary-600 dark:text-primary-300`
|
||||
}
|
||||
target={page.url.startsWith('http') ? '_blank' : undefined}
|
||||
aria-label={`Navigate to ${page.title}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span>{siteConfig.navigation.style === 'minimal' ? page.title.toLowerCase() : page.title}</span>
|
||||
{page.url.startsWith('http') && (
|
||||
<Icon name="external-link" class="w-3 h-3 text-primary-400 dark:text-primary-500" />
|
||||
)}
|
||||
<Icon name="chevron-down" class="w-3 h-3 ml-1 text-primary-400 dark:text-primary-500" title={page.title} />
|
||||
</a>
|
||||
) : (
|
||||
// Dropdown-only parent (non-clickable trigger)
|
||||
<button
|
||||
class={
|
||||
siteConfig.navigation.style === 'minimal'
|
||||
? `flex items-center space-x-1 text-xs font-normal transition-colors hover:text-highlight-600 dark:hover:text-highlight-400 text-primary-500 dark:text-primary-400`
|
||||
: `flex items-center space-x-1 text-sm font-medium transition-colors hover:text-highlight-600 dark:hover:text-highlight-400 text-primary-600 dark:text-primary-300`
|
||||
}
|
||||
aria-label={`${page.title} menu`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span>{siteConfig.navigation.style === 'minimal' ? page.title.toLowerCase() : page.title}</span>
|
||||
<Icon name="chevron-down" class="w-3 h-3 ml-1 text-primary-400 dark:text-primary-500" title={page.title} />
|
||||
</button>
|
||||
)}
|
||||
{/* Dropdown menu */}
|
||||
<div class="nav-dropdown-menu absolute top-full left-0 mt-1 hidden bg-white dark:bg-primary-900 border border-primary-200 dark:border-primary-700 rounded-lg shadow-lg py-2 min-w-[200px] z-50">
|
||||
{page.children.map(child => (
|
||||
<a
|
||||
href={child.url || '#'}
|
||||
class="block px-4 py-2 text-sm text-primary-700 dark:text-primary-200 hover:text-highlight-600 dark:hover:text-highlight-400 transition-colors"
|
||||
target={child.url?.startsWith('http') ? '_blank' : undefined}
|
||||
aria-label={`Navigate to ${child.title}`}
|
||||
>
|
||||
{child.title}
|
||||
{child.url?.startsWith('http') && (
|
||||
<Icon name="external-link" class="w-3 h-3 ml-2 inline text-primary-400 dark:text-primary-500" />
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Regular navigation item without children
|
||||
return (
|
||||
<a
|
||||
href={page.url || '#'}
|
||||
class={
|
||||
siteConfig.navigation.style === 'minimal'
|
||||
? `flex items-center space-x-1 text-xs font-normal transition-colors hover:text-highlight-600 dark:hover:text-highlight-400 text-primary-500 dark:text-primary-400`
|
||||
: `flex items-center space-x-1 text-sm font-medium transition-colors hover:text-highlight-600 dark:hover:text-highlight-400 text-primary-600 dark:text-primary-300`
|
||||
}
|
||||
target={page.url?.startsWith('http') ? '_blank' : undefined}
|
||||
aria-label={`Navigate to ${page.title}`}
|
||||
>
|
||||
<span>{siteConfig.navigation.style === 'minimal' ? page.title.toLowerCase() : page.title}</span>
|
||||
{page.url?.startsWith('http') && (
|
||||
<Icon name="external-link" class="w-3 h-3 text-primary-400 dark:text-primary-500" />
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Feature Button -->
|
||||
{siteConfig.featureButton === "mode" && <ThemeToggle />}
|
||||
{siteConfig.featureButton === "graph" && siteConfig.postOptions.graphView.enabled && (
|
||||
<button
|
||||
id="header-graph-button"
|
||||
class="p-2 text-primary-500 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-800 rounded-lg transition-colors duration-200"
|
||||
aria-label="Open graph view"
|
||||
>
|
||||
<Icon name="git-fork" class="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{siteConfig.featureButton === "theme" && (
|
||||
<button
|
||||
id="header-theme-button"
|
||||
class="p-2 text-primary-500 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-800 rounded-lg transition-colors duration-200"
|
||||
aria-label="Change theme"
|
||||
>
|
||||
<Icon name="palette" class="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<!-- Command palette trigger -->
|
||||
{siteConfig.commandPalette.enabled && (
|
||||
hasShortcut ? (
|
||||
<button
|
||||
id="command-palette-trigger"
|
||||
class="flex items-center space-x-2 px-3 py-2 text-sm text-primary-500 dark:text-primary-400 bg-primary-50 dark:bg-primary-800 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-700 transition-colors"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<Icon name={paletteIcon} class="w-5 h-5" />
|
||||
<kbd class="text-xs font-medium text-primary-600 dark:text-primary-300">
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
id="command-palette-trigger"
|
||||
class="p-2 text-primary-500 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<Icon name={paletteIcon} class="w-5 h-5" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<!-- Mobile menu button (if navigation and mobile menu are enabled) -->
|
||||
{siteConfig.navigation.showNavigation && siteConfig.navigation.showMobileMenu && (
|
||||
<button
|
||||
id="mobile-menu-toggle"
|
||||
class="md:hidden p-2 text-primary-500 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-800 rounded-lg transition-colors"
|
||||
aria-label="Toggle mobile navigation menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<Icon name="menu" class="w-5 h-5 menu-icon" />
|
||||
<Icon name="x" class="w-5 h-5 close-icon hidden" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile navigation menu -->
|
||||
{siteConfig.navigation.showNavigation && siteConfig.navigation.showMobileMenu && (
|
||||
<div id="mobile-menu" class="md:hidden hidden py-4 border-t border-primary-200 dark:border-primary-700" role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mobile navigation menu">
|
||||
<nav class="flex flex-col space-y-1">
|
||||
{siteConfig.navigation.pages.map((page, index) => {
|
||||
const hasChildren = page.children && page.children.length > 0;
|
||||
const hasUrl = page.url && page.url.trim() !== '';
|
||||
|
||||
if (hasChildren) {
|
||||
// Parent item with children - accordion
|
||||
return (
|
||||
<div class="mobile-nav-parent" data-mobile-nav-index={index}>
|
||||
<div class="flex items-center justify-between">
|
||||
{hasUrl ? (
|
||||
// Clickable parent with expand button
|
||||
<a
|
||||
href={page.url}
|
||||
class="flex-1 flex items-center justify-between px-3 py-2 text-primary-600 dark:text-primary-300 hover:text-highlight-600 dark:hover:text-highlight-400 hover:bg-primary-50 dark:hover:bg-primary-800 transition-colors font-medium rounded-md"
|
||||
target={page.url.startsWith('http') ? '_blank' : undefined}
|
||||
aria-label={`Navigate to ${page.title}`}
|
||||
>
|
||||
<span>{page.title}</span>
|
||||
{page.url.startsWith('http') && (
|
||||
<Icon name="external-link" class="w-3 h-3 text-primary-400 dark:text-primary-500" />
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
// Non-clickable parent (just title)
|
||||
<span class="flex-1 px-3 py-2 text-primary-600 dark:text-primary-300 font-medium">
|
||||
{page.title}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
class="mobile-nav-toggle px-3 py-2 text-primary-500 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-800 rounded-md transition-colors"
|
||||
aria-label={`Toggle ${page.title} submenu`}
|
||||
aria-expanded="false"
|
||||
data-toggle-index={index}
|
||||
>
|
||||
<Icon name="chevron-down" class="w-4 h-4 transition-transform" title={page.title} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Children menu (collapsed by default) */}
|
||||
<div class="mobile-nav-children hidden pl-4 pt-1">
|
||||
{page.children.map(child => (
|
||||
<a
|
||||
href={child.url || '#'}
|
||||
class="block px-3 py-2 text-sm text-primary-600 dark:text-primary-300 hover:text-highlight-600 dark:hover:text-highlight-400 hover:bg-primary-50 dark:hover:bg-primary-800 transition-colors font-medium rounded-md"
|
||||
target={child.url?.startsWith('http') ? '_blank' : undefined}
|
||||
aria-label={`Navigate to ${child.title}`}
|
||||
>
|
||||
{child.title}
|
||||
{child.url?.startsWith('http') && (
|
||||
<Icon name="external-link" class="w-3 h-3 ml-2 inline text-primary-400 dark:text-primary-500" />
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Regular navigation item without children
|
||||
return (
|
||||
<a
|
||||
href={page.url || '#'}
|
||||
class="flex items-center justify-between px-3 py-2 text-primary-600 dark:text-primary-300 hover:text-highlight-600 dark:hover:text-highlight-400 hover:bg-primary-50 dark:hover:bg-primary-800 transition-colors font-medium rounded-md"
|
||||
target={page.url?.startsWith('http') ? '_blank' : undefined}
|
||||
aria-label={`Navigate to ${page.title}`}
|
||||
>
|
||||
<span>{page.title}</span>
|
||||
{page.url?.startsWith('http') && (
|
||||
<Icon name="external-link" class="w-3 h-3 text-primary-400 dark:text-primary-500" />
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
// Mobile menu toggle
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const menuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
if (menuToggle && mobileMenu) {
|
||||
menuToggle.addEventListener('click', () => {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
|
||||
// Update menu icon and ARIA attribute
|
||||
const menuIcon = menuToggle.querySelector('.menu-icon');
|
||||
const closeIcon = menuToggle.querySelector('.close-icon');
|
||||
if (menuIcon && closeIcon) {
|
||||
if (mobileMenu.classList.contains('hidden')) {
|
||||
menuIcon.classList.remove('hidden');
|
||||
closeIcon.classList.add('hidden');
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
menuIcon.classList.add('hidden');
|
||||
closeIcon.classList.remove('hidden');
|
||||
menuToggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Desktop dropdown menu hover behavior
|
||||
const dropdownContainers = document.querySelectorAll('.nav-dropdown-container');
|
||||
let hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
dropdownContainers.forEach(container => {
|
||||
const trigger = container.querySelector('a, button');
|
||||
const dropdown = container.querySelector('.nav-dropdown-menu') as HTMLElement;
|
||||
|
||||
if (!trigger || !dropdown) return;
|
||||
|
||||
const showDropdown = () => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
hoverTimeout = null;
|
||||
}
|
||||
dropdown.classList.remove('hidden');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
};
|
||||
|
||||
const hideDropdown = () => {
|
||||
hoverTimeout = window.setTimeout(() => {
|
||||
dropdown.classList.add('hidden');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
}, 150); // Small delay to prevent accidental closes
|
||||
};
|
||||
|
||||
// Show on hover
|
||||
container.addEventListener('mouseenter', showDropdown);
|
||||
container.addEventListener('mouseleave', hideDropdown);
|
||||
|
||||
// Keyboard navigation
|
||||
trigger.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (dropdown.classList.contains('hidden')) {
|
||||
showDropdown();
|
||||
} else {
|
||||
hideDropdown();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
hideDropdown();
|
||||
trigger.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile accordion functionality
|
||||
const mobileNavToggles = document.querySelectorAll('.mobile-nav-toggle');
|
||||
mobileNavToggles.forEach(toggle => {
|
||||
toggle.addEventListener('click', () => {
|
||||
const index = toggle.getAttribute('data-toggle-index');
|
||||
const parent = toggle.closest('.mobile-nav-parent');
|
||||
const children = parent?.querySelector('.mobile-nav-children') as HTMLElement;
|
||||
const icon = toggle.querySelector('svg');
|
||||
|
||||
if (!children || !icon) return;
|
||||
|
||||
const isExpanded = !children.classList.contains('hidden');
|
||||
|
||||
if (isExpanded) {
|
||||
children.classList.add('hidden');
|
||||
icon.classList.remove('rotate-180');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
children.classList.remove('hidden');
|
||||
icon.classList.add('rotate-180');
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Function to close mobile menu
|
||||
function closeMobileMenu() {
|
||||
if (mobileMenu && !mobileMenu.classList.contains('hidden')) {
|
||||
mobileMenu.classList.add('hidden');
|
||||
|
||||
// Update menu icon and ARIA attribute
|
||||
if (menuToggle) {
|
||||
const menuIcon = menuToggle.querySelector('.menu-icon');
|
||||
const closeIcon = menuToggle.querySelector('.close-icon');
|
||||
if (menuIcon && closeIcon) {
|
||||
menuIcon.classList.remove('hidden');
|
||||
closeIcon.classList.add('hidden');
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close mobile menu when navigation links are clicked
|
||||
const mobileMenuLinks = mobileMenu?.querySelectorAll('nav a');
|
||||
mobileMenuLinks?.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
closeMobileMenu();
|
||||
});
|
||||
});
|
||||
|
||||
// Make closeMobileMenu globally accessible for other components
|
||||
(window as any).closeMobileMenu = closeMobileMenu;
|
||||
|
||||
// Close mobile menu when clicking outside of it
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isMenuToggle = target.closest('#mobile-menu-toggle');
|
||||
const isMobileMenu = target.closest('#mobile-menu');
|
||||
|
||||
// If clicking outside the menu and toggle button, close the menu
|
||||
if (!isMenuToggle && !isMobileMenu && mobileMenu && !mobileMenu.classList.contains('hidden')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Command palette triggers
|
||||
const triggers = [
|
||||
document.getElementById('command-palette-trigger'),
|
||||
document.getElementById('command-palette-trigger-mobile')
|
||||
].filter(Boolean);
|
||||
|
||||
triggers.forEach(trigger => {
|
||||
trigger?.addEventListener('click', () => {
|
||||
const event = new CustomEvent('openCommandPalette');
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
});
|
||||
|
||||
// Theme toggle and command palette shortcuts are handled in BaseLayout
|
||||
|
||||
// Header feature button handlers
|
||||
const headerGraphButton = document.getElementById('header-graph-button');
|
||||
if (headerGraphButton) {
|
||||
headerGraphButton.addEventListener('click', () => {
|
||||
if ((window as any).openGraphModal) {
|
||||
(window as any).openGraphModal();
|
||||
} else {
|
||||
// Try to initialize GraphModal if it's not available
|
||||
if ((window as any).initializeGraphModal) {
|
||||
(window as any).initializeGraphModal();
|
||||
// Try again after initialization
|
||||
setTimeout(() => {
|
||||
if ((window as any).openGraphModal) {
|
||||
(window as any).openGraphModal();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const headerThemeButton = document.getElementById('header-theme-button');
|
||||
if (headerThemeButton) {
|
||||
headerThemeButton.addEventListener('click', () => {
|
||||
// Check if command palette is enabled
|
||||
const commandPaletteEnabled = (window as any).siteConfig?.commandPalette?.enabled;
|
||||
|
||||
if (commandPaletteEnabled) {
|
||||
// Use command palette for theme selection
|
||||
const event = new CustomEvent('openCommandPalette');
|
||||
window.dispatchEvent(event);
|
||||
|
||||
// After a brief delay, trigger theme selection
|
||||
setTimeout(() => {
|
||||
const commandPalette = (window as any).commandPaletteInstance;
|
||||
if (commandPalette && commandPalette.executeAction) {
|
||||
commandPalette.executeAction('change-theme');
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
// Use standalone theme selector
|
||||
const themeSelector = (window as any).themeSelectorInstance;
|
||||
if (themeSelector && themeSelector.open) {
|
||||
themeSelector.open();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Nav links: remove from tab order when viewport < md so hidden nav is not focusable
|
||||
function updateNavLinksTabIndex() {
|
||||
const nav = document.querySelector('.nav-desktop-audit-visible');
|
||||
if (!nav) return;
|
||||
const links = nav.querySelectorAll('a');
|
||||
const narrow = window.innerWidth < 768;
|
||||
links.forEach((link: HTMLAnchorElement) => {
|
||||
if (narrow) link.setAttribute('tabindex', '-1');
|
||||
else link.removeAttribute('tabindex');
|
||||
});
|
||||
}
|
||||
updateNavLinksTabIndex();
|
||||
window.addEventListener('resize', updateNavLinksTabIndex);
|
||||
|
||||
// Header shadow on scroll
|
||||
const header = document.getElementById('main-header');
|
||||
if (header) {
|
||||
let isScrolled = false;
|
||||
|
||||
|
||||
const updateHeaderShadow = () => {
|
||||
const scrollY = window.scrollY;
|
||||
const shouldHaveShadow = scrollY > 0;
|
||||
|
||||
if (shouldHaveShadow !== isScrolled) {
|
||||
isScrolled = shouldHaveShadow;
|
||||
if (shouldHaveShadow) {
|
||||
header.classList.add('shadow-sm');
|
||||
} else {
|
||||
header.classList.remove('shadow-sm');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
updateHeaderShadow();
|
||||
|
||||
// Listen for scroll events
|
||||
window.addEventListener('scroll', updateHeaderShadow, { passive: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Keep nav in DOM for audit innerText; visually hidden on small viewports; tab order fixed via JS */
|
||||
.nav-desktop-audit-visible {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.nav-desktop-audit-visible {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
clip-path: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dropdown menu styles */
|
||||
.nav-dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu {
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.dark .nav-dropdown-menu {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* Mobile accordion styles */
|
||||
.mobile-nav-parent {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-nav-children {
|
||||
padding-left: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
border-left: 2px solid rgb(var(--color-primary-200));
|
||||
}
|
||||
|
||||
.dark .mobile-nav-children {
|
||||
border-left-color: rgb(var(--color-primary-700));
|
||||
}
|
||||
|
||||
.mobile-nav-toggle svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-nav-toggle svg.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Command palette grouped navigation styles */
|
||||
.command-palette-nav-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
126
src/components/Icon.astro
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
---
|
||||
import { library, icon } from '@fortawesome/fontawesome-svg-core';
|
||||
import { fab } from '@fortawesome/free-brands-svg-icons';
|
||||
|
||||
export interface Props {
|
||||
name: string;
|
||||
class?: string;
|
||||
size?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const { name, class: className = '', size = 24, title } = Astro.props;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Add ALL Font Awesome brand icons to library at once
|
||||
library.add(fab);
|
||||
|
||||
const icons = {
|
||||
// Navigation icons
|
||||
'arrow-up': '<path d="m7 14 5-5 5 5"/>',
|
||||
'chevron-up': '<path d="m18 15-6-6-6 6"/>',
|
||||
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
|
||||
'arrow-down': '<path d="m6 9 6 6 6-6"/>',
|
||||
'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
'arrow-right': '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
'arrow-up-right': '<path d="M7 7h10v10"/><path d="M7 17 17 7"/>',
|
||||
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
|
||||
// UI icons
|
||||
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
'menu': '<line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>',
|
||||
'search-x': '<path d="m13.5 8.5-5 5"/><path d="m8.5 8.5 5 5"/><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>',
|
||||
'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
'command': '<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>',
|
||||
'list': '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
|
||||
'zoom-in': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/>',
|
||||
|
||||
// Theme icons
|
||||
'sun': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41-1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
||||
'moon': '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
'sun-moon': '<path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/>',
|
||||
'palette': '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
|
||||
|
||||
// Content icons
|
||||
'file-text': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
||||
'file-x': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/>',
|
||||
'tag': '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
|
||||
'link-2': '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
|
||||
'rss': '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/>',
|
||||
'atom': '<circle cx="12" cy="12" r="1"/><path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/><path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/>',
|
||||
'navigation': '<polygon points="3 11 22 2 13 21 11 13 3 11"/>',
|
||||
'git-fork': '<circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/>',
|
||||
|
||||
// Social and brand icons
|
||||
'github': '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
|
||||
'gitlab': '<path d="m22 13.29-3.33-10a.42.42 0 0 0-.14-.18.38.38 0 0 0-.22-.11.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18l-2.26 6.67H8.32L6.1 3.26a.42.42 0 0 0-.1-.18.38.38 0 0 0-.26-.08.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18L2 13.29a.74.74 0 0 0 .27.83L12 21l9.69-6.88a.71.71 0 0 0 .31-.83Z"/>',
|
||||
'folder-git-2': '<path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/><circle cx="13" cy="12" r="2"/><path d="M18 19c-2.8 0-5-2.2-5-5v8"/><circle cx="20" cy="19" r="2"/>',
|
||||
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
'book-text': '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="M8 11h8"/><path d="M8 7h6"/>',
|
||||
'folder': '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||||
'folder-open': '<path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
|
||||
'sticky-note': '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>',
|
||||
'loader-2': '<path d="M21 12a9 9 0 11-6.219-8.56"/>',
|
||||
'grid-3x3': '<path d="M15 3h4a2 2 0 0 1 2 2v4"/><path d="M10 21H6a2 2 0 0 1-2-2v-4"/><path d="M21 15v4a2 2 0 0 1-2 2h-4"/><path d="M3 9V5a2 2 0 0 1 2-2h4"/><path d="M9 9h6v6H9z"/>',
|
||||
|
||||
// Status and alert icons
|
||||
'info': '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="m12 8 .01 0"/>',
|
||||
'lightbulb': '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
|
||||
'star': '<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>',
|
||||
'triangle-alert': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="m12 17 .01 0"/>',
|
||||
'circle-alert': '<circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="m12 16 .01 0"/>',
|
||||
'circle-x': '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
|
||||
'circle-help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
||||
'circle-check': '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
||||
'bug': '<path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/>',
|
||||
'code': '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
||||
'quote': '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>',
|
||||
};
|
||||
|
||||
const iconPath = icons[name as keyof typeof icons];
|
||||
|
||||
// Try Font Awesome brands first (for social icons)
|
||||
const faIcon = icon({ prefix: 'fab', iconName: name as any });
|
||||
|
||||
// Use a graceful fallback icon (link-2) if neither FA nor custom icon exists
|
||||
const fallbackIcon = 'link-2';
|
||||
const fallbackPath = icons[fallbackIcon];
|
||||
---
|
||||
|
||||
{faIcon ? (
|
||||
<div class={className} style={`width: ${size}px; height: ${size}px; display: inline-flex; align-items: center; justify-content: center; transform: scale(0.7);`} set:html={faIcon.html[0]} />
|
||||
) : iconPath ? (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={className}
|
||||
aria-hidden={title ? undefined : true}
|
||||
set:html={title ? `<title>${escapeHtml(title)}</title>${iconPath}` : iconPath}
|
||||
/>
|
||||
) : fallbackPath ? (
|
||||
<div class={className} style={`width: ${size}px; height: ${size}px; display: inline-flex; align-items: center; justify-content: center; transform: scale(0.7);`}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden={title ? undefined : true}
|
||||
set:html={title ? `<title>${escapeHtml(title)}</title>${fallbackPath}` : fallbackPath}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
141
src/components/ImageGallery.astro
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
import type { ImageInfo } from '@/types';
|
||||
import { processImageLayout } from '@/utils/images';
|
||||
import Icon from './Icon.astro';
|
||||
import ImageWrapper from './ImageWrapper.astro';
|
||||
|
||||
export interface Props {
|
||||
images: ImageInfo[];
|
||||
layout?: 'single' | 'grid-2' | 'grid-3' | 'grid-4' | 'auto';
|
||||
}
|
||||
|
||||
const { images, layout = 'auto' } = Astro.props;
|
||||
|
||||
// Process layout if auto
|
||||
const processedLayout = layout === 'auto' ? processImageLayout(images) : { layout, images };
|
||||
const { layout: finalLayout, images: finalImages } = processedLayout;
|
||||
---
|
||||
|
||||
{finalImages.length > 0 && (
|
||||
<div class={`image-gallery ${finalLayout} my-8`}>
|
||||
{finalImages.map((image, index) => (
|
||||
<div class="image-item">
|
||||
<button
|
||||
class="image-button w-full h-full group cursor-pointer"
|
||||
data-image-index={index}
|
||||
data-lightbox="gallery"
|
||||
aria-label={`View ${image.alt} in full size`}
|
||||
>
|
||||
<ImageWrapper
|
||||
src={image.src}
|
||||
basePath="/posts/attachments/"
|
||||
alt={image.alt}
|
||||
class="w-full h-full rounded-lg transition-all duration-300 group-hover:shadow-lg group-hover:scale-[1.02]"
|
||||
width={600}
|
||||
height={400}
|
||||
format="webp"
|
||||
quality={85}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300 rounded-lg flex items-center justify-center">
|
||||
<Icon name="zoom-in" class="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.image-gallery {
|
||||
@apply grid gap-4;
|
||||
}
|
||||
|
||||
.image-gallery.single {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.image-gallery.grid-2 {
|
||||
@apply grid-cols-2;
|
||||
}
|
||||
|
||||
.image-gallery.grid-3 {
|
||||
@apply grid-cols-2 sm:grid-cols-3;
|
||||
}
|
||||
|
||||
.image-gallery.grid-4 {
|
||||
@apply grid-cols-2 sm:grid-cols-3 lg:grid-cols-4;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
@apply relative overflow-hidden aspect-video;
|
||||
}
|
||||
|
||||
.image-button {
|
||||
@apply relative block;
|
||||
}
|
||||
|
||||
/* Responsive adjustments - maintain side-by-side layout on mobile */
|
||||
|
||||
/* Mixed aspect ratios for better layouts */
|
||||
.image-gallery.grid-3 .image-item:first-child {
|
||||
@apply sm:col-span-2;
|
||||
}
|
||||
|
||||
.image-gallery.grid-4 .image-item:nth-child(4n+1) {
|
||||
@apply aspect-square;
|
||||
}
|
||||
|
||||
.image-gallery.grid-4 .image-item:nth-child(4n+2) {
|
||||
@apply aspect-[4/3];
|
||||
}
|
||||
|
||||
.image-gallery.grid-4 .image-item:nth-child(4n+3) {
|
||||
@apply aspect-video;
|
||||
}
|
||||
|
||||
.image-gallery.grid-4 .image-item:nth-child(4n) {
|
||||
@apply aspect-[3/4];
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const imageButtons = document.querySelectorAll('[data-lightbox="gallery"]');
|
||||
|
||||
imageButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const imageIndex = parseInt(button.getAttribute('data-image-index') || '0');
|
||||
|
||||
// Dispatch custom event to open lightbox
|
||||
const event = new CustomEvent('openLightbox', {
|
||||
detail: { index: imageIndex }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Support for press-and-hold on mobile
|
||||
let pressTimer: number;
|
||||
|
||||
button.addEventListener('touchstart', (e) => {
|
||||
pressTimer = window.setTimeout(() => {
|
||||
e.preventDefault();
|
||||
const imageIndex = parseInt(button.getAttribute('data-image-index') || '0');
|
||||
const event = new CustomEvent('openLightbox', {
|
||||
detail: { index: imageIndex }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
button.addEventListener('touchend', () => {
|
||||
clearTimeout(pressTimer);
|
||||
});
|
||||
|
||||
button.addEventListener('touchmove', () => {
|
||||
clearTimeout(pressTimer);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
171
src/components/ImageGalleryManager.astro
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
---
|
||||
import { siteConfig } from '../config';
|
||||
---
|
||||
|
||||
<!-- Image Gallery Manager Component -->
|
||||
<!-- This component handles image gallery initialization and management -->
|
||||
|
||||
<script>
|
||||
// Declare global function for Swup compatibility
|
||||
declare global {
|
||||
interface Window {
|
||||
initializeImageGalleries: () => void;
|
||||
lightboxInstance: any;
|
||||
}
|
||||
}
|
||||
|
||||
function initializeImageGalleries() {
|
||||
const contentElement = document.querySelector('.prose');
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
const paragraphs = contentElement.querySelectorAll('p');
|
||||
|
||||
// Find all paragraphs with images (single or multiple)
|
||||
const imageParagraphs: { paragraph: Element; index: number; imageCount: number }[] = [];
|
||||
paragraphs.forEach((para, index) => {
|
||||
const images = Array.from(para.querySelectorAll('img'));
|
||||
// Filter out linked images (images inside <a> tags)
|
||||
const nonLinkedImages = images.filter(img => !img.closest('a'));
|
||||
if (nonLinkedImages.length > 0) {
|
||||
imageParagraphs.push({ paragraph: para, index: index, imageCount: nonLinkedImages.length });
|
||||
}
|
||||
});
|
||||
|
||||
// Process each image paragraph
|
||||
imageParagraphs.forEach((paraInfo) => {
|
||||
const { paragraph, imageCount } = paraInfo;
|
||||
|
||||
// Process paragraphs with 1 or more images
|
||||
if (imageCount >= 1) {
|
||||
|
||||
// Create gallery container
|
||||
const galleryContainer = document.createElement('div');
|
||||
|
||||
if (imageCount === 1) {
|
||||
galleryContainer.className = 'image-grid image-grid-1 my-2';
|
||||
} else if (imageCount === 2) {
|
||||
galleryContainer.className = 'image-grid image-grid-2 my-8';
|
||||
} else if (imageCount === 3) {
|
||||
galleryContainer.className = 'image-grid image-grid-3 my-8';
|
||||
} else if (imageCount >= 4) {
|
||||
galleryContainer.className = 'image-grid image-grid-4 my-8';
|
||||
}
|
||||
|
||||
// Get all non-linked images from this paragraph
|
||||
const allImages = Array.from(paragraph.querySelectorAll('img'));
|
||||
// Filter out linked images AND manually-sized (Obsidian) images
|
||||
const images = allImages.filter(img => !img.closest('a') && !img.classList.contains('obsidian-sized'));
|
||||
|
||||
// Skip processing if all images were manually sized
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Caption rule: only the very next line (same paragraph in HTML) = caption. Next paragraph (blank line in MD) = body.
|
||||
// ONLY for single images - grids don't have captions
|
||||
let captionContent: Node[] = []; // same-paragraph text = caption (centered)
|
||||
let nextParagraphBody: Element | null = null; // next <p> = had blank line in MD = normal body, move into container
|
||||
if (imageCount === 1) {
|
||||
const sameParagraphContent = Array.from(paragraph.childNodes).filter(node => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as Element;
|
||||
return el.tagName !== 'IMG' && el.tagName !== 'PICTURE';
|
||||
}
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return (node.textContent || '').trim() !== '';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (sameParagraphContent.length > 0) {
|
||||
captionContent = sameParagraphContent;
|
||||
} else {
|
||||
const nextP = paragraph.nextElementSibling;
|
||||
if (nextP && nextP.tagName === 'P' && !nextP.querySelector('h1, h2, h3, h4, h5, h6')) {
|
||||
const text = (nextP.textContent || '').trim();
|
||||
if (text !== '') {
|
||||
nextParagraphBody = nextP;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add each image to gallery
|
||||
images.forEach((img, imgIndex) => {
|
||||
const imageItem = document.createElement('div');
|
||||
imageItem.className = 'image-item';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'image-button w-full h-full group cursor-pointer';
|
||||
button.setAttribute('data-lightbox', 'gallery');
|
||||
button.setAttribute('aria-label', `View ${img.alt} in full size`);
|
||||
|
||||
// Add click event listener to open lightbox
|
||||
button.addEventListener('click', () => {
|
||||
// Dispatch custom event to open lightbox
|
||||
window.dispatchEvent(new CustomEvent('openLightbox', {
|
||||
detail: { index: imgIndex, button: button }
|
||||
}));
|
||||
});
|
||||
|
||||
const imgClone = img.cloneNode(true) as HTMLImageElement;
|
||||
imgClone.className = 'w-full h-full object-cover rounded-lg transition-all duration-300 group-hover:shadow-lg group-hover:scale-[1.02]';
|
||||
imgClone.loading = 'lazy';
|
||||
imgClone.setAttribute('data-lightbox', 'gallery');
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300 rounded-lg flex items-center justify-center';
|
||||
|
||||
const zoomIcon = document.createElement('div');
|
||||
zoomIcon.className = 'w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300';
|
||||
zoomIcon.innerHTML = `
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
overlay.appendChild(zoomIcon);
|
||||
button.appendChild(imgClone);
|
||||
button.appendChild(overlay);
|
||||
imageItem.appendChild(button);
|
||||
galleryContainer.appendChild(imageItem);
|
||||
});
|
||||
|
||||
// Create a container for the gallery and any non-image content
|
||||
const container = document.createElement('div');
|
||||
container.className = 'image-container';
|
||||
container.appendChild(galleryContainer);
|
||||
|
||||
const appendContent = (parent: Element, nodes: Node[]) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.nodeType === Node.TEXT_NODE && (!node.textContent || !node.textContent.trim())) return;
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'BR') return;
|
||||
parent.appendChild(node.cloneNode(true));
|
||||
});
|
||||
};
|
||||
if (captionContent.length > 0) {
|
||||
const captionContainer = document.createElement('div');
|
||||
captionContainer.className = 'image-caption-container';
|
||||
appendContent(captionContainer, captionContent);
|
||||
container.appendChild(captionContainer);
|
||||
}
|
||||
if (nextParagraphBody) {
|
||||
const bodyP = document.createElement('p');
|
||||
appendContent(bodyP, Array.from(nextParagraphBody.childNodes));
|
||||
container.appendChild(bodyP);
|
||||
}
|
||||
|
||||
paragraph.parentNode?.replaceChild(container, paragraph);
|
||||
if (nextParagraphBody?.parentNode) {
|
||||
nextParagraphBody.parentNode.removeChild(nextParagraphBody);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', initializeImageGalleries);
|
||||
|
||||
// Make function available globally for Swup
|
||||
window['initializeImageGalleries'] = initializeImageGalleries;
|
||||
</script>
|
||||
194
src/components/ImageWrapper.astro
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
---
|
||||
import path from "node:path";
|
||||
import { Image } from "astro:assets";
|
||||
import { devConfig, isDev } from "@/config/dev";
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
src: string;
|
||||
class?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
position?: string;
|
||||
basePath?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: "webp" | "avif" | "png" | "jpg" | "jpeg" | "svg";
|
||||
quality?: number;
|
||||
loading?: "lazy" | "eager";
|
||||
decoding?: "async" | "sync" | "auto";
|
||||
fetchpriority?: "high" | "low" | "auto";
|
||||
densities?: number[];
|
||||
}
|
||||
|
||||
const {
|
||||
id,
|
||||
src,
|
||||
alt = "",
|
||||
caption,
|
||||
position = "center",
|
||||
basePath = "/",
|
||||
width = 800,
|
||||
height = 450,
|
||||
format = "webp",
|
||||
quality = 85,
|
||||
loading = "lazy",
|
||||
decoding = "async",
|
||||
fetchpriority = "auto",
|
||||
densities = [1, 2]
|
||||
} = Astro.props;
|
||||
|
||||
const className = Astro.props.class;
|
||||
|
||||
// Clean the src path first - remove Obsidian brackets if present
|
||||
let cleanSrc = src;
|
||||
if (!cleanSrc) {
|
||||
console.warn('ImageWrapper: src is undefined');
|
||||
cleanSrc = '';
|
||||
}
|
||||
if (cleanSrc.startsWith('[[') && cleanSrc.endsWith(']]')) {
|
||||
cleanSrc = cleanSrc.slice(2, -2);
|
||||
}
|
||||
|
||||
// Determine image type using cleaned src
|
||||
// For folder-based content, if basePath indicates a synced folder, treat as local for optimization
|
||||
// Also treat global attachments directories as synced folders for Astro Image optimization
|
||||
const isSyncedFolder = basePath && basePath.includes('/') && (
|
||||
!basePath.endsWith('/attachments/') ||
|
||||
basePath === '/posts/attachments/' ||
|
||||
basePath === '/projects/attachments/' ||
|
||||
basePath === '/docs/attachments/' ||
|
||||
basePath === '/pages/attachments/' ||
|
||||
basePath === '/special/attachments/'
|
||||
);
|
||||
const isLocal = !(
|
||||
cleanSrc.startsWith("/") ||
|
||||
cleanSrc.startsWith("http") ||
|
||||
cleanSrc.startsWith("https") ||
|
||||
cleanSrc.startsWith("data:")
|
||||
) || isSyncedFolder; // Treat synced folders as local for Astro Image optimization
|
||||
const isPublic = cleanSrc.startsWith("/") && !isSyncedFolder;
|
||||
|
||||
// Dynamic import for local images
|
||||
let img: any = null;
|
||||
let fallbackSrc: string | null = null;
|
||||
let isImageMissing = false;
|
||||
|
||||
if (isLocal) {
|
||||
try {
|
||||
const files = import.meta.glob<ImageMetadata>("/public/**", {
|
||||
import: "default",
|
||||
});
|
||||
|
||||
// Remove leading "./" if present for folder-based posts
|
||||
if (cleanSrc.startsWith('./')) {
|
||||
cleanSrc = cleanSrc.slice(2);
|
||||
}
|
||||
|
||||
// Remove leading "images/" if present since basePath already includes it
|
||||
// But only for global images, not folder-based images
|
||||
if (cleanSrc.startsWith('images/') && (basePath === '/posts/attachments/' || basePath === '/projects/attachments/' || basePath === '/docs/attachments/' || basePath === '/pages/attachments/' || basePath === '/special/attachments/')) {
|
||||
cleanSrc = cleanSrc.slice(7);
|
||||
}
|
||||
|
||||
// For folder-based content, if basePath already includes 'images/' and cleanSrc starts with 'images/',
|
||||
// remove the prefix from cleanSrc to avoid double 'images/'
|
||||
if (cleanSrc.startsWith('images/') && basePath.includes('/attachments/') && basePath !== '/posts/attachments/' && basePath !== '/projects/attachments/' && basePath !== '/docs/attachments/' && basePath !== '/pages/attachments/' && basePath !== '/special/attachments/') {
|
||||
cleanSrc = cleanSrc.slice(7);
|
||||
}
|
||||
|
||||
// For content with attachments/ prefix, remove it since the sync script already handles this
|
||||
if (cleanSrc.startsWith('attachments/') && basePath.includes('/projects/') && !basePath.includes('/attachments/')) {
|
||||
cleanSrc = cleanSrc.slice(12); // Remove 'attachments/' prefix
|
||||
}
|
||||
|
||||
// For single-file projects, also remove attachments/ prefix when using global attachments directory
|
||||
if (cleanSrc.startsWith('attachments/') && basePath === '/projects/attachments/') {
|
||||
cleanSrc = cleanSrc.slice(12); // Remove 'attachments/' prefix
|
||||
}
|
||||
|
||||
// Normalize the path for the glob pattern
|
||||
let normalizedPath = path
|
||||
.normalize(path.join("/public", basePath, cleanSrc))
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
// Ensure the path starts with /public
|
||||
if (!normalizedPath.startsWith("/public")) {
|
||||
normalizedPath = `/public${normalizedPath}`;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
if (isDev && devConfig.images.logMissingImages) {
|
||||
// Debug logging removed for production
|
||||
}
|
||||
|
||||
const file = files[normalizedPath];
|
||||
if (file) {
|
||||
img = await file();
|
||||
} else {
|
||||
isImageMissing = true;
|
||||
// No fallback images - just mark as missing
|
||||
}
|
||||
} catch (error) {
|
||||
isImageMissing = true;
|
||||
// No fallback images - just mark as missing
|
||||
}
|
||||
}
|
||||
|
||||
const imageClass = "w-full h-full object-cover";
|
||||
const imageStyle = `object-position: ${position}`;
|
||||
---
|
||||
|
||||
<div id={id} class:list={[className, 'overflow-hidden relative']}>
|
||||
{isLocal && img ? (
|
||||
<Image
|
||||
src={img}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
format={format}
|
||||
quality={quality}
|
||||
class={imageClass}
|
||||
style={imageStyle}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
fetchpriority={fetchpriority}
|
||||
densities={densities}
|
||||
/>
|
||||
) : isPublic ? (
|
||||
<img
|
||||
src={isSyncedFolder ? `${basePath}${cleanSrc}` : cleanSrc}
|
||||
alt={alt}
|
||||
class={imageClass}
|
||||
style={imageStyle}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
fetchpriority={fetchpriority}
|
||||
width={width}
|
||||
height={height}
|
||||
data-image-component
|
||||
/>
|
||||
) : isImageMissing ? (
|
||||
// Don't render anything for missing local images
|
||||
null
|
||||
) : (
|
||||
<img
|
||||
src={cleanSrc}
|
||||
alt={alt}
|
||||
class={imageClass}
|
||||
style={imageStyle}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
fetchpriority={fetchpriority}
|
||||
width={width}
|
||||
height={height}
|
||||
data-image-component
|
||||
/>
|
||||
)}
|
||||
|
||||
{caption && (
|
||||
<figcaption class="mt-2 text-sm text-primary-600 dark:text-primary-400 text-center italic">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</div>
|
||||
497
src/components/Lightbox.astro
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
---
|
||||
// Lightbox component for image viewing
|
||||
import Icon from './Icon.astro';
|
||||
---
|
||||
|
||||
<div id="lightbox-overlay" style="display: none; position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; width: 100vw !important; height: 100vh !important; background: rgba(0, 0, 0, 0.9) !important; z-index: 999999 !important; backdrop-filter: blur(4px) !important; margin: 0 !important; padding: 0 !important;">
|
||||
<button id="lightbox-close" style="position: absolute; top: 16px; right: 16px; z-index: 10; padding: 12px; color: white; background: rgba(0, 0, 0, 0.5); border: none; border-radius: 50%; cursor: pointer; backdrop-filter: blur(4px);">
|
||||
<Icon name="x" class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button id="lightbox-prev" style="position: absolute; left: 16px; top: 50%; transform: translateY(-50%); z-index: 10; padding: 12px; color: white; background: rgba(0, 0, 0, 0.5); border: none; border-radius: 50%; cursor: pointer; backdrop-filter: blur(4px);">
|
||||
<Icon name="chevron-left" class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button id="lightbox-next" style="position: absolute; right: 16px; top: 50%; transform: translateY(-50%); z-index: 10; padding: 12px; color: white; background: rgba(0, 0, 0, 0.5); border: none; border-radius: 50%; cursor: pointer; backdrop-filter: blur(4px);">
|
||||
<Icon name="chevron-right" class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; padding: 2rem; position: relative;">
|
||||
<div style="position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; max-width: 90vw; max-height: 90vh;">
|
||||
<img id="lightbox-image" src="" alt="" style="max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; border-radius: 8px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); display: block; min-width: 200px; min-height: 200px;">
|
||||
|
||||
<div id="lightbox-loading" style="position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.5); border-radius: 8px;">
|
||||
<div style="width: 32px; height: 32px; border: 2px solid white; border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position: absolute; bottom: 0; left: 0; right: 0; padding: 16px; background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.4), transparent);">
|
||||
<div style="text-align: center;">
|
||||
<div id="lightbox-caption" style="color: white; font-size: 14px; margin-bottom: 8px;"></div>
|
||||
<div id="lightbox-counter" style="color: rgba(255, 255, 255, 0.7); font-size: 12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
#lightbox-overlay button:hover {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#lightbox-prev,
|
||||
#lightbox-next {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Improve button spacing on mobile */
|
||||
@media (max-width: 480px) {
|
||||
#lightbox-prev {
|
||||
left: 8px !important;
|
||||
}
|
||||
|
||||
#lightbox-next {
|
||||
right: 8px !important;
|
||||
}
|
||||
|
||||
#lightbox-close {
|
||||
top: 8px !important;
|
||||
right: 8px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
class Lightbox {
|
||||
private overlay: HTMLElement;
|
||||
private image: HTMLImageElement;
|
||||
private caption: HTMLElement;
|
||||
private counter: HTMLElement;
|
||||
private loading: HTMLElement;
|
||||
private prevButton: HTMLElement;
|
||||
private nextButton: HTMLElement;
|
||||
private closeButton: HTMLElement;
|
||||
private images: Array<{ src: string; alt: string; caption?: string }> = [];
|
||||
private currentIndex: number = 0;
|
||||
public isOpen: boolean = false;
|
||||
private currentScale: number = 1;
|
||||
private initialDistance: number = 0;
|
||||
private isPinching: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.overlay = document.getElementById('lightbox-overlay')!;
|
||||
this.image = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
this.caption = document.getElementById('lightbox-caption')!;
|
||||
this.counter = document.getElementById('lightbox-counter')!;
|
||||
this.loading = document.getElementById('lightbox-loading')!;
|
||||
this.prevButton = document.getElementById('lightbox-prev')!;
|
||||
this.nextButton = document.getElementById('lightbox-next')!;
|
||||
this.closeButton = document.getElementById('lightbox-close')!;
|
||||
|
||||
// Add event listeners
|
||||
this.closeButton.addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
this.prevButton.addEventListener('click', () => {
|
||||
this.prev();
|
||||
});
|
||||
this.nextButton.addEventListener('click', () => {
|
||||
this.next();
|
||||
});
|
||||
|
||||
// Close on click outside the image (but not during drag)
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
// Don't close if we just finished dragging
|
||||
if (isDragging) return;
|
||||
|
||||
// Close if clicking on the overlay itself or any element that's not the image or buttons
|
||||
const target = e.target as HTMLElement;
|
||||
const isImage = target === this.image || target.closest('img');
|
||||
const isButton = target === this.closeButton || target === this.prevButton || target === this.nextButton || target.closest('button');
|
||||
|
||||
if (!isImage && !isButton) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
this.close();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
this.prev();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
this.next();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Touch/swipe navigation for mobile and mouse drag for desktop with animation
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let isDragging = false;
|
||||
let currentX = 0;
|
||||
let dragOffset = 0;
|
||||
|
||||
// Touch events for mobile
|
||||
this.overlay.addEventListener('touchstart', (e) => {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
// Only handle single-finger swipe (pinch is handled separately)
|
||||
if (e.touches.length === 1) {
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
currentX = startX;
|
||||
isDragging = true;
|
||||
dragOffset = 0;
|
||||
this.image.style.transition = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('touchmove', (e) => {
|
||||
if (!this.isOpen || !isDragging || this.isPinching) return;
|
||||
currentX = e.touches[0].clientX;
|
||||
dragOffset = currentX - startX;
|
||||
|
||||
// Apply drag offset to image with some resistance
|
||||
const resistance = 0.3;
|
||||
this.image.style.transform = `translateX(${dragOffset * resistance}px)`;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('touchend', (e) => {
|
||||
if (!this.isOpen || !isDragging || this.isPinching) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const deltaX = endX - startX;
|
||||
const deltaY = endY - startY;
|
||||
|
||||
// Reset image position
|
||||
this.image.style.transition = 'transform 0.3s ease-out';
|
||||
this.image.style.transform = 'translateX(0)';
|
||||
|
||||
// Only handle horizontal swipes (ignore vertical scrolling)
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
|
||||
if (deltaX > 0) {
|
||||
// Swipe right - go to previous image
|
||||
this.prev();
|
||||
} else {
|
||||
// Swipe left - go to next image
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
// Mouse events for desktop drag
|
||||
this.overlay.addEventListener('mousedown', (e) => {
|
||||
if (!this.isOpen) return;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
currentX = startX;
|
||||
isDragging = true;
|
||||
dragOffset = 0;
|
||||
this.image.style.transition = 'none';
|
||||
e.preventDefault(); // Prevent text selection
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('mousemove', (e) => {
|
||||
if (!this.isOpen || !isDragging) return;
|
||||
currentX = e.clientX;
|
||||
dragOffset = currentX - startX;
|
||||
|
||||
// Apply drag offset to image with some resistance
|
||||
const resistance = 0.3;
|
||||
this.image.style.transform = `translateX(${dragOffset * resistance}px)`;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('mouseup', (e) => {
|
||||
if (!this.isOpen || !isDragging) return;
|
||||
|
||||
const endX = e.clientX;
|
||||
const endY = e.clientY;
|
||||
const deltaX = endX - startX;
|
||||
const deltaY = endY - startY;
|
||||
|
||||
// Reset image position
|
||||
this.image.style.transition = 'transform 0.3s ease-out';
|
||||
this.image.style.transform = 'translateX(0)';
|
||||
|
||||
// Only handle horizontal drags (ignore vertical scrolling)
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
|
||||
if (deltaX > 0) {
|
||||
// Drag right - go to previous image
|
||||
this.prev();
|
||||
} else {
|
||||
// Drag left - go to next image
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
// Prevent body scroll when lightbox is open
|
||||
this.overlay.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
|
||||
// Add pinch-to-zoom support
|
||||
this.setupPinchZoom();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private setupPinchZoom() {
|
||||
let touches: Touch[] = [];
|
||||
|
||||
this.overlay.addEventListener('touchstart', (e) => {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
// Two fingers detected - start pinch gesture
|
||||
this.isPinching = true;
|
||||
touches = Array.from(e.touches);
|
||||
|
||||
const touch1 = touches[0];
|
||||
const touch2 = touches[1];
|
||||
this.initialDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
|
||||
// Disable transition for smooth pinching
|
||||
this.image.style.transition = 'none';
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('touchmove', (e) => {
|
||||
if (!this.isOpen || !this.isPinching) return;
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const currentDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
|
||||
// Calculate scale based on distance change
|
||||
const scaleChange = currentDistance / this.initialDistance;
|
||||
this.currentScale = Math.max(0.5, Math.min(3, this.currentScale * scaleChange));
|
||||
|
||||
// Update initial distance for next iteration
|
||||
this.initialDistance = currentDistance;
|
||||
|
||||
// Apply scale transform
|
||||
this.image.style.transform = `scale(${this.currentScale})`;
|
||||
}
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('touchend', (e) => {
|
||||
if (this.isPinching && e.touches.length < 2) {
|
||||
// Pinch gesture ended
|
||||
this.isPinching = false;
|
||||
|
||||
// Re-enable transition
|
||||
this.image.style.transition = 'transform 0.3s ease-out';
|
||||
|
||||
// If zoomed out too much, reset to default
|
||||
if (this.currentScale < 0.8) {
|
||||
this.currentScale = 1;
|
||||
this.image.style.transform = 'scale(1)';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Event listeners
|
||||
window.addEventListener('openLightbox', (e: any) => {
|
||||
this.collectImages(e.detail.button);
|
||||
this.open(e.detail.index || 0);
|
||||
});
|
||||
}
|
||||
|
||||
private collectImages(clickedButton?: HTMLElement) {
|
||||
// If a specific button was clicked, find its gallery
|
||||
if (clickedButton) {
|
||||
const gallery = clickedButton.closest('.image-grid');
|
||||
if (gallery) {
|
||||
const galleryImages = gallery.querySelectorAll('img[data-lightbox="gallery"]');
|
||||
|
||||
this.images = Array.from(galleryImages).map(img => {
|
||||
const imgElement = img as HTMLImageElement;
|
||||
const imageData = {
|
||||
src: imgElement.src,
|
||||
alt: imgElement.alt || 'Image',
|
||||
caption: imgElement.getAttribute('data-caption') || imgElement.title || undefined
|
||||
};
|
||||
return imageData;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: collect all images (both gallery and regular images)
|
||||
const allImages = document.querySelectorAll('img');
|
||||
|
||||
this.images = Array.from(allImages).map(img => {
|
||||
const imgElement = img as HTMLImageElement;
|
||||
const imageData = {
|
||||
src: imgElement.src,
|
||||
alt: imgElement.alt || 'Image',
|
||||
caption: imgElement.getAttribute('data-caption') || imgElement.title || undefined
|
||||
};
|
||||
return imageData;
|
||||
});
|
||||
}
|
||||
|
||||
private open(index: number) {
|
||||
|
||||
this.currentIndex = index;
|
||||
this.currentScale = 1; // Reset zoom level
|
||||
this.image.style.transform = 'scale(1)'; // Reset visual transform
|
||||
this.isOpen = true;
|
||||
|
||||
// Move overlay to body to avoid parent container positioning issues
|
||||
if (this.overlay.parentElement !== document.body) {
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
// Show the overlay and force correct positioning
|
||||
this.overlay.style.display = 'flex';
|
||||
this.overlay.style.visibility = 'visible';
|
||||
this.overlay.style.opacity = '1';
|
||||
this.overlay.style.position = 'fixed';
|
||||
this.overlay.style.top = '0';
|
||||
this.overlay.style.left = '0';
|
||||
this.overlay.style.right = '0';
|
||||
this.overlay.style.bottom = '0';
|
||||
this.overlay.style.width = '100vw';
|
||||
this.overlay.style.height = '100vh';
|
||||
this.overlay.style.zIndex = '999999';
|
||||
this.overlay.style.margin = '0';
|
||||
this.overlay.style.padding = '0';
|
||||
this.overlay.style.transform = 'none';
|
||||
this.overlay.style.transformOrigin = 'initial';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
this.loadImage();
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.isOpen = false;
|
||||
this.overlay.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
public closeLightbox() {
|
||||
this.close();
|
||||
}
|
||||
|
||||
private prev() {
|
||||
if (this.images.length <= 1) return;
|
||||
this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
|
||||
this.loadImage();
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
private next() {
|
||||
if (this.images.length <= 1) return;
|
||||
this.currentIndex = (this.currentIndex + 1) % this.images.length;
|
||||
this.loadImage();
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
private loadImage() {
|
||||
if (!this.images[this.currentIndex]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentImage = this.images[this.currentIndex];
|
||||
|
||||
// Show loading spinner
|
||||
this.loading.style.display = 'flex';
|
||||
|
||||
// Create new image element to preload
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this.image.src = currentImage.src;
|
||||
this.image.alt = currentImage.alt;
|
||||
this.loading.style.display = 'none';
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
this.loading.style.display = 'none';
|
||||
};
|
||||
|
||||
img.src = currentImage.src;
|
||||
}
|
||||
|
||||
private updateUI() {
|
||||
if (this.images.length > 1) {
|
||||
this.counter.textContent = `${this.currentIndex + 1} of ${this.images.length}`;
|
||||
this.prevButton.style.display = 'block';
|
||||
this.nextButton.style.display = 'block';
|
||||
} else {
|
||||
this.counter.textContent = '';
|
||||
this.prevButton.style.display = 'none';
|
||||
this.nextButton.style.display = 'none';
|
||||
}
|
||||
|
||||
const currentImage = this.images[this.currentIndex];
|
||||
if (currentImage?.caption) {
|
||||
this.caption.textContent = currentImage.caption;
|
||||
} else {
|
||||
this.caption.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton pattern to prevent multiple lightbox instances
|
||||
let lightboxInstance: Lightbox | null = null;
|
||||
|
||||
function initializeLightbox() {
|
||||
if (lightboxInstance) {
|
||||
return lightboxInstance;
|
||||
}
|
||||
|
||||
lightboxInstance = new Lightbox();
|
||||
// Make lightbox instance globally accessible for Swup cleanup
|
||||
if (typeof window !== 'undefined') {
|
||||
window.lightboxInstance = lightboxInstance;
|
||||
}
|
||||
return lightboxInstance;
|
||||
}
|
||||
|
||||
// Initialize lightbox when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeLightbox();
|
||||
});
|
||||
|
||||
// Also initialize immediately if DOM is already loaded
|
||||
if (document.readyState !== 'loading') {
|
||||
initializeLightbox();
|
||||
}
|
||||
|
||||
// Cleanup lightbox on Swup page transitions
|
||||
// This will be handled by BaseLayout Swup hooks
|
||||
</script>
|
||||
471
src/components/LinkedMentions.astro
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
---
|
||||
import type { Post, LinkedMention } from '@/types';
|
||||
import { findLinkedMentions } from '@/utils/internallinks';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { getFeature, siteConfig } from '@/config';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
export interface Props {
|
||||
currentSlug: string;
|
||||
}
|
||||
|
||||
const { currentSlug } = Astro.props;
|
||||
const isCompact = (siteConfig.postOptions.linkedMentions as any).linkedMentionsCompact;
|
||||
|
||||
// Get all posts and pages to find linked mentions
|
||||
const posts = await getCollection('posts');
|
||||
const pages = await getCollection('pages');
|
||||
const linkedMentions = findLinkedMentions(posts as any[], currentSlug, posts as any[], pages as any[]);
|
||||
|
||||
|
||||
// Function to process links in excerpt text
|
||||
function processExcerptLinks(text: string, targetSlug: string): string {
|
||||
// IMPORTANT: Process markdown links FIRST, before wikilinks
|
||||
// This prevents markdown links from being processed as incomplete links
|
||||
// Then, process wikilinks - they're already in [[link]] or [[link|display]] format from excerpts
|
||||
|
||||
// First, process standard markdown links - convert to wikilink format
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, displayText, url) => {
|
||||
// Skip if this looks like it's already been processed (has mark tag)
|
||||
if (match.includes('<mark')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Parse anchor if present
|
||||
const anchorIndex = url.indexOf('#');
|
||||
const baseUrl = anchorIndex === -1 ? url : url.substring(0, anchorIndex);
|
||||
|
||||
// Extract the link text from URL to check if it matches target
|
||||
let linkText: string | null = null;
|
||||
if (baseUrl.startsWith('/posts/')) {
|
||||
linkText = baseUrl.replace('/posts/', '').replace(/\.md$/, '');
|
||||
if (linkText && linkText.endsWith('/index')) {
|
||||
linkText = linkText.replace('/index', '');
|
||||
}
|
||||
} else if (baseUrl.startsWith('posts/')) {
|
||||
linkText = baseUrl.replace('posts/', '').replace(/\.md$/, '');
|
||||
if (linkText && linkText.endsWith('/index')) {
|
||||
linkText = linkText.replace('/index', '');
|
||||
}
|
||||
} else if (baseUrl.endsWith('.md')) {
|
||||
linkText = baseUrl.replace(/\.md$/, '');
|
||||
if (linkText && linkText.endsWith('/index')) {
|
||||
linkText = linkText.replace('/index', '');
|
||||
}
|
||||
} else if (!baseUrl.includes('/')) {
|
||||
linkText = baseUrl;
|
||||
}
|
||||
|
||||
let isRelevantLink = false;
|
||||
|
||||
if (linkText) {
|
||||
const linkSlug = linkText.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
isRelevantLink = linkSlug === targetSlug;
|
||||
}
|
||||
|
||||
if (!isRelevantLink) {
|
||||
const targetPost = posts.find(p => p.id === targetSlug);
|
||||
if (targetPost) {
|
||||
const titleSlug = targetPost.data.title.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const displaySlug = displayText.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
isRelevantLink = displaySlug === titleSlug;
|
||||
}
|
||||
}
|
||||
|
||||
const wikilinkFormat = `[[${displayText}]]`;
|
||||
|
||||
if (isRelevantLink) {
|
||||
return `<mark class="bg-highlight-200 dark:bg-highlight-800 text-highlight-900 dark:text-highlight-100 px-1 rounded-sm">${wikilinkFormat}</mark>`;
|
||||
} else {
|
||||
return wikilinkFormat;
|
||||
}
|
||||
});
|
||||
|
||||
// Then, process wikilinks - skip if already processed (has mark tag or triple brackets)
|
||||
// CRITICAL: Use a replacement approach that tracks positions to avoid processing inside mark tags
|
||||
const wikilinkMatches: Array<{index: number, match: string, link: string, display: string | undefined, replacement: string}> = [];
|
||||
const wikilinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\](?!\])/g;
|
||||
let wikilinkMatch;
|
||||
|
||||
while ((wikilinkMatch = wikilinkRegex.exec(text)) !== null) {
|
||||
const match = wikilinkMatch[0];
|
||||
const link = wikilinkMatch[1];
|
||||
const display = wikilinkMatch[2];
|
||||
const matchIndex = wikilinkMatch.index;
|
||||
|
||||
// Skip if already processed (has mark tag or weird formatting)
|
||||
if (match.includes('<mark') || match.startsWith('[[[')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is inside a mark tag by looking at the text around it
|
||||
const beforeText = text.substring(Math.max(0, matchIndex - 200), matchIndex);
|
||||
const afterText = text.substring(matchIndex + match.length, matchIndex + match.length + 200);
|
||||
const lastMarkStart = beforeText.lastIndexOf('<mark');
|
||||
const lastMarkEnd = beforeText.lastIndexOf('</mark>');
|
||||
|
||||
// If there's an unclosed mark tag before us, we're inside it
|
||||
if (lastMarkStart > lastMarkEnd && lastMarkStart !== -1) {
|
||||
// Check if the mark tag extends past our position
|
||||
const markTagEnd = text.indexOf('</mark>', lastMarkStart);
|
||||
if (markTagEnd > matchIndex) {
|
||||
continue; // We're inside a mark tag, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Process the wikilink
|
||||
const anchorIndex = link.indexOf('#');
|
||||
const baseLink = anchorIndex === -1 ? link : link.substring(0, anchorIndex);
|
||||
let linkSlug = baseLink.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
|
||||
if (baseLink.startsWith('posts/')) {
|
||||
const postPath = baseLink.replace('posts/', '');
|
||||
const cleanPath = postPath.endsWith('/index') ? postPath.replace('/index', '') : postPath;
|
||||
linkSlug = cleanPath.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
} else if (baseLink.includes('/')) {
|
||||
if (baseLink.endsWith('/index') && baseLink.split('/').length === 2) {
|
||||
const folderName = baseLink.replace('/index', '');
|
||||
linkSlug = folderName.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
let isRelevantLink = linkSlug === targetSlug;
|
||||
|
||||
if (!isRelevantLink && display) {
|
||||
const targetPost = posts.find(p => p.id === targetSlug);
|
||||
if (targetPost) {
|
||||
const displaySlug = display.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const titleSlug = targetPost.data.title.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
isRelevantLink = displaySlug === titleSlug || displaySlug === targetSlug;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRelevantLink && !baseLink.includes('/') && !baseLink.includes('posts/')) {
|
||||
const targetPost = posts.find(p => p.id === targetSlug);
|
||||
if (targetPost) {
|
||||
const titleSlug = targetPost.data.title.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
isRelevantLink = linkSlug === titleSlug || linkSlug === targetSlug;
|
||||
}
|
||||
}
|
||||
|
||||
const displayText = display || (anchorIndex !== -1 ? link.substring(0, anchorIndex) : link);
|
||||
const wikilinkFormat = `[[${displayText}]]`;
|
||||
const replacement = isRelevantLink
|
||||
? `<mark class="bg-highlight-200 dark:bg-highlight-800 text-highlight-900 dark:text-highlight-100 px-1 rounded-sm">${wikilinkFormat}</mark>`
|
||||
: wikilinkFormat;
|
||||
|
||||
wikilinkMatches.push({ index: matchIndex, match, link, display, replacement });
|
||||
}
|
||||
|
||||
// Replace in reverse order
|
||||
wikilinkMatches.reverse().forEach(({ index, match, replacement }) => {
|
||||
text = text.substring(0, index) + replacement + text.substring(index + match.length);
|
||||
});
|
||||
|
||||
// Finally, handle incomplete markdown links (truncated ones like "[Obsidian Vault..." or "[Astro Suite Obsidian Vault")
|
||||
// CRITICAL: This must be VERY careful to not process links that are already part of wikilinks or markdown links
|
||||
// We'll use a more careful approach: find incomplete links but exclude any that are inside processed content
|
||||
|
||||
// First, find all positions where we have mark tags (already processed links)
|
||||
const markTagPositions: Array<{start: number, end: number}> = [];
|
||||
const markTagRegex = /<mark[^>]*>.*?<\/mark>/g;
|
||||
let markMatch;
|
||||
while ((markMatch = markTagRegex.exec(text)) !== null) {
|
||||
markTagPositions.push({ start: markMatch.index, end: markMatch.index + markMatch[0].length });
|
||||
}
|
||||
|
||||
// Also find all complete wikilinks and markdown links (processed or not)
|
||||
const completeLinkPositions: Array<{start: number, end: number}> = [];
|
||||
// Find wikilinks [[...]]
|
||||
const completeWikilinkRegex = /\[\[[^\]]+\]\]/g;
|
||||
let completeWikilinkMatch;
|
||||
while ((completeWikilinkMatch = completeWikilinkRegex.exec(text)) !== null) {
|
||||
completeLinkPositions.push({ start: completeWikilinkMatch.index, end: completeWikilinkMatch.index + completeWikilinkMatch[0].length });
|
||||
}
|
||||
// Find markdown links [...](...)
|
||||
const completeMarkdownLinkRegex = /\[[^\]]+\]\([^)]+\)/g;
|
||||
let completeMarkdownLinkMatch;
|
||||
while ((completeMarkdownLinkMatch = completeMarkdownLinkRegex.exec(text)) !== null) {
|
||||
completeLinkPositions.push({ start: completeMarkdownLinkMatch.index, end: completeMarkdownLinkMatch.index + completeMarkdownLinkMatch[0].length });
|
||||
}
|
||||
|
||||
// Helper to check if a position is inside any processed/complete link
|
||||
const isInsideProcessedLink = (index: number, length: number): boolean => {
|
||||
const end = index + length;
|
||||
// Check if overlaps with any mark tag
|
||||
for (const pos of markTagPositions) {
|
||||
if (index < pos.end && end > pos.start) return true;
|
||||
}
|
||||
// Check if overlaps with any complete link
|
||||
for (const pos of completeLinkPositions) {
|
||||
if (index < pos.end && end > pos.start) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Now find incomplete links, but skip if they're inside processed content
|
||||
const incompleteLinkPattern = /\[([^\]]+?)(?:\]|$)(?!\[)(?!\()/g;
|
||||
let incompleteMatches: Array<{index: number, match: string, displayText: string}> = [];
|
||||
let incompleteMatch;
|
||||
|
||||
while ((incompleteMatch = incompleteLinkPattern.exec(text)) !== null) {
|
||||
const matchIndex = incompleteMatch.index;
|
||||
const match = incompleteMatch[0];
|
||||
const displayText = incompleteMatch[1];
|
||||
|
||||
// Skip if this match is inside already-processed content
|
||||
if (isInsideProcessedLink(matchIndex, match.length)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if character before is `[` (part of `[[`)
|
||||
if (matchIndex > 0 && text[matchIndex - 1] === '[') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if followed by `(` (part of markdown link)
|
||||
const afterMatch = text.substring(matchIndex + match.length, matchIndex + match.length + 5);
|
||||
if (afterMatch.trim().startsWith('(')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process if there's actual text
|
||||
if (!displayText || displayText.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this incomplete link's display text matches the target post title
|
||||
let isRelevantLink = false;
|
||||
const trimmedDisplay = displayText.trim();
|
||||
const targetPost = posts.find(p => p.id === targetSlug);
|
||||
if (targetPost) {
|
||||
const displayLower = trimmedDisplay.toLowerCase();
|
||||
const titleLower = targetPost.data.title.toLowerCase();
|
||||
const displaySlug = displayLower.replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const titleSlug = titleLower.replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const titleWords = titleLower.split(/\s+/).filter(w => w.length >= 3 && !['the', 'and', 'for', 'with'].includes(w));
|
||||
const displayWords = displayLower.split(/\s+/).filter(w => w.length >= 3 && !['the', 'and', 'for', 'with'].includes(w));
|
||||
isRelevantLink =
|
||||
displaySlug === titleSlug ||
|
||||
titleSlug.includes(displaySlug) ||
|
||||
displaySlug.includes(titleSlug) ||
|
||||
titleWords.some(word => displayLower.includes(word) && word.length >= 5) ||
|
||||
displayWords.some(word => titleLower.includes(word) && word.length >= 5);
|
||||
}
|
||||
|
||||
const replacement = isRelevantLink
|
||||
? `<mark class="bg-highlight-200 dark:bg-highlight-800 text-highlight-900 dark:text-highlight-100 px-1 rounded-sm">[[${trimmedDisplay}]]</mark>`
|
||||
: `[[${trimmedDisplay}]]`;
|
||||
|
||||
incompleteMatches.push({ index: matchIndex, match, displayText: replacement });
|
||||
}
|
||||
|
||||
// Process matches in reverse order to preserve indices
|
||||
incompleteMatches.reverse().forEach(({ index, match, displayText }) => {
|
||||
// Double-check we're not overlapping with processed content
|
||||
if (!isInsideProcessedLink(index, match.length)) {
|
||||
text = text.substring(0, index) + displayText + text.substring(index + match.length);
|
||||
}
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
---
|
||||
|
||||
{linkedMentions.length > 0 && (
|
||||
<section class="mt-12 pt-8 border-t border-primary-200 dark:border-primary-700">
|
||||
<button
|
||||
id="linked-mentions-toggle"
|
||||
class="w-full text-left mb-6 flex items-center justify-between"
|
||||
aria-expanded="true"
|
||||
aria-controls="linked-mentions-content"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon name="link-2" class="w-5 h-5 mr-2" />
|
||||
<div class="text-base font-semibold text-primary-900 dark:text-primary-50">
|
||||
Linked Mentions
|
||||
<span class="ml-2 text-sm font-normal text-primary-500 dark:text-primary-400">
|
||||
({linkedMentions.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span id="linked-mentions-arrow">
|
||||
<span id="linked-mentions-arrow-icon">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
class="w-5 h-5 text-primary-500 dark:text-primary-400"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div id="linked-mentions-content" class="overflow-hidden transition-all duration-300 ease-in-out">
|
||||
{isCompact ? (
|
||||
<div class="space-y-2">
|
||||
{linkedMentions.filter(mention => mention !== null).map(mention => (
|
||||
<a
|
||||
href={`/posts/${mention.slug}`}
|
||||
class="group flex items-center justify-between p-3 bg-primary-50 dark:bg-primary-800/30 rounded-lg border border-primary-200 dark:border-primary-700 hover:border-primary-300 dark:hover:border-primary-600 transition-colors block"
|
||||
>
|
||||
<span class="text-primary-900 dark:text-primary-50 group-hover:text-highlight-600 dark:group-hover:text-highlight-400 transition-colors font-medium">
|
||||
{mention.title}
|
||||
</span>
|
||||
<Icon name="arrow-right" class="w-4 h-4 text-white dark:text-primary-100 group-hover:text-highlight-600 dark:group-hover:text-highlight-400 transition-colors" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{linkedMentions.filter(mention => mention !== null).map(mention => (
|
||||
<article class="p-4 bg-primary-50 dark:bg-primary-800/30 rounded-lg border border-primary-200 dark:border-primary-700 hover:border-primary-300 dark:hover:border-primary-600 transition-colors">
|
||||
<div class="font-medium text-primary-900 dark:text-primary-50 mb-2">
|
||||
<a
|
||||
href={`/posts/${mention.slug}`}
|
||||
class="hover:text-highlight-600 dark:hover:text-highlight-400 transition-colors"
|
||||
>
|
||||
{mention.title}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{mention.excerpt && (() => {
|
||||
const processedExcerpt = processExcerptLinks(mention.excerpt, currentSlug);
|
||||
// Check if excerpt appears to be at the end of content
|
||||
// Structural patterns only - no word/phrase matching
|
||||
// Check if excerpt contains or ends with a link (wikilink, markdown link, or processed link in HTML)
|
||||
// Links can be: ]] (wikilink), ) (markdown), </mark> (processed link)
|
||||
const hasLink = /(\[\[[^\]]+\]\]|\[[^\]]+\]\([^\)]+\)|<\/mark>)/i.test(processedExcerpt.trim());
|
||||
const endsWithLink = /(\]\]|\)|<\/mark>)\s*$/i.test(processedExcerpt.trim());
|
||||
// Check if it ends with a complete sentence (period, exclamation, question mark)
|
||||
const endsWithCompleteSentence = /[.!?]\s*$/.test(processedExcerpt.trim());
|
||||
// Check if excerpt ends with structural patterns that suggest incompleteness
|
||||
// (ends with comma, colon, dash, semicolon - suggesting more content)
|
||||
const endsWithIncompletePunctuation = /[,:;-]+\s*$/.test(processedExcerpt.trim());
|
||||
// Check if excerpt ends without proper sentence punctuation (ends with letter/word, not .!?)
|
||||
// Only check this if it doesn't end with a link (links are valid endings)
|
||||
const endsWithoutSentenceEnding = !endsWithLink && !/[.!?]\s*$/.test(processedExcerpt.trim()) && /[a-zA-Z]\s*$/.test(processedExcerpt.trim());
|
||||
|
||||
// Determine if excerpt is at natural end:
|
||||
// - Ends with a link (valid ending - links are complete thoughts), OR
|
||||
// - Has complete sentence AND not incomplete punctuation AND not ending with just a letter
|
||||
const isAtEnd = endsWithLink || (endsWithCompleteSentence && !endsWithIncompletePunctuation && !endsWithoutSentenceEnding);
|
||||
|
||||
// Check if excerpt appears to be at the start
|
||||
// If it starts with a complete sentence (capital letter), likely at start
|
||||
const isAtStart = /^[A-Z]/.test(processedExcerpt.trim());
|
||||
|
||||
// Only add ellipses if content was clearly truncated from middle
|
||||
const needsStartEllipsis = !isAtStart && processedExcerpt.length > 60;
|
||||
// Need end ellipses ONLY if:
|
||||
// - Not at natural end (doesn't end with link or complete sentence), AND
|
||||
// - Doesn't end with incomplete punctuation (which already suggests truncation), AND
|
||||
// - Doesn't end without sentence ending (which already suggests truncation)
|
||||
// Simplified: if it ends with a link, never add ellipses; otherwise use existing logic
|
||||
const needsEndEllipsis = !endsWithLink && (!isAtEnd || endsWithIncompletePunctuation || endsWithoutSentenceEnding);
|
||||
|
||||
const finalExcerpt = `${needsStartEllipsis ? '...' : ''}${processedExcerpt}${needsEndEllipsis ? '...' : ''}`;
|
||||
|
||||
return (
|
||||
<p
|
||||
class="text-sm text-primary-600 dark:text-primary-300 leading-relaxed"
|
||||
set:html={finalExcerpt}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div class="mt-3 flex items-center text-xs text-primary-500 dark:text-primary-400">
|
||||
<Icon name="arrow-right" class="w-3 h-3 mr-1" />
|
||||
<span>Referenced in this post</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<script>
|
||||
// Linked Mentions Toggle Functionality - Swup Compatible
|
||||
function initializeLinkedMentions() {
|
||||
const toggle = document.getElementById('linked-mentions-toggle') as HTMLButtonElement;
|
||||
const content = document.getElementById('linked-mentions-content') as HTMLElement;
|
||||
const arrowIcon = document.getElementById('linked-mentions-arrow-icon') as HTMLElement;
|
||||
|
||||
if (toggle && content && arrowIcon) {
|
||||
// Remove any existing event listeners to prevent duplicates
|
||||
const newToggle = toggle.cloneNode(true) as HTMLButtonElement;
|
||||
const parentNode = toggle.parentNode;
|
||||
if (parentNode) {
|
||||
parentNode.replaceChild(newToggle, toggle);
|
||||
}
|
||||
|
||||
// Get fresh references after cloning
|
||||
const freshContent = document.getElementById('linked-mentions-content') as HTMLElement;
|
||||
const freshArrowIcon = document.getElementById('linked-mentions-arrow-icon') as HTMLElement;
|
||||
|
||||
// Add click handler to the new toggle element
|
||||
newToggle.addEventListener('click', function() {
|
||||
const isExpanded = newToggle.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse
|
||||
freshContent.style.maxHeight = '0';
|
||||
freshContent.style.opacity = '0';
|
||||
freshArrowIcon.style.setProperty('--arrow-rotation', '90deg');
|
||||
newToggle.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
// Expand
|
||||
freshContent.style.maxHeight = freshContent.scrollHeight + 'px';
|
||||
freshContent.style.opacity = '1';
|
||||
freshArrowIcon.style.setProperty('--arrow-rotation', '0deg');
|
||||
newToggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize with expanded state
|
||||
freshContent.style.maxHeight = freshContent.scrollHeight + 'px';
|
||||
freshContent.style.opacity = '1';
|
||||
freshArrowIcon.style.setProperty('--arrow-rotation', '0deg');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeLinkedMentions);
|
||||
} else {
|
||||
initializeLinkedMentions();
|
||||
}
|
||||
|
||||
// Make function globally accessible for Swup hooks
|
||||
(window as any).initializeLinkedMentions = initializeLinkedMentions;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Enhanced styling for linked mentions */
|
||||
.linked-mention-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.linked-mention-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(to bottom, rgb(14 165 233), rgb(59 130 246));
|
||||
border-radius: 2px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.linked-mention-item:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ensure smooth arrow transition */
|
||||
#linked-mentions-arrow-icon {
|
||||
display: inline-block;
|
||||
transition: transform 200ms ease-in-out !important;
|
||||
transform: rotate(var(--arrow-rotation, 0deg));
|
||||
}
|
||||
</style>
|
||||
911
src/components/LocalGraph.astro
Normal file
|
|
@ -0,0 +1,911 @@
|
|||
---
|
||||
import type { Post } from '@/types';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { shouldShowPost } from '@/utils/markdown';
|
||||
import { siteConfig } from '@/config';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
export interface Props {
|
||||
currentSlug?: string;
|
||||
}
|
||||
|
||||
export interface LocalGraphData {
|
||||
shouldRender: boolean;
|
||||
}
|
||||
|
||||
const { currentSlug } = Astro.props;
|
||||
|
||||
// Get all posts for graph data
|
||||
const allPosts = await getCollection('posts');
|
||||
const isDev = import.meta.env.DEV;
|
||||
const visiblePosts = allPosts.filter(p => shouldShowPost(p, isDev));
|
||||
|
||||
// Check if we should show the graph based on connections
|
||||
// This component should only be rendered when there are connections (checked in PostLayout)
|
||||
// But we'll add an additional client-side check as a safety measure
|
||||
const shouldShowGraph = true;
|
||||
---
|
||||
|
||||
{shouldShowGraph && (
|
||||
<div id="local-graph-container" class="relative w-full bg-primary-50 dark:bg-primary-800 rounded-xl border border-primary-200 dark:border-primary-700 shadow-lg backdrop-blur-sm bg-opacity-95 dark:bg-opacity-95 overflow-hidden" style="width: 280px; max-width: 280px; height: 280px;">
|
||||
|
||||
<!-- Icons in top right corner -->
|
||||
<div class="absolute top-3 right-3 z-20 flex items-center gap-2">
|
||||
<!-- Global graph modal button -->
|
||||
<button
|
||||
id="local-graph-modal-btn"
|
||||
class="p-1 text-primary-500 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-200 transition-colors pointer-events-auto"
|
||||
title="Global Graph"
|
||||
>
|
||||
<Icon name="git-fork" class="w-4 h-4" />
|
||||
</button>
|
||||
<!-- Fullscreen button -->
|
||||
<button
|
||||
id="local-graph-fullscreen-btn"
|
||||
class="p-1 text-primary-500 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-200 transition-colors pointer-events-auto"
|
||||
title="Expand"
|
||||
>
|
||||
<Icon name="arrow-up-right" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Graph content -->
|
||||
<div id="local-graph-content" class="w-full h-full">
|
||||
<!-- Graph will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<script>
|
||||
import * as d3 from "d3";
|
||||
import { getGraphThemeColors } from '@/utils/graph-theme-colors';
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
radius: number;
|
||||
group?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
fx?: number | null;
|
||||
fy?: number | null;
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
source: string | GraphNode;
|
||||
target: string | GraphNode;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// Local Graph Functionality
|
||||
let localGraphInitialized = false;
|
||||
let svg: d3.Selection<SVGSVGElement, undefined, null, undefined> | null = null;
|
||||
let simulation: d3.Simulation<any, any> | null = null;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let fullscreenModalOpen = false;
|
||||
|
||||
function getThemeColors() {
|
||||
return getGraphThemeColors();
|
||||
}
|
||||
|
||||
function filterLocalGraphData(data: any, currentSlug: string): GraphData {
|
||||
if (!currentSlug) return { nodes: [], links: [] };
|
||||
|
||||
const currentNode = data.nodes.find((node: any) => node.id === currentSlug);
|
||||
if (!currentNode) return { nodes: [], links: [] };
|
||||
|
||||
const directlyConnectedNodeIds = new Set([currentSlug]);
|
||||
data.connections.forEach((conn: any) => {
|
||||
if (conn.source === currentSlug || conn.target === currentSlug) {
|
||||
directlyConnectedNodeIds.add(conn.source);
|
||||
directlyConnectedNodeIds.add(conn.target);
|
||||
}
|
||||
});
|
||||
|
||||
const relevantNodes = data.nodes.filter((node: any) => directlyConnectedNodeIds.has(node.id));
|
||||
const relevantConnections = data.connections.filter((conn: any) =>
|
||||
directlyConnectedNodeIds.has(conn.source) && directlyConnectedNodeIds.has(conn.target)
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: relevantNodes.map((node: any) => ({
|
||||
id: node.id,
|
||||
name: node.title || node.name,
|
||||
slug: node.slug,
|
||||
radius: Math.max(3, Math.min(8, (node.connections || 0) + 3)),
|
||||
group: node.type
|
||||
})),
|
||||
links: relevantConnections.map((conn: any) => ({
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
value: 1
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function renderLocalGraph(container: HTMLElement, data: GraphData) {
|
||||
// Clean up existing graph
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
|
||||
const colors = getThemeColors();
|
||||
const width = container.offsetWidth;
|
||||
const height = container.offsetHeight;
|
||||
const radiusForce = 100; // Smaller radius for local graph
|
||||
|
||||
const links = data.links.map((d) => ({ ...d }));
|
||||
const nodes = data.nodes.map((d) => ({ ...d }));
|
||||
|
||||
// Create force simulation
|
||||
simulation = d3
|
||||
.forceSimulation<GraphNode>(nodes)
|
||||
.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink(links)
|
||||
.id((d) => (d as GraphNode).id)
|
||||
.distance(30)
|
||||
.strength(0.2),
|
||||
)
|
||||
.force("charge", d3.forceManyBody().strength(-100))
|
||||
.force(
|
||||
"collide",
|
||||
d3.forceCollide((d) => d.radius + 8),
|
||||
)
|
||||
.force("radial", d3.forceRadial(radiusForce))
|
||||
.force("x", d3.forceX(0).strength(0.05))
|
||||
.force("y", d3.forceY(0).strength(0.05));
|
||||
|
||||
// Create SVG
|
||||
svg = d3
|
||||
.create("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.attr("viewBox", [-width / 2, -height / 2, width, height])
|
||||
.attr("style", "max-width: 100%; height: auto;");
|
||||
|
||||
const containerGroup = svg.append("g");
|
||||
|
||||
// Add zoom behavior for panning
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.2, 6]) // Extended zoom range for more flexibility
|
||||
.on("start", function(event) {
|
||||
// Show grabbing cursor only when starting to pan on empty space
|
||||
if (event.sourceEvent && event.sourceEvent.type === 'mousedown') {
|
||||
const isOnNode = !!(event.sourceEvent.target as Element)?.closest('g');
|
||||
// Only set grabbing if not on a node group (background pan)
|
||||
if (!isOnNode && svg) svg.style('cursor', 'grabbing');
|
||||
}
|
||||
})
|
||||
.on("end", function(event) {
|
||||
// Always restore default cursor when zoom behavior ends
|
||||
if (svg) svg.style('cursor', 'default');
|
||||
})
|
||||
.on("zoom", (event) => {
|
||||
containerGroup.attr("transform", event.transform);
|
||||
|
||||
// Hide/show labels based on zoom level
|
||||
const scale = event.transform.k;
|
||||
const labelThreshold = 0.8; // Higher threshold for local graph due to smaller size
|
||||
|
||||
if (scale < labelThreshold) {
|
||||
// Hide all labels when zoomed out
|
||||
labels.style("opacity", 0);
|
||||
} else {
|
||||
// Show all labels when zoomed in
|
||||
labels.style("opacity", 1);
|
||||
}
|
||||
});
|
||||
|
||||
svg.call(zoom as any);
|
||||
if (svg) svg.style('cursor', 'default');
|
||||
|
||||
// Create links
|
||||
const link = containerGroup
|
||||
.append("g")
|
||||
.attr("stroke", colors.linkStroke)
|
||||
.attr("stroke-opacity", 0.3)
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.join("line")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Create nodes
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
const CLICK_DISTANCE = 5;
|
||||
const nodeGroup = containerGroup
|
||||
.append("g")
|
||||
.selectAll("g")
|
||||
.data(nodes)
|
||||
.join("g")
|
||||
.call(
|
||||
d3
|
||||
.drag<SVGGElement, GraphNode>()
|
||||
.on("start", function (event) {
|
||||
dragStartX = event.x;
|
||||
dragStartY = event.y;
|
||||
if (svg) svg.style('cursor', 'default');
|
||||
if (!event.active) simulation!.alphaTarget(0.1).restart();
|
||||
event.subject.fx = event.subject.x;
|
||||
event.subject.fy = event.subject.y;
|
||||
})
|
||||
.on("drag", function (event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", function (event, d) {
|
||||
if (svg) svg.style('cursor', 'default');
|
||||
if (!event.active) simulation!.alphaTarget(0);
|
||||
event.subject.fx = null;
|
||||
event.subject.fy = null;
|
||||
|
||||
const dx = event.x - dragStartX;
|
||||
const dy = event.y - dragStartY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < CLICK_DISTANCE && d.slug) {
|
||||
const modals = document.querySelectorAll('.fixed.inset-0.z-50');
|
||||
modals.forEach(modal => {
|
||||
if (modal.id !== 'command-palette-overlay') {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
resetLocalGraph();
|
||||
|
||||
const url = `/posts/${d.slug}`;
|
||||
if ((window as any).swup) {
|
||||
(window as any).swup.navigate(url);
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// Build neighbor map for hover effects
|
||||
const neighbors = new Map<string, Set<string>>();
|
||||
for (const link of links) {
|
||||
const src = typeof link.source === "object" ? link.source.id : link.source;
|
||||
const tgt = typeof link.target === "object" ? link.target.id : link.target;
|
||||
|
||||
if (!neighbors.has(src)) neighbors.set(src, new Set());
|
||||
if (!neighbors.has(tgt)) neighbors.set(tgt, new Set());
|
||||
|
||||
neighbors.get(src)!.add(tgt);
|
||||
neighbors.get(tgt)!.add(src);
|
||||
}
|
||||
|
||||
// Add hover effects
|
||||
nodeGroup
|
||||
.on("mouseover", function (_event, d) {
|
||||
const connected = neighbors.get(d.id) ?? new Set();
|
||||
connected.add(d.id);
|
||||
|
||||
link.transition().duration(150)
|
||||
.attr("stroke", (l) =>
|
||||
(typeof l.source === 'object' ? l.source.id : l.source) === d.id ||
|
||||
(typeof l.target === 'object' ? l.target.id : l.target) === d.id
|
||||
? colors.tagFill : colors.linkStroke)
|
||||
.attr("stroke-opacity", (l) =>
|
||||
(typeof l.source === 'object' ? l.source.id : l.source) === d.id ||
|
||||
(typeof l.target === 'object' ? l.target.id : l.target) === d.id ? 0.8 : 0.1);
|
||||
|
||||
nodeGroup.selectAll<SVGCircleElement, GraphNode>("circle")
|
||||
.attr("fill-opacity", (n) => connected.has(n.id) ? 1 : 0.3)
|
||||
.attr("fill", (n) => n.id === d.id ? colors.tagFill : colors.linkStroke);
|
||||
|
||||
labels.transition().duration(150)
|
||||
.style("opacity", (n) => connected.has(n.id) ? 1 : 0.1);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
link.transition().duration(150)
|
||||
.attr("stroke", colors.linkStroke)
|
||||
.attr("stroke-opacity", 0.3);
|
||||
|
||||
nodeGroup.selectAll<SVGCircleElement, GraphNode>("circle")
|
||||
.attr("fill", colors.linkStroke)
|
||||
.attr("fill-opacity", 1);
|
||||
|
||||
const currentTransform = svg ? d3.zoomTransform(svg.node() as Element) : d3.zoomIdentity;
|
||||
labels.transition().duration(150)
|
||||
.style("opacity", currentTransform.k < 0.8 ? 0 : 0.6);
|
||||
});
|
||||
|
||||
// Create circles
|
||||
const node = nodeGroup
|
||||
.append("circle")
|
||||
.attr("r", (d) => d.radius)
|
||||
.attr("cursor", "pointer")
|
||||
.attr("fill-opacity", 1)
|
||||
.attr("fill", colors.linkStroke)
|
||||
.attr("data-id", (d) => d.id);
|
||||
|
||||
node.append("title").text((d) => d.name ?? d.id);
|
||||
|
||||
// Create labels
|
||||
const labels = nodeGroup
|
||||
.append("text")
|
||||
.text((d) => d.name ?? d.id)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", (d) => d.radius + 8)
|
||||
.style("font-size", "8px")
|
||||
.style("fill", colors.postText)
|
||||
.style("pointer-events", "none")
|
||||
.style("opacity", 0.6); // Start with slightly faded labels
|
||||
|
||||
// Run simulation
|
||||
for (let i = 0; i < 200; ++i) simulation.tick();
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", (d: any) => d.source.x)
|
||||
.attr("y1", (d: any) => d.source.y)
|
||||
.attr("x2", (d: any) => d.target.x)
|
||||
.attr("y2", (d: any) => d.target.y);
|
||||
|
||||
nodeGroup.attr("transform", (d: any) => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
simulation.alpha(1).restart();
|
||||
|
||||
// Append SVG to container
|
||||
container.appendChild(svg.node()!);
|
||||
|
||||
// Set up cleanup function
|
||||
cleanup = () => {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
simulation = null;
|
||||
}
|
||||
if (svg) {
|
||||
svg.remove();
|
||||
svg = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function initializeLocalGraph() {
|
||||
const container = document.getElementById('local-graph-content');
|
||||
const outerContainer = document.getElementById('local-graph-container');
|
||||
if (!container || !outerContainer) return;
|
||||
|
||||
if (localGraphInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
localGraphInitialized = true;
|
||||
|
||||
// Ensure GlobalGraph is available for the buttons
|
||||
if (!(window as any).openGraphModal && (window as any).initializeGraphModal) {
|
||||
(window as any).initializeGraphModal();
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center h-full text-primary-500 dark:text-primary-400">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin w-6 h-6 mx-auto mb-2 opacity-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs">Loading graph...</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
let currentSlug = '';
|
||||
|
||||
// Try multiple patterns to match the current post
|
||||
const patterns = [
|
||||
/\/posts\/([^\/]+)/, // /posts/slug
|
||||
/\/posts\/([^\/]+)\/$/, // /posts/slug/
|
||||
/\/posts\/([^\/]+)\/index/, // /posts/slug/index
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = currentPath.match(pattern);
|
||||
if (match) {
|
||||
currentSlug = match[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no match, try to extract from the last segment
|
||||
if (!currentSlug) {
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
if (segments.length >= 2 && segments[0] === 'posts') {
|
||||
currentSlug = segments[1];
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/graph/graph-data.json')
|
||||
.then(response => response.json())
|
||||
.then((data) => {
|
||||
|
||||
const filteredData = filterLocalGraphData(data, currentSlug);
|
||||
|
||||
(window as any).localGraphData = data; // Store full data
|
||||
(window as any).localGraphFilteredData = filteredData; // Store filtered data
|
||||
|
||||
// Check if there are actually connections to show
|
||||
if (filteredData.nodes.length <= 1) {
|
||||
// Hide the entire graph container if no connections
|
||||
outerContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the graph since we have connections
|
||||
outerContainer.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
renderLocalGraph(container, filteredData);
|
||||
|
||||
// Track initial dimensions for resize detection
|
||||
lastContainerWidth = container.offsetWidth;
|
||||
lastContainerHeight = container.offsetHeight;
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Could not load local graph data:', error);
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center h-full text-primary-500 dark:text-primary-400">
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6 mx-auto mb-2 opacity-50">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v4"/>
|
||||
<path d="M10 21H6a2 2 0 0 1-2-2v-4"/>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2h-4"/>
|
||||
<path d="M3 9V5a2 2 0 0 1 2-2h4"/>
|
||||
<path d="M9 9h6v6H9z"/>
|
||||
</svg>
|
||||
<p class="text-xs">Graph data not available</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function setupLocalGraphButtons() {
|
||||
// Proactively ensure GlobalGraph is available
|
||||
if (!(window as any).openGraphModal && (window as any).initializeGraphModal) {
|
||||
(window as any).initializeGraphModal();
|
||||
}
|
||||
|
||||
// Remove existing listeners to prevent duplicates
|
||||
const modalBtn = document.getElementById('local-graph-modal-btn');
|
||||
if (modalBtn) {
|
||||
modalBtn.replaceWith(modalBtn.cloneNode(true));
|
||||
const newModalBtn = document.getElementById('local-graph-modal-btn');
|
||||
if (newModalBtn) {
|
||||
newModalBtn.addEventListener('click', () => {
|
||||
|
||||
// Ensure GlobalGraph is available before trying to open it
|
||||
const ensureGlobalGraph = () => {
|
||||
if ((window as any).openGraphModal) {
|
||||
(window as any).openGraphModal();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (ensureGlobalGraph()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((window as any).initializeGraphModal) {
|
||||
(window as any).initializeGraphModal();
|
||||
|
||||
// Try multiple times with increasing delays
|
||||
const attempts = [50, 100, 200, 500];
|
||||
attempts.forEach((delay, index) => {
|
||||
setTimeout(() => {
|
||||
if (ensureGlobalGraph()) {
|
||||
return;
|
||||
}
|
||||
if (index === attempts.length - 1) {
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
} else {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fullscreenBtn = document.getElementById('local-graph-fullscreen-btn');
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.replaceWith(fullscreenBtn.cloneNode(true));
|
||||
const newFullscreenBtn = document.getElementById('local-graph-fullscreen-btn');
|
||||
if (newFullscreenBtn) {
|
||||
newFullscreenBtn.addEventListener('click', () => {
|
||||
openLocalGraphFullscreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openLocalGraphFullscreen() {
|
||||
if (fullscreenModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingModal = document.querySelector('.fixed.inset-0.z-50:not(#command-palette-overlay):not(#graph-modal-overlay)');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
fullscreenModalOpen = true;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm animate-fade-in';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-6xl h-[80vh] bg-primary-50 dark:bg-primary-800 rounded-xl shadow-2xl border border-primary-200 dark:border-primary-700 animate-scale-in">
|
||||
|
||||
<button
|
||||
id="local-graph-fullscreen-close"
|
||||
class="absolute top-4 right-4 z-10 p-2 text-primary-500 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-200 transition-colors rounded-lg hover:bg-primary-100 dark:hover:bg-primary-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18"/>
|
||||
<path d="M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="local-graph-fullscreen-container" class="w-full h-full bg-primary-50 dark:bg-primary-800 rounded-xl overflow-hidden">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const closeBtn = modal.querySelector('#local-graph-fullscreen-close');
|
||||
const closeModal = () => {
|
||||
modal.remove();
|
||||
document.body.style.overflow = '';
|
||||
fullscreenModalOpen = false;
|
||||
|
||||
// Reinitialize the local graph after closing fullscreen
|
||||
setTimeout(() => {
|
||||
const outerContainer = document.getElementById('local-graph-container');
|
||||
const innerContainer = document.getElementById('local-graph-content');
|
||||
if (outerContainer && innerContainer) {
|
||||
// Clear the container and reinitialize
|
||||
innerContainer.innerHTML = '';
|
||||
localGraphInitialized = false;
|
||||
initializeLocalGraph();
|
||||
setupLocalGraphButtons();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
closeBtn?.addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', (e) => {
|
||||
const modalContent = modal.querySelector('.relative');
|
||||
if (e.target === modal || !modalContent?.contains(e.target as Node)) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const container = modal.querySelector('#local-graph-fullscreen-container');
|
||||
|
||||
if (container) {
|
||||
const cachedFilteredData = (window as any).localGraphFilteredData;
|
||||
|
||||
if (cachedFilteredData && cachedFilteredData.nodes.length > 1) {
|
||||
renderLocalGraph(container as HTMLElement, cachedFilteredData);
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center h-full text-primary-500 dark:text-primary-400">
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-4 opacity-50">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v4"/>
|
||||
<path d="M10 21H6a2 2 0 0 1-2-2v-4"/>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2h-4"/>
|
||||
<path d="M3 9V5a2 2 0 0 1 2-2h4"/>
|
||||
<path d="M9 9h6v6H9z"/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium">No connections found</p>
|
||||
<p class="text-sm mt-2">This post doesn't have any connections to other posts</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
function resetLocalGraph() {
|
||||
const container = document.getElementById('local-graph-content');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
localGraphInitialized = false;
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
// Clear any cached data
|
||||
(window as any).localGraphData = null;
|
||||
(window as any).localGraphFilteredData = null;
|
||||
// Reset dimension tracking
|
||||
lastContainerWidth = 0;
|
||||
lastContainerHeight = 0;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeLocalGraph();
|
||||
setupLocalGraphButtons();
|
||||
});
|
||||
} else {
|
||||
initializeLocalGraph();
|
||||
setupLocalGraphButtons();
|
||||
}
|
||||
|
||||
if ((window as any).swup) {
|
||||
(window as any).swup.hooks.on('visit:start', () => {
|
||||
const fullscreenModals = document.querySelectorAll('.fixed.inset-0.z-50:not(#command-palette-overlay):not(#graph-modal-overlay)');
|
||||
fullscreenModals.forEach(modal => {
|
||||
if (modal.id !== 'graph-modal-overlay' && modal.id !== 'command-palette-overlay') {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
fullscreenModalOpen = false;
|
||||
});
|
||||
|
||||
(window as any).swup.hooks.on('page:view', () => {
|
||||
// Reset and re-initialize local graph after page transition
|
||||
resetLocalGraph();
|
||||
localGraphInitialized = false;
|
||||
|
||||
// Wait for DOM to be ready and then reinitialize
|
||||
setTimeout(() => {
|
||||
const outerContainer = document.getElementById('local-graph-container');
|
||||
const innerContainer = document.getElementById('local-graph-content');
|
||||
|
||||
if (outerContainer && innerContainer) {
|
||||
initializeLocalGraph();
|
||||
setupLocalGraphButtons();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
const retryOuterContainer = document.getElementById('local-graph-container');
|
||||
const retryInnerContainer = document.getElementById('local-graph-content');
|
||||
if (retryOuterContainer && retryInnerContainer) {
|
||||
initializeLocalGraph();
|
||||
setupLocalGraphButtons();
|
||||
} else {
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100); // Reduced timeout for faster reinitialization
|
||||
});
|
||||
|
||||
(window as any).swup.hooks.on('visit:end', () => {
|
||||
// Also reinitialize on visit:end as a backup
|
||||
if (!localGraphInitialized) {
|
||||
setTimeout(() => {
|
||||
const outerContainer = document.getElementById('local-graph-container');
|
||||
const innerContainer = document.getElementById('local-graph-content');
|
||||
if (outerContainer && innerContainer) {
|
||||
initializeLocalGraph();
|
||||
setupLocalGraphButtons();
|
||||
} else {
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for theme changes to update graph colors
|
||||
window.addEventListener('themechange', () => {
|
||||
// Re-render the graph with new colors
|
||||
const container = document.getElementById('local-graph-content');
|
||||
if (container && localGraphInitialized) {
|
||||
// Get fresh data and re-render
|
||||
fetch('/graph/graph-data.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const currentPath = window.location.pathname;
|
||||
let currentSlug = '';
|
||||
|
||||
// Try multiple patterns to match the current post
|
||||
const patterns = [
|
||||
/\/posts\/([^\/]+)/, // /posts/slug
|
||||
/\/posts\/([^\/]+)\/$/, // /posts/slug/
|
||||
/\/posts\/([^\/]+)\/index/, // /posts/slug/index
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = currentPath.match(pattern);
|
||||
if (match) {
|
||||
currentSlug = match[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredData = filterLocalGraphData(data, currentSlug);
|
||||
renderLocalGraph(container, filteredData);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('LocalGraph: Error fetching graph data for theme update:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for window resize to re-render graph with correct dimensions
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastContainerWidth = 0;
|
||||
let lastContainerHeight = 0;
|
||||
|
||||
function handleResize() {
|
||||
const container = document.getElementById('local-graph-content');
|
||||
const outerContainer = document.getElementById('local-graph-container');
|
||||
if (!container || !outerContainer || !localGraphInitialized) return;
|
||||
|
||||
// Check if container is visible (not hidden by CSS)
|
||||
const containerRect = outerContainer.getBoundingClientRect();
|
||||
if (containerRect.width === 0 || containerRect.height === 0) {
|
||||
// Container is hidden, wait for it to become visible
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWidth = container.offsetWidth;
|
||||
const currentHeight = container.offsetHeight;
|
||||
|
||||
// Only re-render if dimensions actually changed
|
||||
if (currentWidth !== lastContainerWidth || currentHeight !== lastContainerHeight) {
|
||||
lastContainerWidth = currentWidth;
|
||||
lastContainerHeight = currentHeight;
|
||||
|
||||
// Get cached data if available
|
||||
const cachedFilteredData = (window as any).localGraphFilteredData;
|
||||
if (cachedFilteredData && cachedFilteredData.nodes.length > 1) {
|
||||
renderLocalGraph(container, cachedFilteredData);
|
||||
} else {
|
||||
// Fetch fresh data if cache is not available
|
||||
const currentPath = window.location.pathname;
|
||||
let currentSlug = '';
|
||||
|
||||
const patterns = [
|
||||
/\/posts\/([^\/]+)/,
|
||||
/\/posts\/([^\/]+)\/$/,
|
||||
/\/posts\/([^\/]+)\/index/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = currentPath.match(pattern);
|
||||
if (match) {
|
||||
currentSlug = match[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/graph/graph-data.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const filteredData = filterLocalGraphData(data, currentSlug);
|
||||
(window as any).localGraphFilteredData = filteredData;
|
||||
if (filteredData.nodes.length > 1) {
|
||||
renderLocalGraph(container, filteredData);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('LocalGraph: Error fetching graph data for resize:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced resize handler
|
||||
function onResize() {
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
resizeTimeout = setTimeout(() => {
|
||||
handleResize();
|
||||
}, 150); // 150ms debounce
|
||||
}
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Also listen for orientation changes on mobile
|
||||
window.addEventListener('orientationchange', () => {
|
||||
// Give browser time to update layout after orientation change
|
||||
setTimeout(() => {
|
||||
handleResize();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Make functions globally available for BaseLayout integration
|
||||
(window as any).initializeLocalGraph = initializeLocalGraph;
|
||||
(window as any).setupLocalGraphButtons = setupLocalGraphButtons;
|
||||
(window as any).resetLocalGraph = resetLocalGraph;
|
||||
(window as any).openLocalGraphFullscreen = openLocalGraphFullscreen;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#local-graph-container {
|
||||
min-height: 280px;
|
||||
height: 280px;
|
||||
width: 280px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#local-graph-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
contain: layout style paint;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#local-graph-fullscreen-container {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#local-graph-container {
|
||||
min-height: 240px;
|
||||
height: 240px;
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
117
src/components/MermaidDiagram.astro
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
---
|
||||
// MermaidDiagram.astro
|
||||
// Component for rendering Mermaid diagrams with proper styling and error handling
|
||||
|
||||
export interface Props {
|
||||
id: string;
|
||||
source: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { id, source, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`mermaid-diagram ${className}`} data-mermaid-id={id} data-mermaid-source={source}>
|
||||
<div class="mermaid-diagram-content">
|
||||
<pre class="mermaid-diagram-source"><code>{source}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mermaid-diagram {
|
||||
@apply my-6 mx-auto max-w-full overflow-hidden;
|
||||
}
|
||||
|
||||
.mermaid-diagram-content {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.mermaid-diagram-source {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* Mermaid diagram styling */
|
||||
.mermaid-diagram :global(.mermaid) {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
/* Error state styling */
|
||||
.mermaid-diagram.error {
|
||||
@apply border border-red-200 dark:border-red-800 rounded-lg p-4 bg-red-50 dark:bg-red-900/20;
|
||||
}
|
||||
|
||||
.mermaid-diagram.error .mermaid-diagram-content {
|
||||
@apply text-red-700 dark:text-red-300;
|
||||
}
|
||||
|
||||
/* Loading state styling */
|
||||
.mermaid-diagram.loading {
|
||||
@apply border border-primary-200 dark:border-primary-600 rounded-lg p-4 bg-primary-50 dark:bg-primary-800/20;
|
||||
}
|
||||
|
||||
.mermaid-diagram.loading .mermaid-diagram-content {
|
||||
@apply text-primary-600 dark:text-primary-400;
|
||||
}
|
||||
|
||||
/* Loading skeleton styling */
|
||||
.mermaid-loading-skeleton {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.mermaid-loading-skeleton::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-gradient-to-r from-transparent via-primary-200/50 dark:via-primary-700/50 to-transparent;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* Smooth transitions for rendered diagrams */
|
||||
.mermaid-diagram svg {
|
||||
@apply transition-opacity duration-300 ease-in-out;
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* NUCLEAR BOMB HIDING - Hide all Mermaid error bombs permanently */
|
||||
.mermaid-diagram svg[aria-roledescription="error"],
|
||||
.mermaid-diagram svg:has(.error-icon),
|
||||
.mermaid-diagram svg:has(.error-text),
|
||||
.mermaid-diagram svg[viewBox="0 0 2412 512"],
|
||||
.mermaid-diagram svg[viewBox="0 0 417 625"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Performance optimizations */
|
||||
.mermaid-diagram {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.mermaid-diagram svg {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.mermaid-diagram {
|
||||
@apply mx-0;
|
||||
}
|
||||
|
||||
.mermaid-diagram svg {
|
||||
@apply max-w-full h-auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
151
src/components/Pagination.astro
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
---
|
||||
import type { PaginationInfo } from '@/types';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
export interface Props {
|
||||
pagination: PaginationInfo;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
const { pagination, baseUrl = '/posts' } = Astro.props;
|
||||
const { currentPage, totalPages, hasNext, hasPrev } = pagination;
|
||||
|
||||
// Generate page numbers to show
|
||||
function generatePageNumbers(current: number, total: number) {
|
||||
const pages: (number | string)[] = [];
|
||||
const delta = 2; // Number of pages to show on each side of current page
|
||||
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (current - delta > 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Add pages around current page
|
||||
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (current + delta < total - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page if there's more than one page
|
||||
if (total > 1) {
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
const pageNumbers = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
const prevUrl = hasPrev
|
||||
? (currentPage === 2 ? baseUrl : `${baseUrl}/${currentPage - 1}`)
|
||||
: undefined;
|
||||
|
||||
const nextUrl = hasNext
|
||||
? `${baseUrl}/${currentPage + 1}`
|
||||
: undefined;
|
||||
---
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav class="flex items-center justify-between py-8" aria-label="Pagination">
|
||||
<!-- Previous page -->
|
||||
<div class="flex-1 flex justify-start">
|
||||
{hasPrev ? (
|
||||
<a
|
||||
href={prevUrl || `${baseUrl}/${currentPage - 1}`}
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-primary-600 dark:text-primary-300 bg-white dark:bg-primary-900 border border-primary-300 dark:border-primary-600 rounded-lg hover:bg-primary-50 dark:hover:bg-primary-800 hover:text-primary-800 dark:hover:text-primary-200 transition-colors"
|
||||
>
|
||||
<Icon name="arrow-left" class="w-4 h-4 mr-2" />
|
||||
Previous
|
||||
</a>
|
||||
) : (
|
||||
<span class="inline-flex items-center px-4 py-2 text-sm font-medium text-primary-400 dark:text-primary-500 bg-primary-100 dark:bg-primary-800 border border-primary-200 dark:border-primary-700 rounded-lg cursor-not-allowed">
|
||||
<Icon name="arrow-left" class="w-4 h-4 mr-2" />
|
||||
Previous
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Page numbers -->
|
||||
<div class="hidden md:flex items-center space-x-2">
|
||||
{pageNumbers.map(page => (
|
||||
typeof page === 'number' ? (
|
||||
<a
|
||||
href={page === 1 ? baseUrl : `${baseUrl}/${page}`}
|
||||
class={`inline-flex items-center justify-center w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
|
||||
page === currentPage
|
||||
? 'btn-primary'
|
||||
: 'text-primary-600 dark:text-primary-300 bg-white dark:bg-primary-900 border border-primary-300 dark:border-primary-600 hover:bg-primary-50 dark:hover:bg-primary-800 hover:text-primary-800 dark:hover:text-primary-200'
|
||||
}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
) : (
|
||||
<span class="inline-flex items-center justify-center w-10 h-10 text-sm text-primary-400 dark:text-primary-500">
|
||||
{page}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Mobile page indicator -->
|
||||
<div class="md:hidden flex items-center space-x-2 text-sm text-primary-600 dark:text-primary-300">
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Next page -->
|
||||
<div class="flex-1 flex justify-end">
|
||||
{hasNext ? (
|
||||
<a
|
||||
href={nextUrl || `${baseUrl}/${currentPage + 1}`}
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-primary-600 dark:text-primary-300 bg-white dark:bg-primary-900 border border-primary-300 dark:border-primary-600 rounded-lg hover:bg-primary-50 dark:hover:bg-primary-800 hover:text-primary-800 dark:hover:text-primary-200 transition-colors"
|
||||
>
|
||||
Next
|
||||
<Icon name="arrow-right" class="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
) : (
|
||||
<span class="inline-flex items-center px-4 py-2 text-sm font-medium text-primary-400 dark:text-primary-500 bg-primary-100 dark:bg-primary-800 border border-primary-200 dark:border-primary-700 rounded-lg cursor-not-allowed">
|
||||
Next
|
||||
<Icon name="arrow-right" class="w-4 h-4 ml-2" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<style>
|
||||
/* Enhanced pagination styles */
|
||||
nav[aria-label="Pagination"] {
|
||||
@apply select-none;
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
nav[aria-label="Pagination"] a,
|
||||
nav[aria-label="Pagination"] span {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
nav[aria-label="Pagination"] a:focus-visible {
|
||||
@apply outline-none ring-2 ring-highlight-500 ring-offset-2;
|
||||
}
|
||||
|
||||
/* Active page enhancement */
|
||||
nav[aria-label="Pagination"] [aria-current="page"] {
|
||||
@apply shadow-lg;
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
nav[aria-label="Pagination"] a:hover {
|
||||
@apply transform scale-105;
|
||||
}
|
||||
</style>
|
||||
219
src/components/PostCard.astro
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { formatDate, formatDateMobile, processMarkdown, calculateReadingTime, getReadingTimeMobile } from '../utils/markdown';
|
||||
import { siteConfig, getPostCardAspectRatio } from '../config';
|
||||
import { getOptimizedFormat } from '@/utils/images';
|
||||
import ImageWrapper from './ImageWrapper.astro';
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<'posts'>;
|
||||
featured?: boolean;
|
||||
eager?: boolean;
|
||||
context?: 'featured' | 'recent' | 'posts' | 'tags';
|
||||
}
|
||||
|
||||
const { post, featured = false, eager = false, context = 'posts' } = Astro.props;
|
||||
|
||||
// Get reading time and word count from markdown processing
|
||||
const { excerpt, wordCount } = processMarkdown(post.body || '');
|
||||
const readingTime = calculateReadingTime(post.body || '');
|
||||
|
||||
const { title, description, date, tags, image: rawImage, imageAlt } = post.data;
|
||||
|
||||
// Process Obsidian bracket syntax in image field
|
||||
const image = (() => {
|
||||
if (!rawImage) return rawImage;
|
||||
|
||||
// Handle case where rawImage is an array (unquoted YAML syntax)
|
||||
let imageValue = rawImage;
|
||||
if (Array.isArray(rawImage)) {
|
||||
// If it's an array, take the first element
|
||||
imageValue = rawImage[0];
|
||||
}
|
||||
|
||||
// Ensure imageValue is a string before calling string methods
|
||||
if (typeof imageValue !== 'string') {
|
||||
console.warn('PostCard: imageValue is not a string:', imageValue);
|
||||
return imageValue;
|
||||
}
|
||||
|
||||
// Check if it's Obsidian double bracket syntax
|
||||
if (imageValue.startsWith('[[') && imageValue.endsWith(']]')) {
|
||||
// Extract the content inside the double brackets
|
||||
return imageValue.slice(2, -2);
|
||||
}
|
||||
|
||||
return imageValue;
|
||||
})();
|
||||
|
||||
// Determine if we should show the cover image based on configuration and context
|
||||
const shouldShowCoverImage = (() => {
|
||||
// Only check if image exists - hideCoverImage only affects post content, not cards
|
||||
if (!image) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const showCoverImages = siteConfig.postOptions.showPostCardCoverImages;
|
||||
|
||||
switch (showCoverImages) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'featured':
|
||||
return context === 'featured' || featured;
|
||||
case 'home':
|
||||
return context === 'featured' || context === 'recent';
|
||||
case 'posts':
|
||||
return context === 'posts' || context === 'tags';
|
||||
case 'featured-and-posts':
|
||||
return context === 'featured' || context === 'posts' || context === 'tags' || featured;
|
||||
case 'none':
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// No need for dynamic image import - ImageWrapper handles this
|
||||
---
|
||||
|
||||
<article class={`group ${featured ? 'featured-post' : 'post-card'}`} role="article" aria-labelledby={`post-title-${post.id}`}>
|
||||
<a href={`/posts/${post.id}`} class="block" aria-label={`View post: ${title}`}>
|
||||
{image && shouldShowCoverImage && (
|
||||
<div class="overflow-hidden rounded-t-lg bg-primary-100 dark:bg-primary-800" style={`aspect-ratio: ${getPostCardAspectRatio()};`}>
|
||||
<ImageWrapper
|
||||
src={(() => {
|
||||
// Clean the image path by removing Obsidian brackets
|
||||
let cleanImagePath = image;
|
||||
if (!cleanImagePath || typeof cleanImagePath !== 'string') return '';
|
||||
if (cleanImagePath.startsWith('[[') && cleanImagePath.endsWith(']]')) {
|
||||
cleanImagePath = cleanImagePath.slice(2, -2);
|
||||
}
|
||||
|
||||
// Handle external URLs
|
||||
if (cleanImagePath.startsWith('http')) {
|
||||
return cleanImagePath;
|
||||
}
|
||||
|
||||
// Detect folder-based vs file-based: if image path starts with 'attachments/',
|
||||
// it's a single-file post (shared attachments folder)
|
||||
const isFileBased = cleanImagePath.startsWith('attachments/');
|
||||
|
||||
if (isFileBased) {
|
||||
// Single-file post - remove attachments/ prefix
|
||||
cleanImagePath = cleanImagePath.replace('attachments/', '');
|
||||
} else {
|
||||
// Folder-based post - sync script copies images to post folder root
|
||||
// Remove 'images/' or 'attachments/' prefixes if present (sync script removes them)
|
||||
if (cleanImagePath.startsWith('images/') || cleanImagePath.startsWith('attachments/')) {
|
||||
cleanImagePath = cleanImagePath.replace(/^(images|attachments)\//, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to WebP if applicable (sync-images.js creates WebP versions)
|
||||
return getOptimizedFormat(cleanImagePath);
|
||||
})()}
|
||||
basePath={(() => {
|
||||
// 1. External URLs - no basePath needed
|
||||
if (typeof image === 'string' && image.startsWith('http')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 2. Clean the image path to check for attachments/ prefix
|
||||
let cleanImagePath = image;
|
||||
if (typeof cleanImagePath === 'string' && cleanImagePath.startsWith('[[') && cleanImagePath.endsWith(']]')) {
|
||||
cleanImagePath = cleanImagePath.slice(2, -2);
|
||||
}
|
||||
|
||||
// 3. Detect folder-based vs file-based: if image path starts with 'attachments/',
|
||||
// it's a single-file post (shared attachments folder)
|
||||
if (typeof cleanImagePath === 'string' && cleanImagePath.startsWith('attachments/')) {
|
||||
// Single-file post with image in attachments folder
|
||||
return '/posts/attachments/';
|
||||
}
|
||||
|
||||
// 4. Folder-based post - sync script copies images to post folder root
|
||||
// Safety check: ensure post.id exists before using it
|
||||
if (post.id) {
|
||||
return `/posts/${post.id}/`;
|
||||
} else {
|
||||
// Fallback to attachments directory if post.id is undefined
|
||||
return '/posts/attachments/';
|
||||
}
|
||||
})()}
|
||||
alt={imageAlt || `Featured image for post: ${title}`}
|
||||
class="w-full h-full group-hover:scale-102 dark:group-hover:brightness-100 transition-all duration-300"
|
||||
width={800}
|
||||
height={450}
|
||||
format="webp"
|
||||
quality={85}
|
||||
loading={featured || eager ? "eager" : "lazy"}
|
||||
fetchpriority={featured || eager ? "high" : "auto"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class={`p-4 ${image && shouldShowCoverImage ? 'rounded-b-lg' : 'rounded-lg'}`}>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-primary-500 dark:text-primary-400 mb-1">
|
||||
<time datetime={date.toISOString()}>
|
||||
<span class="hidden sm:inline">{formatDate(date)}</span>
|
||||
<span class="sm:hidden">{formatDateMobile(date)}</span>
|
||||
</time>
|
||||
|
||||
{siteConfig.postOptions.readingTime && readingTime && (
|
||||
<>
|
||||
<span class="text-primary-300 dark:text-primary-600 hidden sm:inline">•</span>
|
||||
<span class="text-primary-300 dark:text-primary-600 sm:hidden">•</span>
|
||||
<span>
|
||||
<span class="hidden sm:inline">{readingTime.text && readingTime.text !== 'read0' && readingTime.text !== '' ? readingTime.text : '1 min read'}</span>
|
||||
<span class="sm:hidden">{getReadingTimeMobile(readingTime)}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{siteConfig.postOptions.wordCount && wordCount !== undefined && (
|
||||
<>
|
||||
<span class="text-primary-300 dark:text-primary-600 hidden sm:inline">•</span>
|
||||
<span class="text-primary-300 dark:text-primary-600 sm:hidden">•</span>
|
||||
<span class="min-w-0">
|
||||
<span class="hidden sm:inline">{wordCount === 1 ? '1 word' : `${wordCount} words`}</span>
|
||||
<span class="sm:hidden">{wordCount === 1 ? '1 word' : `${wordCount.toLocaleString()} words`}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 id={`post-title-${post.id}`} class={`font-medium text-primary-900 dark:text-primary-100 leading-relaxed group-hover:text-highlight-600 dark:group-hover:text-highlight-400 transition-colors ${
|
||||
featured ? 'text-lg' : 'text-base'
|
||||
}`}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{featured && description && (
|
||||
<p class="text-primary-600 dark:text-primary-300 line-clamp-3 text-sm leading-relaxed mt-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tags && tags.length > 0 && siteConfig.postOptions.tags && (
|
||||
<div class="mt-3">
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<a
|
||||
href={`/posts/tag/${tag}`}
|
||||
class="inline-block text-xs text-primary-600 dark:text-primary-300 bg-primary-100 dark:bg-primary-800 px-2.5 py-1 rounded-full border border-primary-200 dark:border-primary-700 transition-colors hover:bg-highlight-100 dark:hover:bg-highlight-800 mr-2 mb-1"
|
||||
aria-label={`View posts tagged with ${tag}`}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<span
|
||||
class="inline-block text-xs text-primary-500 dark:text-primary-400 bg-primary-50 dark:bg-primary-800 px-2.5 py-1 rounded-full border border-primary-200 dark:border-primary-700 mr-2 mb-1"
|
||||
aria-label={`${tags.length - 3} more tags available`}
|
||||
>
|
||||
+ {tags.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||