955 lines
34 KiB
JavaScript
955 lines
34 KiB
JavaScript
#!/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();
|