Initial commit with stock files
This commit is contained in:
commit
8c648aa559
336 changed files with 138234 additions and 0 deletions
233
scripts/check-missing-images.js
Normal file
233
scripts/check-missing-images.js
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to check for missing images in content
|
||||
* Run with: node scripts/check-missing-images.js
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
|
||||
// Get all markdown files in content directory
|
||||
function getAllMarkdownFiles(dir) {
|
||||
const files = [];
|
||||
const items = fs.readdirSync(dir);
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...getAllMarkdownFiles(fullPath));
|
||||
} else if (item.endsWith('.md')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// Extract image references from markdown content
|
||||
function extractImageReferences(content) {
|
||||
const images = [];
|
||||
|
||||
// Match markdown images 
|
||||
const markdownImages = content.match(/!\[([^\]]*)\]\(([^)]+)\)/g) || [];
|
||||
for (const match of markdownImages) {
|
||||
const src = match.match(/!\[[^\]]*\]\(([^)]+)\)/)[1];
|
||||
images.push({ type: 'markdown', src, line: content.substring(0, content.indexOf(match)).split('\n').length });
|
||||
}
|
||||
|
||||
// Match wikilink images ![[src]]
|
||||
const wikilinkImages = content.match(/!\[\[([^\]]+)\]\]/g) || [];
|
||||
for (const match of wikilinkImages) {
|
||||
const src = match.match(/!\[\[([^\]]+)\]\]/)[1];
|
||||
images.push({ type: 'wikilink', src, line: content.substring(0, content.indexOf(match)).split('\n').length });
|
||||
}
|
||||
|
||||
// Match frontmatter images
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (frontmatterMatch) {
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const imageMatch = frontmatter.match(/^image:\s*["']?([^"'\n]+)["']?/m);
|
||||
if (imageMatch) {
|
||||
images.push({ type: 'frontmatter', src: imageMatch[1], line: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
// Check if image exists
|
||||
function checkImageExists(imageSrc, filePath) {
|
||||
// Handle different image path formats
|
||||
let imagePath = imageSrc;
|
||||
|
||||
// Remove Obsidian brackets
|
||||
if (imagePath.startsWith('[[') && imagePath.endsWith(']]')) {
|
||||
imagePath = imagePath.slice(2, -2);
|
||||
}
|
||||
|
||||
// Determine content type and folder structure
|
||||
const isPostsFile = filePath.includes('posts');
|
||||
const isPagesFile = filePath.includes('pages');
|
||||
const isProjectsFile = filePath.includes('projects');
|
||||
const isDocsFile = filePath.includes('docs');
|
||||
const isFolderBasedPost = filePath.endsWith('index.md') && isPostsFile;
|
||||
const isFolderBasedProject = filePath.endsWith('index.md') && isProjectsFile;
|
||||
const isFolderBasedDoc = filePath.endsWith('index.md') && isDocsFile;
|
||||
|
||||
// 1. Check in the same folder as the markdown file (for folder-based content)
|
||||
if (isFolderBasedPost || isFolderBasedProject || isFolderBasedDoc) {
|
||||
const contentDir = path.dirname(filePath);
|
||||
const sameFolderPath = path.join(contentDir, imagePath);
|
||||
if (fs.existsSync(sameFolderPath)) {
|
||||
return { exists: true, path: sameFolderPath };
|
||||
}
|
||||
|
||||
// Check in /attachments/ subfolder within the content folder
|
||||
const imagesSubfolderPath = path.join(contentDir, 'images', imagePath);
|
||||
if (fs.existsSync(imagesSubfolderPath)) {
|
||||
return { exists: true, path: imagesSubfolderPath };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check in general images directory for each content type
|
||||
if (isPostsFile) {
|
||||
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'posts', 'images', imagePath);
|
||||
if (fs.existsSync(generalImagesPath)) {
|
||||
return { exists: true, path: generalImagesPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isPagesFile) {
|
||||
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'pages', 'images', imagePath);
|
||||
if (fs.existsSync(generalImagesPath)) {
|
||||
return { exists: true, path: generalImagesPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isProjectsFile) {
|
||||
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'projects', 'images', imagePath);
|
||||
if (fs.existsSync(generalImagesPath)) {
|
||||
return { exists: true, path: generalImagesPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isDocsFile) {
|
||||
const generalImagesPath = path.join(projectRoot, 'src', 'content', 'docs', 'images', imagePath);
|
||||
if (fs.existsSync(generalImagesPath)) {
|
||||
return { exists: true, path: generalImagesPath };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check in public directory (for synced images)
|
||||
if (isFolderBasedPost) {
|
||||
const postSlug = path.basename(path.dirname(filePath));
|
||||
const publicPath = path.join(projectRoot, 'public', 'posts', postSlug, imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isFolderBasedProject) {
|
||||
const projectSlug = path.basename(path.dirname(filePath));
|
||||
const publicPath = path.join(projectRoot, 'public', 'projects', projectSlug, imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (isFolderBasedDoc) {
|
||||
const docSlug = path.basename(path.dirname(filePath));
|
||||
const publicPath = path.join(projectRoot, 'public', 'docs', docSlug, imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle absolute paths (starting with /)
|
||||
if (imagePath.startsWith('/')) {
|
||||
const publicPath = path.join(projectRoot, 'public', imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle relative paths from public directory
|
||||
const publicPath = path.join(projectRoot, 'public', imagePath);
|
||||
if (fs.existsSync(publicPath)) {
|
||||
return { exists: true, path: publicPath };
|
||||
}
|
||||
|
||||
// Handle external URLs (don't check these)
|
||||
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||
return { exists: true, path: imagePath };
|
||||
}
|
||||
|
||||
return { exists: false, path: imagePath };
|
||||
}
|
||||
|
||||
// Main function
|
||||
function main() {
|
||||
console.log('🔍 Checking for missing images...\n');
|
||||
|
||||
const contentDir = path.join(projectRoot, 'src', 'content');
|
||||
const markdownFiles = getAllMarkdownFiles(contentDir);
|
||||
|
||||
let totalImages = 0;
|
||||
let missingImages = 0;
|
||||
const missingImageDetails = [];
|
||||
|
||||
for (const filePath of markdownFiles) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const images = extractImageReferences(content);
|
||||
|
||||
for (const image of images) {
|
||||
totalImages++;
|
||||
const result = checkImageExists(image.src, filePath);
|
||||
|
||||
if (!result.exists) {
|
||||
missingImages++;
|
||||
const relativePath = path.relative(projectRoot, filePath);
|
||||
missingImageDetails.push({
|
||||
file: relativePath,
|
||||
line: image.line,
|
||||
type: image.type,
|
||||
src: image.src,
|
||||
expectedPath: result.path
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
console.log(`📊 Summary:`);
|
||||
console.log(` Total images: ${totalImages}`);
|
||||
console.log(` Missing images: ${missingImages}`);
|
||||
console.log(` Found images: ${totalImages - missingImages}\n`);
|
||||
|
||||
if (missingImages > 0) {
|
||||
console.log('❌ Missing images:');
|
||||
for (const detail of missingImageDetails) {
|
||||
console.log(` ${detail.file}:${detail.line} (${detail.type})`);
|
||||
console.log(` ${detail.src}`);
|
||||
console.log(` Expected: ${detail.expectedPath}\n`);
|
||||
}
|
||||
|
||||
console.log('💡 Tips:');
|
||||
console.log(' - In development mode, missing images will show placeholder images');
|
||||
console.log(' - Check if images are in the correct folder for folder-based posts');
|
||||
console.log(' - Verify image paths match exactly (case-sensitive)');
|
||||
console.log(' - For Obsidian wikilinks, ensure the image exists in the same folder');
|
||||
} else {
|
||||
console.log('✅ All images found!');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
80
scripts/dev-with-port.js
Normal file
80
scripts/dev-with-port.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Astro server launcher with automatic port detection
|
||||
* Checks if port 5000 is available, falls back to 5001 if occupied
|
||||
* Supports both 'dev' and 'preview' commands
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function checkPort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.once('close', () => {
|
||||
resolve(true); // Port is available
|
||||
});
|
||||
server.close();
|
||||
});
|
||||
|
||||
server.on('error', () => {
|
||||
resolve(false); // Port is occupied
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getAvailablePort() {
|
||||
const port5000Available = await checkPort(5000);
|
||||
|
||||
if (port5000Available) {
|
||||
return 5000;
|
||||
} else {
|
||||
// Port 5000 is occupied (likely AirPlay on macOS)
|
||||
console.log('⚠️ Port 5000 is occupied, using port 5001 instead');
|
||||
return 5001;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get command from first argument (defaults to 'dev')
|
||||
const command = process.argv[2] || 'dev';
|
||||
|
||||
if (!['dev', 'preview'].includes(command)) {
|
||||
console.error(`Invalid command: ${command}. Must be 'dev' or 'preview'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = await getAvailablePort();
|
||||
|
||||
// Use pnpm exec to ensure we use the local Astro installation
|
||||
// On Windows, use 'pnpm.cmd', on Unix use 'pnpm'
|
||||
const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
||||
|
||||
// Spawn astro with the detected port
|
||||
const astroProcess = spawn(pnpmCmd, ['exec', 'astro', command, '--host', '0.0.0.0', '--port', port.toString()], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
});
|
||||
|
||||
astroProcess.on('error', (error) => {
|
||||
console.error(`Failed to start Astro ${command} server:`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
astroProcess.on('exit', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
955
scripts/generate-deployment-config.js
Normal file
955
scripts/generate-deployment-config.js
Normal file
|
|
@ -0,0 +1,955 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Simple logging utility
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const log = {
|
||||
info: (...args) => isDev && console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
warn: (...args) => console.warn(...args)
|
||||
};
|
||||
|
||||
// Import deployment platform helper
|
||||
import getDeploymentPlatform from './get-deployment-platform.js';
|
||||
|
||||
// Deployment platform configuration - use config if no env var is set
|
||||
const DEPLOYMENT_PLATFORM = process.env.DEPLOYMENT_PLATFORM || getDeploymentPlatform();
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
const VALIDATE_ONLY = process.argv.includes('--validate');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Define content directories to process
|
||||
const CONTENT_DIRS = [
|
||||
'src/content/pages',
|
||||
'src/content/posts',
|
||||
'src/content/projects',
|
||||
'src/content/docs'
|
||||
];
|
||||
|
||||
// Function to parse frontmatter from markdown content
|
||||
function parseFrontmatter(content) {
|
||||
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: null, content: content };
|
||||
}
|
||||
|
||||
const frontmatterText = match[1];
|
||||
const body = match[2];
|
||||
|
||||
// Parse YAML-like frontmatter (simple parser)
|
||||
const frontmatter = {};
|
||||
const lines = frontmatterText.split('\n');
|
||||
let currentKey = null;
|
||||
let currentValue = [];
|
||||
let inArray = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.includes(':') && (!inArray || !trimmed.startsWith(' '))) {
|
||||
// Save previous key-value pair
|
||||
if (currentKey) {
|
||||
if (currentValue.length === 1) {
|
||||
frontmatter[currentKey] = currentValue[0];
|
||||
} else {
|
||||
frontmatter[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Start new key-value pair
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
currentKey = trimmed.substring(0, colonIndex).trim();
|
||||
const value = trimmed.substring(colonIndex + 1).trim();
|
||||
|
||||
// Reset array state when starting a new key
|
||||
inArray = false;
|
||||
|
||||
if (value.startsWith('[')) {
|
||||
// Array value
|
||||
inArray = true;
|
||||
currentValue = [];
|
||||
if (value !== '[') {
|
||||
// Single line array
|
||||
const arrayContent = value.substring(1, value.endsWith(']') ? value.length - 1 : value.length);
|
||||
if (arrayContent.trim()) {
|
||||
currentValue = arrayContent.split(',').map(item => item.trim().replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
inArray = false;
|
||||
}
|
||||
} else if (value) {
|
||||
// Single value
|
||||
currentValue = [value.replace(/^["']|["']$/g, '')];
|
||||
} else {
|
||||
// Empty value, might be start of array
|
||||
currentValue = [];
|
||||
inArray = true;
|
||||
}
|
||||
} else if (inArray && trimmed.startsWith('-')) {
|
||||
// Array item
|
||||
const item = trimmed.substring(1).trim().replace(/^["']|["']$/g, '');
|
||||
currentValue.push(item);
|
||||
} else if (inArray && trimmed === ']') {
|
||||
// End of array
|
||||
inArray = false;
|
||||
} else if (currentKey && !inArray) {
|
||||
// Continuation of single value
|
||||
currentValue = [currentValue[0] + ' ' + trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
// Save last key-value pair
|
||||
if (currentKey) {
|
||||
if (currentValue.length === 1) {
|
||||
frontmatter[currentKey] = currentValue[0];
|
||||
} else {
|
||||
frontmatter[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, content: body };
|
||||
}
|
||||
|
||||
// Function to get the final URL for a content file
|
||||
function getContentUrl(filePath, isPost = false) {
|
||||
// Normalize path separators and extract the relevant part
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
|
||||
if (isPost) {
|
||||
// For posts: extract path after 'src/content/posts/' and remove '.md'
|
||||
let postPath = normalizedPath.replace(/^.*src\/content\/posts\//, '').replace(/\.md$/, '');
|
||||
// Handle folder-based content: remove '/index' suffix
|
||||
if (postPath.endsWith('/index')) {
|
||||
postPath = postPath.replace('/index', '');
|
||||
}
|
||||
return `/posts/${postPath}`;
|
||||
} else if (normalizedPath.includes('src/content/projects/')) {
|
||||
// For projects: extract path after 'src/content/projects/' and remove '.md'
|
||||
let projectPath = normalizedPath.replace(/^.*src\/content\/projects\//, '').replace(/\.md$/, '');
|
||||
// Handle folder-based content: remove '/index' suffix
|
||||
if (projectPath.endsWith('/index')) {
|
||||
projectPath = projectPath.replace('/index', '');
|
||||
}
|
||||
return `/projects/${projectPath}`;
|
||||
} else if (normalizedPath.includes('src/content/docs/')) {
|
||||
// For docs: extract path after 'src/content/docs/' and remove '.md'
|
||||
let docPath = normalizedPath.replace(/^.*src\/content\/docs\//, '').replace(/\.md$/, '');
|
||||
// Handle folder-based content: remove '/index' suffix
|
||||
if (docPath.endsWith('/index')) {
|
||||
docPath = docPath.replace('/index', '');
|
||||
}
|
||||
return `/docs/${docPath}`;
|
||||
} else {
|
||||
// For pages: extract path after 'src/content/pages/' and remove '.md'
|
||||
let pagePath = normalizedPath.replace(/^.*src\/content\/pages\//, '').replace(/\.md$/, '');
|
||||
// Handle folder-based content: remove '/index' suffix
|
||||
if (pagePath.endsWith('/index')) {
|
||||
pagePath = pagePath.replace('/index', '');
|
||||
}
|
||||
if (pagePath === 'index') {
|
||||
return '/';
|
||||
}
|
||||
return `/${pagePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Function to process a single markdown file
|
||||
async function processMarkdownFile(filePath, isPost = false) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatter || !frontmatter.aliases) {
|
||||
return []; // No redirects to process
|
||||
}
|
||||
|
||||
// Ensure aliases is an array
|
||||
const aliasesArray = Array.isArray(frontmatter.aliases)
|
||||
? frontmatter.aliases
|
||||
: [frontmatter.aliases];
|
||||
|
||||
const targetUrl = getContentUrl(filePath, isPost);
|
||||
const redirects = [];
|
||||
|
||||
for (const alias of aliasesArray) {
|
||||
const cleanAlias = alias.startsWith('/') ? alias.substring(1) : alias;
|
||||
|
||||
// Normalize path separators for consistent checking
|
||||
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Determine redirect pattern based on content type
|
||||
let redirectFrom;
|
||||
if (isPost) {
|
||||
// Posts: /posts/alias → /posts/actual-slug
|
||||
redirectFrom = `/posts/${cleanAlias}`;
|
||||
} else if (normalizedFilePath.includes('src/content/projects/')) {
|
||||
// Projects: /projects/alias → /projects/actual-slug
|
||||
redirectFrom = `/projects/${cleanAlias}`;
|
||||
} else if (normalizedFilePath.includes('src/content/docs/')) {
|
||||
// Docs: /docs/alias → /docs/actual-slug
|
||||
redirectFrom = `/docs/${cleanAlias}`;
|
||||
} else {
|
||||
// Pages: /alias → /actual-slug
|
||||
redirectFrom = `/${cleanAlias}`;
|
||||
}
|
||||
|
||||
// Skip self-redirects (redirecting to the same URL causes infinite loops)
|
||||
if (redirectFrom !== targetUrl) {
|
||||
redirects.push({
|
||||
from: redirectFrom,
|
||||
to: targetUrl,
|
||||
alias: cleanAlias
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return redirects;
|
||||
} catch (error) {
|
||||
log.error(`❌ Error processing ${filePath}:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Function to process all markdown files in a directory
|
||||
async function processDirectory(dirPath, isPost = false) {
|
||||
try {
|
||||
const files = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
let allRedirects = [];
|
||||
let processedFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isDirectory()) {
|
||||
// Handle folder-based content (e.g., folder-name/index.md)
|
||||
const folderPath = path.join(dirPath, file.name);
|
||||
try {
|
||||
const indexPath = path.join(folderPath, 'index.md');
|
||||
await fs.access(indexPath);
|
||||
const redirects = await processMarkdownFile(indexPath, isPost);
|
||||
allRedirects = allRedirects.concat(redirects);
|
||||
if (redirects.length > 0) {
|
||||
processedFiles++;
|
||||
}
|
||||
} catch (error) {
|
||||
// index.md doesn't exist in this folder, skip
|
||||
}
|
||||
} else if (file.isFile() && file.name.endsWith('.md')) {
|
||||
// Handle single-file content
|
||||
const filePath = path.join(dirPath, file.name);
|
||||
const redirects = await processMarkdownFile(filePath, isPost);
|
||||
allRedirects = allRedirects.concat(redirects);
|
||||
if (redirects.length > 0) {
|
||||
processedFiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { redirects: allRedirects, processedFiles };
|
||||
} catch (error) {
|
||||
log.error(`❌ Error processing directory ${dirPath}:`, error.message);
|
||||
return { redirects: [], processedFiles: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Function to update astro.config.mjs with redirects (dev-only)
|
||||
async function updateAstroConfig(redirects) {
|
||||
const astroConfigPath = 'astro.config.mjs';
|
||||
|
||||
try {
|
||||
let astroContent = await fs.readFile(astroConfigPath, 'utf-8');
|
||||
|
||||
// Create redirects object
|
||||
const redirectsObj = {};
|
||||
for (const redirect of redirects) {
|
||||
redirectsObj[redirect.from] = redirect.to;
|
||||
}
|
||||
|
||||
// Format redirects with conditional dev-only check
|
||||
// Using process.env.NODE_ENV for reliable environment detection at config load time
|
||||
const redirectsString = JSON.stringify(redirectsObj, null, 2).replace(/"/g, "'");
|
||||
const newRedirectsSection = `redirects: (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'build') ? ${redirectsString} : {}`;
|
||||
|
||||
// Remove ALL existing redirects entries (including any comments before them)
|
||||
// This handles multiple redirects entries that may have been duplicated
|
||||
// Match from optional comments + redirects: through the entire conditional expression
|
||||
// Pattern: redirects: (condition) ? { object } : {},
|
||||
// Use [\s\S]*? to match any characters including newlines (non-greedy)
|
||||
const redirectsRegex = /(\s*\/\/[^\n]*\n)*\s*redirects:\s*\([^)]+\)\s*\?\s*\{[\s\S]*?\}\s*:\s*\{\},\s*/g;
|
||||
|
||||
// Remove all existing redirects entries (run multiple times to catch all duplicates)
|
||||
let previousContent = '';
|
||||
while (previousContent !== astroContent) {
|
||||
previousContent = astroContent;
|
||||
astroContent = astroContent.replace(redirectsRegex, '');
|
||||
}
|
||||
|
||||
// Add redirects after devToolbar config
|
||||
const devToolbarRegex = /(devToolbar:\s*\{[^}]*\},)/;
|
||||
if (devToolbarRegex.test(astroContent)) {
|
||||
astroContent = astroContent.replace(devToolbarRegex, `$1\n ${newRedirectsSection},\n`);
|
||||
} else {
|
||||
// Fallback: add after deployment config if devToolbar not found
|
||||
const deploymentRegex = /(deployment:\s*\{[^}]*\},)/;
|
||||
if (deploymentRegex.test(astroContent)) {
|
||||
astroContent = astroContent.replace(deploymentRegex, `$1\n ${newRedirectsSection},\n`);
|
||||
} else {
|
||||
// Last resort: add after site config
|
||||
const siteRegex = /(site:\s*[^,]+),/;
|
||||
astroContent = astroContent.replace(siteRegex, `$1,\n ${newRedirectsSection},\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(astroConfigPath, astroContent, 'utf-8');
|
||||
log.info(`📝 Updated astro.config.mjs with ${redirects.length} redirects (dev-only)`);
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating astro.config.mjs:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Platform-specific configuration generators
|
||||
function generateVercelConfig(redirects) {
|
||||
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
|
||||
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
|
||||
|
||||
const config = {
|
||||
redirects: validRedirects.map(redirect => ({
|
||||
source: redirect.from,
|
||||
destination: redirect.to,
|
||||
permanent: (redirect.status || 301) === 301
|
||||
})),
|
||||
headers: [
|
||||
{
|
||||
source: "/_assets/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: "/(.*\\.(webp|jpg|jpeg|png|gif|svg))",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: "/(.*\\.pdf)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=3600"
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://giscus.app https://platform.twitter.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://giscus.app; frame-src 'self' https://www.youtube.com https://giscus.app https://platform.twitter.com; object-src 'none'; base-uri 'self';"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
function generateGitHubPagesConfig(redirects) {
|
||||
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
|
||||
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
|
||||
|
||||
// Note: Removed '!' suffix to avoid interstitial page - direct redirects like Netlify
|
||||
const redirectLines = validRedirects.map(redirect =>
|
||||
`${redirect.from} ${redirect.to} ${redirect.status || 301}`
|
||||
);
|
||||
|
||||
return redirectLines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function generateNetlifyConfig(redirects) {
|
||||
const redirectLines = [];
|
||||
|
||||
// Filter out self-redirects (redirecting to the same URL causes infinite loops)
|
||||
const validRedirects = redirects.filter(redirect => redirect.from !== redirect.to);
|
||||
|
||||
for (const redirect of validRedirects) {
|
||||
redirectLines.push('[[redirects]]');
|
||||
redirectLines.push(` from = "${redirect.from}"`);
|
||||
redirectLines.push(` to = "${redirect.to}"`);
|
||||
redirectLines.push(` status = ${redirect.status || 301}`);
|
||||
redirectLines.push(' force = true');
|
||||
redirectLines.push('');
|
||||
}
|
||||
|
||||
// Always add the catch-all 404 redirect at the end
|
||||
redirectLines.push('[[redirects]]');
|
||||
redirectLines.push(' from = "/*"');
|
||||
redirectLines.push(' to = "/404"');
|
||||
redirectLines.push(' status = 404');
|
||||
|
||||
return redirectLines.join('\n');
|
||||
}
|
||||
|
||||
function generateCloudflareWorkersConfig(projectName) {
|
||||
// Get current date for compatibility_date
|
||||
const today = new Date();
|
||||
const compatibilityDate = today.toISOString().split('T')[0];
|
||||
|
||||
// Generate Workers-compatible config (recommended by Cloudflare)
|
||||
// Workers uses assets.directory instead of pages_build_output_dir
|
||||
// See: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
|
||||
const configLines = [];
|
||||
configLines.push(`name = "${projectName}"`);
|
||||
configLines.push(`compatibility_date = "${compatibilityDate}"`);
|
||||
configLines.push(``);
|
||||
configLines.push(`[assets]`);
|
||||
configLines.push(` directory = "./dist"`);
|
||||
configLines.push(` not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
|
||||
|
||||
return configLines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// Clean up platform-specific files that don't match the selected platform
|
||||
async function cleanupOtherPlatformFiles(currentPlatform) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
|
||||
// Clean up GitHub Pages/Cloudflare Workers files if not using those platforms
|
||||
// (Both platforms use the same _redirects and _headers format)
|
||||
// These files should ONLY exist for github-pages and cloudflare-workers
|
||||
if (currentPlatform !== 'github-pages' && currentPlatform !== 'cloudflare-workers') {
|
||||
const sharedFiles = [
|
||||
path.join(projectRoot, 'public', '_redirects'),
|
||||
path.join(projectRoot, 'public', '_headers')
|
||||
];
|
||||
|
||||
for (const file of sharedFiles) {
|
||||
try {
|
||||
await fs.access(file);
|
||||
await fs.unlink(file);
|
||||
log.info(`🧹 Removed ${path.basename(file)} (not needed for ${currentPlatform})`);
|
||||
} catch (error) {
|
||||
// File doesn't exist, nothing to clean up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't remove vercel.json, netlify.toml, or wrangler.toml as they may contain
|
||||
// custom configuration (serverless functions, environment variables, bindings, etc.)
|
||||
// Only _redirects and _headers are platform-specific and should be cleaned up
|
||||
}
|
||||
|
||||
// Platform-specific file writers
|
||||
async function writeVercelConfig(redirects) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const vercelJsonPath = path.join(projectRoot, 'vercel.json');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would generate vercel.json:');
|
||||
console.log(generateVercelConfig(redirects));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing vercel.json to preserve custom settings
|
||||
let existingConfig = {};
|
||||
try {
|
||||
const existingContent = await fs.readFile(vercelJsonPath, 'utf-8');
|
||||
existingConfig = JSON.parse(existingContent);
|
||||
} catch (error) {
|
||||
// File doesn't exist or is invalid JSON, create new one
|
||||
}
|
||||
|
||||
// Generate new redirects and headers
|
||||
const newConfig = JSON.parse(generateVercelConfig(redirects));
|
||||
|
||||
// Merge configs - new redirects/headers override existing ones
|
||||
const mergedConfig = {
|
||||
...existingConfig,
|
||||
redirects: newConfig.redirects,
|
||||
headers: newConfig.headers
|
||||
};
|
||||
|
||||
await fs.writeFile(vercelJsonPath, JSON.stringify(mergedConfig, null, 2), 'utf-8');
|
||||
log.info(`📝 Updated vercel.json with ${redirects.length} redirects`);
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating vercel.json:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeGitHubPagesConfig(redirects) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const redirectsPath = path.join(projectRoot, 'public', '_redirects');
|
||||
const headersPath = path.join(projectRoot, 'public', '_headers');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would generate public/_redirects:');
|
||||
console.log(generateGitHubPagesConfig(redirects));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Write redirects file
|
||||
const config = generateGitHubPagesConfig(redirects);
|
||||
await fs.writeFile(redirectsPath, config, 'utf-8');
|
||||
log.info(`📝 Updated public/_redirects with ${redirects.length} redirects`);
|
||||
|
||||
// Write headers file (for GitHub Pages and Cloudflare Pages)
|
||||
// Note: Custom headers require GitHub Pages on a paid plan or GitHub Enterprise
|
||||
// Cloudflare Pages supports custom headers on all plans (including free tier)
|
||||
const headersContent = `# Custom Headers for GitHub Pages / Cloudflare Pages
|
||||
# Note: GitHub Pages requires paid plan for custom headers
|
||||
# Cloudflare Pages supports custom headers on all plans
|
||||
|
||||
# HTML files
|
||||
/*.html
|
||||
Cache-Control: public, max-age=3600, s-maxage=86400
|
||||
|
||||
# CSS files
|
||||
/*.css
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# JavaScript files
|
||||
/*.js
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# JSON files
|
||||
/*.json
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
# XML files
|
||||
/*.xml
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
# Text files
|
||||
/*.txt
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
# Pre-compressed files
|
||||
/*.gz
|
||||
Content-Encoding: gzip
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.br
|
||||
Content-Encoding: br
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Static assets with long-term caching
|
||||
/_assets/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# WebP images
|
||||
/*.webp
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Font files
|
||||
/*.woff2
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.woff
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.ttf
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.eot
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Image files
|
||||
/*.jpg
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.jpeg
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.png
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.gif
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.svg
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.ico
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Favicon files
|
||||
/favicon*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# All pages - Security headers and Content Security Policy
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
Cross-Origin-Embedder-Policy: unsafe-none
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://giscus.app https://platform.twitter.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://giscus.app; frame-src 'self' https://www.youtube.com https://giscus.app https://platform.twitter.com; object-src 'none'; base-uri 'self';
|
||||
|
||||
# PDF files - allow iframe embedding (override X-Frame-Options for PDFs)
|
||||
/*.pdf
|
||||
Cache-Control: public, max-age=3600
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
`;
|
||||
await fs.writeFile(headersPath, headersContent, 'utf-8');
|
||||
log.info(`📝 Created public/_headers for GitHub Pages / Cloudflare Pages`);
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating GitHub Pages config:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeNetlifyConfig(redirects) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const netlifyTomlPath = path.join(projectRoot, 'netlify.toml');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would generate netlify.toml:');
|
||||
console.log(generateNetlifyConfig(redirects));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing netlify.toml to preserve other settings
|
||||
let existingContent = '';
|
||||
try {
|
||||
existingContent = await fs.readFile(netlifyTomlPath, 'utf-8');
|
||||
} catch (error) {
|
||||
// File doesn't exist, create new one
|
||||
}
|
||||
|
||||
// Split content at redirects section
|
||||
const configLines = existingContent.split('[[redirects]]')[0].trim();
|
||||
const redirectConfig = generateNetlifyConfig(redirects);
|
||||
|
||||
const newContent = configLines + '\n\n' + redirectConfig;
|
||||
await fs.writeFile(netlifyTomlPath, newContent, 'utf-8');
|
||||
|
||||
log.info(`📝 Updated netlify.toml with ${redirects.length} redirects`);
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating netlify.toml:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAssetsIgnoreFile() {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const templatePath = path.join(projectRoot, '.assetsignore.template');
|
||||
const distPath = path.join(projectRoot, 'dist');
|
||||
const assetsIgnorePath = path.join(distPath, '.assetsignore');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would copy .assetsignore.template to dist/.assetsignore');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if template exists
|
||||
try {
|
||||
await fs.access(templatePath);
|
||||
} catch (error) {
|
||||
// Template doesn't exist, that's okay - skip copying (optional file)
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if dist directory exists
|
||||
// Note: This script runs before Astro build, so dist may not exist yet
|
||||
// That's okay - the file will be copied if dist exists, or can be added manually
|
||||
try {
|
||||
await fs.access(distPath);
|
||||
|
||||
// dist exists, copy the file
|
||||
const templateContent = await fs.readFile(templatePath, 'utf-8');
|
||||
await fs.writeFile(assetsIgnorePath, templateContent, 'utf-8');
|
||||
log.info('📝 Copied .assetsignore to dist/');
|
||||
} catch (error) {
|
||||
// dist doesn't exist yet - that's fine, it will be created during Astro build
|
||||
// The .assetsignore file is optional and mainly for safety
|
||||
// Users can manually copy it to dist/ if needed, or it will be there on subsequent builds
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-critical error - just log it
|
||||
log.warn(`⚠️ Could not copy .assetsignore file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCloudflareWorkersConfig(projectName) {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const wranglerTomlPath = path.join(projectRoot, 'wrangler.toml');
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('📝 [DRY RUN] Would generate wrangler.toml:');
|
||||
console.log(generateCloudflareWorkersConfig(projectName));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing wrangler.toml to preserve custom settings
|
||||
let existingContent = '';
|
||||
try {
|
||||
existingContent = await fs.readFile(wranglerTomlPath, 'utf-8');
|
||||
} catch (error) {
|
||||
// File doesn't exist, create new one
|
||||
}
|
||||
|
||||
// Generate new config with managed fields (Workers format)
|
||||
const newConfig = generateCloudflareWorkersConfig(projectName);
|
||||
|
||||
if (existingContent) {
|
||||
// Update only the fields we manage while preserving everything else
|
||||
// Migrate from Pages format (pages_build_output_dir) to Workers format (assets.directory)
|
||||
let updatedContent = existingContent;
|
||||
|
||||
// Update name field
|
||||
updatedContent = updatedContent.replace(/^name\s*=\s*["'][^"']*["']/m, `name = "${projectName}"`);
|
||||
if (!updatedContent.match(/^name\s*=/m)) {
|
||||
// name doesn't exist, add it at the beginning
|
||||
updatedContent = `name = "${projectName}"\n${updatedContent}`;
|
||||
}
|
||||
|
||||
// Migrate from Pages format to Workers format
|
||||
// Remove old pages_build_output_dir if it exists
|
||||
updatedContent = updatedContent.replace(/^pages_build_output_dir\s*=\s*["'][^"']*["']\s*\n?/m, '');
|
||||
|
||||
// Update or add assets.directory (Workers format)
|
||||
if (updatedContent.match(/^\[assets\]/m)) {
|
||||
// [assets] section exists, update directory
|
||||
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n\s*)directory\s*=\s*["'][^"']*["']/m, `$1directory = "./dist"`);
|
||||
if (!updatedContent.match(/^\[assets\]\s*\n\s*directory\s*=/m)) {
|
||||
// directory doesn't exist in [assets], add it
|
||||
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n)/m, `$1 directory = "./dist"\n`);
|
||||
}
|
||||
// Ensure not_found_handling is set
|
||||
if (!updatedContent.match(/not_found_handling\s*=/m)) {
|
||||
updatedContent = updatedContent.replace(/^(\[assets\]\s*\n\s*directory\s*=[^\n]+)/m, `$1\n not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
|
||||
}
|
||||
} else {
|
||||
// [assets] section doesn't exist, add it after compatibility_date
|
||||
const today = new Date();
|
||||
const compatibilityDate = today.toISOString().split('T')[0];
|
||||
updatedContent = updatedContent.replace(/^compatibility_date\s*=\s*["'][^"']*["']/m, `compatibility_date = "${compatibilityDate}"`);
|
||||
if (!updatedContent.match(/^compatibility_date\s*=/m)) {
|
||||
// compatibility_date doesn't exist, add both
|
||||
updatedContent = updatedContent.replace(/^(name\s*=[^\n]+)/m, `$1\ncompatibility_date = "${compatibilityDate}"`);
|
||||
}
|
||||
// Add [assets] section
|
||||
updatedContent = updatedContent.replace(/^(compatibility_date\s*=[^\n]+)/m, `$1\n\n[assets]\n directory = "./dist"\n not_found_handling = "404-page" # Serve custom 404 page from src/pages/404.astro`);
|
||||
}
|
||||
|
||||
// Update compatibility_date field
|
||||
const today = new Date();
|
||||
const compatibilityDate = today.toISOString().split('T')[0];
|
||||
updatedContent = updatedContent.replace(/^compatibility_date\s*=\s*["'][^"']*["']/m, `compatibility_date = "${compatibilityDate}"`);
|
||||
|
||||
// Remove old [build] section if it exists (not needed for Workers)
|
||||
updatedContent = updatedContent.replace(/\n*\[build\]\s*\n\s*command\s*=\s*["'][^"']*["']\s*\n*/g, '\n');
|
||||
|
||||
await fs.writeFile(wranglerTomlPath, updatedContent, 'utf-8');
|
||||
log.info(`📝 Updated wrangler.toml (Workers format, preserved custom settings)`);
|
||||
} else {
|
||||
// File doesn't exist, create new one with Workers format
|
||||
await fs.writeFile(wranglerTomlPath, newConfig, 'utf-8');
|
||||
log.info(`📝 Created wrangler.toml (Workers format)`);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`❌ Error updating wrangler.toml:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Validation functions
|
||||
function validateVercelConfig(redirects) {
|
||||
const config = JSON.parse(generateVercelConfig(redirects));
|
||||
|
||||
// Validate redirects
|
||||
for (const redirect of config.redirects) {
|
||||
if (!redirect.source || !redirect.destination) {
|
||||
throw new Error(`Invalid Vercel redirect: ${JSON.stringify(redirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info('✅ Vercel configuration is valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateGitHubPagesConfig(redirects) {
|
||||
const config = generateGitHubPagesConfig(redirects);
|
||||
|
||||
// Basic validation - check format (no '!' suffix - direct redirects)
|
||||
const lines = config.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim() && !line.match(/^[^\s]+\s+[^\s]+\s+\d+$/)) {
|
||||
throw new Error(`Invalid GitHub Pages redirect format: ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info('✅ GitHub Pages configuration is valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateNetlifyConfig(redirects) {
|
||||
const config = generateNetlifyConfig(redirects);
|
||||
|
||||
// Basic validation - check TOML-like format
|
||||
if (!config.includes('[[redirects]]')) {
|
||||
throw new Error('Invalid Netlify configuration format');
|
||||
}
|
||||
|
||||
log.info('✅ Netlify configuration is valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function generateRedirects() {
|
||||
log.info(`🔄 Generating redirects from content aliases for ${DEPLOYMENT_PLATFORM}...`);
|
||||
|
||||
if (VALIDATE_ONLY) {
|
||||
log.info('🔍 Validation mode - checking all platform configurations...');
|
||||
}
|
||||
|
||||
// Get project name from package.json for Cloudflare Workers
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
let projectName = 'astro-modular';
|
||||
try {
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
|
||||
projectName = packageJson.name || 'astro-modular';
|
||||
} catch (error) {
|
||||
// Use default if package.json can't be read
|
||||
}
|
||||
|
||||
let allRedirects = [];
|
||||
let totalProcessedFiles = 0;
|
||||
|
||||
// Process pages
|
||||
const pagesPath = path.join(projectRoot, 'src/content/pages');
|
||||
try {
|
||||
await fs.access(pagesPath);
|
||||
const pageResult = await processDirectory(pagesPath, false);
|
||||
allRedirects = allRedirects.concat(pageResult.redirects);
|
||||
totalProcessedFiles += pageResult.processedFiles;
|
||||
} catch (error) {
|
||||
// Pages directory doesn't exist, skipping
|
||||
}
|
||||
|
||||
// Process posts
|
||||
const postsPath = path.join(projectRoot, 'src/content/posts');
|
||||
try {
|
||||
await fs.access(postsPath);
|
||||
const postResult = await processDirectory(postsPath, true);
|
||||
allRedirects = allRedirects.concat(postResult.redirects);
|
||||
totalProcessedFiles += postResult.processedFiles;
|
||||
} catch (error) {
|
||||
// Posts directory doesn't exist, skipping
|
||||
}
|
||||
|
||||
// Process projects
|
||||
const projectsPath = path.join(projectRoot, 'src/content/projects');
|
||||
try {
|
||||
await fs.access(projectsPath);
|
||||
const projectResult = await processDirectory(projectsPath, false);
|
||||
allRedirects = allRedirects.concat(projectResult.redirects);
|
||||
totalProcessedFiles += projectResult.processedFiles;
|
||||
} catch (error) {
|
||||
// Projects directory doesn't exist, skipping
|
||||
}
|
||||
|
||||
// Process docs
|
||||
const docsPath = path.join(projectRoot, 'src/content/docs');
|
||||
try {
|
||||
await fs.access(docsPath);
|
||||
const docResult = await processDirectory(docsPath, false);
|
||||
allRedirects = allRedirects.concat(docResult.redirects);
|
||||
totalProcessedFiles += docResult.processedFiles;
|
||||
} catch (error) {
|
||||
// Docs directory doesn't exist, skipping
|
||||
}
|
||||
|
||||
if (allRedirects.length > 0) {
|
||||
log.info(`📁 Processing pages directory...`);
|
||||
log.info(`📁 Processing posts directory...`);
|
||||
log.info(`📁 Processing projects directory...`);
|
||||
log.info(`📁 Processing docs directory...`);
|
||||
log.info(` Processed ${totalProcessedFiles} files with redirects`);
|
||||
|
||||
// Update Astro config with redirects (only used in dev mode - instant HTTP redirects)
|
||||
// In production builds, redirects are set to {} to prevent HTML meta refresh files
|
||||
await updateAstroConfig(allRedirects);
|
||||
|
||||
// Generate platform-specific configs
|
||||
if (VALIDATE_ONLY) {
|
||||
// Validate all platforms
|
||||
validateVercelConfig(allRedirects);
|
||||
validateGitHubPagesConfig(allRedirects);
|
||||
validateNetlifyConfig(allRedirects);
|
||||
log.info('🎉 All platform configurations are valid!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up files from other platforms before generating new config
|
||||
// This ensures we only have config files for the selected platform
|
||||
await cleanupOtherPlatformFiles(DEPLOYMENT_PLATFORM);
|
||||
|
||||
// Write platform-specific config - only generate files needed for the selected platform
|
||||
// IMPORTANT: We use platform-specific redirect files (HTTP redirects) for production
|
||||
// Astro config redirects are only active in dev mode (instant HTTP redirects)
|
||||
// In production builds, Astro redirects are set to {} to prevent HTML meta refresh files
|
||||
switch (DEPLOYMENT_PLATFORM) {
|
||||
case 'vercel':
|
||||
// Vercel: Uses vercel.json for instant HTTP redirects
|
||||
// No Astro config needed - Vercel handles redirects at edge level
|
||||
await writeVercelConfig(allRedirects);
|
||||
break;
|
||||
case 'github-pages':
|
||||
// GitHub Pages: Uses _redirects file for instant HTTP redirects
|
||||
// No Astro config needed - would create slow meta refresh HTML files
|
||||
await writeGitHubPagesConfig(allRedirects);
|
||||
break;
|
||||
case 'cloudflare-workers':
|
||||
// Cloudflare Workers: Uses _redirects file for instant HTTP redirects
|
||||
// See migration guide: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
|
||||
// No Astro config needed - would create slow meta refresh HTML files
|
||||
// Generate Workers-compatible wrangler.toml (uses assets.directory instead of pages_build_output_dir)
|
||||
await writeGitHubPagesConfig(allRedirects); // Uses same _redirects/_headers format
|
||||
await writeCloudflareWorkersConfig(projectName); // Generate Workers-compatible config
|
||||
await copyAssetsIgnoreFile(); // Copy .assetsignore to dist/ for Workers
|
||||
break;
|
||||
case 'netlify':
|
||||
default:
|
||||
// Netlify: Uses netlify.toml for instant HTTP redirects
|
||||
// No Astro config needed - Netlify handles redirects at edge level
|
||||
await writeNetlifyConfig(allRedirects);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
log.info('🎉 [DRY RUN] Redirect generation complete! No files were modified.');
|
||||
} else {
|
||||
log.info(`🎉 Redirect generation complete! Created ${allRedirects.length} redirects for ${DEPLOYMENT_PLATFORM}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
generateRedirects();
|
||||
537
scripts/generate-graph-data.js
Normal file
537
scripts/generate-graph-data.js
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Graph Data Generation Script
|
||||
*
|
||||
* This script generates graph data for the local graph feature by analyzing
|
||||
* post connections (both wikilinks and standard links) and tag relationships.
|
||||
*
|
||||
* The generated data includes:
|
||||
* - Post nodes with metadata (title, slug, date, tags)
|
||||
* - Tag nodes with metadata (name, color)
|
||||
* - Connections between posts (direct links)
|
||||
* - Connections between posts and tags (shared tags)
|
||||
*
|
||||
* This data is used by the LocalGraph component to render an Obsidian-like graph view.
|
||||
*
|
||||
* ID Generation Strategy:
|
||||
* - Uses path-based IDs (no frontmatter required)
|
||||
* - Single files: "my-post.md" → ID: "my-post"
|
||||
* - Folder-based: "my-folder/index.md" → ID: "my-folder"
|
||||
* - Nested content: "category/my-post.md" → ID: "category-my-post"
|
||||
*/
|
||||
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const projectRoot = join(__dirname, "..");
|
||||
|
||||
// Configuration
|
||||
const OUTPUT_DIR = join(projectRoot, "public", "graph");
|
||||
const OUTPUT_FILE = join(OUTPUT_DIR, "graph-data.json");
|
||||
|
||||
/**
|
||||
* Read maxNodes from config file
|
||||
*/
|
||||
function getMaxNodesFromConfig() {
|
||||
try {
|
||||
const configPath = join(projectRoot, "src", "config.ts");
|
||||
const configContent = readFileSync(configPath, "utf-8");
|
||||
|
||||
// Extract maxNodes value from config
|
||||
const maxNodesMatch = configContent.match(/maxNodes:\s*(\d+)/);
|
||||
if (maxNodesMatch) {
|
||||
return parseInt(maxNodesMatch[1], 10);
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 100;
|
||||
} catch (error) {
|
||||
log.warn("Could not read config file, using default maxNodes: 100");
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple logging utility
|
||||
const isDev = process.env.NODE_ENV !== "production" && !process.argv.includes("--production");
|
||||
const log = {
|
||||
info: (...args) => isDev && console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
};
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!existsSync(OUTPUT_DIR)) {
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable ID from file path
|
||||
* @param {string} filePath - The file path
|
||||
* @param {string} collectionType - The collection type (e.g., 'posts')
|
||||
* @returns {string} - The generated ID
|
||||
*/
|
||||
function generateNodeId(filePath, collectionType) {
|
||||
// Remove collection prefix and extension
|
||||
let id = filePath.replace(`src/content/${collectionType}/`, "");
|
||||
id = id.replace(".md", "");
|
||||
id = id.replace("/index", ""); // Handle folder-based posts
|
||||
|
||||
// Clean up the ID: lowercase, replace spaces/special chars with hyphens
|
||||
id = id.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
|
||||
// Remove multiple consecutive hyphens
|
||||
id = id.replace(/-+/g, "-");
|
||||
|
||||
// Remove leading/trailing hyphens
|
||||
id = id.replace(/^-+|-+$/g, "");
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract wikilinks from content (Obsidian-style)
|
||||
*/
|
||||
function extractWikilinks(content) {
|
||||
const matches = [];
|
||||
const wikilinkRegex = /!?\[\[([^\]]+)\]\]/g;
|
||||
let match;
|
||||
|
||||
while ((match = wikilinkRegex.exec(content)) !== null) {
|
||||
const [fullMatch, linkContent] = match;
|
||||
const isImageWikilink = fullMatch.startsWith("!");
|
||||
|
||||
// Skip image wikilinks, only process link wikilinks
|
||||
if (!isImageWikilink) {
|
||||
const [link, displayText] = linkContent.includes("|")
|
||||
? linkContent.split("|", 2)
|
||||
: [linkContent, linkContent];
|
||||
|
||||
// Parse anchor if present
|
||||
const anchorIndex = link.indexOf("#");
|
||||
const baseLink =
|
||||
anchorIndex === -1 ? link : link.substring(0, anchorIndex);
|
||||
|
||||
// Generate target ID from the link
|
||||
const targetId = generateNodeId(baseLink, "posts");
|
||||
|
||||
matches.push({
|
||||
link: baseLink,
|
||||
display: displayText.trim(),
|
||||
slug: targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract standard markdown links from content
|
||||
*/
|
||||
function extractStandardLinks(content) {
|
||||
const matches = [];
|
||||
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
|
||||
while ((match = markdownLinkRegex.exec(content)) !== null) {
|
||||
const [fullMatch, displayText, url] = match;
|
||||
|
||||
// Check if this is an internal link
|
||||
if (isInternalLink(url)) {
|
||||
const { linkText } = extractLinkTextFromUrl(url);
|
||||
if (linkText) {
|
||||
// Only include posts in graph data - this includes:
|
||||
// - posts/ prefixed links
|
||||
// - /posts/ relative links
|
||||
// - .md files (assumed to be posts)
|
||||
// - Simple slugs (assumed to be posts for backward compatibility)
|
||||
const isPostLink =
|
||||
linkText.startsWith("posts/") ||
|
||||
url.startsWith("/posts/") ||
|
||||
url.startsWith("posts/") ||
|
||||
url.endsWith(".md") ||
|
||||
(!linkText.includes("/") && !url.startsWith("/"));
|
||||
|
||||
if (isPostLink) {
|
||||
// Generate target ID from the link
|
||||
const targetId = generateNodeId(linkText, "posts");
|
||||
|
||||
matches.push({
|
||||
link: linkText,
|
||||
display: displayText.trim(),
|
||||
slug: targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is an internal link
|
||||
*/
|
||||
function isInternalLink(url) {
|
||||
url = url.trim();
|
||||
|
||||
// Skip external URLs
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip email links
|
||||
if (url.startsWith("mailto:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip anchors only
|
||||
if (url.startsWith("#")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's an internal link:
|
||||
// - Ends with .md (markdown files)
|
||||
// - Starts with /posts/ or posts/ (post relative URLs)
|
||||
// - Is just a slug (no slashes) - assumes posts for backward compatibility
|
||||
const isInternal =
|
||||
url.endsWith(".md") ||
|
||||
url.startsWith("/posts/") ||
|
||||
url.startsWith("posts/") ||
|
||||
!url.includes("/");
|
||||
|
||||
return isInternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract link text from URL
|
||||
*/
|
||||
function extractLinkTextFromUrl(url) {
|
||||
url = url.trim();
|
||||
|
||||
// Parse anchor if present
|
||||
const anchorIndex = url.indexOf("#");
|
||||
const link = anchorIndex === -1 ? url : url.substring(0, anchorIndex);
|
||||
const anchor = anchorIndex === -1 ? null : url.substring(anchorIndex + 1);
|
||||
|
||||
// Handle posts/ prefixed links
|
||||
if (link.startsWith("posts/")) {
|
||||
let linkText = link.replace("posts/", "").replace(/\.md$/, "");
|
||||
// Remove /index for folder-based posts
|
||||
if (linkText.endsWith("/index") && linkText.split("/").length === 2) {
|
||||
linkText = linkText.replace("/index", "");
|
||||
}
|
||||
return {
|
||||
linkText: linkText,
|
||||
anchor: anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle /posts/ URLs (relative links)
|
||||
if (link.startsWith("/posts/")) {
|
||||
let linkText = link.replace("/posts/", "").replace(/\.md$/, "");
|
||||
// Remove /index for folder-based posts
|
||||
if (linkText.endsWith("/index") && linkText.split("/").length === 2) {
|
||||
linkText = linkText.replace("/index", "");
|
||||
}
|
||||
return {
|
||||
linkText: linkText,
|
||||
anchor: anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle .md files
|
||||
if (link.endsWith(".md")) {
|
||||
let linkText = link.replace(/\.md$/, "");
|
||||
// Remove /index for folder-based posts
|
||||
if (linkText.endsWith("/index") && linkText.split("/").length === 1) {
|
||||
linkText = linkText.replace("/index", "");
|
||||
}
|
||||
return {
|
||||
linkText: linkText,
|
||||
anchor: anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// If it's just a slug (no slashes), use it directly
|
||||
if (!link.includes("/")) {
|
||||
return {
|
||||
linkText: link,
|
||||
anchor: anchor,
|
||||
};
|
||||
}
|
||||
|
||||
return { linkText: null, anchor: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse markdown files from content directory
|
||||
*/
|
||||
function readContentFiles(dirPath) {
|
||||
const posts = [];
|
||||
|
||||
try {
|
||||
const items = readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = join(dirPath, item);
|
||||
const stat = statSync(itemPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Handle folder-based posts
|
||||
const indexPath = join(itemPath, "index.md");
|
||||
if (existsSync(indexPath)) {
|
||||
const content = readFileSync(indexPath, "utf-8");
|
||||
const parsed = parseMarkdownFile(content, item);
|
||||
if (parsed) {
|
||||
posts.push(parsed);
|
||||
}
|
||||
}
|
||||
} else if (item.endsWith(".md")) {
|
||||
// Handle single-file posts
|
||||
const content = readFileSync(itemPath, "utf-8");
|
||||
const slug = item.replace(".md", "");
|
||||
const parsed = parseMarkdownFile(content, slug);
|
||||
if (parsed) {
|
||||
posts.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Error reading content directory:", error);
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown file and extract frontmatter and content
|
||||
*/
|
||||
function parseMarkdownFile(content, slug) {
|
||||
try {
|
||||
// Extract frontmatter (handle both \n and \r\n line endings)
|
||||
const frontmatterMatch = content.match(
|
||||
/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
|
||||
);
|
||||
if (!frontmatterMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, frontmatter, body] = frontmatterMatch;
|
||||
const lines = frontmatter.split(/\r?\n/);
|
||||
const data = {};
|
||||
|
||||
// Parse frontmatter (improved YAML parser)
|
||||
let currentKey = null;
|
||||
let currentArray = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
// Check if this is a key-value pair
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0 && !line.startsWith(" ")) {
|
||||
// Save previous array if we have one
|
||||
if (currentKey && currentArray.length > 0) {
|
||||
data[currentKey] = [...currentArray];
|
||||
currentArray = [];
|
||||
}
|
||||
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
let value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Check if this is an array key (next line starts with dash)
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("- ")) {
|
||||
currentKey = key;
|
||||
currentArray = [];
|
||||
} else {
|
||||
// Single value
|
||||
if (key === "date") {
|
||||
data[key] = new Date(value);
|
||||
} else if (key === "draft") {
|
||||
data[key] = value === "true";
|
||||
} else if (
|
||||
key === "imageOG" ||
|
||||
key === "hideCoverImage" ||
|
||||
key === "noIndex" ||
|
||||
key === "featured"
|
||||
) {
|
||||
data[key] = value === "true";
|
||||
} else {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
} else if (trimmedLine.startsWith("- ")) {
|
||||
// This is an array item
|
||||
const item = trimmedLine.substring(2).trim();
|
||||
currentArray.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Save final array if we have one
|
||||
if (currentKey && currentArray.length > 0) {
|
||||
data[currentKey] = [...currentArray];
|
||||
}
|
||||
|
||||
return {
|
||||
id: slug,
|
||||
data,
|
||||
body,
|
||||
};
|
||||
} catch (error) {
|
||||
log.warn(`Error parsing file ${slug}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate graph data from posts
|
||||
*/
|
||||
async function generateGraphData() {
|
||||
log.info("🔍 Analyzing post connections...");
|
||||
|
||||
try {
|
||||
// Get configuration values
|
||||
const maxNodes = getMaxNodesFromConfig();
|
||||
|
||||
// Read all posts from the content directory
|
||||
const postsDir = join(projectRoot, "src", "content", "posts");
|
||||
log.info("📁 Reading posts from:", postsDir);
|
||||
|
||||
const posts = readContentFiles(postsDir);
|
||||
log.info(`📄 Found ${posts.length} posts`);
|
||||
|
||||
// Filter out draft posts in production
|
||||
const isDev = process.env.NODE_ENV !== "production" && !process.argv.includes("--production");
|
||||
const visiblePosts = posts.filter((post) => isDev || !post.data.draft);
|
||||
|
||||
log.info(`📄 Processing ${visiblePosts.length} visible posts`);
|
||||
|
||||
// Generate nodes and connections
|
||||
const nodes = [];
|
||||
const connections = [];
|
||||
|
||||
// Process each post
|
||||
for (const post of visiblePosts) {
|
||||
// Add post node
|
||||
const postNode = {
|
||||
id: post.id,
|
||||
type: "post",
|
||||
title: post.data.title,
|
||||
slug: post.id,
|
||||
date: post.data.date
|
||||
? post.data.date.toISOString()
|
||||
: new Date().toISOString(),
|
||||
connections: 0,
|
||||
};
|
||||
nodes.push(postNode);
|
||||
|
||||
// Extract links from post content
|
||||
const wikilinks = extractWikilinks(post.body);
|
||||
const standardLinks = extractStandardLinks(post.body);
|
||||
const allLinks = [...wikilinks, ...standardLinks];
|
||||
|
||||
// Process links to other posts
|
||||
for (const link of allLinks) {
|
||||
const targetPost = visiblePosts.find((p) => p.id === link.slug);
|
||||
if (targetPost && targetPost.id !== post.id) {
|
||||
// Add post-to-post connection
|
||||
connections.push({
|
||||
source: post.id,
|
||||
target: targetPost.id,
|
||||
type: "link",
|
||||
});
|
||||
|
||||
// Update connection counts
|
||||
postNode.connections++;
|
||||
const targetNode = nodes.find((n) => n.id === targetPost.id);
|
||||
if (targetNode) {
|
||||
targetNode.connections++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply maxNodes filtering if configured
|
||||
let filteredNodes = nodes;
|
||||
let filteredConnections = connections;
|
||||
|
||||
if (maxNodes && nodes.length > maxNodes) {
|
||||
// Sort posts by connection count (descending), then by date (descending)
|
||||
const sortedPosts = nodes.sort((a, b) => {
|
||||
if (b.connections !== a.connections) {
|
||||
return b.connections - a.connections;
|
||||
}
|
||||
return new Date(b.date) - new Date(a.date);
|
||||
});
|
||||
|
||||
filteredNodes = sortedPosts.slice(0, maxNodes);
|
||||
|
||||
// Filter connections to only include those between selected nodes
|
||||
const selectedNodeIds = new Set(filteredNodes.map((n) => n.id));
|
||||
filteredConnections = connections.filter(
|
||||
(conn) =>
|
||||
selectedNodeIds.has(conn.source) && selectedNodeIds.has(conn.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Remove date field from nodes before exporting (only needed for sorting)
|
||||
const nodesForExport = filteredNodes.map(({ date, ...node }) => node);
|
||||
|
||||
// Generate graph data
|
||||
const graphData = {
|
||||
nodes: nodesForExport,
|
||||
connections: filteredConnections,
|
||||
metadata: {
|
||||
totalPosts: filteredNodes.length,
|
||||
totalConnections: filteredConnections.length,
|
||||
maxNodesApplied: maxNodes && nodes.length > maxNodes,
|
||||
originalNodeCount: nodes.length,
|
||||
},
|
||||
};
|
||||
|
||||
// Write graph data to file
|
||||
writeFileSync(OUTPUT_FILE, JSON.stringify(graphData, null, 2));
|
||||
|
||||
log.info("✅ Graph data generated successfully!");
|
||||
if (graphData.metadata.maxNodesApplied) {
|
||||
log.info(
|
||||
`📊 Stats: ${graphData.metadata.totalPosts} posts, ${graphData.metadata.totalConnections} connections (filtered from ${graphData.metadata.originalNodeCount} total nodes)`
|
||||
);
|
||||
} else {
|
||||
log.info(
|
||||
`📊 Stats: ${graphData.metadata.totalPosts} posts, ${graphData.metadata.totalConnections} connections`
|
||||
);
|
||||
}
|
||||
log.info(`💾 Saved to: ${OUTPUT_FILE}`);
|
||||
} catch (error) {
|
||||
log.error("❌ Error generating graph data:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
generateGraphData();
|
||||
37
scripts/get-deployment-platform.js
Normal file
37
scripts/get-deployment-platform.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Get deployment platform from config
|
||||
* This script reads the deployment platform from src/config.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
function getDeploymentPlatform() {
|
||||
try {
|
||||
const configPath = join(process.cwd(), 'src', 'config.ts');
|
||||
const configContent = readFileSync(configPath, 'utf8');
|
||||
|
||||
// Extract platform from config
|
||||
const platformMatch = configContent.match(/platform:\s*["']([^"']+)["']/);
|
||||
|
||||
if (platformMatch) {
|
||||
return platformMatch[1];
|
||||
}
|
||||
|
||||
// Fallback to environment variable
|
||||
return process.env.DEPLOYMENT_PLATFORM || 'netlify';
|
||||
} catch (error) {
|
||||
console.error('Error reading config:', error.message);
|
||||
return process.env.DEPLOYMENT_PLATFORM || 'netlify';
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
export default getDeploymentPlatform;
|
||||
|
||||
// If run directly, output the platform
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
console.log(getDeploymentPlatform());
|
||||
}
|
||||
73
scripts/get-version.js
Normal file
73
scripts/get-version.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Get version information for Astro Modular theme
|
||||
* This script provides version information for use in other scripts
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Get the current theme version
|
||||
*/
|
||||
export function getThemeVersion() {
|
||||
try {
|
||||
// Try to read from VERSION file first
|
||||
const versionFile = join(process.cwd(), 'VERSION');
|
||||
const version = readFileSync(versionFile, 'utf8').trim();
|
||||
return version;
|
||||
} catch (error) {
|
||||
try {
|
||||
// Fallback to package.json
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
return packageJson.version || '0.1.0';
|
||||
} catch (error) {
|
||||
return '0.1.0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme name
|
||||
*/
|
||||
export function getThemeName() {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
return packageJson.name || 'astro-modular';
|
||||
} catch (error) {
|
||||
return 'astro-modular';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full theme identifier (name@version)
|
||||
*/
|
||||
export function getThemeIdentifier() {
|
||||
return `${getThemeName()}@${getThemeVersion()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the version in both VERSION file and package.json
|
||||
*/
|
||||
export function updateVersion(newVersion) {
|
||||
try {
|
||||
// Update VERSION file
|
||||
writeFileSync('VERSION', newVersion + '\n');
|
||||
|
||||
// Update package.json
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
packageJson.version = newVersion;
|
||||
writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update version:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If run directly, output the version
|
||||
if (process.argv[1] && process.argv[1].endsWith('get-version.js')) {
|
||||
console.log(getThemeVersion());
|
||||
}
|
||||
337
scripts/process-aliases.js
Normal file
337
scripts/process-aliases.js
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Simple logging utility
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const log = {
|
||||
info: (...args) => isDev && console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
warn: (...args) => console.warn(...args)
|
||||
};
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Define content directories to process
|
||||
const CONTENT_DIRS = [
|
||||
'src/content/pages',
|
||||
'src/content/posts',
|
||||
'src/content/projects',
|
||||
'src/content/docs'
|
||||
];
|
||||
|
||||
// Function to parse frontmatter from markdown content
|
||||
function parseFrontmatter(content) {
|
||||
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: null, content: content };
|
||||
}
|
||||
|
||||
const frontmatterText = match[1];
|
||||
const body = match[2];
|
||||
|
||||
// Parse YAML-like frontmatter (simple parser)
|
||||
const frontmatter = {};
|
||||
const lines = frontmatterText.split('\n');
|
||||
let currentKey = null;
|
||||
let currentValue = [];
|
||||
let inArray = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.includes(':') && (!inArray || !trimmed.startsWith(' '))) {
|
||||
// Save previous key-value pair
|
||||
if (currentKey) {
|
||||
if (currentValue.length === 1) {
|
||||
frontmatter[currentKey] = currentValue[0];
|
||||
} else {
|
||||
frontmatter[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Start new key-value pair
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
currentKey = trimmed.substring(0, colonIndex).trim();
|
||||
const value = trimmed.substring(colonIndex + 1).trim();
|
||||
|
||||
// Reset array state when starting a new key
|
||||
inArray = false;
|
||||
|
||||
if (value.startsWith('[')) {
|
||||
// Array value
|
||||
inArray = true;
|
||||
currentValue = [];
|
||||
if (value !== '[') {
|
||||
// Single line array
|
||||
const arrayContent = value.substring(1, value.endsWith(']') ? value.length - 1 : value.length);
|
||||
if (arrayContent.trim()) {
|
||||
currentValue = arrayContent.split(',').map(item => {
|
||||
const trimmed = item.trim();
|
||||
if (trimmed.startsWith('"[[') && trimmed.endsWith(']]"')) {
|
||||
// Keep quotes for Obsidian bracket syntax
|
||||
return trimmed;
|
||||
} else {
|
||||
// Remove quotes for regular values
|
||||
return trimmed.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
});
|
||||
}
|
||||
inArray = false;
|
||||
}
|
||||
} else if (value) {
|
||||
// Single value - preserve quotes for Obsidian bracket syntax
|
||||
if (value.startsWith('"[[') && value.endsWith(']]"')) {
|
||||
// Keep quotes for Obsidian bracket syntax
|
||||
currentValue = [value];
|
||||
} else {
|
||||
// Remove quotes for regular values
|
||||
currentValue = [value.replace(/^["']|["']$/g, '')];
|
||||
}
|
||||
} else {
|
||||
// Empty value, might be start of array
|
||||
currentValue = [];
|
||||
inArray = true;
|
||||
}
|
||||
} else if (inArray && trimmed.startsWith('-')) {
|
||||
// Array item - preserve quotes for Obsidian bracket syntax
|
||||
const item = trimmed.substring(1).trim();
|
||||
if (item.startsWith('"[[') && item.endsWith(']]"')) {
|
||||
// Keep quotes for Obsidian bracket syntax
|
||||
currentValue.push(item);
|
||||
} else {
|
||||
// Remove quotes for regular values
|
||||
currentValue.push(item.replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
} else if (inArray && trimmed === ']') {
|
||||
// End of array
|
||||
inArray = false;
|
||||
} else if (currentKey && !inArray) {
|
||||
// Continuation of single value
|
||||
currentValue = [currentValue[0] + ' ' + trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
// Save last key-value pair
|
||||
if (currentKey) {
|
||||
if (currentValue.length === 1) {
|
||||
frontmatter[currentKey] = currentValue[0];
|
||||
} else {
|
||||
frontmatter[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
return { frontmatter, content: body };
|
||||
}
|
||||
|
||||
// Function to convert frontmatter back to YAML string
|
||||
function frontmatterToString(frontmatter) {
|
||||
const lines = ['---'];
|
||||
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (Array.isArray(value)) {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
// Preserve quotes for Obsidian bracket syntax in arrays
|
||||
if (typeof item === 'string' && item.startsWith('"[[') && item.endsWith(']]"')) {
|
||||
lines.push(` - ${item}`);
|
||||
} else {
|
||||
lines.push(` - ${item}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Preserve quotes for Obsidian bracket syntax in single values
|
||||
if (typeof value === 'string' && value.startsWith('"[[') && value.endsWith(']]"')) {
|
||||
lines.push(`${key}: ${value}`);
|
||||
} else {
|
||||
lines.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('---');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Function to get the current slug from a file path
|
||||
function getCurrentSlug(filePath) {
|
||||
// Normalize path separators
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Extract slug based on content type
|
||||
if (normalizedPath.includes('/posts/')) {
|
||||
let postPath = normalizedPath.replace(/^.*\/posts\//, '').replace(/\.md$/, '');
|
||||
if (postPath.endsWith('/index')) {
|
||||
postPath = postPath.replace('/index', '');
|
||||
}
|
||||
return postPath;
|
||||
} else if (normalizedPath.includes('/projects/')) {
|
||||
let projectPath = normalizedPath.replace(/^.*\/projects\//, '').replace(/\.md$/, '');
|
||||
if (projectPath.endsWith('/index')) {
|
||||
projectPath = projectPath.replace('/index', '');
|
||||
}
|
||||
return projectPath;
|
||||
} else if (normalizedPath.includes('/docs/')) {
|
||||
let docPath = normalizedPath.replace(/^.*\/docs\//, '').replace(/\.md$/, '');
|
||||
if (docPath.endsWith('/index')) {
|
||||
docPath = docPath.replace('/index', '');
|
||||
}
|
||||
return docPath;
|
||||
} else if (normalizedPath.includes('/pages/')) {
|
||||
let pagePath = normalizedPath.replace(/^.*\/pages\//, '').replace(/\.md$/, '');
|
||||
if (pagePath.endsWith('/index')) {
|
||||
pagePath = pagePath.replace('/index', '');
|
||||
}
|
||||
if (pagePath === 'index') {
|
||||
return 'index';
|
||||
}
|
||||
return pagePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function to process a single markdown file
|
||||
async function processMarkdownFile(filePath) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const { frontmatter, content: body } = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatter || !frontmatter.aliases) {
|
||||
return false; // No aliases to process
|
||||
}
|
||||
|
||||
// Get the current slug for this file
|
||||
const currentSlug = getCurrentSlug(filePath);
|
||||
|
||||
// Ensure aliases is an array
|
||||
const aliasesArray = Array.isArray(frontmatter.aliases)
|
||||
? frontmatter.aliases
|
||||
: [frontmatter.aliases];
|
||||
|
||||
// Ensure aliases are in the correct format (no leading slash) and filter out self-aliases
|
||||
const cleanAliases = aliasesArray
|
||||
.map(alias => alias.startsWith('/') ? alias.substring(1) : alias)
|
||||
.filter(alias => {
|
||||
// Remove aliases that match the current slug (prevents self-redirects)
|
||||
if (currentSlug && alias === currentSlug) {
|
||||
log.warn(`⚠️ Removing self-alias "${alias}" from ${filePath} (matches current slug)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no valid aliases remain, remove the aliases field entirely
|
||||
if (cleanAliases.length === 0) {
|
||||
delete frontmatter.aliases;
|
||||
} else {
|
||||
// Update frontmatter with cleaned aliases
|
||||
frontmatter.aliases = cleanAliases.length === 1 ? cleanAliases[0] : cleanAliases;
|
||||
}
|
||||
|
||||
// Only write back if we made changes (removed aliases or cleaned them)
|
||||
const originalAliasesCount = aliasesArray.length;
|
||||
const hadChanges = cleanAliases.length !== originalAliasesCount ||
|
||||
(cleanAliases.length === 0 && originalAliasesCount > 0);
|
||||
|
||||
if (hadChanges) {
|
||||
// Rebuild the file content
|
||||
const newFrontmatter = frontmatterToString(frontmatter);
|
||||
const newContent = `${newFrontmatter}\n${body}`;
|
||||
|
||||
// Write back to file
|
||||
await fs.writeFile(filePath, newContent, 'utf-8');
|
||||
}
|
||||
|
||||
return { processed: hadChanges, aliases: cleanAliases.length };
|
||||
} catch (error) {
|
||||
log.error(`❌ Error processing ${filePath}:`, error.message);
|
||||
return { processed: false, aliases: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Function to process all markdown files in a directory (including folder-based content)
|
||||
async function processDirectory(dirPath) {
|
||||
try {
|
||||
const files = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
let processedCount = 0;
|
||||
let totalAliases = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isDirectory()) {
|
||||
// Handle folder-based content (e.g., folder-name/index.md)
|
||||
const folderPath = path.join(dirPath, file.name);
|
||||
try {
|
||||
const indexPath = path.join(folderPath, 'index.md');
|
||||
await fs.access(indexPath);
|
||||
const result = await processMarkdownFile(indexPath);
|
||||
if (result.processed) {
|
||||
processedCount++;
|
||||
totalAliases += result.aliases;
|
||||
}
|
||||
} catch (error) {
|
||||
// index.md doesn't exist in this folder, skip
|
||||
}
|
||||
} else if (file.isFile() && file.name.endsWith('.md')) {
|
||||
// Handle single-file content
|
||||
const filePath = path.join(dirPath, file.name);
|
||||
const result = await processMarkdownFile(filePath);
|
||||
if (result.processed) {
|
||||
processedCount++;
|
||||
totalAliases += result.aliases;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { processedCount, totalAliases };
|
||||
} catch (error) {
|
||||
log.error(`❌ Error processing directory ${dirPath}:`, error.message);
|
||||
return { processedCount: 0, totalAliases: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function processAllAliases() {
|
||||
log.info('🔄 Processing aliases and converting to redirect_from...');
|
||||
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
let totalProcessed = 0;
|
||||
let totalAliases = 0;
|
||||
|
||||
for (const dir of CONTENT_DIRS) {
|
||||
const fullPath = path.join(projectRoot, dir);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
const result = await processDirectory(fullPath);
|
||||
totalProcessed += result.processedCount;
|
||||
totalAliases += result.totalAliases;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
log.error(`❌ Error accessing directory ${dir}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalProcessed > 0) {
|
||||
log.info(`📁 Processing pages directory...`);
|
||||
log.info(`📁 Processing posts directory...`);
|
||||
log.info(`📁 Processing projects directory...`);
|
||||
log.info(`📁 Processing docs directory...`);
|
||||
log.info(` Processed ${totalProcessed} files with ${totalAliases} aliases`);
|
||||
}
|
||||
|
||||
log.info(`🎉 Alias processing complete! Processed ${totalProcessed} files.`);
|
||||
}
|
||||
|
||||
// Run the script
|
||||
processAllAliases();
|
||||
38
scripts/setup-dev.mjs
Normal file
38
scripts/setup-dev.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { existsSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const astroCacheDir = path.join(projectRoot, '.astro');
|
||||
const viteCacheDir = path.join(projectRoot, 'node_modules', '.vite');
|
||||
|
||||
console.log('⚙️ Setting up development environment...');
|
||||
|
||||
// Wipe .astro completely so content layer and route cache are fresh every dev run
|
||||
try {
|
||||
if (existsSync(astroCacheDir)) {
|
||||
console.log(`Cleaning .astro cache: ${astroCacheDir}`);
|
||||
rmSync(astroCacheDir, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(astroCacheDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(`Failed to clean .astro: ${error.message}`);
|
||||
}
|
||||
|
||||
// Wipe Vite deps cache so modules are re-resolved
|
||||
try {
|
||||
const viteDeps = path.join(viteCacheDir, 'deps');
|
||||
if (existsSync(viteDeps)) {
|
||||
console.log(`Cleaning Vite deps cache: ${viteDeps}`);
|
||||
rmSync(viteDeps, { recursive: true, force: true });
|
||||
}
|
||||
if (!existsSync(viteCacheDir)) {
|
||||
mkdirSync(viteCacheDir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to clean Vite cache: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('✅ Development environment setup complete.');
|
||||
378
scripts/sync-images.js
Normal file
378
scripts/sync-images.js
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Simple logging utility
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const log = {
|
||||
info: (...args) => isDev && console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
warn: (...args) => console.warn(...args)
|
||||
};
|
||||
|
||||
// Define source and target directories for posts, pages, projects, docs, and special
|
||||
const IMAGE_SYNC_CONFIGS = [
|
||||
{
|
||||
source: 'src/content/posts/attachments',
|
||||
target: 'public/posts/attachments',
|
||||
name: 'posts'
|
||||
},
|
||||
{
|
||||
source: 'src/content/pages/attachments',
|
||||
target: 'public/pages/attachments',
|
||||
name: 'pages'
|
||||
},
|
||||
{
|
||||
source: 'src/content/projects/attachments',
|
||||
target: 'public/projects/attachments',
|
||||
name: 'projects'
|
||||
},
|
||||
{
|
||||
source: 'src/content/docs/attachments',
|
||||
target: 'public/docs/attachments',
|
||||
name: 'docs'
|
||||
},
|
||||
{
|
||||
source: 'src/content/special/attachments',
|
||||
target: 'public/special/attachments',
|
||||
name: 'special'
|
||||
}
|
||||
];
|
||||
|
||||
// Recursively find all media files in a directory (images, audio, video, PDF)
|
||||
async function findImageFiles(dir, relativePath = '') {
|
||||
const imageFiles = [];
|
||||
|
||||
try {
|
||||
const items = await fs.readdir(dir);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dir, item);
|
||||
const itemRelativePath = path.join(relativePath, item);
|
||||
const stat = await fs.stat(itemPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Recursively search subdirectories
|
||||
const subImages = await findImageFiles(itemPath, itemRelativePath);
|
||||
imageFiles.push(...subImages);
|
||||
} else if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|tif|ico|mp3|wav|ogg|m4a|3gp|flac|aac|mp4|webm|ogv|mov|mkv|avi|pdf)$/i.test(item)) {
|
||||
// Skip WebP files if we already have the original (to avoid duplicate processing)
|
||||
// We'll generate WebP from originals
|
||||
if (item.toLowerCase().endsWith('.webp')) {
|
||||
const originalName = item.replace(/\.webp$/i, '');
|
||||
const hasOriginal = items.some(i => {
|
||||
const nameWithoutExt = i.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '');
|
||||
return nameWithoutExt === originalName && /\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(i);
|
||||
});
|
||||
if (hasOriginal) {
|
||||
// Skip WebP if we have the original - we'll generate it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
imageFiles.push({
|
||||
sourcePath: itemPath,
|
||||
relativePath: itemRelativePath
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might not exist or be readable, continue
|
||||
if (error.code !== 'ENOENT') {
|
||||
log.warn(`Warning: Could not read directory ${dir}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return imageFiles;
|
||||
}
|
||||
|
||||
// Function to find folder-based content and sync their images
|
||||
async function syncFolderBasedImages(contentType) {
|
||||
const contentDir = `src/content/${contentType}`;
|
||||
const publicContentDir = `public/${contentType}`;
|
||||
|
||||
try {
|
||||
const items = await fs.readdir(contentDir);
|
||||
let totalSynced = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(contentDir, item);
|
||||
const stat = await fs.stat(itemPath);
|
||||
|
||||
// Check if it's a directory (folder-based content)
|
||||
if (stat.isDirectory()) {
|
||||
const targetDir = path.join(publicContentDir, item);
|
||||
|
||||
// Find all media files recursively
|
||||
const imageFiles = await findImageFiles(itemPath);
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
// Handle attachments subfolder within folder-based content
|
||||
// Convert src/content/posts/post-name/attachments/image.png -> public/posts/post-name/image.png
|
||||
let targetRelativePath = imageFile.relativePath;
|
||||
// Handle both forward and backward slashes for cross-platform compatibility
|
||||
if (targetRelativePath.startsWith('attachments/') || targetRelativePath.startsWith('attachments\\')) {
|
||||
targetRelativePath = targetRelativePath.replace(/^attachments[/\\]/, '');
|
||||
}
|
||||
|
||||
const targetPath = path.join(targetDir, targetRelativePath);
|
||||
const targetDirForFile = path.dirname(targetPath);
|
||||
|
||||
// Ensure target directory exists
|
||||
await ensureDir(targetDirForFile);
|
||||
|
||||
// Check if file needs updating - check both original and WebP versions
|
||||
let needsUpdate = true;
|
||||
|
||||
// Check if this is an image that would be converted to WebP
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
||||
const webpPath = path.join(targetDir, targetRelativePath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp'));
|
||||
try {
|
||||
const sourceStats = await fs.stat(imageFile.sourcePath);
|
||||
const webpStats = await fs.stat(webpPath);
|
||||
// Only update if source is newer than WebP
|
||||
needsUpdate = sourceStats.mtime > webpStats.mtime;
|
||||
} catch {
|
||||
// WebP doesn't exist, needs update
|
||||
needsUpdate = true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const sourceStats = await fs.stat(imageFile.sourcePath);
|
||||
const targetStats = await fs.stat(targetPath);
|
||||
// Only update if source is newer or different size
|
||||
needsUpdate = sourceStats.mtime > targetStats.mtime || sourceStats.size !== targetStats.size;
|
||||
} catch {
|
||||
// Target doesn't exist, definitely needs update
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
// Optimize image if it's an image format (not audio, video, or PDF)
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
||||
try {
|
||||
// Convert to WebP and optimize
|
||||
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
||||
await sharp(imageFile.sourcePath)
|
||||
.webp({ quality: 85 })
|
||||
.toFile(webpPath);
|
||||
totalSynced++;
|
||||
} catch (error) {
|
||||
// If Sharp fails, fall back to copying original
|
||||
log.warn(`⚠️ Could not optimize ${imageFile.relativePath}, copying original:`, error.message);
|
||||
await fs.copyFile(imageFile.sourcePath, targetPath);
|
||||
totalSynced++;
|
||||
}
|
||||
} else {
|
||||
// Non-image files (audio, video, PDF) - just copy as-is
|
||||
await fs.copyFile(imageFile.sourcePath, targetPath);
|
||||
totalSynced++;
|
||||
}
|
||||
} else {
|
||||
totalSkipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSynced > 0 || totalSkipped > 0) {
|
||||
log.info(`📁 Syncing folder-based ${contentType} images...`);
|
||||
if (totalSynced > 0) log.info(` Synced ${totalSynced} files`);
|
||||
if (totalSkipped > 0) log.info(` Skipped ${totalSkipped} files that were unchanged`);
|
||||
}
|
||||
|
||||
return { synced: totalSynced, skipped: totalSkipped };
|
||||
} catch (error) {
|
||||
log.error(`❌ Error syncing folder-based ${contentType} images:`, error);
|
||||
return { synced: 0, skipped: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
try {
|
||||
await fs.access(dir);
|
||||
} catch {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function syncImagesForConfig(config) {
|
||||
// Ensure target directory exists
|
||||
await ensureDir(config.target);
|
||||
|
||||
try {
|
||||
// Check if source directory exists
|
||||
let sourceFiles = [];
|
||||
try {
|
||||
sourceFiles = await fs.readdir(config.source);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return { synced: 0, skipped: 0, removed: 0 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Find all image files recursively (including subdirectories)
|
||||
const imageFiles = await findImageFiles(config.source);
|
||||
|
||||
let synced = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
const targetPath = path.join(config.target, imageFile.relativePath);
|
||||
const targetDirForFile = path.dirname(targetPath);
|
||||
|
||||
// Ensure target directory exists (including subdirectories)
|
||||
await ensureDir(targetDirForFile);
|
||||
|
||||
// Check if file needs updating - check both original and WebP versions
|
||||
let needsUpdate = true;
|
||||
|
||||
// Check if this is an image that would be converted to WebP
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
||||
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
||||
try {
|
||||
const sourceStats = await fs.stat(imageFile.sourcePath);
|
||||
const webpStats = await fs.stat(webpPath);
|
||||
// Only update if source is newer than WebP
|
||||
needsUpdate = sourceStats.mtime > webpStats.mtime;
|
||||
} catch {
|
||||
// WebP doesn't exist, needs update
|
||||
needsUpdate = true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const sourceStats = await fs.stat(imageFile.sourcePath);
|
||||
const targetStats = await fs.stat(targetPath);
|
||||
// Only update if source is newer or different size
|
||||
needsUpdate = sourceStats.mtime > targetStats.mtime || sourceStats.size !== targetStats.size;
|
||||
} catch {
|
||||
// Target doesn't exist, definitely needs update
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
// Optimize image if it's an image format (not audio, video, or PDF)
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
||||
try {
|
||||
// Convert to WebP and optimize
|
||||
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
||||
await sharp(imageFile.sourcePath)
|
||||
.webp({ quality: 85 })
|
||||
.toFile(webpPath);
|
||||
synced++;
|
||||
} catch (error) {
|
||||
// If Sharp fails, fall back to copying original
|
||||
log.warn(`⚠️ Could not optimize ${imageFile.relativePath}, copying original:`, error.message);
|
||||
await fs.copyFile(imageFile.sourcePath, targetPath);
|
||||
synced++;
|
||||
}
|
||||
} else {
|
||||
// Non-image files (audio, video, PDF) - just copy as-is
|
||||
await fs.copyFile(imageFile.sourcePath, targetPath);
|
||||
synced++;
|
||||
}
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: Remove files from target that no longer exist in source
|
||||
// We need to recursively clean up the target directory
|
||||
await cleanupTargetDirectory(config.target, imageFiles);
|
||||
|
||||
return { synced, skipped, removed: 0 }; // removed count handled in cleanup function
|
||||
} catch (error) {
|
||||
log.error(`❌ Error syncing ${config.name} images:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively clean up target directory, removing files that no longer exist in source
|
||||
async function cleanupTargetDirectory(targetDir, sourceImageFiles) {
|
||||
// Create a set that maps both original paths and attachments subfolder paths
|
||||
const sourceFileSet = new Set();
|
||||
sourceImageFiles.forEach(f => {
|
||||
sourceFileSet.add(f.relativePath);
|
||||
// Also add the path without attachments/ prefix for cleanup
|
||||
if (f.relativePath.startsWith('attachments/') || f.relativePath.startsWith('attachments\\')) {
|
||||
sourceFileSet.add(f.relativePath.replace(/^attachments[/\\]/, ''));
|
||||
}
|
||||
// Add WebP version of image paths (since we generate WebP from originals)
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(f.relativePath)) {
|
||||
const webpPath = f.relativePath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
||||
sourceFileSet.add(webpPath);
|
||||
if (f.relativePath.startsWith('attachments/') || f.relativePath.startsWith('attachments\\')) {
|
||||
const webpPathNoAttachments = webpPath.replace(/^attachments[/\\]/, '');
|
||||
sourceFileSet.add(webpPathNoAttachments);
|
||||
}
|
||||
}
|
||||
});
|
||||
let removed = 0;
|
||||
|
||||
try {
|
||||
const items = await fs.readdir(targetDir);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(targetDir, item);
|
||||
const stat = await fs.stat(itemPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Recursively clean subdirectories
|
||||
const subRemoved = await cleanupTargetDirectory(itemPath, sourceImageFiles);
|
||||
removed += subRemoved;
|
||||
|
||||
// Remove empty directories
|
||||
try {
|
||||
const remainingItems = await fs.readdir(itemPath);
|
||||
if (remainingItems.length === 0) {
|
||||
await fs.rmdir(itemPath);
|
||||
}
|
||||
} catch {
|
||||
// Directory might not be empty or might have been removed already
|
||||
}
|
||||
} else {
|
||||
// Check if this file exists in source
|
||||
const relativePath = path.relative(targetDir, itemPath).replace(/\\/g, '/');
|
||||
if (!sourceFileSet.has(relativePath)) {
|
||||
await fs.unlink(itemPath);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might not exist or be readable
|
||||
if (error.code !== 'ENOENT') {
|
||||
log.warn(`Warning: Could not clean directory ${targetDir}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function syncAllImages() {
|
||||
log.info('🖼️ Syncing images from content to public directory...');
|
||||
|
||||
for (const config of IMAGE_SYNC_CONFIGS) {
|
||||
const result = await syncImagesForConfig(config);
|
||||
if (result.synced > 0 || result.skipped > 0) {
|
||||
log.info(`📁 Syncing ${config.name} images...`);
|
||||
if (result.synced > 0) log.info(` Synced ${result.synced} files`);
|
||||
if (result.skipped > 0) log.info(` Skipped ${result.skipped} files that were unchanged`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync folder-based images for all content types
|
||||
const contentTypes = ['posts', 'pages', 'projects', 'docs', 'special'];
|
||||
for (const contentType of contentTypes) {
|
||||
await syncFolderBasedImages(contentType);
|
||||
}
|
||||
|
||||
log.info('🎉 Image sync complete!');
|
||||
}
|
||||
|
||||
syncAllImages();
|
||||
317
scripts/update.mjs
Normal file
317
scripts/update.mjs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Astro Modular Update Script
|
||||
*
|
||||
* Downloads the latest release from GitHub, replaces framework files,
|
||||
* and preserves user content and assets. After updating, open Obsidian
|
||||
* and click "Apply all settings" in the Astro Modular Settings plugin
|
||||
* to rewrite your config.ts with your saved settings.
|
||||
*
|
||||
* Usage: pnpm run update
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { createWriteStream, existsSync, mkdirSync, cpSync, rmSync, readFileSync, readdirSync, statSync, renameSync, writeFileSync } from 'fs';
|
||||
import { join, resolve, basename } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { get as httpsGet } from 'https';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const REPO = 'davidvkimball/astro-modular';
|
||||
|
||||
// Files and directories that belong to the USER and must be preserved
|
||||
const USER_PATHS = [
|
||||
'src/content', // All user content + .obsidian vault (plugin data.json files)
|
||||
'public/profile.jpg',
|
||||
'public/profile.png',
|
||||
'public/profile.webp',
|
||||
'public/favicon.png',
|
||||
'public/favicon-dark.png',
|
||||
'public/favicon-light.png',
|
||||
'public/open-graph.png',
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.production',
|
||||
];
|
||||
|
||||
// Directories that should never be touched
|
||||
const SKIP_PATHS = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.astro',
|
||||
'.netlify',
|
||||
];
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function log(msg) { console.log(` ${msg}`); }
|
||||
function logStep(msg) { console.log(`\n> ${msg}`); }
|
||||
function logError(msg) { console.error(`\n ERROR: ${msg}`); }
|
||||
|
||||
function getCurrentVersion() {
|
||||
const versionFile = join(ROOT, 'VERSION');
|
||||
if (existsSync(versionFile)) {
|
||||
return readFileSync(versionFile, 'utf-8').trim();
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
async function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpsGet(url, { headers: { 'User-Agent': 'astro-modular-updater' } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return fetchJSON(res.headers.location).then(resolve, reject);
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { reject(new Error(`Invalid JSON from ${url}`)); }
|
||||
});
|
||||
res.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpsGet(url, { headers: { 'User-Agent': 'astro-modular-updater' } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return downloadFile(res.headers.location, dest).then(resolve, reject);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
||||
}
|
||||
const ws = createWriteStream(dest);
|
||||
pipeline(res, ws).then(resolve, reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function copyIfExists(src, dest) {
|
||||
if (!existsSync(src)) return false;
|
||||
const stat = statSync(src);
|
||||
if (stat.isDirectory()) {
|
||||
cpSync(src, dest, { recursive: true });
|
||||
} else {
|
||||
const dir = join(dest, '..');
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
cpSync(src, dest);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('\nAstro Modular Updater');
|
||||
console.log('====================');
|
||||
|
||||
// 1. Get current version
|
||||
const currentVersion = getCurrentVersion();
|
||||
log(`Current version: ${currentVersion}`);
|
||||
|
||||
// 2. Check latest release
|
||||
logStep('Checking for updates...');
|
||||
let release;
|
||||
try {
|
||||
release = await fetchJSON(`https://api.github.com/repos/${REPO}/releases/latest`);
|
||||
} catch (err) {
|
||||
logError(`Could not reach GitHub: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const latestVersion = release.tag_name.replace(/^v/, '');
|
||||
log(`Latest version: ${latestVersion}`);
|
||||
|
||||
if (currentVersion === latestVersion) {
|
||||
log('Already up to date!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Download release archive
|
||||
logStep(`Downloading v${latestVersion}...`);
|
||||
const isWindows = process.platform === 'win32';
|
||||
const archiveUrl = isWindows ? release.zipball_url : release.tarball_url;
|
||||
const tempDir = join(tmpdir(), `astro-modular-update-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const archivePath = join(tempDir, isWindows ? 'release.zip' : 'release.tar.gz');
|
||||
|
||||
try {
|
||||
await downloadFile(archiveUrl, archivePath);
|
||||
} catch (err) {
|
||||
logError(`Download failed: ${err.message}`);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
log(`Downloaded to temp directory.`);
|
||||
|
||||
// 4. Extract archive
|
||||
logStep('Extracting...');
|
||||
const extractDir = join(tempDir, 'extracted');
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
try {
|
||||
if (isWindows) {
|
||||
execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`, { stdio: 'pipe' });
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, { stdio: 'pipe' });
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`Extraction failed: ${err.message}`);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// GitHub tarballs extract into a directory like "owner-repo-hash/"
|
||||
const extractedContents = readdirSync(extractDir);
|
||||
if (extractedContents.length !== 1) {
|
||||
logError('Unexpected archive structure.');
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
const sourceDir = join(extractDir, extractedContents[0]);
|
||||
log('Extracted successfully.');
|
||||
|
||||
// 5. Back up user files
|
||||
logStep('Backing up user content...');
|
||||
const backupDir = join(tempDir, 'backup');
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
let backedUp = 0;
|
||||
for (const userPath of USER_PATHS) {
|
||||
const src = join(ROOT, userPath);
|
||||
const dest = join(backupDir, userPath);
|
||||
if (copyIfExists(src, dest)) {
|
||||
log(` Backed up: ${userPath}`);
|
||||
backedUp++;
|
||||
}
|
||||
}
|
||||
|
||||
// Also back up any user-added public/ files not in the new release
|
||||
const publicDir = join(ROOT, 'public');
|
||||
const newPublicDir = join(sourceDir, 'public');
|
||||
if (existsSync(publicDir)) {
|
||||
for (const file of readdirSync(publicDir)) {
|
||||
const fullPath = join(publicDir, file);
|
||||
if (statSync(fullPath).isFile()) {
|
||||
const alreadyBacked = USER_PATHS.some(p => p === `public/${file}`);
|
||||
if (!alreadyBacked) {
|
||||
// Check if this file does NOT exist in the new release (user-added)
|
||||
if (!existsSync(join(newPublicDir, file))) {
|
||||
const dest = join(backupDir, 'public', file);
|
||||
mkdirSync(join(backupDir, 'public'), { recursive: true });
|
||||
cpSync(fullPath, dest);
|
||||
log(` Backed up (user asset): public/${file}`);
|
||||
backedUp++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`${backedUp} item(s) backed up.`);
|
||||
|
||||
// 6. Replace framework files
|
||||
logStep('Updating framework files...');
|
||||
|
||||
// Get list of all paths in the new release (excluding user/skip paths)
|
||||
function listPaths(dir, base = '') {
|
||||
const results = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const rel = base ? `${base}/${entry}` : entry;
|
||||
const full = join(dir, entry);
|
||||
|
||||
// Skip paths we should never touch
|
||||
if (SKIP_PATHS.includes(rel)) continue;
|
||||
// Skip user content paths (we'll restore from backup)
|
||||
if (USER_PATHS.includes(rel)) continue;
|
||||
|
||||
if (statSync(full).isDirectory()) {
|
||||
// If it's a user directory root, skip recursing
|
||||
if (USER_PATHS.some(p => p.startsWith(rel + '/'))) {
|
||||
// Only skip exact user-path prefixes like src/content
|
||||
if (USER_PATHS.includes(rel)) continue;
|
||||
results.push(...listPaths(full, rel));
|
||||
} else {
|
||||
results.push(...listPaths(full, rel));
|
||||
}
|
||||
} else {
|
||||
results.push(rel);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const newFiles = listPaths(sourceDir);
|
||||
let updated = 0;
|
||||
|
||||
for (const relPath of newFiles) {
|
||||
const src = join(sourceDir, relPath);
|
||||
const dest = join(ROOT, relPath);
|
||||
const destDir = join(dest, '..');
|
||||
|
||||
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
||||
cpSync(src, dest);
|
||||
updated++;
|
||||
}
|
||||
log(`${updated} framework file(s) updated.`);
|
||||
|
||||
// 7. Restore user files from backup
|
||||
logStep('Restoring user content...');
|
||||
let restored = 0;
|
||||
|
||||
for (const userPath of USER_PATHS) {
|
||||
const src = join(backupDir, userPath);
|
||||
const dest = join(ROOT, userPath);
|
||||
if (copyIfExists(src, dest)) {
|
||||
log(` Restored: ${userPath}`);
|
||||
restored++;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore user-added public/ files
|
||||
const backupPublicDir = join(backupDir, 'public');
|
||||
if (existsSync(backupPublicDir)) {
|
||||
for (const file of readdirSync(backupPublicDir)) {
|
||||
const alreadyRestored = USER_PATHS.some(p => p === `public/${file}`);
|
||||
if (!alreadyRestored) {
|
||||
cpSync(join(backupPublicDir, file), join(ROOT, 'public', file));
|
||||
log(` Restored (user asset): public/${file}`);
|
||||
restored++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`${restored} item(s) restored.`);
|
||||
|
||||
// 8. Install dependencies
|
||||
logStep('Installing dependencies...');
|
||||
try {
|
||||
execSync('pnpm install', { cwd: ROOT, stdio: 'inherit' });
|
||||
} catch {
|
||||
logError('pnpm install failed. You may need to run it manually.');
|
||||
}
|
||||
|
||||
// 9. Clean up temp directory
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
// 10. Done
|
||||
console.log('\n====================================');
|
||||
console.log(` Updated to v${latestVersion}!`);
|
||||
console.log('====================================');
|
||||
console.log('\n Next steps:');
|
||||
console.log(' 1. Open your vault in Obsidian');
|
||||
console.log(' 2. Go to Astro Modular Settings > Advanced tab');
|
||||
console.log(' 3. Click "Apply to config.ts" under "Apply all settings"');
|
||||
console.log(' to write your saved settings to the new config.ts');
|
||||
console.log(' 4. Run `pnpm dev` to verify everything works');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
logError(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue