Initial commit with stock files

This commit is contained in:
Martin Dimitrov 2026-03-28 12:38:39 +02:00
commit 8c648aa559
336 changed files with 138234 additions and 0 deletions

40
.assetsignore.template Normal file
View 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.*

View file

@ -0,0 +1,7 @@
{
"telemetry": {
"enabled": true,
"notifiedAt": "1756406630228",
"anonymousId": "82b7175d26b171e9f07d9a80e388260f202e2356cbfbe75a95e2377580689494"
}
}

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
cli/ export-ignore
.github/ export-ignore
AGENTS.md export-ignore

50
.gitignore vendored Normal file
View 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
View 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
View file

@ -0,0 +1,176 @@
# Astro Modular
A flexible [Astro](https://astro.build) blog theme designed for [Obsidian](https://obsidian.md) users.
[![Alt](https://github.com/user-attachments/assets/612b2171-d141-412b-a891-5b1caa1447b8 "Astro Modular theme shown off in 3 screenshots, with the left screenshot showing the homepage, the top right showing the bottom of a post, and the bottom right showing a minimal homepage")](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).
![Alt](https://github.com/user-attachments/assets/3db459e6-6b9a-4c37-b86d-0f661e88e204 "Astro Modular Obsidian vault")
## Stats
![Alt](https://repobeats.axiom.co/api/embed/66fe41c94d95b32b92c1a2fd8d6dc83d386bc10a.svg "Repobeats analytics image")
![Alt](https://github.com/user-attachments/assets/e09c8ee1-6f91-46d6-84a5-e2d8a35c1e14 "Pagespeed scores")
### Status
[![Netlify Status](https://api.netlify.com/api/v1/badges/3f849f7a-71e6-463b-84af-01c523012348/deploy-status)](https://app.netlify.com/sites/astro-modular/deploys)
[![GitHub last commit](https://img.shields.io/github/last-commit/davidvkimball/astro-modular/master)](https://github.com/davidvkimball/astro-modular)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/davidvkimball/astro-modular)](https://github.com/davidvkimball/astro-modular)
### Tech Stack
[![Astro](https://img.shields.io/badge/Astro-6.0.0--beta.1-FF5D01?logo=astro&logoColor=white)](https://astro.build/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9.2-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-3.4.17-38B2AC?logo=tailwind-css&logoColor=white)](https://tailwindcss.com/)
[![Node.js](https://img.shields.io/badge/Node.js-24.13.0-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
[![pnpm](https://img.shields.io/badge/pnpm-10.29.3-F69220?logo=pnpm&logoColor=white)](https://pnpm.io/)
[![Obsidian](https://img.shields.io/badge/Obsidian-1.12.2-7C3AED?logo=obsidian&logoColor=white)](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
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/davidvkimball/astro-modular)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/davidvkimball/astro-modular)
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](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
```
[![npm version](https://img.shields.io/npm/v/create-astro-modular)](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
[![Star History Chart](https://api.star-history.com/svg?repos=davidvkimball/astro-modular&type=date&legend=top-left)](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
View file

@ -0,0 +1 @@
0.8.6

225
astro.config.mjs Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/favicon-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
public/favicon-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
public/profile.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View 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;

View 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 ![alt](src)
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
View 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);
});

View 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();

View 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();

View 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
View 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
View 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
View 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
View 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
View 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);
});

View file

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

View 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
View 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>

View 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>

File diff suppressed because it is too large Load diff

View 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>

View 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
View 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>

View 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>

View 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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// 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}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show more