Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 51 additions & 16 deletions craco.config.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,51 @@
const path = require("path");
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;

// Import the remote configurations
// Note: Since this is a craco.config.js (Node.js environment), we use require.
const remotes = require("./src/remotes/mf-remotes.config.js").default;
// The remote list is no longer needed at build time: containers are resolved and
// fetched in the browser by src/remotes/loadRemote.js, so nothing here has to
// know their URLs.

module.exports = {
webpack: {
configure: (webpackConfig) => {
webpackConfig.output.publicPath = "auto";

// Dynamically generate the remotes object based on the environment
const isProduction = process.env.NODE_ENV === "production";
const remoteEntries = remotes.reduce((acc, remote) => {
const url = isProduction ? remote.prodUrl : remote.devUrl;
acc[remote.name] = `${remote.name}@${url}`;
return acc;
}, {});

webpackConfig.plugins.push(
new ModuleFederationPlugin({
name: "host",
remotes: remoteEntries, // Use the dynamically generated object
// Deliberately no `remotes` option.
//
// A statically-declared remote gets an `initExternal()` call emitted
// into webpack's share-scope initialiser, which runs at app startup —
// so all four remoteEntry.js files were fetched on every route,
// including the landing page, which renders none of them.
//
// Containers are loaded on demand instead, by src/remotes/loadRemote.js.
// The `shared` scope below is still required: it is what the runtime
// hands to each container's init() so React stays a singleton.
// Only share what actually runs in the browser and is worth
// deduplicating with the remotes. Spreading every entry of
// `dependencies` here also shares build-only packages
// (react-scripts, @craco/craco, @testing-library/*), which webpack
// then has to resolve into the share scope for no benefit.
//
// These are NOT `eager: true`: src/index.js already provides the
// async boundary (`import("./bootstrap")`) that eager consumption
// exists to work around. Keeping them lazy lets webpack emit React
// and the router as their own long-lived chunks instead of inlining
// them into main.js on every build.
shared: {
...deps,
react: {
singleton: true,
eager: true,
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
eager: true,
requiredVersion: deps["react-dom"],
},
"react-router-dom": {
singleton: true,
eager: true,
requiredVersion: deps["react-router-dom"],
},
},
Expand All @@ -54,4 +63,30 @@ module.exports = {
devServer: {
historyApiFallback: true,
},
jest: {
configure: (jestConfig) => {
// CRA 5 pins jest 27, whose resolver predates `exports` maps entirely.
// react-router 7 relies on them:
//
// - react-router-dom declares `main: "./dist/main.js"`, a file it does
// not ship (only dist/index.js exists), so the `main` fallback fails
// outright — this is what made every router-importing test die with
// "Cannot find module 'react-router-dom'".
// - react-router/dom is exports-only, with no `main` to fall back to,
// and Node's own resolution picks the .mjs build that jest 27 cannot
// parse.
//
// Map both to the CJS builds. webpack resolves these correctly on its
// own, so this affects tests only.
const cjs = (p) => path.resolve(__dirname, "node_modules", p);
jestConfig.moduleNameMapper = {
...jestConfig.moduleNameMapper,
"^react-router-dom$": cjs("react-router-dom/dist/index.js"),
"^react-router/dom$": cjs(
"react-router/dist/development/dom-export.js"
),
};
return jestConfig;
},
},
};
98 changes: 98 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
[build]
command = "npm run build"
publish = "build"

# ---------------------------------------------------------------------------
# Caching
# ---------------------------------------------------------------------------
# Netlify's default for this site was `public, max-age=0, must-revalidate` on
# everything, including webpack's content-hashed assets. Those filenames change
# whenever their contents change, so revalidating them costs a round-trip per
# asset per visit and can never return anything but 304.
[[headers]]
for = "/static/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

# index.html is the only file whose URL is stable while its contents change —
# it must never be cached, or visitors keep booting a stale asset manifest.
[[headers]]
for = "/index.html"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"

[[headers]]
for = "/"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"

# Unhashed public/ assets: cache briefly, but let them be replaced same-day.
[[headers]]
for = "/*.webp"
[headers.values]
Cache-Control = "public, max-age=86400"

[[headers]]
for = "/*.png"
[headers.values]
Cache-Control = "public, max-age=86400"

[[headers]]
for = "/favicon.svg"
[headers.values]
Cache-Control = "public, max-age=86400"

[[headers]]
for = "/Devi_R_Senior_Frontend_Engineer_Resume.pdf"
[headers.values]
Cache-Control = "public, max-age=86400"

# ---------------------------------------------------------------------------
# Security headers
# ---------------------------------------------------------------------------
# This shell executes JavaScript fetched from four Render origins and frames two
# Vercel origins at runtime. A CSP is the one place that trust boundary is
# written down and enforced; without it any injected script can pull code from
# anywhere. The allowlist below is the architecture, stated explicitly.
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=(), interest-cohort=()"
Content-Security-Policy = """
default-src 'self'; \
script-src 'self' 'unsafe-inline' \
https://react-post-login-dashboard.onrender.com \
https://react-ecommerce-catalogue-page.onrender.com \
https://react-syntax-highlighter.onrender.com \
https://react-webgl-paint-splatter.onrender.com \
https://cdn.jsdelivr.net \
https://www.googletagmanager.com \
https://www.google-analytics.com; \
style-src 'self' 'unsafe-inline' \
https://react-post-login-dashboard.onrender.com \
https://react-ecommerce-catalogue-page.onrender.com \
https://react-syntax-highlighter.onrender.com \
https://react-webgl-paint-splatter.onrender.com; \
img-src 'self' data: blob: https:; \
font-src 'self' data:; \
worker-src 'self' blob: \
https://react-syntax-highlighter.onrender.com; \
connect-src 'self' \
https://react-post-login-dashboard.onrender.com \
https://react-ecommerce-catalogue-page.onrender.com \
https://react-syntax-highlighter.onrender.com \
https://react-webgl-paint-splatter.onrender.com \
https://express-mock-server-rose.vercel.app \
https://cdn.jsdelivr.net \
https://www.google-analytics.com \
https://region1.google-analytics.com; \
frame-src 'self' \
https://nextjs-portfolio-blogs.vercel.app \
https://nextjs-fullstack-ai-fe-system-desig.vercel.app; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self'; \
object-src 'none'
"""
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"scripts": {
"start": "PORT=3000 craco start",
"build": "craco build",
"build": "craco build && node scripts/generate-seo.js",
"test": "craco test",
"eject": "react-scripts eject"
},
Expand Down
9 changes: 8 additions & 1 deletion public/_redirects
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Fallback only.
#
# `npm run build` overwrites this file in build/ via scripts/generate-seo.js,
# which emits an explicit 200 rule per known route plus a catch-all that returns
# a real 404 status for everything else. This blanket rule stays here so that a
# bare `craco build` (without the generate step) still produces a working SPA
# rather than 404ing every deep link.

# SPA fallback rule (must be last)
/* /index.html 200

13 changes: 7 additions & 6 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,33 @@
<meta name="robots" content="max-image-preview:large" />

<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.devi-r.com/" />
<meta property="og:url" content="https://devi-r.com/" />
<meta property="og:title" content="Devi R" />
<meta
property="og:description"
content="Devi R, Senior Frontend Engineer, Portfolio: An intentional dive into microfrontend architecture. This 'over-engineered' site serves as a case study in modern frontend system design."
/>
<meta
property="og:image"
content="https://www.devi-r.com/landing-preview.png"
content="https://devi-r.com/landing-preview.png"
/>
<meta property="og:site_name" content="Devi R" />

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://www.devi-r.com/" />
<meta name="twitter:url" content="https://devi-r.com/" />
<meta name="twitter:title" content="Devi R" />
<meta
name="twitter:description"
content="Devi R, Senior Frontend Engineer, Portfolio: An intentional dive into microfrontend architecture. This 'over-engineered' site serves as a case study in modern frontend system design."
/>
<meta
name="twitter:image"
content="https://www.devi-r.com/landing-preview.png"
content="https://devi-r.com/landing-preview.png"
/>

<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="canonical" href="https://devi-r.com/" />
<title>Devi R</title>
<script type="importmap">
{
Expand All @@ -56,13 +57,13 @@
"@type": "Person",
"name": "Devi R"
},
"image": "https://www.devi-r.com/landing-preview.png",
"image": "https://devi-r.com/landing-preview.png",
"publisher": {
"@type": "Person",
"name": "Devi R"
},
"datePublished": "2025-10-06",
"url": "https://www.devi-r.com"
"url": "https://devi-r.com"
}
</script>
</head>
Expand Down
96 changes: 96 additions & 0 deletions scripts/generate-seo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/* eslint-disable */
// Generates build/sitemap.xml and build/_redirects from the route metadata in
// src/constants/pageMeta.js, so the deployed site's SEO surface can never drift
// from the routes the router actually serves.
//
// Run as part of `npm run build` (see package.json). Requires Node >= 22, which
// can require() an ES module graph — the same assumption craco.config.js
// already makes when it requires src/remotes/mf-remotes.config.js.

const fs = require("fs");
const path = require("path");

let SITE_ORIGIN, getIndexableRoutes, getServableRoutes;
try {
({
SITE_ORIGIN,
getIndexableRoutes,
getServableRoutes,
} = require("../src/constants/pageMeta.js"));
} catch (err) {
console.error(
"[generate-seo] Could not load src/constants/pageMeta.js.\n" +
"This script require()s an ES module, which needs Node >= 22.12.\n" +
`Current Node: ${process.version}\n\n` +
err.message
);
process.exit(1);
}

const BUILD_DIR = path.join(__dirname, "..", "build");

if (!fs.existsSync(BUILD_DIR)) {
console.error(
"[generate-seo] build/ not found — run this after `craco build`."
);
process.exit(1);
}

const routes = getIndexableRoutes();
const lastmod = new Date().toISOString().slice(0, 10);

// ---------------------------------------------------------------- sitemap.xml
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${routes
.map(
({ path: routePath, priority }) =>
` <url>
<loc>${SITE_ORIGIN}${routePath}</loc>
<lastmod>${lastmod}</lastmod>
<priority>${priority}</priority>
</url>`
)
.join("\n")}
</urlset>
`;

fs.writeFileSync(path.join(BUILD_DIR, "sitemap.xml"), sitemap);

// ------------------------------------------------------------------ robots.txt
// Point crawlers at the sitemap we just generated.
const robotsPath = path.join(BUILD_DIR, "robots.txt");
let robots = fs.existsSync(robotsPath)
? fs.readFileSync(robotsPath, "utf8").replace(/^Sitemap:.*$/gm, "").trimEnd()
: "User-agent: *\nAllow: /";
robots += `\n\nSitemap: ${SITE_ORIGIN}/sitemap.xml\n`;
fs.writeFileSync(robotsPath, robots);

// ------------------------------------------------------------------ _redirects
// A blanket `/* /index.html 200` makes every unknown URL answer 200 OK with the
// app shell — a soft 404. Search engines then index unlimited junk URLs as real
// pages. Instead: serve the shell with 200 for routes the router knows about,
// and serve the same shell with a genuine 404 status for everything else. The
// custom NotFoundPage still renders either way.
//
// Note this uses every servable route, not just the indexable ones: a route can
// legitimately be excluded from the sitemap (unlisted, or noindex) while still
// having to answer 200 when someone visits it directly.
const knownRoutes = getServableRoutes();

const redirects = `# Generated by scripts/generate-seo.js — do not edit by hand.
# Known application routes: serve the SPA shell with 200 OK.
${knownRoutes
.map((routePath) => `${routePath.padEnd(34)}/index.html 200`)
.join("\n")}

# Anything else is genuinely not found. The shell still boots and renders the
# custom 404 page, but the response carries a 404 status for crawlers.
/*${" ".repeat(32)}/index.html 404
`;

fs.writeFileSync(path.join(BUILD_DIR, "_redirects"), redirects);

console.log(
`[generate-seo] wrote sitemap.xml (${routes.length} urls), robots.txt and _redirects`
);
5 changes: 5 additions & 0 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import { remoteRoutesMetadata } from "./remotes/mf-remote-routes";
import ModuleFederationRemote from "./components/remoteWrappers/ModuleFederationRemote";
import { iframeRoutesMetadata } from "./remotes/iframe-routes";
import IframeRemote from "./components/remoteWrappers/IFrameRemote";
import useDocumentMeta from "./hooks/useDocumentMeta";

const RootLayout = () => {
// Keeps <title>, description, canonical and the OG/Twitter tags in step with
// the active route. Lives here so every route is covered, remotes included.
useDocumentMeta();

return (
<ErrorBoundary>
<ConditionalLayout>
Expand Down
8 changes: 0 additions & 8 deletions src/App.test.js

This file was deleted.

Loading