diff --git a/.circleci/config.yml b/.circleci/config.yml index 89a815ef02..2ad9210b01 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -123,8 +123,9 @@ jobs: ../bin/compile build /tmp mv build/bin/nginx ../bin/ - run: - name: Require redirects file to be generated - command: test -f config/nginx-redirects.conf + name: Require redirects file to be generated with content + command: | + ./bin/assert-nginx-redirects.sh - run: name: Verify all files are compressed command: ./bin/assert-compressed.sh diff --git a/bin/assert-nginx-redirects.sh b/bin/assert-nginx-redirects.sh new file mode 100755 index 0000000000..eacc2c9075 --- /dev/null +++ b/bin/assert-nginx-redirects.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# +# A utility script to assert that the nginx redirects configuration file exists and is valid +# +# Usage: assert-nginx-redirects.sh +# + +echo "Validating redirect file..." + +# Check file exists +if [ ! -f config/nginx-redirects.conf ]; then + echo "ERROR: config/nginx-redirects.conf does not exist" + exit 1 +fi +echo "✓ File exists" + +# Check file is not empty +if [ ! -s config/nginx-redirects.conf ]; then + echo "ERROR: config/nginx-redirects.conf is empty (0 bytes)" + ls -lah config/nginx-redirects.conf + exit 1 +fi + +# Count redirects (lines ending with semicolon) +REDIRECT_COUNT=$(grep -c ';$' config/nginx-redirects.conf || echo "0") +echo "✓ Found ${REDIRECT_COUNT} redirects" + +echo "✓ Validation passed: ${REDIRECT_COUNT} redirects in config/nginx-redirects.conf" diff --git a/data/createPages/index.ts b/data/createPages/index.ts index 08bac2ccba..4b96204cb0 100644 --- a/data/createPages/index.ts +++ b/data/createPages/index.ts @@ -9,13 +9,12 @@ import { createLanguagePageVariants } from './createPageVariants'; import { LATEST_ABLY_API_VERSION_STRING } from '../transform/constants'; import { createContentMenuDataFromPage } from './createContentMenuDataFromPage'; import { DEFAULT_LANGUAGE } from './constants'; -import { writeRedirectToConfigFile } from './writeRedirectToConfigFile'; +import { writeRedirectToConfigFile, getRedirectCount } from './writeRedirectToConfigFile'; import { siteMetadata } from '../../gatsby-config'; -import { GatsbyNode } from 'gatsby'; +import { GatsbyNode, Reporter } from 'gatsby'; import { examples, DEFAULT_EXAMPLE_LANGUAGES } from '../../src/data/examples/'; import { Example } from '../../src/data/examples/types'; -const writeRedirect = writeRedirectToConfigFile('config/nginx-redirects.conf'); const documentTemplate = path.resolve(`src/templates/document.tsx`); const apiReferenceTemplate = path.resolve(`src/templates/apiReference.tsx`); const examplesTemplate = path.resolve(`src/templates/examples.tsx`); @@ -101,7 +100,11 @@ interface MdxRedirectsQueryResult { }; } -export const createPages: GatsbyNode['createPages'] = async ({ graphql, actions: { createPage, createRedirect } }) => { +export const createPages: GatsbyNode['createPages'] = async ({ + graphql, + actions: { createPage, createRedirect }, + reporter, +}) => { /** * It's not ideal to have: * * the reusable function `documentCreator` defined inline like this @@ -111,6 +114,9 @@ export const createPages: GatsbyNode['createPages'] = async ({ graphql, actions: * and testable function. */ + // Initialize redirect writer with reporter + const writeRedirect = writeRedirectToConfigFile('config/nginx-redirects.conf', reporter); + // DOCUMENT TEMPLATE const documentResult = await graphql(` query { @@ -245,6 +251,8 @@ export const createPages: GatsbyNode['createPages'] = async ({ graphql, actions: // We need to be prefix aware just like Gatsby's internals so it works // with nginx redirects writeRedirect(redirectFrom, pagePath); + } else { + reporter.info(`[REDIRECTS] Skipping hash fragment redirect: ${redirectFrom} (hash: ${redirectFromUrl.hash})`); } createRedirect({ @@ -341,6 +349,8 @@ export const createPages: GatsbyNode['createPages'] = async ({ graphql, actions: // We need to be prefix aware just like Gatsby's internals so it works // with nginx redirects writeRedirect(redirectFrom, toPath); + } else { + reporter.info(`[REDIRECTS] Skipping MDX hash fragment redirect: ${redirectFrom} (hash: ${redirectFromUrl.hash})`); } createRedirect({ @@ -360,4 +370,6 @@ export const createPages: GatsbyNode['createPages'] = async ({ graphql, actions: ...examples.map(exampleCreator), ...mdxRedirectsResult.data.allMdx.nodes.map(mdxRedirectCreator), ]); + + reporter.info(`[REDIRECTS] Completed writing ${getRedirectCount()} redirects`); }; diff --git a/data/createPages/writeRedirectToConfigFile.ts b/data/createPages/writeRedirectToConfigFile.ts index 3e4bd19e77..ae84745614 100644 --- a/data/createPages/writeRedirectToConfigFile.ts +++ b/data/createPages/writeRedirectToConfigFile.ts @@ -1,10 +1,44 @@ import * as fs from 'fs'; +import { Reporter } from 'gatsby'; + +let redirectCount = 0; +let isInitialized = false; + +export const writeRedirectToConfigFile = (filePath: string, reporter?: Reporter) => { + // Detect re-initialization + if (isInitialized) { + reporter?.warn(`[REDIRECTS] WARNING: writeRedirectToConfigFile called multiple times!`); + return (from: string, to: string) => { + reporter?.warn(`[REDIRECTS] Skipping redirect write due to re-initialization: ${from} -> ${to}`); + }; + } + + reporter?.info(`[REDIRECTS] Initializing redirect file at ${filePath}`); + + try { + fs.writeFileSync(filePath, ''); + isInitialized = true; + } catch (error) { + reporter?.error(`[REDIRECTS] Failed to initialize redirect file: ${error}`); + throw error; + } -export const writeRedirectToConfigFile = (filePath: string) => { - fs.writeFileSync(filePath, ''); return (from: string, to: string) => { - fs.appendFileSync(filePath, createRedirectForConfigFile(from, to)); + try { + fs.appendFileSync(filePath, createRedirectForConfigFile(from, to)); + redirectCount++; + } catch (error) { + reporter?.error(`[REDIRECTS] Failed to write redirect ${from} -> ${to}: ${error}`); + throw error; + } }; }; +export const getRedirectCount = (): number => redirectCount; + +export const resetRedirectCount = (): void => { + redirectCount = 0; + isInitialized = false; +}; + const createRedirectForConfigFile = (from: string, to: string): string => `${from} ${to};\n`; diff --git a/data/onPostBuild/index.ts b/data/onPostBuild/index.ts index 844392b4d6..b5738663c9 100644 --- a/data/onPostBuild/index.ts +++ b/data/onPostBuild/index.ts @@ -1,8 +1,36 @@ -import { GatsbyNode } from 'gatsby'; +import { GatsbyNode, Reporter } from 'gatsby'; import { onPostBuild as llmstxt } from './llmstxt'; import { onPostBuild as compressAssets } from './compressAssets'; +import { validateRedirectFile, REDIRECT_FILE_PATH } from '../utils/validateRedirectFile'; + +const validateRedirects = async (reporter: Reporter): Promise => { + reporter.info(`[REDIRECTS] Validating redirect file...`); + + const result = validateRedirectFile({ + minRedirects: 10, // arbitrarily small, just enough to get a sense of if things are healthy + validateFormat: true, + }); + + // Report errors + if (result.errors.length > 0) { + result.errors.forEach((error) => reporter.error(`[REDIRECTS] ${error}`)); + reporter.panicOnBuild( + `CRITICAL: ${REDIRECT_FILE_PATH} validation failed. This will cause all redirects to fail in production!`, + ); + return; + } + + // Report warnings + result.warnings.forEach((warning) => reporter.warn(`[REDIRECTS] ${warning}`)); + + // Report success + reporter.info(`[REDIRECTS] ✓ Validation passed: ${result.lineCount} redirects found`); +}; export const onPostBuild: GatsbyNode['onPostBuild'] = async (args) => { + // Validate redirects first - fail fast if there's an issue + await validateRedirects(args.reporter); + // Run all onPostBuild functions in sequence await llmstxt(args); await compressAssets(args); diff --git a/data/utils/validateRedirectFile.ts b/data/utils/validateRedirectFile.ts new file mode 100644 index 0000000000..75f293c838 --- /dev/null +++ b/data/utils/validateRedirectFile.ts @@ -0,0 +1,85 @@ +import * as fs from 'fs'; + +export const REDIRECT_FILE_PATH = 'config/nginx-redirects.conf'; + +export interface RedirectValidationResult { + exists: boolean; + lineCount: number; + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Validates the nginx redirects configuration file + * @param options Optional configuration for validation + * @returns Validation result with status and any errors/warnings + */ +export const validateRedirectFile = (options?: { + minRedirects?: number; + validateFormat?: boolean; +}): RedirectValidationResult => { + const { minRedirects = 0, validateFormat = true } = options ?? {}; + + const result: RedirectValidationResult = { + exists: false, + lineCount: 0, + isValid: true, + errors: [], + warnings: [], + }; + + // Check if file exists + if (!fs.existsSync(REDIRECT_FILE_PATH)) { + result.errors.push(`${REDIRECT_FILE_PATH} does not exist`); + result.isValid = false; + return result; + } + result.exists = true; + + // Read and count non-empty lines + const content = fs.readFileSync(REDIRECT_FILE_PATH, 'utf-8'); + const lines = content.trim().split('\n').filter((line) => line.length > 0); + result.lineCount = lines.length; + + // Check if file has content + if (result.lineCount === 0) { + result.errors.push(`${REDIRECT_FILE_PATH} is empty (no redirect rules found)`); + result.isValid = false; + return result; + } + + // Check minimum redirect count + if (minRedirects > 0 && result.lineCount < minRedirects) { + result.warnings.push( + `Found only ${result.lineCount} redirects, expected at least ${minRedirects}. ` + + `This may indicate an issue with redirect generation.`, + ); + } + + // Validate redirect format if requested + if (validateFormat) { + const invalidLines = lines.filter((line) => !line.match(/^\/[^\s]+ \/[^\s]+;$/)); + + if (invalidLines.length > 0) { + result.warnings.push( + `Found ${invalidLines.length} lines with invalid redirect format. ` + + `Expected format: "/from /to;" - First few: ${invalidLines.slice(0, 3).map((l) => `"${l}"`).join(', ')}`, + ); + } + } + + return result; +}; + +/** + * Counts the number of redirect lines written so far + * Used for in-process validation during build + */ +export const countRedirectLines = (): number => { + if (!fs.existsSync(REDIRECT_FILE_PATH)) { + return 0; + } + const content = fs.readFileSync(REDIRECT_FILE_PATH, 'utf-8'); + return content.trim().split('\n').filter((line) => line.length > 0).length; +};