diff --git a/.github/workflows/framework-candidate.yml b/.github/workflows/framework-candidate.yml new file mode 100644 index 0000000..d41b00c --- /dev/null +++ b/.github/workflows/framework-candidate.yml @@ -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 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d909feb..29d4b01 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,8 +1,6 @@ name: Deploy static content to Pages on: - push: - branches: ['main'] workflow_dispatch: permissions: @@ -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 }} @@ -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 diff --git a/.gitignore b/.gitignore index ea4b6a9..fd4092d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 09b310a..17bad1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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. @@ -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. @@ -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: @@ -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: diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 0000000..95df630 --- /dev/null +++ b/app/entry.server.tsx @@ -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((resolve, reject) => { + const { pipe } = renderToPipeableStream( + , + { + 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); + }, + } + ); + }); +} diff --git a/app/lib/contentRouteData.server.ts b/app/lib/contentRouteData.server.ts new file mode 100644 index 0000000..bab3032 --- /dev/null +++ b/app/lib/contentRouteData.server.ts @@ -0,0 +1 @@ +export { loadContentRouteData } from "../../shared/content/loadContentRouteData.mjs"; diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..815793e --- /dev/null +++ b/app/root.tsx @@ -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 ( + + + + + + +``` + +### Decision: Build Script Ownership + +Keep production build steps explicit while the SEO pipeline is being stabilized. + +Preferred conceptual steps: + +```txt +generate:curriculum +generate:seo-manifest +build:client +build:ssg or framework prerender +seo:postbuild +fallback:404 +seo:validate +``` + +Rules: + +- Avoid hiding too much behind `prebuild` and `postbuild` until the flow is stable. +- If the main `build` script explicitly runs `generate:curriculum`, remove or adjust the existing `prebuild` script so generation does not run twice. +- `generate:seo-manifest` may be a separate command or part of `generate:curriculum`, but ownership must be clear. +- `fallback:404` must generate a dedicated Not Found + `noindex` document, not blindly copy `dist/index.html`. +- If `fallback:404` is separate from `seo:postbuild`, run final SEO validation after the fallback file exists. +- CI/GitHub Actions should use the same ordered build steps as local production validation. + +### Decision: Pre-render Output Budgets + +Define output and build-time budgets before scaling from the POC to every sitemap-eligible route. + +Initial budgets to choose during the POC: + +- Maximum total `dist` size. +- Maximum HTML file size per route. +- Maximum serialized route-data JSON size per route. +- Maximum number of pre-rendered routes in the first sitemap release. +- Maximum production build time. + +Important risk: + +If each content route includes full rendered HTML and also serializes full Markdown as route data, output size can grow quickly. Prefer the smallest route data needed for hydration, and measure the POC before generating every topic page. + +### Acceptance Criteria + +- `https://www.dev-net-core.com/sitemap.xml` is accessible. +- The sitemap includes all required sitemap-eligible routes that have validated static/pre-rendered production output. +- Every curriculum topic has a `/content/:topicId/` entry. +- `/practice/:topicId/` routes are intentionally excluded from the first SEO sitemap pass. +- Sitemap URLs are absolute canonical URLs, not relative paths. +- Sitemap URLs use the trailing-slash format. +- Sitemap URLs are indexable, direct-loadable, not redirected, not blocked by `robots.txt`, and not marked `noindex`. +- Every sitemap URL has route-specific source HTML metadata and visible page content in source HTML. +- Every sitemap-included content URL receives Markdown and curriculum metadata before pre-rendering. +- Every sitemap-included content URL hydrates with the same initial route data used during pre-rendering. +- No sitemap-included content URL renders `Loading content...` as its primary source HTML content. +- Sitemap generation uses manifest `canonicalPath` values for content pages. +- Sitemap and pre-render generation use the Node-readable SEO manifest, not the TypeScript app manifest. +- Generated route HTML is based on `dist/index.html` and preserves Vite-generated hashed asset references. +- Generated route HTML includes safe serialized route data only when needed for hydration. +- The private server/static render build output is not deployed to GitHub Pages. +- Sitemap generation omits `` unless a reliable automated source can provide an accurate value. +- Sitemap generation does not include `` or ``. +- SEO postbuild output does not break `npm run build`. +- Local development does not require production sitemap generation. + +### Decision: Practice Topic Indexing + +For the first SEO pass, do not pre-render or index `/practice/:topicId/` pages. + +Use `/content/:topicId/` as the primary SEO landing page and source of truth for each curriculum topic. Treat `/practice/:topicId/` as an interactive app workflow route for users who want to practice after reading the topic content. + +Initial indexing strategy: + +```txt +Index and include in sitemap after static/pre-render validation: +/ +/content/ +/content/:topicId/ +/roadmap/ +/about-us/ +/privacy/ +/terms/ + +Optional index and sitemap only if intentionally designed to rank: +/changelog/ +/simulation/ + +Do not include in sitemap initially: +/practice/ +/practice/:topicId/ +/simulation/* +/bug-report/ + +Add noindex initially: +/practice/:topicId/ +/simulation/* +/bug-report/ +``` + +Rules: + +- `/content/:topicId/` pages should be the canonical searchable landing pages. +- `/practice/:topicId/` pages should be linked from their corresponding content pages for user flow. +- `/practice/:topicId/` pages should not be included in the sitemap until they are intentionally designed as standalone searchable Q&A landing pages. +- `/practice/:topicId/` pages should use `noindex` initially to avoid thin-content or duplicate-intent indexing risk. +- `/practice/` itself should not be included in the first sitemap pass unless it has enough visible source HTML content and is intended to rank as a practice landing page. +- `/simulation/` itself may remain indexable only if it has enough visible source HTML content and is intended to rank as a stable public tool landing route. +- `/simulation/*` session, result, or user-flow routes should use `noindex` and should not be included in the sitemap. + +Only consider indexing `/practice/:topicId/` later if those pages become strong standalone pages with: + +- Unique title and description +- Enough meaningful initial HTML content +- Clear question/answer content visible without relying only on interaction +- Distinct purpose from the corresponding content page +- Self-canonical URL +- Structured data only if it accurately matches the visible content and Google guidelines + +## Phase 3: Crawlable Internal Links + +### 3. Ensure Topic Links Use Real Anchors + +Search engines discover links through real HTML anchor tags. + +Ensure navigation to topic pages renders links with valid `href` values. + +Good: + +```tsx + + Classes, Structs, and Records + +``` + +Also good: + +```tsx + + Classes, Structs, and Records + +``` + +Avoid: + +```tsx + +``` + +Buttons are fine for app interactions, but important SEO navigation should also be available through real links. + +### Decision: Curriculum Link Semantics + +Follow the crawlable-link plan with a targeted navigation semantics change, not a redesign. + +Keep curriculum folder rows as `