Initial commit
4
.cursor/rules/astro-rules.mdc
Normal 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.
|
||||
4
.cursor/rules/design-system-guidelines.mdc
Normal 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.
|
||||
4
.cursor/rules/pnpm-is-the-package-manager.mdc
Normal 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
@ -0,0 +1,7 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"# fnm use",
|
||||
"# npm install",
|
||||
"# cp $ROOT_WORKTREE_PATH/.env .env"
|
||||
]
|
||||
}
|
||||
42
.env.example
Normal 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
@ -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
@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal 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
@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"wrangler.json": "jsonc"
|
||||
}
|
||||
}
|
||||
78
CLAUDE.md
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
2
public/.assetsignore
Normal file
@ -0,0 +1,2 @@
|
||||
_worker.js
|
||||
_routes.json
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/favicon-192.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
9
public/favicon.svg
Normal 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 |
BIN
public/fonts/atkinson-bold.woff
Normal file
BIN
public/fonts/atkinson-regular.woff
Normal file
BIN
public/media/GSTR_01_260_breakdown.mp4
Normal file
BIN
public/media/GSTR_03_070_v10_breakdown_v01.mp4
Normal file
132
public/media/PLACEHOLDER_ASSETS.md
Normal 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
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/media/nicholai-wild-portrait.JPEG
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
5
public/robots.txt
Normal file
@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://nicholai.work/sitemap-index.xml
|
||||
|
||||
BIN
src/assets/PENCIL_1.3.1_wipe.avif
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
src/assets/claude-nuke.avif
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/foxrenderfarm-arch-linux.avif
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/g-star-image.avif
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/assets/nicholai-closeup-portrait.avif
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/assets/nicholai-medium-portrait.avif
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
54
src/components/BaseHead.astro
Normal 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()} />}
|
||||
25
src/components/BlogCard.astro
Normal 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>
|
||||
17
src/components/FormattedDate.astro
Normal 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
@ -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
@ -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 };
|
||||
54
src/content/blog/welcome-to-your-portfolio.mdx
Normal 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
@ -0,0 +1,5 @@
|
||||
type Runtime = import("@astrojs/cloudflare").Runtime<Env>;
|
||||
|
||||
declare namespace App {
|
||||
interface Locals extends Runtime {}
|
||||
}
|
||||
41
src/layouts/BaseLayout.astro
Normal 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>
|
||||
78
src/layouts/BlogPost.astro
Normal 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">← 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
@ -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>
|
||||
26
src/pages/blog/[...slug].astro
Normal 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>
|
||||
29
src/pages/blog/index.astro
Normal 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="/">← 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
@ -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="/">← 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
@ -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>
|
||||
82
src/pages/llms-full.txt.ts
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
||||
```
|
||||
192
src/utils/convert-to-avif.js
Normal 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
@ -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
@ -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
@ -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
46
wrangler.jsonc
Normal 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" }]
|
||||
}
|
||||