Initial commit with stock files

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

View file

@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* Script to check for missing images in content
* Run with: node scripts/check-missing-images.js
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.join(__dirname, '..');
// Get all markdown files in content directory
function getAllMarkdownFiles(dir) {
const files = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files.push(...getAllMarkdownFiles(fullPath));
} else if (item.endsWith('.md')) {
files.push(fullPath);
}
}
return files;
}
// Extract image references from markdown content
function extractImageReferences(content) {
const images = [];
// Match markdown images ![alt](src)
const markdownImages = content.match(/!\[([^\]]*)\]\(([^)]+)\)/g) || [];
for (const match of markdownImages) {
const src = match.match(/!\[[^\]]*\]\(([^)]+)\)/)[1];
images.push({ type: 'markdown', src, line: content.substring(0, content.indexOf(match)).split('\n').length });
}
// Match wikilink images ![[src]]
const wikilinkImages = content.match(/!\[\[([^\]]+)\]\]/g) || [];
for (const match of wikilinkImages) {
const src = match.match(/!\[\[([^\]]+)\]\]/)[1];
images.push({ type: 'wikilink', src, line: content.substring(0, content.indexOf(match)).split('\n').length });
}
// Match frontmatter images
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
const imageMatch = frontmatter.match(/^image:\s*["']?([^"'\n]+)["']?/m);
if (imageMatch) {
images.push({ type: 'frontmatter', src: imageMatch[1], line: 1 });
}
}
return images;
}
// Check if image exists
function checkImageExists(imageSrc, filePath) {
// Handle different image path formats
let imagePath = imageSrc;
// Remove Obsidian brackets
if (imagePath.startsWith('[[') && imagePath.endsWith(']]')) {
imagePath = imagePath.slice(2, -2);
}
// Determine content type and folder structure
const isPostsFile = filePath.includes('posts');
const isPagesFile = filePath.includes('pages');
const isProjectsFile = filePath.includes('projects');
const isDocsFile = filePath.includes('docs');
const isFolderBasedPost = filePath.endsWith('index.md') && isPostsFile;
const isFolderBasedProject = filePath.endsWith('index.md') && isProjectsFile;
const isFolderBasedDoc = filePath.endsWith('index.md') && isDocsFile;
// 1. Check in the same folder as the markdown file (for folder-based content)
if (isFolderBasedPost || isFolderBasedProject || isFolderBasedDoc) {
const contentDir = path.dirname(filePath);
const sameFolderPath = path.join(contentDir, imagePath);
if (fs.existsSync(sameFolderPath)) {
return { exists: true, path: sameFolderPath };
}
// Check in /attachments/ subfolder within the content folder
const imagesSubfolderPath = path.join(contentDir, 'images', imagePath);
if (fs.existsSync(imagesSubfolderPath)) {
return { exists: true, path: imagesSubfolderPath };
}
}
// 2. Check in general images directory for each content type
if (isPostsFile) {
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'posts', 'images', imagePath);
if (fs.existsSync(generalImagesPath)) {
return { exists: true, path: generalImagesPath };
}
}
if (isPagesFile) {
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'pages', 'images', imagePath);
if (fs.existsSync(generalImagesPath)) {
return { exists: true, path: generalImagesPath };
}
}
if (isProjectsFile) {
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'projects', 'images', imagePath);
if (fs.existsSync(generalImagesPath)) {
return { exists: true, path: generalImagesPath };
}
}
if (isDocsFile) {
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'docs', 'images', imagePath);
if (fs.existsSync(generalImagesPath)) {
return { exists: true, path: generalImagesPath };
}
}
// 3. Check in public directory (for synced images)
if (isFolderBasedPost) {
const postSlug = path.basename(path.dirname(filePath));
const publicPath = path.join(projectRoot, 'public', 'posts', postSlug, imagePath);
if (fs.existsSync(publicPath)) {
return { exists: true, path: publicPath };
}
}
if (isFolderBasedProject) {
const projectSlug = path.basename(path.dirname(filePath));
const publicPath = path.join(projectRoot, 'public', 'projects', projectSlug, imagePath);
if (fs.existsSync(publicPath)) {
return { exists: true, path: publicPath };
}
}
if (isFolderBasedDoc) {
const docSlug = path.basename(path.dirname(filePath));
const publicPath = path.join(projectRoot, 'public', 'docs', docSlug, imagePath);
if (fs.existsSync(publicPath)) {
return { exists: true, path: publicPath };
}
}
// Handle absolute paths (starting with /)
if (imagePath.startsWith('/')) {
const publicPath = path.join(projectRoot, 'public', imagePath);
if (fs.existsSync(publicPath)) {
return { exists: true, path: publicPath };
}
}
// Handle relative paths from public directory
const publicPath = path.join(projectRoot, 'public', imagePath);
if (fs.existsSync(publicPath)) {
return { exists: true, path: publicPath };
}
// Handle external URLs (don't check these)
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return { exists: true, path: imagePath };
}
return { exists: false, path: imagePath };
}
// Main function
function main() {
console.log('🔍 Checking for missing images...\n');
const contentDir = path.join(projectRoot, 'src', 'content');
const markdownFiles = getAllMarkdownFiles(contentDir);
let totalImages = 0;
let missingImages = 0;
const missingImageDetails = [];
for (const filePath of markdownFiles) {
const content = fs.readFileSync(filePath, 'utf-8');
const images = extractImageReferences(content);
for (const image of images) {
totalImages++;
const result = checkImageExists(image.src, filePath);
if (!result.exists) {
missingImages++;
const relativePath = path.relative(projectRoot, filePath);
missingImageDetails.push({
file: relativePath,
line: image.line,
type: image.type,
src: image.src,
expectedPath: result.path
});
}
}
}
// Report results
console.log(`📊 Summary:`);
console.log(` Total images: ${totalImages}`);
console.log(` Missing images: ${missingImages}`);
console.log(` Found images: ${totalImages - missingImages}\n`);
if (missingImages > 0) {
console.log('❌ Missing images:');
for (const detail of missingImageDetails) {
console.log(` ${detail.file}:${detail.line} (${detail.type})`);
console.log(` ${detail.src}`);
console.log(` Expected: ${detail.expectedPath}\n`);
}
console.log('💡 Tips:');
console.log(' - In development mode, missing images will show placeholder images');
console.log(' - Check if images are in the correct folder for folder-based posts');
console.log(' - Verify image paths match exactly (case-sensitive)');
console.log(' - For Obsidian wikilinks, ensure the image exists in the same folder');
} else {
console.log('✅ All images found!');
}
}
main();

80
scripts/dev-with-port.js Normal file
View file

@ -0,0 +1,80 @@
/**
* Astro server launcher with automatic port detection
* Checks if port 5000 is available, falls back to 5001 if occupied
* Supports both 'dev' and 'preview' commands
*/
import { spawn } from 'node:child_process';
import { createServer } from 'node:net';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
function checkPort(port) {
return new Promise((resolve) => {
const server = createServer();
server.listen(port, () => {
server.once('close', () => {
resolve(true); // Port is available
});
server.close();
});
server.on('error', () => {
resolve(false); // Port is occupied
});
});
}
async function getAvailablePort() {
const port5000Available = await checkPort(5000);
if (port5000Available) {
return 5000;
} else {
// Port 5000 is occupied (likely AirPlay on macOS)
console.log('⚠️ Port 5000 is occupied, using port 5001 instead');
return 5001;
}
}
async function main() {
// Get command from first argument (defaults to 'dev')
const command = process.argv[2] || 'dev';
if (!['dev', 'preview'].includes(command)) {
console.error(`Invalid command: ${command}. Must be 'dev' or 'preview'.`);
process.exit(1);
}
const port = await getAvailablePort();
// Use pnpm exec to ensure we use the local Astro installation
// On Windows, use 'pnpm.cmd', on Unix use 'pnpm'
const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
// Spawn astro with the detected port
const astroProcess = spawn(pnpmCmd, ['exec', 'astro', command, '--host', '0.0.0.0', '--port', port.toString()], {
cwd: projectRoot,
stdio: 'inherit',
shell: true
});
astroProcess.on('error', (error) => {
console.error(`Failed to start Astro ${command} server:`, error);
process.exit(1);
});
astroProcess.on('exit', (code) => {
process.exit(code || 0);
});
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});

View file

@ -0,0 +1,955 @@
#!/usr/bin/env node
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Simple logging utility
const isDev = process.env.NODE_ENV !== 'production';
const log = {
info: (...args) => isDev && console.log(...args),
error: (...args) => console.error(...args),
warn: (...args) => console.warn(...args)
};
// Import deployment platform helper
import getDeploymentPlatform from './get-deployment-platform.js';
// Deployment platform configuration - use config if no env var is set
const DEPLOYMENT_PLATFORM = process.env.DEPLOYMENT_PLATFORM || getDeploymentPlatform();
const DRY_RUN = process.argv.includes('--dry-run');
const VALIDATE_ONLY = process.argv.includes('--validate');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Define content directories to process
const CONTENT_DIRS = [
'src/content/pages',
'src/content/posts',
'src/content/projects',
'src/content/docs'
];
// Function to parse frontmatter from markdown content
function parseFrontmatter(content) {
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
return { frontmatter: null, content: content };
}
const frontmatterText = match[1];
const body = match[2];
// Parse YAML-like frontmatter (simple parser)
const frontmatter = {};
const lines = frontmatterText.split('\n');
let currentKey = null;
let currentValue = [];
let inArray = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) {
continue;
}
if (trimmed.includes(':') && (!inArray || !trimmed.startsWith(' '))) {
// Save previous key-value pair
if (currentKey) {
if (currentValue.length === 1) {
frontmatter[currentKey] = currentValue[0];
} else {
frontmatter[currentKey] = currentValue;
}
}
// Start new key-value pair
const colonIndex = trimmed.indexOf(':');
currentKey = trimmed.substring(0, colonIndex).trim();
const value = trimmed.substring(colonIndex + 1).trim();
// Reset array state when starting a new key
inArray = false;
if (value.startsWith('[')) {
// Array value
inArray = true;
currentValue = [];
if (value !== '[') {
// Single line array
const arrayContent = value.substring(1, value.endsWith(']') ? value.length - 1 : value.length);
if (arrayContent.trim()) {
currentValue = arrayContent.split(',').map(item => item.trim().replace(/^["']|["']$/g, ''));
}
inArray = false;
}
} else if (value) {
// Single value
currentValue = [value.replace(/^["']|["']$/g, '')];
} else {
// Empty value, might be start of array
currentValue = [];
inArray = true;
}
} else if (inArray && trimmed.startsWith('-')) {
// Array item
const item = trimmed.substring(1).trim().replace(/^["']|["']$/g, '');
currentValue.push(item);
} else if (inArray && trimmed === ']') {
// End of array
inArray = false;
} else if (currentKey && !inArray) {
// Continuation of single value
currentValue = [currentValue[0] + ' ' + trimmed];
}
}
// Save last key-value pair
if (currentKey) {
if (currentValue.length === 1) {
frontmatter[currentKey] = currentValue[0];
} else {
frontmatter[currentKey] = currentValue;
}
}
return { frontmatter, content: body };
}
// Function to get the final URL for a content file
function getContentUrl(filePath, isPost = false) {
// Normalize path separators and extract the relevant part
const normalizedPath = filePath.replace(/\\/g, '/');
if (isPost) {
// For posts: extract path after 'src/content/posts/' and remove '.md'
let postPath = normalizedPath.replace(/^.*src\/content\/posts\//, '').replace(/\.md$/, '');
// Handle folder-based content: remove '/index' suffix
if (postPath.endsWith('/index')) {
postPath = postPath.replace('/index', '');
}
return `/posts/${postPath}`;
} else if (normalizedPath.includes('src/content/projects/')) {
// For projects: extract path after 'src/content/projects/' and remove '.md'
let projectPath = normalizedPath.replace(/^.*src\/content\/projects\//, '').replace(/\.md$/, '');
// Handle folder-based content: remove '/index' suffix
if (projectPath.endsWith('/index')) {
projectPath = projectPath.replace('/index', '');
}
return `/projects/${projectPath}`;
} else if (normalizedPath.includes('src/content/docs/')) {
// For docs: extract path after 'src/content/docs/' and remove '.md'
let docPath = normalizedPath.replace(/^.*src\/content\/docs\//, '').replace(/\.md$/, '');
// Handle folder-based content: remove '/index' suffix
if (docPath.endsWith('/index')) {
docPath = docPath.replace('/index', '');
}
return `/docs/${docPath}`;
} else {
// For pages: extract path after 'src/content/pages/' and remove '.md'
let pagePath = normalizedPath.replace(/^.*src\/content\/pages\//, '').replace(/\.md$/, '');
// Handle folder-based content: remove '/index' suffix
if (pagePath.endsWith('/index')) {
pagePath = pagePath.replace('/index', '');
}
if (pagePath === 'index') {
return '/';
}
return `/${pagePath}`;
}
}
// Function to process a single markdown file
async function processMarkdownFile(filePath, isPost = false) {
try {
const content = await fs.readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (!frontmatter || !frontmatter.aliases) {
return []; // No redirects to process
}
// Ensure aliases is an array
const aliasesArray = Array.isArray(frontmatter.aliases)
? frontmatter.aliases
: [frontmatter.aliases];
const targetUrl = getContentUrl(filePath, isPost);
const redirects = [];
for (const alias of aliasesArray) {
const cleanAlias = alias.startsWith('/') ? alias.substring(1) : alias;
// Normalize path separators for consistent checking
const normalizedFilePath = filePath.replace(/\\/g, '/');
// Determine redirect pattern based on content type
let redirectFrom;
if (isPost) {
// Posts: /posts/alias → /posts/actual-slug
redirectFrom = `/posts/${cleanAlias}`;
} else if (normalizedFilePath.includes('src/content/projects/')) {
// Projects: /projects/alias → /projects/actual-slug
redirectFrom = `/projects/${cleanAlias}`;
} else if (normalizedFilePath.includes('src/content/docs/')) {
// Docs: /docs/alias → /docs/actual-slug
redirectFrom = `/docs/${cleanAlias}`;
} else {
// Pages: /alias → /actual-slug
redirectFrom = `/${cleanAlias}`;
}
// Skip self-redirects (redirecting to the same URL causes infinite loops)
if (redirectFrom !== targetUrl) {
redirects.push({
from: redirectFrom,
to: targetUrl,
alias: cleanAlias
});
}
}
return redirects;
} catch (error) {
log.error(`❌ Error processing ${filePath}:`, error.message);
return [];
}
}
// Function to process all markdown files in a directory
async function processDirectory(dirPath, isPost = false) {
try {
const files = await fs.readdir(dirPath, { withFileTypes: true });
let allRedirects = [];
let processedFiles = 0;
for (const file of files) {
if (file.isDirectory()) {
// Handle folder-based content (e.g., folder-name/index.md)
const folderPath = path.join(dirPath, file.name);
try {
const indexPath = path.join(folderPath, 'index.md');
await fs.access(indexPath);
const redirects = await processMarkdownFile(indexPath, isPost);
allRedirects = allRedirects.concat(redirects);
if (redirects.length > 0) {
processedFiles++;
}
} catch (error) {
// index.md doesn't exist in this folder, skip
}
} else if (file.isFile() && file.name.endsWith('.md')) {
// Handle single-file content
const filePath = path.join(dirPath, file.name);
const redirects = await processMarkdownFile(filePath, isPost);
allRedirects = allRedirects.concat(redirects);
if (redirects.length > 0) {
processedFiles++;
}
}
}
return { redirects: allRedirects, processedFiles };
} catch (error) {
log.error(`❌ Error processing directory ${dirPath}:`, error.message);
return { redirects: [], processedFiles: 0 };
}
}
// Function to update astro.config.mjs with redirects (dev-only)
async function updateAstroConfig(redirects) {
const astroConfigPath = 'astro.config.mjs';
try {
let astroContent = await fs.readFile(astroConfigPath, 'utf-8');
// Create redirects object
const redirectsObj = {};
for (const redirect of redirects) {
redirectsObj[redirect.from] = redirect.to;
}
// Format redirects with conditional dev-only check
// Using process.env.NODE_ENV for reliable environment detection at config load time
const redirectsString = JSON.stringify(redirectsObj, null, 2).replace(/"/g, "'");
const newRedirectsSection = `redirects: (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'build') ? ${redirectsString} : {}`;
// Remove ALL existing redirects entries (including any comments before them)
// This handles multiple redirects entries that may have been duplicated
// Match from optional comments + redirects: through the entire conditional expression
// Pattern: redirects: (condition) ? { object } : {},
// Use [\s\S]*? to match any characters including newlines (non-greedy)
const redirectsRegex = /(\s*\/\/[^\n]*\n)*\s*redirects:\s*\([^)]+\)\s*\?\s*\{[\s\S]*?\}\s*:\s*\{\},\s*/g;
// Remove all existing redirects entries (run multiple times to catch all duplicates)
let previousContent = '';
while (previousContent !== astroContent) {
previousContent = astroContent;
astroContent = astroContent.replace(redirectsRegex, '');
}
// Add redirects after devToolbar config
const devToolbarRegex = /(devToolbar:\s*\{[^}]*\},)/;
if (devToolbarRegex.test(astroContent)) {
astroContent = astroContent.replace(devToolbarRegex, `$1\n ${newRedirectsSection},\n`);
} else {
// Fallback: add after deployment config if devToolbar not found
const deploymentRegex = /(deployment:\s*\{[^}]*\},)/;
if (deploymentRegex.test(astroContent)) {
astroContent = astroContent.replace(deploymentRegex, `$1\n ${newRedirectsSection},\n`);
} else {
// Last resort: add after site config
const siteRegex = /(site:\s*[^,]+),/;
astroContent = astroContent.replace(siteRegex, `$1,\n ${newRedirectsSection},\n`);
}
}
await fs.writeFile(astroConfigPath, astroContent, 'utf-8');
log.info(`📝 Updated astro.config.mjs with ${redirects.length} redirects (dev-only)`);
} catch (error) {
log.error(`❌ Error updating astro.config.mjs:`, error.message);
}
}
// Platform-specific configuration generators
function generateVercelConfig(redirects) {
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
const config = {
redirects: validRedirects.map(redirect => ({
source: redirect.from,
destination: redirect.to,
permanent: (redirect.status || 301) === 301
})),
headers: [
{
source: "/_assets/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable"
}
]
},
{
source: "/(.*\\.(webp|jpg|jpeg|png|gif|svg))",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable"
}
]
},
{
source: "/(.*\\.pdf)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=3600"
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN"
}
]
},
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://giscus.app https://platform.twitter.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://giscus.app; frame-src 'self' https://www.youtube.com https://giscus.app https://platform.twitter.com; object-src 'none'; base-uri 'self';"
}
]
}
]
};
return JSON.stringify(config, null, 2);
}
function generateGitHubPagesConfig(redirects) {
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
// Note: Removed '!' suffix to avoid interstitial page - direct redirects like Netlify
const redirectLines = validRedirects.map(redirect =>
`${redirect.from} ${redirect.to} ${redirect.status || 301}`
);
return redirectLines.join('\n') + '\n';
}
function generateNetlifyConfig(redirects) {
const redirectLines = [];
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
for (const redirect of validRedirects) {
redirectLines.push('[[redirects]]');
redirectLines.push(` from = "${redirect.from}"`);
redirectLines.push(` to = "${redirect.to}"`);
redirectLines.push(` status = ${redirect.status || 301}`);
redirectLines.push(' force = true');
redirectLines.push('');
}
// Always add the catch-all 404 redirect at the end
redirectLines.push('[[redirects]]');
redirectLines.push(' from = "/*"');
redirectLines.push(' to = "/404"');
redirectLines.push(' status = 404');
return redirectLines.join('\n');
}
function generateCloudflareWorkersConfig(projectName) {
// Get current date for compatibility_date
const today = new Date();
const compatibilityDate = today.toISOString().split('T')[0];
// Generate Workers-compatible config (recommended by Cloudflare)
// Workers uses assets.directory instead of pages_build_output_dir
// See: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
const configLines = [];
configLines.push(`name = "${projectName}"`);
configLines.push(`compatibility_date = "${compatibilityDate}"`);
configLines.push(``);
configLines.push(`[assets]`);
configLines.push(` directory = "./dist"`);
configLines.push(` not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
return configLines.join('\n') + '\n';
}
// Clean up platform-specific files that don't match the selected platform
async function cleanupOtherPlatformFiles(currentPlatform) {
const projectRoot = path.join(__dirname, '..');
// Clean up GitHub Pages/Cloudflare Workers files if not using those platforms
// (Both platforms use the same _redirects and _headers format)
// These files should ONLY exist for github-pages and cloudflare-workers
if (currentPlatform !== 'github-pages' && currentPlatform !== 'cloudflare-workers') {
const sharedFiles = [
path.join(projectRoot, 'public', '_redirects'),
path.join(projectRoot, 'public', '_headers')
];
for (const file of sharedFiles) {
try {
await fs.access(file);
await fs.unlink(file);
log.info(`🧹 Removed ${path.basename(file)} (not needed for ${currentPlatform})`);
} catch (error) {
// File doesn't exist, nothing to clean up
}
}
}
// Note: We don't remove vercel.json, netlify.toml, or wrangler.toml as they may contain
// custom configuration (serverless functions, environment variables, bindings, etc.)
// Only _redirects and _headers are platform-specific and should be cleaned up
}
// Platform-specific file writers
async function writeVercelConfig(redirects) {
const projectRoot = path.join(__dirname, '..');
const vercelJsonPath = path.join(projectRoot, 'vercel.json');
if (DRY_RUN) {
log.info('📝 [DRY RUN] Would generate vercel.json:');
console.log(generateVercelConfig(redirects));
return;
}
try {
// Read existing vercel.json to preserve custom settings
let existingConfig = {};
try {
const existingContent = await fs.readFile(vercelJsonPath, 'utf-8');
existingConfig = JSON.parse(existingContent);
} catch (error) {
// File doesn't exist or is invalid JSON, create new one
}
// Generate new redirects and headers
const newConfig = JSON.parse(generateVercelConfig(redirects));
// Merge configs - new redirects/headers override existing ones
const mergedConfig = {
...existingConfig,
redirects: newConfig.redirects,
headers: newConfig.headers
};
await fs.writeFile(vercelJsonPath, JSON.stringify(mergedConfig, null, 2), 'utf-8');
log.info(`📝 Updated vercel.json with ${redirects.length} redirects`);
} catch (error) {
log.error(`❌ Error updating vercel.json:`, error.message);
}
}
async function writeGitHubPagesConfig(redirects) {
const projectRoot = path.join(__dirname, '..');
const redirectsPath = path.join(projectRoot, 'public', '_redirects');
const headersPath = path.join(projectRoot, 'public', '_headers');
if (DRY_RUN) {
log.info('📝 [DRY RUN] Would generate public/_redirects:');
console.log(generateGitHubPagesConfig(redirects));
return;
}
try {
// Write redirects file
const config = generateGitHubPagesConfig(redirects);
await fs.writeFile(redirectsPath, config, 'utf-8');
log.info(`📝 Updated public/_redirects with ${redirects.length} redirects`);
// Write headers file (for GitHub Pages and Cloudflare Pages)
// Note: Custom headers require GitHub Pages on a paid plan or GitHub Enterprise
// Cloudflare Pages supports custom headers on all plans (including free tier)
const headersContent = `# Custom Headers for GitHub Pages / Cloudflare Pages
# Note: GitHub Pages requires paid plan for custom headers
# Cloudflare Pages supports custom headers on all plans
# HTML files
/*.html
Cache-Control: public, max-age=3600, s-maxage=86400
# CSS files
/*.css
Cache-Control: public, max-age=31536000, immutable
# JavaScript files
/*.js
Cache-Control: public, max-age=31536000, immutable
# JSON files
/*.json
Cache-Control: public, max-age=3600
# XML files
/*.xml
Cache-Control: public, max-age=3600
# Text files
/*.txt
Cache-Control: public, max-age=3600
# Pre-compressed files
/*.gz
Content-Encoding: gzip
Cache-Control: public, max-age=31536000, immutable
/*.br
Content-Encoding: br
Cache-Control: public, max-age=31536000, immutable
# Static assets with long-term caching
/_assets/*
Cache-Control: public, max-age=31536000, immutable
# WebP images
/*.webp
Cache-Control: public, max-age=31536000, immutable
# Font files
/*.woff2
Cache-Control: public, max-age=31536000, immutable
/*.woff
Cache-Control: public, max-age=31536000, immutable
/*.ttf
Cache-Control: public, max-age=31536000, immutable
/*.eot
Cache-Control: public, max-age=31536000, immutable
# Image files
/*.jpg
Cache-Control: public, max-age=31536000, immutable
/*.jpeg
Cache-Control: public, max-age=31536000, immutable
/*.png
Cache-Control: public, max-age=31536000, immutable
/*.gif
Cache-Control: public, max-age=31536000, immutable
/*.svg
Cache-Control: public, max-age=31536000, immutable
/*.ico
Cache-Control: public, max-age=31536000, immutable
# Favicon files
/favicon*
Cache-Control: public, max-age=31536000, immutable
# All pages - Security headers and Content Security Policy
/*
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Embedder-Policy: unsafe-none
Cross-Origin-Opener-Policy: same-origin
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://giscus.app https://platform.twitter.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://giscus.app; frame-src 'self' https://www.youtube.com https://giscus.app https://platform.twitter.com; object-src 'none'; base-uri 'self';
# PDF files - allow iframe embedding (override X-Frame-Options for PDFs)
/*.pdf
Cache-Control: public, max-age=3600
X-Frame-Options: SAMEORIGIN
`;
await fs.writeFile(headersPath, headersContent, 'utf-8');
log.info(`📝 Created public/_headers for GitHub Pages / Cloudflare Pages`);
} catch (error) {
log.error(`❌ Error updating GitHub Pages config:`, error.message);
}
}
async function writeNetlifyConfig(redirects) {
const projectRoot = path.join(__dirname, '..');
const netlifyTomlPath = path.join(projectRoot, 'netlify.toml');
if (DRY_RUN) {
log.info('📝 [DRY RUN] Would generate netlify.toml:');
console.log(generateNetlifyConfig(redirects));
return;
}
try {
// Read existing netlify.toml to preserve other settings
let existingContent = '';
try {
existingContent = await fs.readFile(netlifyTomlPath, 'utf-8');
} catch (error) {
// File doesn't exist, create new one
}
// Split content at redirects section
const configLines = existingContent.split('[[redirects]]')[0].trim();
const redirectConfig = generateNetlifyConfig(redirects);
const newContent = configLines + '\n\n' + redirectConfig;
await fs.writeFile(netlifyTomlPath, newContent, 'utf-8');
log.info(`📝 Updated netlify.toml with ${redirects.length} redirects`);
} catch (error) {
log.error(`❌ Error updating netlify.toml:`, error.message);
}
}
async function copyAssetsIgnoreFile() {
const projectRoot = path.join(__dirname, '..');
const templatePath = path.join(projectRoot, '.assetsignore.template');
const distPath = path.join(projectRoot, 'dist');
const assetsIgnorePath = path.join(distPath, '.assetsignore');
if (DRY_RUN) {
log.info('📝 [DRY RUN] Would copy .assetsignore.template to dist/.assetsignore');
return;
}
try {
// Check if template exists
try {
await fs.access(templatePath);
} catch (error) {
// Template doesn't exist, that's okay - skip copying (optional file)
return;
}
// Check if dist directory exists
// Note: This script runs before Astro build, so dist may not exist yet
// That's okay - the file will be copied if dist exists, or can be added manually
try {
await fs.access(distPath);
// dist exists, copy the file
const templateContent = await fs.readFile(templatePath, 'utf-8');
await fs.writeFile(assetsIgnorePath, templateContent, 'utf-8');
log.info('📝 Copied .assetsignore to dist/');
} catch (error) {
// dist doesn't exist yet - that's fine, it will be created during Astro build
// The .assetsignore file is optional and mainly for safety
// Users can manually copy it to dist/ if needed, or it will be there on subsequent builds
}
} catch (error) {
// Non-critical error - just log it
log.warn(`⚠️ Could not copy .assetsignore file: ${error.message}`);
}
}
async function writeCloudflareWorkersConfig(projectName) {
const projectRoot = path.join(__dirname, '..');
const wranglerTomlPath = path.join(projectRoot, 'wrangler.toml');
if (DRY_RUN) {
log.info('📝 [DRY RUN] Would generate wrangler.toml:');
console.log(generateCloudflareWorkersConfig(projectName));
return;
}
try {
// Read existing wrangler.toml to preserve custom settings
let existingContent = '';
try {
existingContent = await fs.readFile(wranglerTomlPath, 'utf-8');
} catch (error) {
// File doesn't exist, create new one
}
// Generate new config with managed fields (Workers format)
const newConfig = generateCloudflareWorkersConfig(projectName);
if (existingContent) {
// Update only the fields we manage while preserving everything else
// Migrate from Pages format (pages_build_output_dir) to Workers format (assets.directory)
let updatedContent = existingContent;
// Update name field
updatedContent = updatedContent.replace(/^name\s*=\s*["'][^"']*["']/m, `name = "${projectName}"`);
if (!updatedContent.match(/^name\s*=/m)) {
// name doesn't exist, add it at the beginning
updatedContent = `name = "${projectName}"\n${updatedContent}`;
}
// Migrate from Pages format to Workers format
// Remove old pages_build_output_dir if it exists
updatedContent = updatedContent.replace(/^pages_build_output_dir\s*=\s*["'][^"']*["']\s*\n?/m, '');
// Update or add assets.directory (Workers format)
if (updatedContent.match(/^\[assets\]/m)) {
// [assets] section exists, update directory
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n\s*)directory\s*=\s*["'][^"']*["']/m, `$1directory = "./dist"`);
if (!updatedContent.match(/^\[assets\]\s*\n\s*directory\s*=/m)) {
// directory doesn't exist in [assets], add it
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n)/m, `$1 directory = "./dist"\n`);
}
// Ensure not_found_handling is set
if (!updatedContent.match(/not_found_handling\s*=/m)) {
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n\s*directory\s*=[^\n]+)/m, `$1\n not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
}
} else {
// [assets] section doesn't exist, add it after compatibility_date
const today = new Date();
const compatibilityDate = today.toISOString().split('T')[0];
updatedContent = updatedContent.replace(/^compatibility_date\s*=\s*["'][^"']*["']/m, `compatibility_date = "${compatibilityDate}"`);
if (!updatedContent.match(/^compatibility_date\s*=/m)) {
// compatibility_date doesn't exist, add both
updatedContent = updatedContent.replace(/^(name\s*=[^\n]+)/m, `$1\ncompatibility_date = "${compatibilityDate}"`);
}
// Add [assets] section
updatedContent = updatedContent.replace(/^(compatibility_date\s*=[^\n]+)/m, `$1\n\n[assets]\n directory = "./dist"\n not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
}
// Update compatibility_date field
const today = new Date();
const compatibilityDate = today.toISOString().split('T')[0];
updatedContent = updatedContent.replace(/^compatibility_date\s*=\s*["'][^"']*["']/m, `compatibility_date = "${compatibilityDate}"`);
// Remove old [build] section if it exists (not needed for Workers)
updatedContent = updatedContent.replace(/\n*\[build\]\s*\n\s*command\s*=\s*["'][^"']*["']\s*\n*/g, '\n');
await fs.writeFile(wranglerTomlPath, updatedContent, 'utf-8');
log.info(`📝 Updated wrangler.toml (Workers format, preserved custom settings)`);
} else {
// File doesn't exist, create new one with Workers format
await fs.writeFile(wranglerTomlPath, newConfig, 'utf-8');
log.info(`📝 Created wrangler.toml (Workers format)`);
}
} catch (error) {
log.error(`❌ Error updating wrangler.toml:`, error.message);
}
}
// Validation functions
function validateVercelConfig(redirects) {
const config = JSON.parse(generateVercelConfig(redirects));
// Validate redirects
for (const redirect of config.redirects) {
if (!redirect.source || !redirect.destination) {
throw new Error(`Invalid Vercel redirect: ${JSON.stringify(redirect)}`);
}
}
log.info('✅ Vercel configuration is valid');
return true;
}
function validateGitHubPagesConfig(redirects) {
const config = generateGitHubPagesConfig(redirects);
// Basic validation - check format (no '!' suffix - direct redirects)
const lines = config.trim().split('\n');
for (const line of lines) {
if (line.trim() && !line.match(/^[^\s]+\s+[^\s]+\s+\d+$/)) {
throw new Error(`Invalid GitHub Pages redirect format: ${line}`);
}
}
log.info('✅ GitHub Pages configuration is valid');
return true;
}
function validateNetlifyConfig(redirects) {
const config = generateNetlifyConfig(redirects);
// Basic validation - check TOML-like format
if (!config.includes('[[redirects]]')) {
throw new Error('Invalid Netlify configuration format');
}
log.info('✅ Netlify configuration is valid');
return true;
}
// Main function
async function generateRedirects() {
log.info(`🔄 Generating redirects from content aliases for ${DEPLOYMENT_PLATFORM}...`);
if (VALIDATE_ONLY) {
log.info('🔍 Validation mode - checking all platform configurations...');
}
// Get project name from package.json for Cloudflare Workers
const projectRoot = path.join(__dirname, '..');
let projectName = 'astro-modular';
try {
const packageJsonPath = path.join(projectRoot, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
projectName = packageJson.name || 'astro-modular';
} catch (error) {
// Use default if package.json can't be read
}
let allRedirects = [];
let totalProcessedFiles = 0;
// Process pages
const pagesPath = path.join(projectRoot, 'src/content/pages');
try {
await fs.access(pagesPath);
const pageResult = await processDirectory(pagesPath, false);
allRedirects = allRedirects.concat(pageResult.redirects);
totalProcessedFiles += pageResult.processedFiles;
} catch (error) {
// Pages directory doesn't exist, skipping
}
// Process posts
const postsPath = path.join(projectRoot, 'src/content/posts');
try {
await fs.access(postsPath);
const postResult = await processDirectory(postsPath, true);
allRedirects = allRedirects.concat(postResult.redirects);
totalProcessedFiles += postResult.processedFiles;
} catch (error) {
// Posts directory doesn't exist, skipping
}
// Process projects
const projectsPath = path.join(projectRoot, 'src/content/projects');
try {
await fs.access(projectsPath);
const projectResult = await processDirectory(projectsPath, false);
allRedirects = allRedirects.concat(projectResult.redirects);
totalProcessedFiles += projectResult.processedFiles;
} catch (error) {
// Projects directory doesn't exist, skipping
}
// Process docs
const docsPath = path.join(projectRoot, 'src/content/docs');
try {
await fs.access(docsPath);
const docResult = await processDirectory(docsPath, false);
allRedirects = allRedirects.concat(docResult.redirects);
totalProcessedFiles += docResult.processedFiles;
} catch (error) {
// Docs directory doesn't exist, skipping
}
if (allRedirects.length > 0) {
log.info(`📁 Processing pages directory...`);
log.info(`📁 Processing posts directory...`);
log.info(`📁 Processing projects directory...`);
log.info(`📁 Processing docs directory...`);
log.info(` Processed ${totalProcessedFiles} files with redirects`);
// Update Astro config with redirects (only used in dev mode - instant HTTP redirects)
// In production builds, redirects are set to {} to prevent HTML meta refresh files
await updateAstroConfig(allRedirects);
// Generate platform-specific configs
if (VALIDATE_ONLY) {
// Validate all platforms
validateVercelConfig(allRedirects);
validateGitHubPagesConfig(allRedirects);
validateNetlifyConfig(allRedirects);
log.info('🎉 All platform configurations are valid!');
return;
}
// Clean up files from other platforms before generating new config
// This ensures we only have config files for the selected platform
await cleanupOtherPlatformFiles(DEPLOYMENT_PLATFORM);
// Write platform-specific config - only generate files needed for the selected platform
// IMPORTANT: We use platform-specific redirect files (HTTP redirects) for production
// Astro config redirects are only active in dev mode (instant HTTP redirects)
// In production builds, Astro redirects are set to {} to prevent HTML meta refresh files
switch (DEPLOYMENT_PLATFORM) {
case 'vercel':
// Vercel: Uses vercel.json for instant HTTP redirects
// No Astro config needed - Vercel handles redirects at edge level
await writeVercelConfig(allRedirects);
break;
case 'github-pages':
// GitHub Pages: Uses _redirects file for instant HTTP redirects
// No Astro config needed - would create slow meta refresh HTML files
await writeGitHubPagesConfig(allRedirects);
break;
case 'cloudflare-workers':
// Cloudflare Workers: Uses _redirects file for instant HTTP redirects
// See migration guide: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
// No Astro config needed - would create slow meta refresh HTML files
// Generate Workers-compatible wrangler.toml (uses assets.directory instead of pages_build_output_dir)
await writeGitHubPagesConfig(allRedirects); // Uses same _redirects/_headers format
await writeCloudflareWorkersConfig(projectName); // Generate Workers-compatible config
await copyAssetsIgnoreFile(); // Copy .assetsignore to dist/ for Workers
break;
case 'netlify':
default:
// Netlify: Uses netlify.toml for instant HTTP redirects
// No Astro config needed - Netlify handles redirects at edge level
await writeNetlifyConfig(allRedirects);
break;
}
}
if (DRY_RUN) {
log.info('🎉 [DRY RUN] Redirect generation complete! No files were modified.');
} else {
log.info(`🎉 Redirect generation complete! Created ${allRedirects.length} redirects for ${DEPLOYMENT_PLATFORM}.`);
}
}
// Run the script
generateRedirects();

View file

@ -0,0 +1,537 @@
#!/usr/bin/env node
/**
* Graph Data Generation Script
*
* This script generates graph data for the local graph feature by analyzing
* post connections (both wikilinks and standard links) and tag relationships.
*
* The generated data includes:
* - Post nodes with metadata (title, slug, date, tags)
* - Tag nodes with metadata (name, color)
* - Connections between posts (direct links)
* - Connections between posts and tags (shared tags)
*
* This data is used by the LocalGraph component to render an Obsidian-like graph view.
*
* ID Generation Strategy:
* - Uses path-based IDs (no frontmatter required)
* - Single files: "my-post.md" ID: "my-post"
* - Folder-based: "my-folder/index.md" ID: "my-folder"
* - Nested content: "category/my-post.md" ID: "category-my-post"
*/
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
readdirSync,
statSync,
} from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectRoot = join(__dirname, "..");
// Configuration
const OUTPUT_DIR = join(projectRoot, "public", "graph");
const OUTPUT_FILE = join(OUTPUT_DIR, "graph-data.json");
/**
* Read maxNodes from config file
*/
function getMaxNodesFromConfig() {
try {
const configPath = join(projectRoot, "src", "config.ts");
const configContent = readFileSync(configPath, "utf-8");
// Extract maxNodes value from config
const maxNodesMatch = configContent.match(/maxNodes:\s*(\d+)/);
if (maxNodesMatch) {
return parseInt(maxNodesMatch[1], 10);
}
// Default fallback
return 100;
} catch (error) {
log.warn("Could not read config file, using default maxNodes: 100");
return 100;
}
}
// Simple logging utility
const isDev = process.env.NODE_ENV !== "production" && !process.argv.includes("--production");
const log = {
info: (...args) => isDev && console.log(...args),
error: (...args) => console.error(...args),
warn: (...args) => console.warn(...args),
};
// Ensure output directory exists
if (!existsSync(OUTPUT_DIR)) {
mkdirSync(OUTPUT_DIR, { recursive: true });
}
/**
* Generate a stable ID from file path
* @param {string} filePath - The file path
* @param {string} collectionType - The collection type (e.g., 'posts')
* @returns {string} - The generated ID
*/
function generateNodeId(filePath, collectionType) {
// Remove collection prefix and extension
let id = filePath.replace(`src/content/${collectionType}/`, "");
id = id.replace(".md", "");
id = id.replace("/index", ""); // Handle folder-based posts
// Clean up the ID: lowercase, replace spaces/special chars with hyphens
id = id.toLowerCase().replace(/[^a-z0-9-]/g, "-");
// Remove multiple consecutive hyphens
id = id.replace(/-+/g, "-");
// Remove leading/trailing hyphens
id = id.replace(/^-+|-+$/g, "");
return id;
}
/**
* Extract wikilinks from content (Obsidian-style)
*/
function extractWikilinks(content) {
const matches = [];
const wikilinkRegex = /!?\[\[([^\]]+)\]\]/g;
let match;
while ((match = wikilinkRegex.exec(content)) !== null) {
const [fullMatch, linkContent] = match;
const isImageWikilink = fullMatch.startsWith("!");
// Skip image wikilinks, only process link wikilinks
if (!isImageWikilink) {
const [link, displayText] = linkContent.includes("|")
? linkContent.split("|", 2)
: [linkContent, linkContent];
// Parse anchor if present
const anchorIndex = link.indexOf("#");
const baseLink =
anchorIndex === -1 ? link : link.substring(0, anchorIndex);
// Generate target ID from the link
const targetId = generateNodeId(baseLink, "posts");
matches.push({
link: baseLink,
display: displayText.trim(),
slug: targetId,
});
}
}
return matches;
}
/**
* Extract standard markdown links from content
*/
function extractStandardLinks(content) {
const matches = [];
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let match;
while ((match = markdownLinkRegex.exec(content)) !== null) {
const [fullMatch, displayText, url] = match;
// Check if this is an internal link
if (isInternalLink(url)) {
const { linkText } = extractLinkTextFromUrl(url);
if (linkText) {
// Only include posts in graph data - this includes:
// - posts/ prefixed links
// - /posts/ relative links
// - .md files (assumed to be posts)
// - Simple slugs (assumed to be posts for backward compatibility)
const isPostLink =
linkText.startsWith("posts/") ||
url.startsWith("/posts/") ||
url.startsWith("posts/") ||
url.endsWith(".md") ||
(!linkText.includes("/") && !url.startsWith("/"));
if (isPostLink) {
// Generate target ID from the link
const targetId = generateNodeId(linkText, "posts");
matches.push({
link: linkText,
display: displayText.trim(),
slug: targetId,
});
}
}
}
}
return matches;
}
/**
* Check if a URL is an internal link
*/
function isInternalLink(url) {
url = url.trim();
// Skip external URLs
if (url.startsWith("http://") || url.startsWith("https://")) {
return false;
}
// Skip email links
if (url.startsWith("mailto:")) {
return false;
}
// Skip anchors only
if (url.startsWith("#")) {
return false;
}
// Check if it's an internal link:
// - Ends with .md (markdown files)
// - Starts with /posts/ or posts/ (post relative URLs)
// - Is just a slug (no slashes) - assumes posts for backward compatibility
const isInternal =
url.endsWith(".md") ||
url.startsWith("/posts/") ||
url.startsWith("posts/") ||
!url.includes("/");
return isInternal;
}
/**
* Extract link text from URL
*/
function extractLinkTextFromUrl(url) {
url = url.trim();
// Parse anchor if present
const anchorIndex = url.indexOf("#");
const link = anchorIndex === -1 ? url : url.substring(0, anchorIndex);
const anchor = anchorIndex === -1 ? null : url.substring(anchorIndex + 1);
// Handle posts/ prefixed links
if (link.startsWith("posts/")) {
let linkText = link.replace("posts/", "").replace(/\.md$/, "");
// Remove /index for folder-based posts
if (linkText.endsWith("/index") && linkText.split("/").length === 2) {
linkText = linkText.replace("/index", "");
}
return {
linkText: linkText,
anchor: anchor,
};
}
// Handle /posts/ URLs (relative links)
if (link.startsWith("/posts/")) {
let linkText = link.replace("/posts/", "").replace(/\.md$/, "");
// Remove /index for folder-based posts
if (linkText.endsWith("/index") && linkText.split("/").length === 2) {
linkText = linkText.replace("/index", "");
}
return {
linkText: linkText,
anchor: anchor,
};
}
// Handle .md files
if (link.endsWith(".md")) {
let linkText = link.replace(/\.md$/, "");
// Remove /index for folder-based posts
if (linkText.endsWith("/index") && linkText.split("/").length === 1) {
linkText = linkText.replace("/index", "");
}
return {
linkText: linkText,
anchor: anchor,
};
}
// If it's just a slug (no slashes), use it directly
if (!link.includes("/")) {
return {
linkText: link,
anchor: anchor,
};
}
return { linkText: null, anchor: null };
}
/**
* Read and parse markdown files from content directory
*/
function readContentFiles(dirPath) {
const posts = [];
try {
const items = readdirSync(dirPath);
for (const item of items) {
const itemPath = join(dirPath, item);
const stat = statSync(itemPath);
if (stat.isDirectory()) {
// Handle folder-based posts
const indexPath = join(itemPath, "index.md");
if (existsSync(indexPath)) {
const content = readFileSync(indexPath, "utf-8");
const parsed = parseMarkdownFile(content, item);
if (parsed) {
posts.push(parsed);
}
}
} else if (item.endsWith(".md")) {
// Handle single-file posts
const content = readFileSync(itemPath, "utf-8");
const slug = item.replace(".md", "");
const parsed = parseMarkdownFile(content, slug);
if (parsed) {
posts.push(parsed);
}
}
}
} catch (error) {
log.error("Error reading content directory:", error);
}
return posts;
}
/**
* Parse markdown file and extract frontmatter and content
*/
function parseMarkdownFile(content, slug) {
try {
// Extract frontmatter (handle both \n and \r\n line endings)
const frontmatterMatch = content.match(
/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
);
if (!frontmatterMatch) {
return null;
}
const [, frontmatter, body] = frontmatterMatch;
const lines = frontmatter.split(/\r?\n/);
const data = {};
// Parse frontmatter (improved YAML parser)
let currentKey = null;
let currentArray = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
// Skip empty lines
if (!trimmedLine) continue;
// Check if this is a key-value pair
const colonIndex = line.indexOf(":");
if (colonIndex > 0 && !line.startsWith(" ")) {
// Save previous array if we have one
if (currentKey && currentArray.length > 0) {
data[currentKey] = [...currentArray];
currentArray = [];
}
const key = line.substring(0, colonIndex).trim();
let value = line.substring(colonIndex + 1).trim();
// Remove quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// Check if this is an array key (next line starts with dash)
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("- ")) {
currentKey = key;
currentArray = [];
} else {
// Single value
if (key === "date") {
data[key] = new Date(value);
} else if (key === "draft") {
data[key] = value === "true";
} else if (
key === "imageOG" ||
key === "hideCoverImage" ||
key === "noIndex" ||
key === "featured"
) {
data[key] = value === "true";
} else {
data[key] = value;
}
}
} else if (trimmedLine.startsWith("- ")) {
// This is an array item
const item = trimmedLine.substring(2).trim();
currentArray.push(item);
}
}
// Save final array if we have one
if (currentKey && currentArray.length > 0) {
data[currentKey] = [...currentArray];
}
return {
id: slug,
data,
body,
};
} catch (error) {
log.warn(`Error parsing file ${slug}:`, error.message);
return null;
}
}
/**
* Generate graph data from posts
*/
async function generateGraphData() {
log.info("🔍 Analyzing post connections...");
try {
// Get configuration values
const maxNodes = getMaxNodesFromConfig();
// Read all posts from the content directory
const postsDir = join(projectRoot, "src", "content", "posts");
log.info("📁 Reading posts from:", postsDir);
const posts = readContentFiles(postsDir);
log.info(`📄 Found ${posts.length} posts`);
// Filter out draft posts in production
const isDev = process.env.NODE_ENV !== "production" && !process.argv.includes("--production");
const visiblePosts = posts.filter((post) => isDev || !post.data.draft);
log.info(`📄 Processing ${visiblePosts.length} visible posts`);
// Generate nodes and connections
const nodes = [];
const connections = [];
// Process each post
for (const post of visiblePosts) {
// Add post node
const postNode = {
id: post.id,
type: "post",
title: post.data.title,
slug: post.id,
date: post.data.date
? post.data.date.toISOString()
: new Date().toISOString(),
connections: 0,
};
nodes.push(postNode);
// Extract links from post content
const wikilinks = extractWikilinks(post.body);
const standardLinks = extractStandardLinks(post.body);
const allLinks = [...wikilinks, ...standardLinks];
// Process links to other posts
for (const link of allLinks) {
const targetPost = visiblePosts.find((p) => p.id === link.slug);
if (targetPost && targetPost.id !== post.id) {
// Add post-to-post connection
connections.push({
source: post.id,
target: targetPost.id,
type: "link",
});
// Update connection counts
postNode.connections++;
const targetNode = nodes.find((n) => n.id === targetPost.id);
if (targetNode) {
targetNode.connections++;
}
}
}
}
// Apply maxNodes filtering if configured
let filteredNodes = nodes;
let filteredConnections = connections;
if (maxNodes && nodes.length > maxNodes) {
// Sort posts by connection count (descending), then by date (descending)
const sortedPosts = nodes.sort((a, b) => {
if (b.connections !== a.connections) {
return b.connections - a.connections;
}
return new Date(b.date) - new Date(a.date);
});
filteredNodes = sortedPosts.slice(0, maxNodes);
// Filter connections to only include those between selected nodes
const selectedNodeIds = new Set(filteredNodes.map((n) => n.id));
filteredConnections = connections.filter(
(conn) =>
selectedNodeIds.has(conn.source) && selectedNodeIds.has(conn.target)
);
}
// Remove date field from nodes before exporting (only needed for sorting)
const nodesForExport = filteredNodes.map(({ date, ...node }) => node);
// Generate graph data
const graphData = {
nodes: nodesForExport,
connections: filteredConnections,
metadata: {
totalPosts: filteredNodes.length,
totalConnections: filteredConnections.length,
maxNodesApplied: maxNodes && nodes.length > maxNodes,
originalNodeCount: nodes.length,
},
};
// Write graph data to file
writeFileSync(OUTPUT_FILE, JSON.stringify(graphData, null, 2));
log.info("✅ Graph data generated successfully!");
if (graphData.metadata.maxNodesApplied) {
log.info(
`📊 Stats: ${graphData.metadata.totalPosts} posts, ${graphData.metadata.totalConnections} connections (filtered from ${graphData.metadata.originalNodeCount} total nodes)`
);
} else {
log.info(
`📊 Stats: ${graphData.metadata.totalPosts} posts, ${graphData.metadata.totalConnections} connections`
);
}
log.info(`💾 Saved to: ${OUTPUT_FILE}`);
} catch (error) {
log.error("❌ Error generating graph data:", error);
process.exit(1);
}
}
// Run the script
generateGraphData();

View file

@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* Get deployment platform from config
* This script reads the deployment platform from src/config.ts
*/
import { readFileSync } from 'fs';
import { join } from 'path';
function getDeploymentPlatform() {
try {
const configPath = join(process.cwd(), 'src', 'config.ts');
const configContent = readFileSync(configPath, 'utf8');
// Extract platform from config
const platformMatch = configContent.match(/platform:\s*["']([^"']+)["']/);
if (platformMatch) {
return platformMatch[1];
}
// Fallback to environment variable
return process.env.DEPLOYMENT_PLATFORM || 'netlify';
} catch (error) {
console.error('Error reading config:', error.message);
return process.env.DEPLOYMENT_PLATFORM || 'netlify';
}
}
// Export for use in other scripts
export default getDeploymentPlatform;
// If run directly, output the platform
if (import.meta.url === `file://${process.argv[1]}`) {
console.log(getDeploymentPlatform());
}

73
scripts/get-version.js Normal file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Get version information for Astro Modular theme
* This script provides version information for use in other scripts
*/
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
/**
* Get the current theme version
*/
export function getThemeVersion() {
try {
// Try to read from VERSION file first
const versionFile = join(process.cwd(), 'VERSION');
const version = readFileSync(versionFile, 'utf8').trim();
return version;
} catch (error) {
try {
// Fallback to package.json
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
return packageJson.version || '0.1.0';
} catch (error) {
return '0.1.0';
}
}
}
/**
* Get the theme name
*/
export function getThemeName() {
try {
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
return packageJson.name || 'astro-modular';
} catch (error) {
return 'astro-modular';
}
}
/**
* Get the full theme identifier (name@version)
*/
export function getThemeIdentifier() {
return `${getThemeName()}@${getThemeVersion()}`;
}
/**
* Update the version in both VERSION file and package.json
*/
export function updateVersion(newVersion) {
try {
// Update VERSION file
writeFileSync('VERSION', newVersion + '\n');
// Update package.json
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
packageJson.version = newVersion;
writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n');
return true;
} catch (error) {
console.error('Failed to update version:', error.message);
return false;
}
}
// If run directly, output the version
if (process.argv[1] && process.argv[1].endsWith('get-version.js')) {
console.log(getThemeVersion());
}

337
scripts/process-aliases.js Normal file
View file

@ -0,0 +1,337 @@
#!/usr/bin/env node
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Simple logging utility
const isDev = process.env.NODE_ENV !== 'production';
const log = {
info: (...args) => isDev && console.log(...args),
error: (...args) => console.error(...args),
warn: (...args) => console.warn(...args)
};
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Define content directories to process
const CONTENT_DIRS = [
'src/content/pages',
'src/content/posts',
'src/content/projects',
'src/content/docs'
];
// Function to parse frontmatter from markdown content
function parseFrontmatter(content) {
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
return { frontmatter: null, content: content };
}
const frontmatterText = match[1];
const body = match[2];
// Parse YAML-like frontmatter (simple parser)
const frontmatter = {};
const lines = frontmatterText.split('\n');
let currentKey = null;
let currentValue = [];
let inArray = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) {
continue;
}
if (trimmed.includes(':') && (!inArray || !trimmed.startsWith(' '))) {
// Save previous key-value pair
if (currentKey) {
if (currentValue.length === 1) {
frontmatter[currentKey] = currentValue[0];
} else {
frontmatter[currentKey] = currentValue;
}
}
// Start new key-value pair
const colonIndex = trimmed.indexOf(':');
currentKey = trimmed.substring(0, colonIndex).trim();
const value = trimmed.substring(colonIndex + 1).trim();
// Reset array state when starting a new key
inArray = false;
if (value.startsWith('[')) {
// Array value
inArray = true;
currentValue = [];
if (value !== '[') {
// Single line array
const arrayContent = value.substring(1, value.endsWith(']') ? value.length - 1 : value.length);
if (arrayContent.trim()) {
currentValue = arrayContent.split(',').map(item => {
const trimmed = item.trim();
if (trimmed.startsWith('"[[') && trimmed.endsWith(']]"')) {
// Keep quotes for Obsidian bracket syntax
return trimmed;
} else {
// Remove quotes for regular values
return trimmed.replace(/^["']|["']$/g, '');
}
});
}
inArray = false;
}
} else if (value) {
// Single value - preserve quotes for Obsidian bracket syntax
if (value.startsWith('"[[') && value.endsWith(']]"')) {
// Keep quotes for Obsidian bracket syntax
currentValue = [value];
} else {
// Remove quotes for regular values
currentValue = [value.replace(/^["']|["']$/g, '')];
}
} else {
// Empty value, might be start of array
currentValue = [];
inArray = true;
}
} else if (inArray && trimmed.startsWith('-')) {
// Array item - preserve quotes for Obsidian bracket syntax
const item = trimmed.substring(1).trim();
if (item.startsWith('"[[') && item.endsWith(']]"')) {
// Keep quotes for Obsidian bracket syntax
currentValue.push(item);
} else {
// Remove quotes for regular values
currentValue.push(item.replace(/^["']|["']$/g, ''));
}
} else if (inArray && trimmed === ']') {
// End of array
inArray = false;
} else if (currentKey && !inArray) {
// Continuation of single value
currentValue = [currentValue[0] + ' ' + trimmed];
}
}
// Save last key-value pair
if (currentKey) {
if (currentValue.length === 1) {
frontmatter[currentKey] = currentValue[0];
} else {
frontmatter[currentKey] = currentValue;
}
}
return { frontmatter, content: body };
}
// Function to convert frontmatter back to YAML string
function frontmatterToString(frontmatter) {
const lines = ['---'];
for (const [key, value] of Object.entries(frontmatter)) {
if (Array.isArray(value)) {
lines.push(`${key}:`);
for (const item of value) {
// Preserve quotes for Obsidian bracket syntax in arrays
if (typeof item === 'string' && item.startsWith('"[[') && item.endsWith(']]"')) {
lines.push(` - ${item}`);
} else {
lines.push(` - ${item}`);
}
}
} else {
// Preserve quotes for Obsidian bracket syntax in single values
if (typeof value === 'string' && value.startsWith('"[[') && value.endsWith(']]"')) {
lines.push(`${key}: ${value}`);
} else {
lines.push(`${key}: ${value}`);
}
}
}
lines.push('---');
return lines.join('\n');
}
// Function to get the current slug from a file path
function getCurrentSlug(filePath) {
// Normalize path separators
const normalizedPath = filePath.replace(/\\/g, '/');
// Extract slug based on content type
if (normalizedPath.includes('/posts/')) {
let postPath = normalizedPath.replace(/^.*\/posts\//, '').replace(/\.md$/, '');
if (postPath.endsWith('/index')) {
postPath = postPath.replace('/index', '');
}
return postPath;
} else if (normalizedPath.includes('/projects/')) {
let projectPath = normalizedPath.replace(/^.*\/projects\//, '').replace(/\.md$/, '');
if (projectPath.endsWith('/index')) {
projectPath = projectPath.replace('/index', '');
}
return projectPath;
} else if (normalizedPath.includes('/docs/')) {
let docPath = normalizedPath.replace(/^.*\/docs\//, '').replace(/\.md$/, '');
if (docPath.endsWith('/index')) {
docPath = docPath.replace('/index', '');
}
return docPath;
} else if (normalizedPath.includes('/pages/')) {
let pagePath = normalizedPath.replace(/^.*\/pages\//, '').replace(/\.md$/, '');
if (pagePath.endsWith('/index')) {
pagePath = pagePath.replace('/index', '');
}
if (pagePath === 'index') {
return 'index';
}
return pagePath;
}
return null;
}
// Function to process a single markdown file
async function processMarkdownFile(filePath) {
try {
const content = await fs.readFile(filePath, 'utf-8');
const { frontmatter, content: body } = parseFrontmatter(content);
if (!frontmatter || !frontmatter.aliases) {
return false; // No aliases to process
}
// Get the current slug for this file
const currentSlug = getCurrentSlug(filePath);
// Ensure aliases is an array
const aliasesArray = Array.isArray(frontmatter.aliases)
? frontmatter.aliases
: [frontmatter.aliases];
// Ensure aliases are in the correct format (no leading slash) and filter out self-aliases
const cleanAliases = aliasesArray
.map(alias => alias.startsWith('/') ? alias.substring(1) : alias)
.filter(alias => {
// Remove aliases that match the current slug (prevents self-redirects)
if (currentSlug && alias === currentSlug) {
log.warn(`⚠️ Removing self-alias "${alias}" from ${filePath} (matches current slug)`);
return false;
}
return true;
});
// If no valid aliases remain, remove the aliases field entirely
if (cleanAliases.length === 0) {
delete frontmatter.aliases;
} else {
// Update frontmatter with cleaned aliases
frontmatter.aliases = cleanAliases.length === 1 ? cleanAliases[0] : cleanAliases;
}
// Only write back if we made changes (removed aliases or cleaned them)
const originalAliasesCount = aliasesArray.length;
const hadChanges = cleanAliases.length !== originalAliasesCount ||
(cleanAliases.length === 0 && originalAliasesCount > 0);
if (hadChanges) {
// Rebuild the file content
const newFrontmatter = frontmatterToString(frontmatter);
const newContent = `${newFrontmatter}\n${body}`;
// Write back to file
await fs.writeFile(filePath, newContent, 'utf-8');
}
return { processed: hadChanges, aliases: cleanAliases.length };
} catch (error) {
log.error(`❌ Error processing ${filePath}:`, error.message);
return { processed: false, aliases: 0 };
}
}
// Function to process all markdown files in a directory (including folder-based content)
async function processDirectory(dirPath) {
try {
const files = await fs.readdir(dirPath, { withFileTypes: true });
let processedCount = 0;
let totalAliases = 0;
for (const file of files) {
if (file.isDirectory()) {
// Handle folder-based content (e.g., folder-name/index.md)
const folderPath = path.join(dirPath, file.name);
try {
const indexPath = path.join(folderPath, 'index.md');
await fs.access(indexPath);
const result = await processMarkdownFile(indexPath);
if (result.processed) {
processedCount++;
totalAliases += result.aliases;
}
} catch (error) {
// index.md doesn't exist in this folder, skip
}
} else if (file.isFile() && file.name.endsWith('.md')) {
// Handle single-file content
const filePath = path.join(dirPath, file.name);
const result = await processMarkdownFile(filePath);
if (result.processed) {
processedCount++;
totalAliases += result.aliases;
}
}
}
return { processedCount, totalAliases };
} catch (error) {
log.error(`❌ Error processing directory ${dirPath}:`, error.message);
return { processedCount: 0, totalAliases: 0 };
}
}
// Main function
async function processAllAliases() {
log.info('🔄 Processing aliases and converting to redirect_from...');
const projectRoot = path.join(__dirname, '..');
let totalProcessed = 0;
let totalAliases = 0;
for (const dir of CONTENT_DIRS) {
const fullPath = path.join(projectRoot, dir);
try {
await fs.access(fullPath);
const result = await processDirectory(fullPath);
totalProcessed += result.processedCount;
totalAliases += result.totalAliases;
} catch (error) {
if (error.code !== 'ENOENT') {
log.error(`❌ Error accessing directory ${dir}:`, error.message);
}
}
}
if (totalProcessed > 0) {
log.info(`📁 Processing pages directory...`);
log.info(`📁 Processing posts directory...`);
log.info(`📁 Processing projects directory...`);
log.info(`📁 Processing docs directory...`);
log.info(` Processed ${totalProcessed} files with ${totalAliases} aliases`);
}
log.info(`🎉 Alias processing complete! Processed ${totalProcessed} files.`);
}
// Run the script
processAllAliases();

38
scripts/setup-dev.mjs Normal file
View file

@ -0,0 +1,38 @@
import { existsSync, rmSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const astroCacheDir = path.join(projectRoot, '.astro');
const viteCacheDir = path.join(projectRoot, 'node_modules', '.vite');
console.log('⚙️ Setting up development environment...');
// Wipe .astro completely so content layer and route cache are fresh every dev run
try {
if (existsSync(astroCacheDir)) {
console.log(`Cleaning .astro cache: ${astroCacheDir}`);
rmSync(astroCacheDir, { recursive: true, force: true });
}
mkdirSync(astroCacheDir, { recursive: true });
} catch (error) {
console.error(`Failed to clean .astro: ${error.message}`);
}
// Wipe Vite deps cache so modules are re-resolved
try {
const viteDeps = path.join(viteCacheDir, 'deps');
if (existsSync(viteDeps)) {
console.log(`Cleaning Vite deps cache: ${viteDeps}`);
rmSync(viteDeps, { recursive: true, force: true });
}
if (!existsSync(viteCacheDir)) {
mkdirSync(viteCacheDir, { recursive: true });
}
} catch (error) {
console.error(`Failed to clean Vite cache: ${error.message}`);
}
console.log('✅ Development environment setup complete.');

378
scripts/sync-images.js Normal file
View file

@ -0,0 +1,378 @@
#!/usr/bin/env node
import { promises as fs } from 'fs';
import path from 'path';
import sharp from 'sharp';
// Simple logging utility
const isDev = process.env.NODE_ENV !== 'production';
const log = {
info: (...args) => isDev && console.log(...args),
error: (...args) => console.error(...args),
warn: (...args) => console.warn(...args)
};
// Define source and target directories for posts, pages, projects, docs, and special
const IMAGE_SYNC_CONFIGS = [
{
source: 'src/content/posts/attachments',
target: 'public/posts/attachments',
name: 'posts'
},
{
source: 'src/content/pages/attachments',
target: 'public/pages/attachments',
name: 'pages'
},
{
source: 'src/content/projects/attachments',
target: 'public/projects/attachments',
name: 'projects'
},
{
source: 'src/content/docs/attachments',
target: 'public/docs/attachments',
name: 'docs'
},
{
source: 'src/content/special/attachments',
target: 'public/special/attachments',
name: 'special'
}
];
// Recursively find all media files in a directory (images, audio, video, PDF)
async function findImageFiles(dir, relativePath = '') {
const imageFiles = [];
try {
const items = await fs.readdir(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const itemRelativePath = path.join(relativePath, item);
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
// Recursively search subdirectories
const subImages = await findImageFiles(itemPath, itemRelativePath);
imageFiles.push(...subImages);
} else if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|tif|ico|mp3|wav|ogg|m4a|3gp|flac|aac|mp4|webm|ogv|mov|mkv|avi|pdf)$/i.test(item)) {
// Skip WebP files if we already have the original (to avoid duplicate processing)
// We'll generate WebP from originals
if (item.toLowerCase().endsWith('.webp')) {
const originalName = item.replace(/\.webp$/i, '');
const hasOriginal = items.some(i => {
const nameWithoutExt = i.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '');
return nameWithoutExt === originalName && /\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(i);
});
if (hasOriginal) {
// Skip WebP if we have the original - we'll generate it
continue;
}
}
imageFiles.push({
sourcePath: itemPath,
relativePath: itemRelativePath
});
}
}
} catch (error) {
// Directory might not exist or be readable, continue
if (error.code !== 'ENOENT') {
log.warn(`Warning: Could not read directory ${dir}:`, error.message);
}
}
return imageFiles;
}
// Function to find folder-based content and sync their images
async function syncFolderBasedImages(contentType) {
const contentDir = `src/content/${contentType}`;
const publicContentDir = `public/${contentType}`;
try {
const items = await fs.readdir(contentDir);
let totalSynced = 0;
let totalSkipped = 0;
for (const item of items) {
const itemPath = path.join(contentDir, item);
const stat = await fs.stat(itemPath);
// Check if it's a directory (folder-based content)
if (stat.isDirectory()) {
const targetDir = path.join(publicContentDir, item);
// Find all media files recursively
const imageFiles = await findImageFiles(itemPath);
for (const imageFile of imageFiles) {
// Handle attachments subfolder within folder-based content
// Convert src/content/posts/post-name/attachments/image.png -> public/posts/post-name/image.png
let targetRelativePath = imageFile.relativePath;
// Handle both forward and backward slashes for cross-platform compatibility
if (targetRelativePath.startsWith('attachments/') || targetRelativePath.startsWith('attachments\\')) {
targetRelativePath = targetRelativePath.replace(/^attachments[/\\]/, '');
}
const targetPath = path.join(targetDir, targetRelativePath);
const targetDirForFile = path.dirname(targetPath);
// Ensure target directory exists
await ensureDir(targetDirForFile);
// Check if file needs updating - check both original and WebP versions
let needsUpdate = true;
// Check if this is an image that would be converted to WebP
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
const webpPath = path.join(targetDir, targetRelativePath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp'));
try {
const sourceStats = await fs.stat(imageFile.sourcePath);
const webpStats = await fs.stat(webpPath);
// Only update if source is newer than WebP
needsUpdate = sourceStats.mtime > webpStats.mtime;
} catch {
// WebP doesn't exist, needs update
needsUpdate = true;
}
} else {
try {
const sourceStats = await fs.stat(imageFile.sourcePath);
const targetStats = await fs.stat(targetPath);
// Only update if source is newer or different size
needsUpdate = sourceStats.mtime > targetStats.mtime || sourceStats.size !== targetStats.size;
} catch {
// Target doesn't exist, definitely needs update
needsUpdate = true;
}
}
if (needsUpdate) {
// Optimize image if it's an image format (not audio, video, or PDF)
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
try {
// Convert to WebP and optimize
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
await sharp(imageFile.sourcePath)
.webp({ quality: 85 })
.toFile(webpPath);
totalSynced++;
} catch (error) {
// If Sharp fails, fall back to copying original
log.warn(`⚠️ Could not optimize ${imageFile.relativePath}, copying original:`, error.message);
await fs.copyFile(imageFile.sourcePath, targetPath);
totalSynced++;
}
} else {
// Non-image files (audio, video, PDF) - just copy as-is
await fs.copyFile(imageFile.sourcePath, targetPath);
totalSynced++;
}
} else {
totalSkipped++;
}
}
}
}
if (totalSynced > 0 || totalSkipped > 0) {
log.info(`📁 Syncing folder-based ${contentType} images...`);
if (totalSynced > 0) log.info(` Synced ${totalSynced} files`);
if (totalSkipped > 0) log.info(` Skipped ${totalSkipped} files that were unchanged`);
}
return { synced: totalSynced, skipped: totalSkipped };
} catch (error) {
log.error(`❌ Error syncing folder-based ${contentType} images:`, error);
return { synced: 0, skipped: 0 };
}
}
async function ensureDir(dir) {
try {
await fs.access(dir);
} catch {
await fs.mkdir(dir, { recursive: true });
}
}
async function syncImagesForConfig(config) {
// Ensure target directory exists
await ensureDir(config.target);
try {
// Check if source directory exists
let sourceFiles = [];
try {
sourceFiles = await fs.readdir(config.source);
} catch (error) {
if (error.code === 'ENOENT') {
return { synced: 0, skipped: 0, removed: 0 };
}
throw error;
}
// Find all image files recursively (including subdirectories)
const imageFiles = await findImageFiles(config.source);
let synced = 0;
let skipped = 0;
for (const imageFile of imageFiles) {
const targetPath = path.join(config.target, imageFile.relativePath);
const targetDirForFile = path.dirname(targetPath);
// Ensure target directory exists (including subdirectories)
await ensureDir(targetDirForFile);
// Check if file needs updating - check both original and WebP versions
let needsUpdate = true;
// Check if this is an image that would be converted to WebP
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
try {
const sourceStats = await fs.stat(imageFile.sourcePath);
const webpStats = await fs.stat(webpPath);
// Only update if source is newer than WebP
needsUpdate = sourceStats.mtime > webpStats.mtime;
} catch {
// WebP doesn't exist, needs update
needsUpdate = true;
}
} else {
try {
const sourceStats = await fs.stat(imageFile.sourcePath);
const targetStats = await fs.stat(targetPath);
// Only update if source is newer or different size
needsUpdate = sourceStats.mtime > targetStats.mtime || sourceStats.size !== targetStats.size;
} catch {
// Target doesn't exist, definitely needs update
needsUpdate = true;
}
}
if (needsUpdate) {
// Optimize image if it's an image format (not audio, video, or PDF)
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
try {
// Convert to WebP and optimize
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
await sharp(imageFile.sourcePath)
.webp({ quality: 85 })
.toFile(webpPath);
synced++;
} catch (error) {
// If Sharp fails, fall back to copying original
log.warn(`⚠️ Could not optimize ${imageFile.relativePath}, copying original:`, error.message);
await fs.copyFile(imageFile.sourcePath, targetPath);
synced++;
}
} else {
// Non-image files (audio, video, PDF) - just copy as-is
await fs.copyFile(imageFile.sourcePath, targetPath);
synced++;
}
} else {
skipped++;
}
}
// Cleanup: Remove files from target that no longer exist in source
// We need to recursively clean up the target directory
await cleanupTargetDirectory(config.target, imageFiles);
return { synced, skipped, removed: 0 }; // removed count handled in cleanup function
} catch (error) {
log.error(`❌ Error syncing ${config.name} images:`, error);
process.exit(1);
}
}
// Recursively clean up target directory, removing files that no longer exist in source
async function cleanupTargetDirectory(targetDir, sourceImageFiles) {
// Create a set that maps both original paths and attachments subfolder paths
const sourceFileSet = new Set();
sourceImageFiles.forEach(f => {
sourceFileSet.add(f.relativePath);
// Also add the path without attachments/ prefix for cleanup
if (f.relativePath.startsWith('attachments/') || f.relativePath.startsWith('attachments\\')) {
sourceFileSet.add(f.relativePath.replace(/^attachments[/\\]/, ''));
}
// Add WebP version of image paths (since we generate WebP from originals)
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(f.relativePath)) {
const webpPath = f.relativePath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
sourceFileSet.add(webpPath);
if (f.relativePath.startsWith('attachments/') || f.relativePath.startsWith('attachments\\')) {
const webpPathNoAttachments = webpPath.replace(/^attachments[/\\]/, '');
sourceFileSet.add(webpPathNoAttachments);
}
}
});
let removed = 0;
try {
const items = await fs.readdir(targetDir);
for (const item of items) {
const itemPath = path.join(targetDir, item);
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
// Recursively clean subdirectories
const subRemoved = await cleanupTargetDirectory(itemPath, sourceImageFiles);
removed += subRemoved;
// Remove empty directories
try {
const remainingItems = await fs.readdir(itemPath);
if (remainingItems.length === 0) {
await fs.rmdir(itemPath);
}
} catch {
// Directory might not be empty or might have been removed already
}
} else {
// Check if this file exists in source
const relativePath = path.relative(targetDir, itemPath).replace(/\\/g, '/');
if (!sourceFileSet.has(relativePath)) {
await fs.unlink(itemPath);
removed++;
}
}
}
} catch (error) {
// Directory might not exist or be readable
if (error.code !== 'ENOENT') {
log.warn(`Warning: Could not clean directory ${targetDir}:`, error.message);
}
}
return removed;
}
async function syncAllImages() {
log.info('🖼️ Syncing images from content to public directory...');
for (const config of IMAGE_SYNC_CONFIGS) {
const result = await syncImagesForConfig(config);
if (result.synced > 0 || result.skipped > 0) {
log.info(`📁 Syncing ${config.name} images...`);
if (result.synced > 0) log.info(` Synced ${result.synced} files`);
if (result.skipped > 0) log.info(` Skipped ${result.skipped} files that were unchanged`);
}
}
// Sync folder-based images for all content types
const contentTypes = ['posts', 'pages', 'projects', 'docs', 'special'];
for (const contentType of contentTypes) {
await syncFolderBasedImages(contentType);
}
log.info('🎉 Image sync complete!');
}
syncAllImages();

317
scripts/update.mjs Normal file
View file

@ -0,0 +1,317 @@
#!/usr/bin/env node
/**
* Astro Modular Update Script
*
* Downloads the latest release from GitHub, replaces framework files,
* and preserves user content and assets. After updating, open Obsidian
* and click "Apply all settings" in the Astro Modular Settings plugin
* to rewrite your config.ts with your saved settings.
*
* Usage: pnpm run update
*/
import { execSync } from 'child_process';
import { createWriteStream, existsSync, mkdirSync, cpSync, rmSync, readFileSync, readdirSync, statSync, renameSync, writeFileSync } from 'fs';
import { join, resolve, basename } from 'path';
import { tmpdir } from 'os';
import { get as httpsGet } from 'https';
import { pipeline } from 'stream/promises';
const ROOT = resolve(import.meta.dirname, '..');
const REPO = 'davidvkimball/astro-modular';
// Files and directories that belong to the USER and must be preserved
const USER_PATHS = [
'src/content', // All user content + .obsidian vault (plugin data.json files)
'public/profile.jpg',
'public/profile.png',
'public/profile.webp',
'public/favicon.png',
'public/favicon-dark.png',
'public/favicon-light.png',
'public/open-graph.png',
'.env',
'.env.local',
'.env.production',
];
// Directories that should never be touched
const SKIP_PATHS = [
'node_modules',
'.git',
'.astro',
'.netlify',
];
// ── Helpers ─────────────────────────────────────────────────────────────────
function log(msg) { console.log(` ${msg}`); }
function logStep(msg) { console.log(`\n> ${msg}`); }
function logError(msg) { console.error(`\n ERROR: ${msg}`); }
function getCurrentVersion() {
const versionFile = join(ROOT, 'VERSION');
if (existsSync(versionFile)) {
return readFileSync(versionFile, 'utf-8').trim();
}
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
return pkg.version;
}
async function fetchJSON(url) {
return new Promise((resolve, reject) => {
httpsGet(url, { headers: { 'User-Agent': 'astro-modular-updater' } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return fetchJSON(res.headers.location).then(resolve, reject);
}
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { reject(new Error(`Invalid JSON from ${url}`)); }
});
res.on('error', reject);
}).on('error', reject);
});
}
async function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
httpsGet(url, { headers: { 'User-Agent': 'astro-modular-updater' } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return downloadFile(res.headers.location, dest).then(resolve, reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
}
const ws = createWriteStream(dest);
pipeline(res, ws).then(resolve, reject);
}).on('error', reject);
});
}
function copyIfExists(src, dest) {
if (!existsSync(src)) return false;
const stat = statSync(src);
if (stat.isDirectory()) {
cpSync(src, dest, { recursive: true });
} else {
const dir = join(dest, '..');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
cpSync(src, dest);
}
return true;
}
// ── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log('\nAstro Modular Updater');
console.log('====================');
// 1. Get current version
const currentVersion = getCurrentVersion();
log(`Current version: ${currentVersion}`);
// 2. Check latest release
logStep('Checking for updates...');
let release;
try {
release = await fetchJSON(`https://api.github.com/repos/${REPO}/releases/latest`);
} catch (err) {
logError(`Could not reach GitHub: ${err.message}`);
process.exit(1);
}
const latestVersion = release.tag_name.replace(/^v/, '');
log(`Latest version: ${latestVersion}`);
if (currentVersion === latestVersion) {
log('Already up to date!');
process.exit(0);
}
// 3. Download release archive
logStep(`Downloading v${latestVersion}...`);
const isWindows = process.platform === 'win32';
const archiveUrl = isWindows ? release.zipball_url : release.tarball_url;
const tempDir = join(tmpdir(), `astro-modular-update-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
const archivePath = join(tempDir, isWindows ? 'release.zip' : 'release.tar.gz');
try {
await downloadFile(archiveUrl, archivePath);
} catch (err) {
logError(`Download failed: ${err.message}`);
rmSync(tempDir, { recursive: true, force: true });
process.exit(1);
}
log(`Downloaded to temp directory.`);
// 4. Extract archive
logStep('Extracting...');
const extractDir = join(tempDir, 'extracted');
mkdirSync(extractDir, { recursive: true });
try {
if (isWindows) {
execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`, { stdio: 'pipe' });
} else {
execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, { stdio: 'pipe' });
}
} catch (err) {
logError(`Extraction failed: ${err.message}`);
rmSync(tempDir, { recursive: true, force: true });
process.exit(1);
}
// GitHub tarballs extract into a directory like "owner-repo-hash/"
const extractedContents = readdirSync(extractDir);
if (extractedContents.length !== 1) {
logError('Unexpected archive structure.');
rmSync(tempDir, { recursive: true, force: true });
process.exit(1);
}
const sourceDir = join(extractDir, extractedContents[0]);
log('Extracted successfully.');
// 5. Back up user files
logStep('Backing up user content...');
const backupDir = join(tempDir, 'backup');
mkdirSync(backupDir, { recursive: true });
let backedUp = 0;
for (const userPath of USER_PATHS) {
const src = join(ROOT, userPath);
const dest = join(backupDir, userPath);
if (copyIfExists(src, dest)) {
log(` Backed up: ${userPath}`);
backedUp++;
}
}
// Also back up any user-added public/ files not in the new release
const publicDir = join(ROOT, 'public');
const newPublicDir = join(sourceDir, 'public');
if (existsSync(publicDir)) {
for (const file of readdirSync(publicDir)) {
const fullPath = join(publicDir, file);
if (statSync(fullPath).isFile()) {
const alreadyBacked = USER_PATHS.some(p => p === `public/${file}`);
if (!alreadyBacked) {
// Check if this file does NOT exist in the new release (user-added)
if (!existsSync(join(newPublicDir, file))) {
const dest = join(backupDir, 'public', file);
mkdirSync(join(backupDir, 'public'), { recursive: true });
cpSync(fullPath, dest);
log(` Backed up (user asset): public/${file}`);
backedUp++;
}
}
}
}
}
log(`${backedUp} item(s) backed up.`);
// 6. Replace framework files
logStep('Updating framework files...');
// Get list of all paths in the new release (excluding user/skip paths)
function listPaths(dir, base = '') {
const results = [];
for (const entry of readdirSync(dir)) {
const rel = base ? `${base}/${entry}` : entry;
const full = join(dir, entry);
// Skip paths we should never touch
if (SKIP_PATHS.includes(rel)) continue;
// Skip user content paths (we'll restore from backup)
if (USER_PATHS.includes(rel)) continue;
if (statSync(full).isDirectory()) {
// If it's a user directory root, skip recursing
if (USER_PATHS.some(p => p.startsWith(rel + '/'))) {
// Only skip exact user-path prefixes like src/content
if (USER_PATHS.includes(rel)) continue;
results.push(...listPaths(full, rel));
} else {
results.push(...listPaths(full, rel));
}
} else {
results.push(rel);
}
}
return results;
}
const newFiles = listPaths(sourceDir);
let updated = 0;
for (const relPath of newFiles) {
const src = join(sourceDir, relPath);
const dest = join(ROOT, relPath);
const destDir = join(dest, '..');
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
cpSync(src, dest);
updated++;
}
log(`${updated} framework file(s) updated.`);
// 7. Restore user files from backup
logStep('Restoring user content...');
let restored = 0;
for (const userPath of USER_PATHS) {
const src = join(backupDir, userPath);
const dest = join(ROOT, userPath);
if (copyIfExists(src, dest)) {
log(` Restored: ${userPath}`);
restored++;
}
}
// Restore user-added public/ files
const backupPublicDir = join(backupDir, 'public');
if (existsSync(backupPublicDir)) {
for (const file of readdirSync(backupPublicDir)) {
const alreadyRestored = USER_PATHS.some(p => p === `public/${file}`);
if (!alreadyRestored) {
cpSync(join(backupPublicDir, file), join(ROOT, 'public', file));
log(` Restored (user asset): public/${file}`);
restored++;
}
}
}
log(`${restored} item(s) restored.`);
// 8. Install dependencies
logStep('Installing dependencies...');
try {
execSync('pnpm install', { cwd: ROOT, stdio: 'inherit' });
} catch {
logError('pnpm install failed. You may need to run it manually.');
}
// 9. Clean up temp directory
rmSync(tempDir, { recursive: true, force: true });
// 10. Done
console.log('\n====================================');
console.log(` Updated to v${latestVersion}!`);
console.log('====================================');
console.log('\n Next steps:');
console.log(' 1. Open your vault in Obsidian');
console.log(' 2. Go to Astro Modular Settings > Advanced tab');
console.log(' 3. Click "Apply to config.ts" under "Apply all settings"');
console.log(' to write your saved settings to the new config.ts');
console.log(' 4. Run `pnpm dev` to verify everything works');
console.log('');
}
main().catch(err => {
logError(err.message);
process.exit(1);
});