Initial commit

This commit is contained in:
Nicholai 2025-12-27 19:36:09 +00:00
commit 3c0f1d6a66
65 changed files with 18937 additions and 0 deletions

View File

@ -0,0 +1,4 @@
---
alwaysApply: true
---
CRITICAL: Reference https://docs.astro.build/llms.txt before executing any plans. Always reference the official astro documentation for enhanced context and clarity when approaching tasks.

View File

@ -0,0 +1,4 @@
---
alwaysApply: true
---
When designing UI elements, you must always reference the most current design.json file, and any reference html files included in the codebase.

View File

@ -0,0 +1,4 @@
---
alwaysApply: true
---
The required package manager for this repository is `pnpm`. Do not use any other package managers like npm, bun, etc.

7
.cursor/worktrees.json Normal file
View File

@ -0,0 +1,7 @@
{
"setup-worktree": [
"# fnm use",
"# npm install",
"# cp $ROOT_WORKTREE_PATH/.env .env"
]
}

42
.env.example Normal file
View File

@ -0,0 +1,42 @@
# Environment Variables Template
# Copy this file to .env and fill in your actual values
# ====================
# Development
# ====================
# Node environment
NODE_ENV=development
# ====================
# Cloudflare (Optional)
# ====================
# Cloudflare API credentials for deployment
# Only needed if deploying via CLI instead of Git integration
# CLOUDFLARE_API_TOKEN=your_api_token_here
# CLOUDFLARE_ACCOUNT_ID=your_account_id_here
# ====================
# Contact Form (Optional)
# ====================
# If using a contact form service, add credentials here
# Example: Formspree, Web3Forms, etc.
# FORM_API_KEY=your_form_api_key_here
# FORM_ENDPOINT=https://api.formservice.com/submit
# ====================
# Analytics (Optional)
# ====================
# If using analytics services, add keys here
# PLAUSIBLE_DOMAIN=yoursite.com
# GA_MEASUREMENT_ID=G-XXXXXXXXXX
# ====================
# Other Services
# ====================
# Add any other API keys or service credentials your site needs
# EXAMPLE_API_KEY=your_key_here

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
src/utils/.env
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
!.env.example
.specstory/**
.specstory/
.cursorindexingignore
# AGENTS.md symlink
AGENTS.md

4
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"files.associations": {
"wrangler.json": "jsonc"
}
}

78
CLAUDE.md Normal file
View File

@ -0,0 +1,78 @@
# CLAUDE.md
Development guidance for this minimal Astro template repository.
## Commands
### Core Development
```bash
pnpm dev # Development server
pnpm build # Build for production
pnpm preview # Preview build with Wrangler
pnpm deploy # Deploy to Cloudflare Pages
```
### Utilities
```bash
pnpm commit # AI-powered commit messages
pnpm convert:avif:all # Convert all images to AVIF
pnpm convert:avif:jpeg
pnpm convert:avif:png
pnpm cf-typegen # Generate Cloudflare types
```
## Change Documentation
**IMPORTANT**: Update `dev/continuity.md` when making changes to document:
- What changed and why
- Decisions made
- Next steps
## Architecture
Minimal Astro template with barebones structure:
### Content Layer (`src/content/`)
- **blog/** - MDX blog posts with schema validation (title, description, pubDate, updatedDate, heroImage, category, tags)
Schema defined in `src/content.config.ts`
### Component Layer
Minimal components only:
- **BaseHead.astro** - Basic SEO metadata
- **BlogCard.astro** - Simple blog post card
- **FormattedDate.astro** - Date formatting utility
### Routes
- `/` - Homepage with links to blog and contact
- `/blog` - Blog index listing all posts
- `/blog/[slug]` - Individual blog post pages
- `/contact` - Basic contact form
- `/404` - 404 error page
### Layouts
- **BaseLayout.astro** - Basic HTML structure with head
- **BlogPost.astro** - Blog post layout with metadata
## Styling
Minimal global CSS in `src/styles/global.css`:
- Basic typography
- Simple prose styles for markdown content
- No elaborate themes, animations, or custom styling
## Image Handling
- `src/assets/` - Processed by Astro (use for heroImage in frontmatter)
- `public/media/` - Served as-is (use absolute paths like `/media/file.mp4`)
- AVIF conversion utility available
## Deployment
- Cloudflare Pages adapter configured
- Image service: "compile" mode
- Platform proxy enabled for development
## Utility Scripts
- **`src/utils/convert-to-avif.js`** - Image optimization
- **`src/utils/git-commit.js`** - AI commit message generation (requires OpenRouter API key in `src/utils/.env`)
- **`src/utils/reading-time.ts`** - Calculate reading time for blog posts

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 [Your Name]
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.

105
README.md Normal file
View File

@ -0,0 +1,105 @@
# Astro Template
Minimal Astro development template with React, Tailwind CSS, and Cloudflare Pages deployment.
## Stack
- **Astro 5** - Static site framework
- **React 19** - Interactive components
- **Tailwind CSS 4** - Minimal styling
- **MDX** - Markdown with JSX for blog posts
- **TypeScript** - Type safety
- **Cloudflare Pages** - Deployment
- **pnpm** - Package manager
## Quick Start
```bash
# Install dependencies
pnpm install
# Start dev server
pnpm dev
# Build
pnpm build
# Deploy
pnpm deploy
```
## Project Structure
```
src/
├── assets/ # Images (processed by Astro)
├── components/ # Minimal components (BaseHead, BlogCard, FormattedDate)
├── content/
│ └── blog/ # Blog posts (MDX)
├── layouts/ # BaseLayout, BlogPost
├── pages/ # Routes (index, blog, contact, 404)
├── styles/ # Minimal global CSS
└── utils/ # Utility scripts
public/
└── media/ # Static assets
dev/
├── design.json # Design system reference
└── continuity.md # Development log
```
## Pages
- `/` - Homepage with navigation
- `/blog` - Blog index
- `/blog/[slug]` - Individual blog posts
- `/contact` - Contact form
- `/404` - 404 page
## Blog Posts
Create MDX files in `src/content/blog/`:
```yaml
---
title: 'Post Title'
description: 'Post description'
pubDate: 'Dec 27 2024'
heroImage: '../../assets/image.avif' # Optional
category: 'Category' # Optional
tags: ['tag1', 'tag2'] # Optional
---
Your content here...
```
## Utilities
```bash
# AI-powered commit messages (requires OpenRouter API key)
pnpm commit
# Convert images to AVIF
pnpm convert:avif:all
pnpm convert:avif:jpeg
pnpm convert:avif:png
# Generate Cloudflare types
pnpm cf-typegen
```
## Configuration
- `src/consts.ts` - Site title, description, social links
- `astro.config.mjs` - Astro configuration
- `wrangler.jsonc` - Cloudflare Pages configuration
- `dev/design.json` - Design system reference
## Development
See `CLAUDE.md` for detailed architecture and development guidance.
## License
MIT

29
astro.config.mjs Normal file
View File

@ -0,0 +1,29 @@
// @ts-check
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import tailwindcss from '@tailwindcss/vite';
import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
site: 'https://yoursite.com',
integrations: [mdx(), sitemap(), react()],
adapter: cloudflare({
platformProxy: {
enabled: true
},
imageService: "compile"
}),
vite: {
plugins: [tailwindcss()],
},
});

30
dev/blog_template.mdx Normal file
View File

@ -0,0 +1,30 @@
---
title: 'Your Blog Post Title Here'
description: 'A compelling one-sentence description that summarizes the blog post. This appears in meta tags and previews.'
pubDate: 'Jan 1 2024'
heroImage: '../../assets/blog-placeholder-1.jpg'
featured: true
category: 'Case Study'
tags: ['VFX', 'Houdini', 'Nuke', 'AI/ML', 'Brand Film']
---
<!-- Introduction paragraph: Set the context and hook the reader -->
[Write your opening paragraph here. Introduce the project, collaboration, or topic. This should be engaging and provide context for what follows.]
<!-- Optional: Main hero video or image -->
<div class="video-container my-10">
<video controls class="w-full border border-white/10">
<source src="https://media.nicholai.work/your-video.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
<p class="text-slate-500 text-sm mt-3 font-mono">/// FINAL FILM</p>
</div>
<!-- Alternative: If using an image instead of video -->
<!--
<div class="my-10">
<img src="path/to/image.jpg" alt="Description" class="w-full border border-white/10" />
<p class="text-slate-500 text-sm mt-3 font-mono">/// HERO IMAGE</p>
</div>
-->

84
dev/continuity.md Normal file
View File

@ -0,0 +1,84 @@
# Continuity Log
Development log for tracking changes, decisions, and next steps.
## Entry Template
```markdown
## YYYY-MM-DD - Brief Description
### Changes
- What changed
- Why it changed
### Decisions
- Key decisions made
- Trade-offs considered
### Next Steps
- [ ] Follow-up items
```
---
## 2024-12-27 - Stripped to Barebones Template
### Changes
- **Removed all elaborate features and styling** to create truly minimal template
- Removed fancy components: CustomCursor, SearchDialog, GridOverlay, ThemeToggle, ThemePreferenceDialog, Navigation, Footer, section components, blog components (RelatedPosts, PostNavigation, BlogFilters, ReadingProgress, TableOfContents)
- Removed all portfolio/section content (hero, experience, skills, featured-project)
- Removed pages content collection
- Simplified all layouts to bare minimum HTML structure
- Simplified all pages to basic content with no styling
- Stripped global.css from 800 lines to ~70 lines (basic typography + simple prose styles)
- Simplified BaseHead component to just essential meta tags (removed elaborate structured data, font loading, etc.)
- Simplified BlogCard to just title, date, description, link
- Updated content.config.ts to only include blog collection
### Current Structure
**Pages:**
- `/` - Homepage with title, description, and basic nav links
- `/blog` - Simple list of blog posts
- `/blog/[slug]` - Basic blog post with header, content, footer
- `/contact` - Basic contact form (non-functional, just markup)
- `/404` - Simple 404 page
**Components (only 3):**
- BaseHead.astro - Essential SEO metadata
- BlogCard.astro - Minimal blog card
- FormattedDate.astro - Date formatting
**Layouts (only 2):**
- BaseLayout.astro - Basic HTML wrapper
- BlogPost.astro - Simple blog post layout
**Content:**
- Single example blog post showing MDX structure
- Blog schema: title, description, pubDate, updatedDate, heroImage, category, tags
### Decisions
- Chose absolute minimalism over feature-rich starter
- Template is meant to be a clean foundation, not a portfolio showcase
- Removed all theming, animations, and visual flourishes
- Kept only essential blog functionality
- Removed all complex data flow (related posts, next/prev navigation, featured posts, filtering)
- Kept utility scripts (AVIF conversion, AI commits, reading time)
- Kept Cloudflare Pages deployment setup
### Stack
- Astro 5 + React 19 + Tailwind CSS 4 (minimal usage)
- TypeScript
- MDX content collections
- Cloudflare Pages deployment
- pnpm package manager
### Next Steps
- [ ] Clone this template when starting new Astro projects
- [ ] Add only the components and features you actually need
- [ ] Replace placeholder content in src/consts.ts
- [ ] Add your own blog posts
- [ ] Customize styling as needed
---
Add new entries below...

11
dev/design.json Normal file
View File

@ -0,0 +1,11 @@
{
"_readme": "Design system documentation. Add your design tokens, color palette, typography scale, and component patterns here as your project grows.",
"design_system": {
"name": "Your Design System",
"version": "1.0.0",
"colors": {},
"typography": {},
"spacing": {},
"components": {}
}
}

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "astro-portfolio-template",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro build && wrangler pages dev",
"astro": "astro",
"deploy": "astro build && wrangler pages deploy",
"cf-typegen": "wrangler types",
"convert:avif": "node src/utils/convert-to-avif.js",
"convert:avif:all": "node src/utils/convert-to-avif.js --all",
"convert:avif:jpeg": "node src/utils/convert-to-avif.js --jpeg",
"convert:avif:png": "node src/utils/convert-to-avif.js --png",
"commit": "node src/utils/git-commit.js"
},
"dependencies": {
"@astrojs/cloudflare": "^12.6.12",
"@astrojs/mdx": "^4.3.12",
"@astrojs/react": "^4.4.2",
"@astrojs/rss": "^4.0.14",
"@astrojs/sitemap": "^3.6.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"astro": "^5.16.4",
"lunr": "^2.3.9",
"marked": "^17.0.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sharp": "^0.34.3",
"tailwindcss": "^4.1.17"
},
"devDependencies": {
"@types/node": "^24.10.1",
"wrangler": "^4.53.0"
}
}

5845
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
public/.assetsignore Normal file
View File

@ -0,0 +1,2 @@
_worker.js
_routes.json

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
public/favicon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

9
public/favicon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,132 @@
# Placeholder Media Assets
This template references several media files that need to be replaced with your own content.
## Required Assets to Replace
### Images (`src/assets/`)
The following images in `src/assets/` are currently placeholders and should be replaced with your own:
1. **nicholai-closeup-portrait.avif**
- Purpose: Used in various components
- Recommended dimensions: 800x800px minimum
- Format: AVIF (use `pnpm convert:avif:all` to convert)
2. **nicholai-medium-portrait.avif**
- Purpose: Default OG (Open Graph) image for social media shares
- Referenced in: `src/components/BaseHead.astro:6`
- Recommended dimensions: 1200x630px (Open Graph standard)
- Format: AVIF
3. **g-star-image.avif**
- Purpose: Project/case study imagery
- Referenced in: Blog post hero images
- Recommended dimensions: 1920x1080px or larger
- Format: AVIF
4. **claude-nuke.avif**
- Purpose: Blog post hero image
- Recommended dimensions: 1920x1080px or larger
- Format: AVIF
5. **foxrenderfarm-arch-linux.avif**
- Purpose: Blog post hero image
- Recommended dimensions: 1920x1080px or larger
- Format: AVIF
6. **PENCIL_1.3.1_wipe.avif**
- Purpose: Blog post hero image
- Recommended dimensions: 1920x1080px or larger
- Format: AVIF
### Videos (`public/media/`)
The following video files need to be replaced:
1. **placeholder-video.mp4**
- Purpose: Featured project video, blog post demonstrations
- Referenced in: `src/content/sections/featured-project.mdx:18`
- Recommended: MP4 format, H.264 codec
- Max file size: Keep under 50MB for good performance
- Dimensions: 1920x1080px or 3840x2160px (4K)
2. **GSTR_01_260_breakdown.mp4** (Remove/Replace)
- Purpose: Project breakdown video
- Should be replaced with your own project video
3. **GSTR_03_070_v10_breakdown_v01.mp4** (Remove/Replace)
- Purpose: Project breakdown video
- Should be replaced with your own project video
### Favicons (`public/`)
These should be customized with your own branding:
1. **favicon.ico**
2. **favicon-32.png**
3. **favicon-192.png**
4. **apple-touch-icon.png**
5. **favicon.svg**
6. **favicon.JPG** (appears to be personal, should be replaced)
### Profile Images (`public/media/`)
1. **nicholai-wild-portrait.JPEG**
- Replace with your own portrait
- Recommended dimensions: 1200x1200px or larger
## How to Add Your Own Assets
### For Images
1. **Place your images** in the appropriate directory:
- Processed images: `src/assets/` (these get optimized by Astro)
- Static images: `public/media/` (served as-is)
2. **Convert to AVIF** for best performance:
```bash
pnpm convert:avif:all
```
3. **Update references**:
- Blog post frontmatter (heroImage field)
- Section MDX files in `src/content/sections/`
- Default OG image in `src/components/BaseHead.astro`
### For Videos
1. **Optimize your video**:
- Use H.264 codec for MP4
- Keep file size reasonable (< 50MB if possible)
- Consider providing multiple resolutions
2. **Place in `public/media/`**
3. **Update references**:
- `src/content/sections/featured-project.mdx` (videoUrl field)
- Blog posts where videos are embedded
### For Favicons
1. **Generate favicons** from your logo/brand mark using a tool like:
- [favicon.io](https://favicon.io)
- [RealFaviconGenerator](https://realfavicongenerator.net)
2. **Replace files in `public/`**
## Quick Replace Checklist
- [ ] Default OG image (`src/assets/nicholai-medium-portrait.avif`)
- [ ] All blog post hero images
- [ ] Featured project video (`/media/placeholder-video.mp4`)
- [ ] Favicon set (ico, png, svg)
- [ ] Remove or replace personal videos (GSTR files)
- [ ] Profile/portrait images
## Notes
- **AVIF format** is recommended for images as it provides excellent compression
- The template includes a conversion utility: `pnpm convert:avif:all`
- Always optimize images before uploading (compress, resize)
- Consider using a CDN for large media files in production

BIN
public/media/favicon.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

5
public/robots.txt Normal file
View File

@ -0,0 +1,5 @@
User-agent: *
Allow: /
Sitemap: https://nicholai.work/sitemap-index.xml

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
src/assets/claude-nuke.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -0,0 +1,54 @@
---
import '../styles/global.css';
import type { ImageMetadata } from 'astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
interface Props {
title: string;
description: string;
image?: ImageMetadata;
type?: 'website' | 'article';
publishedTime?: Date;
modifiedTime?: Date;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const {
title,
description,
image,
type = 'website',
publishedTime,
modifiedTime,
} = Astro.props;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="canonical" href={canonicalURL} />
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
{image && (
<>
<meta property="og:image" content={new URL(image.src, Astro.url)} />
<meta name="twitter:image" content={new URL(image.src, Astro.url).toString()} />
</>
)}
<meta property="og:type" content={type} />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime.toISOString()} />}

View File

@ -0,0 +1,25 @@
---
import FormattedDate from './FormattedDate.astro';
interface Props {
title: string;
description: string;
pubDate: Date;
href: string;
readTime?: string;
}
const { title, description, pubDate, href, readTime } = Astro.props;
---
<article>
<a href={href}>
<h3>{title}</h3>
</a>
<p>
<FormattedDate date={pubDate} />
{readTime && <span> • {readTime}</span>}
</p>
<p>{description}</p>
<a href={href}>Read more</a>
</article>

View File

@ -0,0 +1,17 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>

18
src/consts.ts Normal file
View File

@ -0,0 +1,18 @@
// Place any global data in this file.
// You can import this data from anywhere in your site by using the `import` keyword.
// SEO-optimized title (under 60 characters for full display in search results)
export const SITE_TITLE = 'Your Name — Your Profession';
// SEO-optimized description (under 160 characters, includes keywords and CTA)
export const SITE_DESCRIPTION = 'Your professional description here. Describe what you do, who you work with, and what makes you unique.';
// Visible in raw HTML output for diagnostics (curl/view-source). Keep short.
export const HTML_MARKER = "Built with Astro Template";
export const SOCIAL_LINKS = {
email: 'your@email.com',
website: 'https://yoursite.com',
linkedin: 'https://linkedin.com/in/yourprofile',
github: 'https://github.com/yourusername'
};

19
src/content.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
featured: z.boolean().optional().default(false),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { blog };

View File

@ -0,0 +1,54 @@
---
title: 'Example Blog Post'
description: 'This is an example blog post showing the content schema and MDX capabilities.'
pubDate: 'Dec 27 2024'
heroImage: '../../assets/nicholai-medium-portrait.avif'
featured: true
category: 'Example'
tags: ['Template', 'Example']
---
## Example Post
This is an example blog post demonstrating the MDX content structure.
### Frontmatter Schema
Required fields:
- `title` - Post title
- `description` - Brief description
- `pubDate` - Publication date
Optional fields:
- `heroImage` - Header image
- `featured` - Boolean for featured posts
- `category` - Category for filtering
- `tags` - Array of tags
- `updatedDate` - Last update date
### Content Features
You can use standard Markdown:
- Lists
- **Bold text**
- *Italic text*
- `Code snippets`
```javascript
// Code blocks with syntax highlighting
const example = "Hello World";
```
And embed HTML/JSX:
<div class="p-4 border border-white/10 my-4">
Custom styled content
</div>
### Images and Media
Images from `src/assets/` are processed by Astro.
Static files from `public/media/` are served as-is.
Replace this example post with your own content.

5
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
type Runtime = import("@astrojs/cloudflare").Runtime<Env>;
declare namespace App {
interface Locals extends Runtime {}
}

View File

@ -0,0 +1,41 @@
---
import type { ImageMetadata } from 'astro';
import BaseHead from '../components/BaseHead.astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
interface Props {
title?: string;
description?: string;
image?: ImageMetadata;
type?: 'website' | 'article';
publishedTime?: Date;
modifiedTime?: Date;
}
const {
title = SITE_TITLE,
description = SITE_DESCRIPTION,
image,
type = 'website',
publishedTime,
modifiedTime,
} = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<BaseHead
title={title}
description={description}
image={image}
type={type}
publishedTime={publishedTime}
modifiedTime={modifiedTime}
/>
</head>
<body>
<slot />
</body>
</html>

View File

@ -0,0 +1,78 @@
---
import type { ImageMetadata } from 'astro';
import BaseLayout from './BaseLayout.astro';
import FormattedDate from '../components/FormattedDate.astro';
import { Image } from 'astro:assets';
interface Props {
title: string;
description: string;
pubDate: Date;
updatedDate?: Date;
heroImage?: ImageMetadata;
category?: string;
tags?: string[];
readTime?: string;
}
const {
title,
description,
pubDate,
updatedDate,
heroImage,
category,
tags,
readTime,
} = Astro.props;
---
<BaseLayout
title={title}
description={description}
image={heroImage}
type="article"
publishedTime={pubDate}
modifiedTime={updatedDate}
>
<article>
<a href="/blog">&larr; Back to blog</a>
<header>
<h1>{title}</h1>
<p>{description}</p>
<p>
<FormattedDate date={pubDate} />
{readTime && <span> • {readTime}</span>}
{category && <span> • {category}</span>}
</p>
{tags && tags.length > 0 && (
<p>
{tags.map((tag) => (
<span>{tag} </span>
))}
</p>
)}
</header>
{heroImage && (
<Image
src={heroImage}
alt=""
width={1200}
height={630}
/>
)}
<div>
<slot />
</div>
<footer>
Published <FormattedDate date={pubDate} />
{updatedDate && (
<span> • Updated <FormattedDate date={updatedDate} /></span>
)}
</footer>
</article>
</BaseLayout>

10
src/pages/404.astro Normal file
View File

@ -0,0 +1,10 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { SITE_TITLE } from '../consts';
---
<BaseLayout title={`404 - Page Not Found | ${SITE_TITLE}`} description="Page not found">
<h1>404</h1>
<p>Page not found</p>
<a href="/">Go home</a>
</BaseLayout>

View File

@ -0,0 +1,26 @@
---
import { type CollectionEntry, getCollection, render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
import { calculateReadingTime } from '../../utils/reading-time';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
const { Content } = await render(post);
const readTimeText = calculateReadingTime(post.body);
---
<BlogPost {...post.data} readTime={readTimeText}>
<Content />
</BlogPost>

View File

@ -0,0 +1,29 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import BlogCard from '../../components/BlogCard.astro';
import { SITE_TITLE } from '../../consts';
import { calculateReadingTime } from '../../utils/reading-time';
const allPosts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
---
<BaseLayout title={`Blog | ${SITE_TITLE}`} description="Blog posts and articles">
<a href="/">&larr; Back to home</a>
<h1>Blog</h1>
<div>
{allPosts.map((post) => (
<BlogCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
href={`/blog/${post.id}/`}
readTime={calculateReadingTime(post.body)}
/>
))}
</div>
</BaseLayout>

32
src/pages/contact.astro Normal file
View File

@ -0,0 +1,32 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { SITE_TITLE } from '../consts';
const pageTitle = `Contact | ${SITE_TITLE}`;
---
<BaseLayout title={pageTitle} description="Get in touch">
<a href="/">&larr; Back to home</a>
<h1>Contact</h1>
<p>Get in touch</p>
<form>
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<button type="submit">Send Message</button>
</form>
</BaseLayout>

16
src/pages/index.astro Normal file
View File

@ -0,0 +1,16 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
---
<BaseLayout>
<h1>{SITE_TITLE}</h1>
<p>{SITE_DESCRIPTION}</p>
<nav>
<ul>
<li><a href="/blog">Blog</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</BaseLayout>

View File

@ -0,0 +1,82 @@
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
export const prerender = true;
export const GET: APIRoute = async (context) => {
const site = context.site?.toString().replace(/\/$/, '') ?? 'https://nicholai.work';
// Fetch and sort blog posts by date (newest first)
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
// Build llms-full.txt content with full post bodies
const lines: string[] = [
`# ${SITE_TITLE}`,
'',
`> ${SITE_DESCRIPTION}`,
'',
'## About This File',
'',
'This file contains the full content of all blog posts on this site, formatted for LLM consumption.',
'For a shorter index of available content, see /llms.txt',
'',
'## Pages',
'',
`- [Home](${site}/)`,
`- [Blog](${site}/blog/)`,
`- [Contact](${site}/contact/)`,
'',
'---',
'',
'## Blog Posts',
'',
];
// Add each blog post with full content
for (const post of posts) {
const url = `${site}/blog/${post.id}/`;
const date = post.data.pubDate.toISOString().split('T')[0];
const category = post.data.category ?? 'Uncategorized';
const tags = post.data.tags?.join(', ') ?? '';
lines.push(`### ${post.data.title}`);
lines.push('');
lines.push(`- **URL**: ${url}`);
lines.push(`- **Date**: ${date}`);
lines.push(`- **Category**: ${category}`);
if (tags) {
lines.push(`- **Tags**: ${tags}`);
}
lines.push(`- **Description**: ${post.data.description}`);
lines.push('');
lines.push('#### Content');
lines.push('');
// Include the raw body content (MDX source)
if (post.body) {
lines.push(post.body);
} else {
lines.push('*No content body available*');
}
lines.push('');
lines.push('---');
lines.push('');
}
lines.push('## Additional Resources');
lines.push('');
lines.push(`- [RSS Feed](${site}/rss.xml)`);
lines.push(`- [Sitemap](${site}/sitemap-index.xml)`);
lines.push('');
const body = lines.join('\n');
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
};

54
src/pages/llms.txt.ts Normal file
View File

@ -0,0 +1,54 @@
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
export const prerender = true;
export const GET: APIRoute = async (context) => {
const site = context.site?.toString().replace(/\/$/, '') ?? 'https://nicholai.work';
// Fetch and sort blog posts by date (newest first)
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
// Build llms.txt content following the standard format
const lines: string[] = [
`# ${SITE_TITLE}`,
'',
`> ${SITE_DESCRIPTION}`,
'',
'## Pages',
'',
`- [Home](${site}/)`,
`- [Blog](${site}/blog/)`,
`- [Contact](${site}/contact/)`,
'',
'## Blog Posts',
'',
];
// Add each blog post
for (const post of posts) {
const url = `${site}/blog/${post.id}/`;
const date = post.data.pubDate.toISOString().split('T')[0];
lines.push(`- [${post.data.title}](${url}) - ${date}`);
}
lines.push('');
lines.push('## Additional Resources');
lines.push('');
lines.push(`- [RSS Feed](${site}/rss.xml)`);
lines.push(`- [Sitemap](${site}/sitemap-index.xml)`);
lines.push(`- [Full LLM Context](${site}/llms-full.txt)`);
lines.push('');
const body = lines.join('\n');
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
};

16
src/pages/rss.xml.js Normal file
View File

@ -0,0 +1,16 @@
import { getCollection } from 'astro:content';
import rss from '@astrojs/rss';
import { SITE_DESCRIPTION, SITE_TITLE } from '../consts';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: SITE_TITLE,
description: SITE_DESCRIPTION,
site: context.site,
items: posts.map((post) => ({
...post.data,
link: `/blog/${post.id}/`,
})),
});
}

23
src/pages/search.json.ts Normal file
View File

@ -0,0 +1,23 @@
import { getCollection } from 'astro:content';
export async function GET() {
const posts = await getCollection('blog');
const searchData = posts.map((post) => ({
id: post.id,
title: post.data.title,
description: post.data.description,
content: post.body,
category: post.data.category || '',
tags: post.data.tags || [],
url: `/blog/${post.id}/`,
pubDate: post.data.pubDate.toISOString(),
}));
return new Response(JSON.stringify(searchData), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}

69
src/styles/global.css Normal file
View File

@ -0,0 +1,69 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* Basic styles */
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
}
/* Basic markdown prose styles */
.prose-custom h1,
.prose-custom h2,
.prose-custom h3,
.prose-custom h4 {
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.3;
}
.prose-custom p {
margin-bottom: 1em;
}
.prose-custom a {
color: #0066cc;
text-decoration: underline;
}
.prose-custom ul,
.prose-custom ol {
margin-bottom: 1em;
padding-left: 2em;
}
.prose-custom li {
margin-bottom: 0.5em;
}
.prose-custom code {
background: #f4f4f4;
padding: 2px 6px;
font-family: monospace;
}
.prose-custom pre {
background: #f4f4f4;
padding: 1em;
overflow-x: auto;
margin-bottom: 1em;
}
.prose-custom pre code {
background: none;
padding: 0;
}
.prose-custom blockquote {
border-left: 3px solid #ddd;
padding-left: 1em;
margin-left: 0;
color: #666;
}
.prose-custom img {
max-width: 100%;
height: auto;
}

4
src/utils/.env.example Normal file
View File

@ -0,0 +1,4 @@
# OpenRouter API Configuration
# Get your API key from: https://openrouter.ai/keys
OPENROUTER_API_KEY=your_api_key_here

107
src/utils/README.md Normal file
View File

@ -0,0 +1,107 @@
# Utilities
This directory contains utility scripts for the project.
## Git Commit Automation Script
### git-commit.js
Automatically generates commit messages using OpenRouter AI (inception/mercury-coder) based on your staged changes. The script analyzes both the git diff and status to create meaningful commit messages, then allows you to review, edit, and approve before committing.
**Prerequisites:**
- OpenRouter API key (free to get started)
- Sign up: [openrouter.ai](https://openrouter.ai)
- Get your API key: [openrouter.ai/keys](https://openrouter.ai/keys)
- Create a `.env` file in `src/utils/` directory:
```bash
# Copy the example file
cp src/utils/.env.example src/utils/.env
# Edit the file and add your API key
OPENROUTER_API_KEY=your_actual_api_key_here
```
**Usage:**
```bash
# 1. Stage your changes
git add <files>
# 2. Run the commit script
pnpm commit
# 3. Review the AI-generated message
# 4. Choose to [A]ccept, [E]dit, or [C]ancel
# 5. Optionally push to remote
```
**Options:**
- `--help`, `-h` - Show help message
**Troubleshooting:**
- If you get ".env file not found" error, create `src/utils/.env` with your OpenRouter API key
## Image Conversion Script
### convert-to-avif.js
Converts images in the `assets/` directory to AVIF format using ffmpeg. Original images are preserved, and `.avif` versions are created alongside them.
**Prerequisites:**
- ffmpeg must be installed on your system
- Linux: `sudo apt install ffmpeg` `sudo pacman -S ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
**Usage via pnpm scripts:**
```bash
# Show help and available options
pnpm run convert:avif
# Convert all supported formats (jpeg, png, webp, gif, bmp, tiff)
pnpm run convert:avif:all
# Convert only JPEG images
pnpm run convert:avif:jpeg
# Convert only PNG images
pnpm run convert:avif:png
# Convert with custom quality (0-100, default: 65)
node src/utils/convert-to-avif.js --jpeg --quality 80
# Convert multiple formats at once
node src/utils/convert-to-avif.js --jpeg --png
```
**Options:**
- `--all` - Convert all supported formats
- `--jpeg` - Convert JPEG/JPG files only
- `--png` - Convert PNG files only
- `--webp` - Convert WebP files only
- `--gif` - Convert GIF files only
- `--bmp` - Convert BMP files only
- `--tiff` - Convert TIFF files only
- `--quality <n>` - Set quality (0-100, default: 65)
**Quality Guide:**
- High (80+): Larger file sizes, excellent quality
- Medium (60-75): Balanced file size and quality (recommended)
- Low (40-55): Smaller files, good for web performance
**Features:**
- Preserves original images
- Skips files that already have AVIF versions
- Shows file size savings
- Progress indicators
- Error handling and reporting
**Example output:**
```
🎨 Converting 3 image(s) to AVIF format
📁 Source: /path/to/assets
⚙️ Quality: 65
🔄 [1/3] Converting blog-placeholder-1.jpg...
✅ Created blog-placeholder-1.avif (45.2KB, 67.3% smaller)
```

View File

@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Image to AVIF Converter
*
* Converts images in the assets/ directory to AVIF format using ffmpeg.
* Originals are preserved, and .avif versions are created alongside them.
*
* Usage:
* node utils/convert-to-avif.js --all
* node utils/convert-to-avif.js --jpeg
* node utils/convert-to-avif.js --png
* node utils/convert-to-avif.js --jpeg --png
* node utils/convert-to-avif.js --webp
*
* Options:
* --all Convert all supported formats (jpeg, png, webp, gif, bmp, tiff)
* --jpeg Convert JPEG/JPG files only
* --png Convert PNG files only
* --webp Convert WebP files only
* --gif Convert GIF files only
* --bmp Convert BMP files only
* --tiff Convert TIFF files only
* --quality Set quality (default: 65, range: 0-100)
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Get __dirname equivalent in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuration
const ASSETS_DIR = path.join(__dirname, '../assets');
const DEFAULT_QUALITY = 65;
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
all: args.includes('--all'),
jpeg: args.includes('--jpeg'),
png: args.includes('--png'),
webp: args.includes('--webp'),
gif: args.includes('--gif'),
bmp: args.includes('--bmp'),
tiff: args.includes('--tiff'),
quality: DEFAULT_QUALITY
};
// Parse quality option
const qualityIndex = args.indexOf('--quality');
if (qualityIndex !== -1 && args[qualityIndex + 1]) {
const quality = parseInt(args[qualityIndex + 1]);
if (!isNaN(quality) && quality >= 0 && quality <= 100) {
options.quality = quality;
} else {
console.error('⚠️ Invalid quality value. Using default: 65');
}
}
// If no specific format is selected, show help
if (!options.all && !options.jpeg && !options.png && !options.webp && !options.gif && !options.bmp && !options.tiff) {
console.log(`
Image to AVIF Converter
Usage:
node utils/convert-to-avif.js [options]
Options:
--all Convert all supported formats
--jpeg Convert JPEG/JPG files only
--png Convert PNG files only
--webp Convert WebP files only
--gif Convert GIF files only
--bmp Convert BMP files only
--tiff Convert TIFF files only
--quality <n> Set quality (0-100, default: 65)
Examples:
node utils/convert-to-avif.js --all
node utils/convert-to-avif.js --jpeg --png
node utils/convert-to-avif.js --jpeg --quality 80
`);
process.exit(0);
}
// Check if ffmpeg is installed
try {
execSync('ffmpeg -version', { stdio: 'ignore' });
} catch (error) {
console.error('❌ ffmpeg is not installed or not in PATH');
console.error(' Install it with: sudo apt install ffmpeg (Linux) or brew install ffmpeg (macOS)');
process.exit(1);
}
// Check if assets directory exists
if (!fs.existsSync(ASSETS_DIR)) {
console.error(`❌ Assets directory not found: ${ASSETS_DIR}`);
process.exit(1);
}
// Define supported formats
const formatExtensions = {
jpeg: ['.jpg', '.jpeg'],
png: ['.png'],
webp: ['.webp'],
gif: ['.gif'],
bmp: ['.bmp'],
tiff: ['.tiff', '.tif']
};
// Determine which extensions to process
let extensionsToProcess = [];
if (options.all) {
extensionsToProcess = Object.values(formatExtensions).flat();
} else {
Object.keys(formatExtensions).forEach(format => {
if (options[format]) {
extensionsToProcess.push(...formatExtensions[format]);
}
});
}
// Get all image files in assets directory
const allFiles = fs.readdirSync(ASSETS_DIR);
const imageFiles = allFiles.filter(file => {
const ext = path.extname(file).toLowerCase();
return extensionsToProcess.includes(ext);
});
if (imageFiles.length === 0) {
console.log(' No matching images found in assets directory');
process.exit(0);
}
console.log(`\n🎨 Converting ${imageFiles.length} image(s) to AVIF format`);
console.log(`📁 Source: ${ASSETS_DIR}`);
console.log(`⚙️ Quality: ${options.quality}`);
console.log('');
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
// Convert each image
imageFiles.forEach((file, index) => {
const inputPath = path.join(ASSETS_DIR, file);
const outputPath = path.join(ASSETS_DIR, path.basename(file, path.extname(file)) + '.avif');
// Skip if AVIF already exists
if (fs.existsSync(outputPath)) {
console.log(`⏭️ [${index + 1}/${imageFiles.length}] Skipping ${file} (AVIF already exists)`);
skipCount++;
return;
}
try {
console.log(`🔄 [${index + 1}/${imageFiles.length}] Converting ${file}...`);
// Run ffmpeg conversion
// -i: input file
// -c:v libaom-av1: use AV1 codec for AVIF
// -still-picture 1: encode as still image
// -crf: quality (0=best, 63=worst, 65 is good balance)
// -y: overwrite output file if it exists
execSync(
`ffmpeg -i "${inputPath}" -c:v libaom-av1 -still-picture 1 -crf ${100 - options.quality} -y "${outputPath}"`,
{ stdio: 'ignore' }
);
const inputStats = fs.statSync(inputPath);
const outputStats = fs.statSync(outputPath);
const savings = ((1 - outputStats.size / inputStats.size) * 100).toFixed(1);
console.log(` ✅ Created ${path.basename(outputPath)} (${(outputStats.size / 1024).toFixed(1)}KB, ${savings}% smaller)`);
successCount++;
} catch (error) {
console.error(` ❌ Failed to convert ${file}: ${error.message}`);
errorCount++;
}
});
// Summary
console.log('\n' + '='.repeat(50));
console.log(`✨ Conversion complete!`);
console.log(` ✅ Converted: ${successCount}`);
if (skipCount > 0) console.log(` ⏭️ Skipped: ${skipCount}`);
if (errorCount > 0) console.log(` ❌ Failed: ${errorCount}`);
console.log('='.repeat(50) + '\n');

535
src/utils/git-commit.js Normal file
View File

@ -0,0 +1,535 @@
#!/usr/bin/env node
/**
* Git Commit Automation Script
*
* Automatically generates commit messages using OpenRouter AI (inception/mercury-coder)
* based on staged changes. Supports message editing and optional pushing.
*
* Usage:
* 1. Stage your changes: git add <files>
* 2. Run: pnpm commit
* 3. Review/edit the generated message
* 4. Approve and optionally push
*/
import { execSync, spawnSync } from 'child_process';
import { createInterface } from 'readline';
import { readFileSync, writeFileSync, unlinkSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { tmpdir } from 'os';
// Get current directory for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load environment variables from .env file
function loadEnv() {
try {
const envPath = join(__dirname, '.env');
const envContent = readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, ...valueParts] = trimmed.split('=');
const value = valueParts.join('=').trim();
if (key && value) {
process.env[key.trim()] = value.replace(/^["']|["']$/g, '');
}
}
} catch (error) {
console.error(`${colors.red}❌ Failed to load .env file${colors.reset}`);
console.error(`${colors.yellow}💡 Create a .env file in src/utils/ with:${colors.reset}`);
console.error(` ${colors.dim}OPENROUTER_API_KEY=your_api_key_here${colors.reset}\n`);
process.exit(1);
}
}
// Configuration
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1';
const MODEL_NAME = 'inception/mercury-coder';
// Color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
/**
* Execute a git command and return the output
*/
function git(command, silent = false) {
try {
return execSync(`git ${command}`, {
encoding: 'utf-8',
stdio: silent ? 'pipe' : ['pipe', 'pipe', 'pipe']
}).trim();
} catch (error) {
if (!silent) {
console.error(`${colors.red}❌ Git command failed: ${command}${colors.reset}`);
console.error(error.message);
}
return null;
}
}
/**
* Check if there are staged changes
*/
function checkStagedChanges() {
const stagedFiles = git('diff --staged --name-only', true);
return stagedFiles && stagedFiles.length > 0;
}
/**
* Get git context for AI commit message generation
*/
function getGitContext() {
console.log(`${colors.cyan}🔍 Gathering git context...${colors.reset}`);
const status = git('status --short');
const stagedFiles = git('diff --staged --name-only');
// Get diff with increased buffer size to avoid ENOBUFS error
let diff = '';
try {
diff = execSync('git diff --staged', {
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch (error) {
console.error(`${colors.yellow}⚠️ Warning: Could not get full diff (too large or error occurred)${colors.reset}`);
// Fallback to stat summary if diff is too large
try {
diff = execSync('git diff --staged --stat', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch {
diff = 'Unable to retrieve diff';
}
}
return {
status,
diff,
stagedFiles
};
}
/**
* Call OpenRouter API to generate commit message
*/
async function generateCommitMessage(context) {
console.log(`${colors.cyan}Generating commit message...${colors.reset}`);
const systemPrompt = `You are a professional git commit message generator. Your purpose is to create clear, concise, and conventional commit messages based on code changes.
Generate commit messages following these guidelines:
- Use imperative mood (e.g., "Add", "Fix", "Update", "Refactor", "Remove")
- Keep it concise but descriptive
- First line should be a short summary (50-72 characters max)
- If needed, add a blank line followed by bullet points for additional details
- Focus on WHAT changed and WHY, not HOW it was implemented
- Follow conventional commit format when applicable (feat:, fix:, docs:, style:, refactor:, test:, chore:)
- Be professional and straightforward
- Use fewer words for more impact
Generate ONLY the commit message, nothing else. Do not include explanations, meta-commentary, or signatures.`;
const diffContent = context.diff || 'Unable to retrieve diff';
const truncatedDiff = diffContent.length > 8000
? diffContent.slice(0, 8000) + '\n... (diff truncated)'
: diffContent;
const userPrompt = `Based on the following git changes, generate a commit message:
Staged files:
${context.stagedFiles}
Git status:
${context.status}
Git diff:
${truncatedDiff}`;
try {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error('OPENROUTER_API_KEY not found in environment variables');
}
const response = await fetch(`${OPENROUTER_API_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': 'https://github.com/yourusername/git-commit-automation',
'X-Title': 'Git Commit Automation',
},
body: JSON.stringify({
model: MODEL_NAME,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}\n${JSON.stringify(errorData, null, 2)}`);
}
const data = await response.json();
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
throw new Error('Unexpected API response format');
}
return data.choices[0].message.content.trim();
} catch (error) {
console.error(`${colors.red}❌ Failed to generate commit message${colors.reset}`);
console.error(error.message);
// Check for common errors
if (error.message.includes('OPENROUTER_API_KEY not found')) {
console.log(`\n${colors.yellow}💡 Make sure you have a .env file in src/utils/ with:${colors.reset}`);
console.log(` ${colors.dim}OPENROUTER_API_KEY=your_api_key_here${colors.reset}`);
console.log(`\n${colors.yellow}💡 Get your API key from:${colors.reset}`);
console.log(` ${colors.dim}https://openrouter.ai/keys${colors.reset}`);
} else if (error.message.includes('ECONNREFUSED') || error.message.includes('fetch failed')) {
console.log(`\n${colors.yellow}💡 Check your internet connection${colors.reset}`);
} else if (error.message.includes('401')) {
console.log(`\n${colors.yellow}💡 Invalid API key. Check your OPENROUTER_API_KEY in .env${colors.reset}`);
}
process.exit(1);
}
}
/**
* Create readline interface for user input
*/
function createReadlineInterface() {
return createInterface({
input: process.stdin,
output: process.stdout,
});
}
/**
* Ask user a question and get input
*/
function question(rl, query) {
return new Promise((resolve) => {
rl.question(query, resolve);
});
}
/**
* Open neovim to edit the commit message
*/
function editInNeovim(message) {
// Create a temporary file for editing
const tempFile = join(tmpdir(), `git-commit-${Date.now()}.txt`);
try {
// Write the current message to the temp file
writeFileSync(tempFile, message, 'utf-8');
console.log(`\n${colors.cyan}✏️ Opening neovim to edit commit message...${colors.reset}`);
// Open neovim with the temp file
const result = spawnSync('nvim', [tempFile], {
stdio: 'inherit',
shell: false
});
if (result.error) {
throw new Error(`Failed to open neovim: ${result.error.message}`);
}
// Read the edited content
const editedMessage = readFileSync(tempFile, 'utf-8').trim();
// Clean up temp file
unlinkSync(tempFile);
return editedMessage;
} catch (error) {
// Clean up temp file if it exists
try {
unlinkSync(tempFile);
} catch { }
console.error(`${colors.red}❌ Failed to edit in neovim${colors.reset}`);
console.error(error.message);
if (error.message.includes('Failed to open neovim')) {
console.log(`\n${colors.yellow}💡 Make sure neovim is installed:${colors.reset}`);
console.log(` ${colors.dim}# Arch Linux${colors.reset}`);
console.log(` ${colors.dim}sudo pacman -S neovim${colors.reset}`);
}
// Return the original message if editing fails
return message;
}
}
/**
* Display the commit message and get user approval
*/
async function getUserApproval(message, rl) {
console.log(`\n${colors.bright}${colors.green}📝 Generated commit message:${colors.reset}`);
console.log(`${colors.dim}${'─'.repeat(60)}${colors.reset}`);
console.log(message);
console.log(`${colors.dim}${'─'.repeat(60)}${colors.reset}\n`);
while (true) {
const answer = await question(
rl,
`${colors.yellow}[A]ccept / [E]dit / [C]ancel?${colors.reset} `
);
const choice = answer.trim().toLowerCase();
if (choice === 'a' || choice === 'accept') {
return { approved: true, message };
} else if (choice === 'e' || choice === 'edit') {
// Close readline to give full control to neovim
rl.pause();
// Open neovim for editing
const editedMessage = editInNeovim(message);
// Resume readline
rl.resume();
// Show the edited message and ask for approval again
return getUserApproval(editedMessage, rl);
} else if (choice === 'c' || choice === 'cancel') {
return { approved: false, message: null };
} else {
console.log(`${colors.red}Invalid option. Please enter A, E, or C.${colors.reset}`);
}
}
}
/**
* Create the commit with the approved message
*/
function createCommit(message) {
console.log(`\n${colors.cyan}📦 Creating commit...${colors.reset}`);
try {
// Use a temporary file for the commit message to handle multi-line messages
execSync(`git commit -F -`, {
input: message,
encoding: 'utf-8',
stdio: ['pipe', 'inherit', 'inherit']
});
console.log(`${colors.green}✅ Commit created successfully!${colors.reset}`);
return true;
} catch (error) {
console.error(`${colors.red}❌ Failed to create commit${colors.reset}`);
console.error(error.message);
return false;
}
}
/**
* Ask if user wants to push to remote
*/
async function askToPush(rl) {
const answer = await question(
rl,
`\n${colors.yellow}Push to remote? [y/N]${colors.reset} `
);
return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes';
}
/**
* Push to remote repository
*/
function pushToRemote() {
console.log(`${colors.cyan}🚀 Pushing to remote...${colors.reset}`);
try {
// Get current branch
const branch = git('rev-parse --abbrev-ref HEAD');
execSync(`git push origin ${branch}`, {
encoding: 'utf-8',
stdio: 'inherit'
});
console.log(`${colors.green}✅ Pushed successfully!${colors.reset}`);
return true;
} catch (error) {
console.error(`${colors.red}❌ Failed to push${colors.reset}`);
console.error(error.message);
return false;
}
}
/**
* Show help message
*/
function showHelp() {
console.log(`
${colors.bright}Git Commit Automation Script${colors.reset}
${colors.dim}Generates commit messages using OpenRouter AI${colors.reset}
${colors.bright}Usage:${colors.reset}
1. Stage your changes:
${colors.cyan}git add <files>${colors.reset}
2. Run this script:
${colors.cyan}pnpm commit [options]${colors.reset}
3. Review the AI-generated commit message
4. Choose to accept, edit, or cancel
5. Optionally push to remote
${colors.bright}Requirements:${colors.reset}
- OpenRouter API key in .env file
- Create ${colors.dim}src/utils/.env${colors.reset} with:
${colors.dim}OPENROUTER_API_KEY=your_api_key_here${colors.reset}
- Get your key from: ${colors.dim}https://openrouter.ai/keys${colors.reset}
${colors.bright}Options:${colors.reset}
--help, -h Show this help message
--accept, -a Auto-accept the generated commit message without prompting
--push, -p Automatically push to remote after committing
--no-push, -n Skip the push prompt (commit only, don't push)
${colors.bright}Examples:${colors.reset}
${colors.cyan}pnpm commit${colors.reset}
Interactive mode - review, accept/edit, optionally push
${colors.cyan}pnpm commit --accept${colors.reset}
Auto-accept commit message, still prompt for push
${colors.cyan}pnpm commit --accept --no-push${colors.reset}
Auto-accept and commit without pushing
${colors.cyan}pnpm commit --accept --push${colors.reset}
Fully automated - accept and push without any prompts
`);
}
/**
* Main function
*/
async function main() {
// Check for help flag
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
showHelp();
process.exit(0);
}
// Check for flags
const autoAccept = args.includes('--accept') || args.includes('-a');
const autoPush = args.includes('--push') || args.includes('-p');
const noPush = args.includes('--no-push') || args.includes('-n');
// Load environment variables
loadEnv();
console.log(`${colors.bright}${colors.blue}🚀 Git Commit Automation${colors.reset}\n`);
// Check if we're in a git repository
if (!git('rev-parse --git-dir', true)) {
console.error(`${colors.red}❌ Not a git repository${colors.reset}`);
process.exit(1);
}
// Check for staged changes
if (!checkStagedChanges()) {
console.error(`${colors.red}❌ No staged changes found${colors.reset}`);
console.log(`\n${colors.yellow}💡 Stage your changes first:${colors.reset}`);
console.log(` ${colors.dim}git add <files>${colors.reset}\n`);
process.exit(1);
}
// Get git context
const context = getGitContext();
// Generate commit message using OpenRouter
const generatedMessage = await generateCommitMessage(context);
let approved = autoAccept;
let message = generatedMessage;
// Get user approval if not auto-accepting
if (!autoAccept) {
const rl = createReadlineInterface();
const result = await getUserApproval(generatedMessage, rl);
approved = result.approved;
message = result.message;
rl.close();
if (!approved) {
console.log(`\n${colors.yellow}⏭️ Commit cancelled${colors.reset}`);
process.exit(0);
}
} else {
console.log(`\n${colors.bright}${colors.green}📝 Generated commit message:${colors.reset}`);
console.log(`${colors.dim}${'─'.repeat(60)}${colors.reset}`);
console.log(message);
console.log(`${colors.dim}${'─'.repeat(60)}${colors.reset}\n`);
console.log(`${colors.cyan}Auto-accepting with --accept flag${colors.reset}`);
}
// Create the commit
const commitSuccess = createCommit(message);
if (!commitSuccess) {
process.exit(1);
}
// Handle push logic
let shouldPush = false;
if (noPush) {
console.log(`${colors.cyan}Skipping push with --no-push flag${colors.reset}`);
} else if (autoPush) {
console.log(`${colors.cyan}Auto-pushing with --push flag${colors.reset}`);
shouldPush = true;
} else {
const rl = createReadlineInterface();
shouldPush = await askToPush(rl);
rl.close();
}
if (shouldPush) {
pushToRemote();
}
console.log(`\n${colors.green}✨ Done!${colors.reset}\n`);
}
// Run the script
main().catch((error) => {
console.error(`${colors.red}❌ Unexpected error:${colors.reset}`, error);
process.exit(1);
});

11
src/utils/reading-time.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Calculate reading time for a given text content
* @param content - The text content to analyze
* @param wordsPerMinute - Reading speed (default: 200 wpm)
* @returns Reading time string (e.g., "5 min read")
*/
export function calculateReadingTime(content: string, wordsPerMinute: number = 200): string {
const wordCount = content?.split(/\s+/).length || 0;
const readingTime = Math.max(1, Math.ceil(wordCount / wordsPerMinute));
return `${readingTime} min read`;
}

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"strictNullChecks": true,
"types": [
"./worker-configuration.d.ts",
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

10845
worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

46
wrangler.jsonc Normal file
View File

@ -0,0 +1,46 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "astro-portfolio-template",
"compatibility_date": "2025-12-05",
"compatibility_flags": [
"nodejs_compat"
],
"pages_build_output_dir": "./dist",
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" }
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" }
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" }
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}