Skip to content
Merged
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
68 changes: 68 additions & 0 deletions .github/workflows/framework-candidate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Framework candidate validation

on:
pull_request:
branches: ['main']
push:
branches: ['main']
workflow_dispatch:

permissions:
contents: read

concurrency:
group: framework-candidate-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
name: Validate framework candidate
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Test theme bootstrap
run: npm run test:theme-bootstrap

- name: Test SEO
run: npm run test:seo

- name: Test content route data
run: npm run test:content-route-data

- name: Build current SPA
run: npm run build

- name: Build framework candidate
shell: bash
run: |
started_at=$SECONDS
npm run build:framework
duration=$((SECONDS - started_at))
echo "Framework build duration: ${duration}s"
echo "Framework build duration: ${duration}s" >> "$GITHUB_STEP_SUMMARY"
if (( duration > 60 )); then
echo "::error::Framework build exceeded the 60-second budget (${duration}s)."
exit 1
fi

- name: Finalize framework candidate
run: npm run finalize:framework

- name: Validate framework candidate
run: npm run validate:framework
40 changes: 36 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
name: Deploy static content to Pages

on:
push:
branches: ['main']
workflow_dispatch:

permissions:
Expand All @@ -16,6 +14,9 @@ concurrency:

jobs:
deploy:
name: Validate and deploy current SPA
if: github.ref == 'refs/heads/main'
timeout-minutes: 15
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
Expand All @@ -28,15 +29,46 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version: 22
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build
- name: Lint
run: npm run lint

- name: Test theme bootstrap
run: npm run test:theme-bootstrap

- name: Test SEO
run: npm run test:seo

- name: Test content route data
run: npm run test:content-route-data

- name: Build current SPA
run: npm run build

- name: Build framework candidate
shell: bash
run: |
started_at=$SECONDS
npm run build:framework
duration=$((SECONDS - started_at))
echo "Framework build duration: ${duration}s"
echo "Framework build duration: ${duration}s" >> "$GITHUB_STEP_SUMMARY"
if (( duration > 60 )); then
echo "::error::Framework build exceeded the 60-second budget (${duration}s)."
exit 1
fi

- name: Finalize framework candidate
run: npm run finalize:framework

- name: Validate framework candidate
run: npm run validate:framework

- name: Create SPA fallback
run: cp dist/index.html dist/404.html

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ lerna-debug.log*
node_modules
dist
dist-ssr
/.react-router/
/spikes/react-router-7-ssg/build/
/build-framework/
*.local
/.playwright-cli/

Expand Down
63 changes: 62 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,36 @@ npm run lint
npm run preview
```

Framework migration candidate commands:

```bash
npm run build:framework
npm run finalize:framework
npm run validate:framework
npm run preview:framework
```

Important: `npm run dev` and `npm run build` both generate the curriculum manifest first through the configured pre-scripts. If curriculum content or question markers are changed, make sure the generated manifest still works.

The project currently uses a dual-build cutover. `npm run build` writes the
deployable SPA to `dist/`. `npm run build:framework` writes the React Router
static/pre-render candidate to `build-framework/client/`.
`npm run finalize:framework` writes the candidate sitemap, candidate
`robots.txt`, and dedicated `404.html` into that client output. Do not switch
the GitHub Pages deployment artifact until the framework route matrix, fallback
handling, and production URLs pass their dedicated cutover validation.

GitHub Actions intentionally separates validation from deployment:

* `.github/workflows/framework-candidate.yml` runs the dual-build validation
gate for pull requests and pushes to `main`, but has read-only permissions and
does not upload or deploy a Pages artifact. It is advisory until repository
branch protection is explicitly updated.
* `.github/workflows/main.yml` is manual-only, runs the same validation gate,
and continues to deploy the existing `dist/` SPA from `main`.
* Do not make the candidate workflow a required branch check or change the
deployed directory to `build-framework/client/` without explicit approval.

## Important Project Areas

### Main Routes
Expand All @@ -94,6 +122,8 @@ The application includes these important routes:

Do not break existing route behavior unless explicitly requested.

Canonical public SEO URLs should use trailing slashes, for example `/content/`, `/content/:topicId/`, `/roadmap/`, `/about-us/`, `/privacy/`, and `/terms/`. Non-trailing variants may continue to work for user convenience, but they should not be used in sitemap URLs, canonical tags, Open Graph URLs, structured data URLs, or internal generated SEO links.

### Content System

The curriculum is Markdown-driven.
Expand Down Expand Up @@ -309,7 +339,7 @@ Important behavior:
* The progress section should be hidden.
* The "On This Page" section should not show "Interview Practice".
* `/content/:topicId` displays the selected curriculum topic.
* Invalid topic IDs should redirect to the first available topic.
* Invalid topic IDs should render a clear Not Found state with `noindex`, not redirect to the first available topic.
* The left panel contains the curriculum navigation.
* The main area renders Markdown content.
* The right panel contains "On This Page", topic progress, and interview practice navigation when applicable.
Expand Down Expand Up @@ -524,6 +554,8 @@ Before changing code:
7. Avoid unrelated refactors.
8. Avoid renaming public IDs, slugs, routes, or question markers unless required.
9. Run validation commands when possible.
10. Create a local Git commit after each completed implementation step.
11. Ask the user for permission before pushing or reverting changes.

After changing code, report:

Expand Down Expand Up @@ -562,6 +594,35 @@ When a task affects routing, check:
* Navigation links
* GitHub Pages or production base path compatibility

## SEO and Pre-rendering Rules

Follow the SEO plan in `docs/plans/dev-net-core-seo-improvement-plan.md` when working on metadata, routing, sitemap output, pre-rendering, or production deployment behavior.

Important SEO route rules:

* `/content/:topicId/` is the primary searchable landing page for each curriculum topic.
* `/practice/:topicId/`, `/simulation/*`, `/bug-report/`, and utility/user-flow routes are `noindex` and excluded from the sitemap by default.
* `/practice/:topicId/` should stay out of the sitemap unless it is later redesigned as a standalone searchable page with unique visible content.
* `/simulation/` and `/changelog/` are optional sitemap routes only if they have enough unique visible source HTML content and are intended to rank.
* Invalid content or practice topic IDs should render Not Found + `noindex`; do not redirect them to another valid topic.
* Do not block `noindex` pages in `robots.txt`; crawlers must be able to read the `noindex` directive.

Sitemap and static output rules:

* Every sitemap URL must be absolute, canonical, trailing-slash, indexable, direct-loadable, and production validated.
* A sitemap URL must return `200 OK` on direct production load and must not redirect.
* A sitemap URL must have route-specific source HTML metadata and visible source HTML content.
* A sitemap URL must have matching static/pre-rendered output and must not depend on the GitHub Pages `404.html` SPA fallback.
* Do not include routes in `sitemap.xml` until their production static/pre-rendered output is validated.
* Submit the sitemap to Google Search Console only after the production validation gate passes.
* A generated public `404.html` must be a dedicated Not Found + `noindex` document, not a blind copy of the homepage.

Build and metadata ownership rules:

* Markdown frontmatter and the generated curriculum/SEO manifests are the source of truth for content metadata.
* Shared SEO metadata should drive sitemap generation, pre-rendered HTML, React route metadata, Open Graph tags, Twitter/X tags, and JSON-LD.
* Framework finalization must preserve React Router/Vite-generated hashed assets. Deploy only the final public client output and never a private server/static build.

## GitHub Pages and Domain Notes

The production website is:
Expand Down
36 changes: 36 additions & 0 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@react-router/node";
import type { EntryContext } from "react-router";
import { ServerRouter } from "react-router";
import { renderToPipeableStream } from "react-dom/server";

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext
) {
return new Promise<Response>((resolve, reject) => {
const { pipe } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
onAllReady() {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set("Content-Type", "text/html");
pipe(body);
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
},
onShellError(error) {
reject(error);
},
}
);
});
}
1 change: 1 addition & 0 deletions app/lib/contentRouteData.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { loadContentRouteData } from "../../shared/content/loadContentRouteData.mjs";
87 changes: 87 additions & 0 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { ReactNode } from "react";
import {
Links,
Meta,
Navigate,
Outlet,
Scripts,
ScrollRestoration,
useLocation,
} from "react-router";
import "../src/App.css";
import "../src/index.css";
import { AppProviders } from "../src/AppProviders";
import { getCanonicalPathname } from "../src/routing/canonicalPath";

export function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="/icon.png" />
<script src="/theme-bootstrap.js" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function Root() {
return (
<AppProviders hydrateBrowserPersistence>
<CanonicalOutlet />
</AppProviders>
);
}

export function HydrateFallback() {
return (
<main className="theme-page flex min-h-screen items-center justify-center px-6">
<section className="theme-content-card max-w-xl rounded-lg p-8 text-center">
<p className="gleeple-heading theme-accent text-sm font-semibold uppercase">
Error 404
</p>
<h1 className="gleeple-heading theme-text mt-3 text-3xl font-semibold">
404 - Page Not Found
</h1>
<p className="theme-muted mt-4">
The requested DEV_NET_CORE page could not be found.
</p>
<a
className="gleeple-heading theme-accent mt-6 inline-block font-semibold"
href="/"
>
Return home
</a>
</section>
</main>
);
}

function CanonicalOutlet() {
const location = useLocation();
const canonicalPathname = getCanonicalPathname(location.pathname);

if (canonicalPathname) {
return (
<Navigate
replace
state={location.state}
to={{
hash: location.hash,
pathname: canonicalPathname,
search: location.search,
}}
/>
);
}

return <Outlet />;
}
Loading