diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e6c500f..8d80262 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,6 +3,7 @@ version: 2 # updates, when enabled in the repository settings, do not follow this schedule. updates: - package-ecosystem: npm + target-branch: dev directory: / schedule: interval: monthly @@ -38,6 +39,7 @@ updates: update-types: - version-update:semver-major - package-ecosystem: nuget + target-branch: dev # Dependabot expands ProCoderJi.slnx here, covering the API and test projects # together so shared .NET packages can move in one reviewed pull request. directory: /backend @@ -56,6 +58,7 @@ updates: - minor - patch - package-ecosystem: github-actions + target-branch: dev directory: / schedule: interval: monthly diff --git a/.github/scripts/branch-policy.test.mjs b/.github/scripts/branch-policy.test.mjs new file mode 100644 index 0000000..1ed88bf --- /dev/null +++ b/.github/scripts/branch-policy.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { branchPolicy } from "./check-branch-policy.mjs"; + +const repository = "procoderji-com/procoderji"; +const check = (base, head, sourceRepository = repository) => branchPolicy({ base, head, sourceRepository, repository }); + +test("contributors and bots can send changes to dev", () => { + assert.equal(check("dev", "feat/student-experience", "contributor/procoderji"), null); + assert.equal(check("dev", "dependabot/npm_and_yarn/example"), null); +}); + +test("main accepts only the canonical dev branch", () => { + assert.equal(check("main", "dev"), null); + assert.ok(check("main", "feat/student-experience")); + assert.ok(check("main", "dependabot/npm_and_yarn/example")); + assert.ok(check("main", "dev", "contributor/procoderji")); +}); + +test("unknown or absent targets do not silently pass", () => { + assert.ok(check("release/1.0", "dev")); + assert.ok(check(undefined, undefined)); + assert.ok(branchPolicy({ base: "main", head: "dev" })); +}); diff --git a/.github/scripts/check-branch-policy.mjs b/.github/scripts/check-branch-policy.mjs new file mode 100644 index 0000000..e2b468a --- /dev/null +++ b/.github/scripts/check-branch-policy.mjs @@ -0,0 +1,26 @@ +import { pathToFileURL } from "node:url"; + +export function branchPolicy({ base, head, sourceRepository, repository }) { + if (!base || !head || !sourceRepository || !repository) return "Pull-request branch metadata is incomplete."; + if (base === "dev") return null; + if (base === "main" && head === "dev" && sourceRepository === repository) return null; + if (base === "main") { + return "Production PRs must promote this repository's dev branch. Send feature and dependency PRs to dev first."; + } + return "Contributions target dev; production promotions target main."; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const failure = branchPolicy({ + base: process.env.PR_BASE, + head: process.env.PR_HEAD, + sourceRepository: process.env.PR_SOURCE_REPOSITORY, + repository: process.env.TARGET_REPOSITORY, + }); + if (failure) { + console.error(failure); + process.exitCode = 1; + } else { + console.log("Branch policy passed."); + } +} diff --git a/.github/workflows/backend-containers.yml b/.github/workflows/backend-containers.yml index 6433440..c414780 100644 --- a/.github/workflows/backend-containers.yml +++ b/.github/workflows/backend-containers.yml @@ -12,7 +12,7 @@ on: - 'vercel.backend.json' - '.github/workflows/backend-containers.yml' push: - branches: [main] + branches: [dev, main] paths: - 'backend/**' - 'shared/**' diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 0000000..4e01a3d --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,30 @@ +name: Branch policy + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + +jobs: + branch-policy: + name: Branch policy + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Check out proposed policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Test branch policy + run: node --test .github/scripts/branch-policy.test.mjs + + - name: Enforce contribution and promotion targets + env: + PR_BASE: ${{ github.event.pull_request.base.ref }} + PR_HEAD: ${{ github.event.pull_request.head.ref }} + PR_SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + TARGET_REPOSITORY: ${{ github.repository }} + run: node .github/scripts/check-branch-policy.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 536ef43..0489dd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main] + branches: [dev, main] workflow_dispatch: permissions: @@ -11,7 +11,8 @@ permissions: concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # Cancel stale PR checks, but never interrupt a push run during a deployment. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: verify: @@ -40,6 +41,9 @@ jobs: - name: Unit tests run: npm test + - name: Deployment gate tests + run: node --test scripts/*.test.mjs + - name: Production build run: npm run build @@ -148,3 +152,43 @@ jobs: ConnectionStrings__Platform: 'Host=localhost;Port=5432;Database=procoderji_test;Username=postgres;Password=postgres' PublicOrigin: http://localhost:3000 run: dotnet run --project backend/src/ProCoderJi.Api --configuration Release --no-build --no-launch-profile -- --migrate + + deploy: + name: Deploy checked branch + permissions: + contents: read + deployments: write + needs: [verify, site-quality, backend] + if: github.event_name == 'push' && github.repository == 'procoderji-com/procoderji' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: + name: ${{ github.ref_name == 'main' && 'production' || 'staging' }} + url: ${{ vars.PUBLIC_ORIGIN }} + concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: false + steps: + - name: Check out checked commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: 24 + + - name: Install deployment CLI + run: npm install --global vercel@65.14.0 + + - name: Deploy backend, verify storage, then deploy frontend + env: + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} + VERCEL_FRONTEND_PROJECT_ID: ${{ vars.VERCEL_FRONTEND_PROJECT_ID }} + VERCEL_BACKEND_PROJECT_ID: ${{ vars.VERCEL_BACKEND_PROJECT_ID }} + PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }} + BACKEND_ORIGIN: ${{ vars.BACKEND_ORIGIN }} + VERCEL_FRONTEND_TOKEN: ${{ secrets.VERCEL_FRONTEND_TOKEN }} + VERCEL_BACKEND_TOKEN: ${{ secrets.VERCEL_BACKEND_TOKEN }} + run: node scripts/deploy-vercel.mjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..db00921 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Repository guidance + +Read `CONTRIBUTING.md` before making changes. Contributions target `dev`; +production promotions follow `docs/branching-and-releases.md`. + +For UI work, follow `design-system/MASTER.md` and reuse the shared brand and +action styles. Keep real student activity distinct from interactive samples. +Verify keyboard navigation and narrow layouts alongside the relevant checks. + + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc5a1a9..6f6c5b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,13 +53,13 @@ fork as `origin`, and continue from the same checkout. ## Start a branch -Update your local `main` before starting a new change: +Update your local `dev` before starting a new change: ```bash -git switch main git fetch upstream -git merge --ff-only upstream/main -git push origin main +git switch dev +git merge --ff-only upstream/dev +git push origin dev git switch -c feat/describe-the-change ``` @@ -120,10 +120,17 @@ git commit -m "Describe the change" git push -u origin ``` -Open a pull request from your fork branch to `procoderji-com/procoderji:main`. Fill in the +Open a pull request from your fork branch to `procoderji-com/procoderji:dev`. Fill in the template, link a related issue when one exists, explain the resulting behavior, and list the checks you ran. Include screenshots for visible UI changes and sources or permissions for learning content. +For interface changes, follow the [design system](design-system/MASTER.md): use the shared +brand, typography, buttons and colour tokens, and check both keyboard and mobile navigation. + +`dev` is the integration/staging branch; `main` is production. Feature and dependency changes +go through `dev`. Maintainers promote the canonical `dev` branch to `main` after staging checks; +contributors do not send feature branches directly to `main`. See the +[branch and deployment workflow](docs/branching-and-releases.md). Respond to review feedback and keep the branch up to date when needed. Maintainers review student value, correctness, accessibility, and maintenance cost before merging. A proposed diff --git a/README.md b/README.md index 8e1dee3..1ea5608 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ # ProCoderJii Learning Hub -### Practise for campus placements. Understand your answers. Build a study habit. +### For the semester. For what’s next. -A student learning workspace with original practice questions, explanations, and saved progress. +A student learning brand bringing together course resources, placement practice, games, +portfolios, concept videos and community. This repository builds the open-source web platform. [Website](https://procoderji.com) · [Documentation](docs/README.md) · [Roadmap](https://github.com/procoderji-com/procoderji/issues/22) · [Good first issues](https://github.com/procoderji-com/procoderji/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22) · [Report a bug](https://github.com/procoderji-com/procoderji/issues/new?template=bug_report.yml) · [Propose a question](https://github.com/procoderji-com/procoderji/issues/new?template=practice_question.yml) @@ -21,8 +22,18 @@ A student learning workspace with original practice questions, explanations, and > [!NOTE] > This is an early student-product foundation. Four practice topics contain 24 original multiple-choice questions. Practice and browser-saved progress work without login. An optional ASP.NET Core backend provides Google sign-in, account progress, and private portfolio drafts. The portfolio editor, two themes, Markdown import, and public-page support are implemented; public publishing is off by default pending deployment configuration. Billing and checkout are not enabled. Course notes, guides, and job listings are awaiting published content. +![ProCoderJii homepage with a placement practice sample](docs/images/homepage.png) + +
+Student learning workspace + ![Guest learning workspace before the first session](docs/images/learning-workspace.png) +
+ +Interface contributions follow the [design system](design-system/MASTER.md), with shared +navigation, self-hosted typography, keyboard access and responsive layouts. + See the [product roadmap](docs/product-roadmap.md) for student validation, content growth, and future monetization work. ## Contents diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ed624bc..2e63418 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -20,6 +20,14 @@ is distributed. Preserve applicable package licenses and component notices when frontend archive or image, and inspect that actual artifact against the upstream terms. The backend-only container does not bundle the Next.js image-processing dependencies. +## DM Sans + +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts). + +The self-hosted font in `public/fonts/dm-sans-variable.ttf` is distributed under the +[SIL Open Font License 1.1](public/fonts/OFL.txt). Its full notice is included alongside +the font. See [the source and usage notes](public/fonts/README.md). + ## UI/UX Pro Max Source: [nextlevelbuilder/ui-ux-pro-max-skill](https://github.com/nextlevelbuilder/ui-ux-pro-max-skill) diff --git a/design-system/MASTER.md b/design-system/MASTER.md new file mode 100644 index 0000000..eb08ec4 --- /dev/null +++ b/design-system/MASTER.md @@ -0,0 +1,44 @@ +# ProCoderJii interface + +ProCoderJii is a student learning brand, spanning notes, placement practice, games, videos, +community and portfolios. The interface should feel like a useful study desk, with clear +choices and readable content. It must not imply hiring guarantees or invent social proof. + +## Direction + +UI/UX Pro Max's Swiss Modernism guidance informs the grid, hierarchy, restrained decoration +and keyboard accessibility. Use an editorial opening on the public site and compact, +functional layouts in the student workspace. The existing gold brand remains an accent. + +| Token | Value | Use | +| ------------- | --------- | ------------------------------------------- | +| Paper | `#f8f7f2` | Page background | +| Surface | `#ffffff` | Forms, reading and practice | +| Ink | `#242b25` | Headings and primary text | +| Secondary ink | `#60685d` | Supporting text | +| Forest | `#345443` | Links, primary actions, selected navigation | +| Gold | `#edc76e` | Small highlights and warm-up activities | +| Rule | `#dedfd5` | Quiet dividers and input boundaries | + +DM Sans is self-hosted under the SIL Open Font License. Georgia provides occasional editorial +display headings; the workspace keeps sans-serif headings. Body text is at least 16px for +reading and form inputs. Small labels supplement, never replace, visible field labels. + +## Patterns + +- One clear primary action per section. Prefer content rows and dividers over repeated cards. +- Desktop public navigation is shared across public pages. Mobile navigation has visible + controls, an expanded state, Escape dismissal and focus restoration. +- Learning navigation stays consistent between practice, account and portfolio editing. +- Progress displays real saved values, with useful empty states; homepage samples are labelled + as samples and do not impersonate saved activity. +- Notes keep branch/semester URLs and readable document layouts. Games keep their original + controls and feedback. Portfolio drafts remain private until explicit publication. +- Avoid animated backgrounds, gradient text, glass effects, floating decorations and invented + testimonials, rankings, company affiliations or activity counts. + +## Verification + +Check 375px, 768px, 1024px and 1440px layouts; keyboard focus and mobile menu; practice feedback; +notes navigation; portfolio preview; reading contrast; reduced motion; and existing browser/SEO +checks. Stage changes through `dev`; production uses the reviewed promotion workflow. diff --git a/docs/README.md b/docs/README.md index 6d82303..2f372d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ synchronization is a separate capability; paid billing is future work. | Import a portfolio from a text template | [Markdown import](portfolio-markdown.md), [template](../public/templates/portfolio.md) | | Work on portfolio fields, policy, publication, or storage | [Shared rules](../shared/portfolio/README.md), [Portfolio API](portfolio-api.md) | | Configure production hosting and the site domain | [Deployment](deployment.md) | +| Contribute to dev, test staging and promote a production release | [Branches and releases](branching-and-releases.md) | | Run the API and choose a database with Docker | [Containers](containers.md) | | Configure the selected hosted PostgreSQL database | [Neon setup](neon.md) | | Report a vulnerability privately | [Security policy](../SECURITY.md) | diff --git a/docs/branching-and-releases.md b/docs/branching-and-releases.md new file mode 100644 index 0000000..afe0cac --- /dev/null +++ b/docs/branching-and-releases.md @@ -0,0 +1,90 @@ +# Branches and deployments + +ProCoderJii uses two long-lived branches: + +| Branch | Purpose | Deployment | +| --- | --- | --- | +| `dev` | Default contribution branch; reviewed features and dependency updates integrate here. | Staging, after CI passes. | +| `main` | Reviewed production releases. | Production, after CI passes. | + +The flow is `feature/* -> PR -> dev -> staging checks -> PR -> main`. There is no permanent +`release` branch. Version tags can identify releases without creating another integration branch. + +## Contributions + +Create a feature branch from current `dev` in your fork and open the PR against canonical `dev`. +Both branches reject deletion and force pushes and require PR review and passing checks. +The Branch policy check rejects a feature/fork PR directly into `main`; only this repository's +`dev` branch can be promoted. A fork branch named `dev` is not a production promotion. + +Commits and contribution PRs use the contributor account. For the project owner's two-account +workflow, contributions use `Coding-Professional`; reviews and merges use `procoder-ji`. + +## Promote a release + +1. Make sure `dev` includes current `main`. If production has advanced independently, open a + `main -> dev` synchronization PR and run checks before testing staging again. +2. Wait for the exact `dev` commit's CI and staging deployment to pass. Exercise the changed + student journey on staging with sample data. Check migrations, account/portfolio privacy, + mobile and keyboard behavior when affected. +3. Open a PR from **canonical `dev` to `main`**, describing the behavior being released and + the staging checks. Review as `procoder-ji` and satisfy all required checks. +4. Use a **merge commit** for this promotion, preserving ancestry between the long-lived + branches. Squash merges remain suitable for individual feature PRs into `dev`. +5. Production deploys after the new `main` commit passes CI. Confirm the deployment summary, + health/session checks, canonical redirects and affected public routes. + +Do not deploy unreviewed feature commits to production. Urgent fixes follow the same route; +there is no permanent bypass branch. Database schema changes remain deliberate release +operations: test the migrations on staging and apply approved production migrations with the +direct connection before rolling out code that requires them. The runtime keeps migration on +startup disabled. Never use a production database to run browser fixtures. + +## Deployment environments + +Each GitHub environment has branch restrictions and separate project-scoped deployment tokens. +The CI deployment job runs only on canonical `dev`/`main` pushes, after frontend, browser/SEO +and backend checks pass. PRs and fork workflows receive no deployment credentials. + +| Environment | Allowed branch | Frontend project | Backend project | +| --- | --- | --- | --- | +| `staging` | `dev` | `procoderji-dev` | `procoderji-api-dev` | +| `production` | `main` | `procoderji` | `procoderji-api` | + +Environment variables: `VERCEL_ORG_ID`, `VERCEL_FRONTEND_PROJECT_ID`, +`VERCEL_BACKEND_PROJECT_ID`, `PUBLIC_ORIGIN`, and `BACKEND_ORIGIN`. +Environment secrets: `VERCEL_FRONTEND_TOKEN` and `VERCEL_BACKEND_TOKEN`. Scope each token to +its project, give it an expiration date and rotate it before expiry. Tokens are never printed +or committed. The script passes them directly to the CLI child process without a shell or +command echo; it never dumps provider output into the workflow logs. +Database and Google credentials stay in the appropriate Vercel project's secret settings. + +Staging uses a schema-only Neon branch and a fresh `procoderji_dev` database, with a separate +runtime role. Production student records and Data Protection keys are not copied into it. +Use a separate Google OAuth client with the staging frontend callback. Until configured, +staging supports guest practice and editor previews while account saving is unavailable. + +Set `DEPLOYMENT_ENVIRONMENT=staging` in the staging frontend. Staging and Vercel previews send +`noindex, nofollow` and disallow crawling; a dedicated staging project is still a production +*build*, so `NODE_ENV` alone is not an environment boundary. Indexing directives are not access +control. Use sample data and configure hosting access protection when private QA is needed. + +The deployment script copies tracked source only. It prepares the backend's service configuration +in an isolated directory, deploys/verifies the API first, then deploys the frontend. The live +summary records the checked source commit and both origins. A failure stops the rollout; inspect +the relevant Vercel project and restore its previous deployment if a rollback is needed. + +Disable a previous independent Git deployment connection after this pipeline has been verified; +otherwise a second deployment path can publish before CI finishes. The canonical GitHub Actions +pipeline does not require granting Vercel's GitHub integration access to an organization. + +## Operator recovery + +For a manual staging check from a trusted checkout, set the staging project IDs and origins in +the current process, authenticate Vercel CLI, and run +`node scripts/deploy-vercel.mjs --local-staging`. This mode is unavailable in GitHub Actions and +cannot select production. Source files and the operator's existing auth remain separate. + +For a production incident, use the hosting dashboard's rollback to the previous verified +deployment, then send the fix through `dev`. Do not delete student records or blindly reverse a +schema migration as part of an application rollback. diff --git a/docs/images/homepage.png b/docs/images/homepage.png new file mode 100644 index 0000000..3b67191 Binary files /dev/null and b/docs/images/homepage.png differ diff --git a/docs/images/learning-workspace.png b/docs/images/learning-workspace.png index a450fc4..7ec5774 100644 Binary files a/docs/images/learning-workspace.png and b/docs/images/learning-workspace.png differ diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 197b481..f14e1e3 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -19,7 +19,8 @@ These steps do not need repeated owner permission. Existing decisions, including backend and selected Neon PostgreSQL deployment, remain valid. A person implementing that approved scope should continue through a reviewable change and its relevant checks. -Use the pull-request workflow for changes to `main`, and satisfy the repository's active review +Use feature PRs into `dev`, then a reviewed promotion PR from canonical `dev` into `main`. +Follow [branching and releases](branching-and-releases.md), and satisfy the active review and status-check rules before merging. Maintainers can merge routine changes within their authorised scope once the relevant checks and review are complete. Production rollout and private-credential access follow the separately agreed operating scope. diff --git a/e2e/checks.mjs b/e2e/checks.mjs index 44e9479..4be8357 100644 --- a/e2e/checks.mjs +++ b/e2e/checks.mjs @@ -183,6 +183,23 @@ await interaction("home: desktop nav has working links", "desktop", async (page) expectTrue((await page.locator("a[href*='procoderji.xyz']").count()) === 0, "no .xyz links"); }); +for (const vp of ["desktop", "mobile"]) { + await interaction("home: sample explains answers without saving a session", vp, async (page) => { + await page.goto(BASE + "/", { waitUntil: "networkidle" }); + const sample = page.locator(".sample-question"); + const check = sample.getByRole("button", { name: "Check answer" }); + expectTrue(await check.isDisabled(), "an answer is required"); + await sample.getByRole("radio").first().check(); + await check.click(); + expectTrue((await sample.locator(".sample-answer").textContent()).length > 30, "show a useful explanation"); + await sample.getByRole("radio").nth(1).check(); + expectTrue(await sample.locator(".sample-answer").count() === 0, "changing an answer clears outdated feedback"); + await page.goto(BASE + "/learn", { waitUntil: "networkidle" }); + const sessions = page.locator("section").filter({ has: page.getByRole("heading", { name: "Completed sessions", exact: true }) }); + expectTrue(await sessions.getByText("0", { exact: true }).isVisible(), "a sample is not recorded as a practice session"); + }); +} + await interaction("games: pattern round reaches a result", "desktop", async (page) => { await page.goto(BASE + "/games/pattern-sprint", { waitUntil: "networkidle" }); await page.getByRole("button", { name: "Start game" }).click(); @@ -292,6 +309,7 @@ await interaction("dashboard: mobile drawer opens, Escape closes", "mobile", asy expectTrue(await dlg.getByRole("link", { name: /Browse Notes/ }).isVisible(), "drawer has Browse Notes"); await page.keyboard.press("Escape"); await dlg.waitFor({ state: "hidden" }); + expectTrue(await page.getByRole("button", { name: /open navigation menu/i }).evaluate(el => el === document.activeElement), "Escape restores focus to the drawer toggle"); }); await interaction("dashboard: sidebar expands to semester and navigates (desktop)", "desktop", async (page) => { @@ -360,40 +378,22 @@ await interaction("legal pages: completed details are indexable", "desktop", asy expectTrue((await page.locator('link[rel="canonical"]').getAttribute("href")) === "https://procoderji.com/terms", "canonical URL"); }); -await interaction("hero headline is never covered by the floating logo (several desktop widths)", "desktop", async (page) => { - // The floating logo tile has pointer-events:none, so elementFromPoint cannot see it: compare geometry instead. - for (const [width, height] of [[768, 900], [1024, 768], [1280, 800], [1366, 768], [1440, 900], [1920, 1080]]) { +await interaction("home: headline and sample stay separate at every breakpoint", "desktop", async (page) => { + for (const [width, height] of [[375, 900], [768, 900], [1024, 768], [1280, 800], [1366, 768], [1440, 900], [1920, 1080]]) { await page.setViewportSize({ width, height }); - await page.goto(BASE + "/", { waitUntil: "networkidle" }); - await page.waitForTimeout(1200); // let the entrance animations finish - const overlaps = await page.evaluate(() => { - const h1 = document.querySelector("h1"); - const range = document.createRange(); - range.selectNodeContents(h1); - const textLines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0); - // Outermost pointer-events:none wrapper around every visible image = decorative overlays such as the logo tile. - const overlays = new Set(); - for (const img of document.querySelectorAll("img")) { - if (!img.getClientRects().length) continue; - let el = img; - while (el.parentElement && getComputedStyle(el.parentElement).pointerEvents === "none") el = el.parentElement; - if (el !== img || getComputedStyle(img).pointerEvents === "none") overlays.add(el); - } - const hits = []; - for (const el of overlays) { - const o = el.getBoundingClientRect(); - if (o.width === 0 || o.height === 0 || getComputedStyle(el).display === "none") continue; - for (const t of textLines) { - if (o.left < t.right && o.right > t.left && o.top < t.bottom && o.bottom > t.top) hits.push(`${el.tagName}.${String(el.className).slice(0, 50)}`); - } - } - return [...new Set(hits)]; - }); - expectTrue(overlaps.length === 0, `at ${width}px the headline text is overlapped by: ${overlaps.join(", ")}`); + // Visible layout and fonts are the readiness signal. Responsive link prefetches + // need not become network-idle before we can measure the rendered interface. + await page.goto(BASE + "/", { waitUntil: "domcontentloaded" }); + await page.getByRole("heading", { level: 1 }).waitFor({ state: "visible" }); + await page.locator(".sample-question").waitFor({ state: "visible" }); + await page.evaluate(() => document.fonts.ready); + const heading = await page.getByRole("heading", { level: 1 }).boundingBox(); + const sample = await page.locator(".sample-question").boundingBox(); + const overlaps = heading.x < sample.x + sample.width && heading.x + heading.width > sample.x && heading.y < sample.y + sample.height && heading.y + heading.height > sample.y; + expectTrue(!overlaps, "headline and question overlap at " + width + "px"); } }); - // Needs the live channel feed, so it only runs when YOUTUBE_CHANNEL_ID is set (otherwise the section is hidden by design). const videosEnabled = /^UC[A-Za-z0-9_-]{22}$/.test(process.env.YOUTUBE_CHANNEL_ID ?? ""); if (!videosEnabled) console.log("SKIP latest videos responsive checks (set YOUTUBE_CHANNEL_ID to run them)"); @@ -427,7 +427,7 @@ if (videosEnabled) { await interaction("privacy page discloses every third-party host the site contacts", "desktop", async (page) => { // Every external host that these pages request must be named on the Privacy page. - const DISCLOSED_AS = { "lottie.host": "lottie.host", "cdn.jsdelivr.net": "jsdelivr", "i.ytimg.com": "i.ytimg.com" }; + const DISCLOSED_AS = { "i.ytimg.com": "i.ytimg.com" }; const base = new URL(BASE); const hosts = new Set(); page.on("request", (r) => { @@ -441,7 +441,8 @@ await interaction("privacy page discloses every third-party host the site contac await page.waitForTimeout(120); } } - expectTrue(hosts.size > 0, "expected the home page to contact at least the animation hosts"); + const retiredServices = [...hosts].filter(h => /(^|\.)(lottie\.host|jsdelivr\.net|googleapis\.com|gstatic\.com)$/.test(h)); + expectTrue(retiredServices.length === 0, "interface fonts and artwork must stay self-hosted: " + retiredServices.join(", ")); await page.goto(BASE + "/privacy", { waitUntil: "networkidle" }); const text = (await page.locator("main").innerText()).toLowerCase(); const missing = [...hosts].filter((h) => !text.includes((DISCLOSED_AS[h] ?? h).toLowerCase())); @@ -459,6 +460,7 @@ if (FULLSTACK) { await browser.close(); console.log("\n========== FINDINGS =========="); +fs.writeFileSync(path.join(OUT, "findings.json"), JSON.stringify(findings, null, 2) + "\n"); if (!findings.length) console.log("none"); const grouped = {}; for (const f of findings) (grouped[f.kind] ??= []).push(f); diff --git a/e2e/portfolio-checks.mjs b/e2e/portfolio-checks.mjs index acc9f56..f963e24 100644 --- a/e2e/portfolio-checks.mjs +++ b/e2e/portfolio-checks.mjs @@ -92,7 +92,10 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) { const url = new URL(request.url()); if (/^https?:$/.test(url.protocol) && url.origin !== new URL(base).origin) foreignRequests.add(url.origin); }); - const response = await page.goto(base + "/u/" + username, { waitUntil: "networkidle" }); + // Next.js prefetch requests can outlive rendering; assert the loaded page's + // status, content and privacy boundaries instead of waiting for network idle. + const response = await page.goto(base + "/u/" + username, { waitUntil: "load" }); + await page.evaluate(() => document.fonts.ready); assert.equal(response?.status(), expectedStatus, `Public ${username} should return ${expectedStatus}`); if (expectedStatus === 200) { assert.equal(await page.locator("h1").count(), 1); @@ -109,7 +112,7 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) { } async function editor(page) { - const response = await page.goto(base + EDITOR, { waitUntil: "networkidle" }); + const response = await page.goto(base + EDITOR, { waitUntil: "load" }); assert.equal(response?.status(), 200); await page.getByRole("heading", { name: "Portfolio builder", exact: true }).waitFor(); await until(() => page.getByRole("button", { name: "Save draft", exact: true }).isEnabled(), "Authenticated editor did not become ready"); @@ -403,7 +406,7 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) { await run("guest", "guests can preview while private APIs and unpublished pages stay protected", async contexts => { const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); contexts.push(context); - const page = await context.newPage(); await page.goto(base + EDITOR, { waitUntil: "networkidle" }); + const page = await context.newPage(); await page.goto(base + EDITOR, { waitUntil: "load" }); await page.getByRole("heading", { name: "Try the editor, then sign in to save", exact: true }).waitFor(); await page.getByLabel("Name", { exact: true }).fill("Guest preview"); assert.equal(await page.getByRole("button", { name: "Save draft", exact: true }).isDisabled(), true); diff --git a/next.config.ts b/next.config.ts index be9615d..4d83f47 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { isNonProductionDeployment } from "./src/lib/deployment.ts"; /** * Conservative response headers. No Content-Security-Policy yet: the site loads @@ -16,7 +17,10 @@ const nextConfig: NextConfig = { // Do not announce the framework in an "X-Powered-By: Next.js" response header. poweredByHeader: false, async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + const headers = isNonProductionDeployment(process.env.DEPLOYMENT_ENVIRONMENT, process.env.VERCEL_ENV) + ? [...securityHeaders, { key: "X-Robots-Tag", value: "noindex, nofollow" }] + : securityHeaders; + return [{ source: "/:path*", headers }]; }, // The old plain /courses/... reader duplicated the dashboard (same URL structure, no styling). // Send visitors and any old links to the real pages instead of serving duplicate content. diff --git a/package-lock.json b/package-lock.json index 4ab4f51..964d33d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,9 @@ "name": "procoderji-notes", "version": "0.1.0", "dependencies": { - "@lottiefiles/dotlottie-react": "^0.21.1", "@radix-ui/react-slot": "^1.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "framer-motion": "^14.10.1", "lucide-react": "^0.577.0", "next": "16.4.5", "next-mdx-remote": "6.0.0", @@ -1140,32 +1138,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@lottiefiles/dotlottie-react": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.21.1.tgz", - "integrity": "sha512-y5ec2Ipko1TvUfoUmS9zn+g0Ud+oYtUEJS5Xj27edv1kO8ZAowoA2v6DUWNniZ/+A9m5wjEswqPzShOK6+pdAw==", - "license": "MIT", - "dependencies": { - "@lottiefiles/dotlottie-web": "0.82.1" - }, - "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" - }, - "peerDependencies": { - "react": "^17 || ^18 || ^19" - } - }, - "node_modules/@lottiefiles/dotlottie-web": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.82.1.tgz", - "integrity": "sha512-hLPdAL0dUeHKvQvXqw1p3FtvzZUHRtBsTMt3FLLgphFThsUPNUj2+U+GyttJ5H5qSt5JreLzoaD+IaKO8H/VnA==", - "license": "MIT", - "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" - } - }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -4325,33 +4297,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/framer-motion": { - "version": "14.10.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.10.1.tgz", - "integrity": "sha512-hT8WLczAcreOHG4EHymMtATztwUc3al1/88/stal3tpxYkMefGNzIPHHgFFHW3iLnrNY36BrKKJDuT2V7o8Tfw==", - "license": "MIT", - "dependencies": { - "motion-dom": "14.10.1", - "motion-utils": "14.7.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "remix": "^3.1.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "remix": { - "optional": true - } - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6561,21 +6506,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/motion-dom": { - "version": "14.10.1", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.10.1.tgz", - "integrity": "sha512-NzVzM7y3JJl3rYlVN4yM6kbziab/tHNBEV6gqOoAQIg8npHlIMhW2oPnzr0HkP+rR8xMoiX9+zSZ4BLaVuJadw==", - "license": "MIT", - "dependencies": { - "motion-utils": "14.7.0" - } - }, - "node_modules/motion-utils": { - "version": "14.7.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.7.0.tgz", - "integrity": "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 27a1826..eaa0468 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,9 @@ "launch-check": "node e2e/launch.mjs" }, "dependencies": { - "@lottiefiles/dotlottie-react": "^0.21.1", "@radix-ui/react-slot": "^1.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "framer-motion": "^14.10.1", "lucide-react": "^0.577.0", "next": "16.4.5", "next-mdx-remote": "6.0.0", diff --git a/public/fonts/OFL.txt b/public/fonts/OFL.txt new file mode 100644 index 0000000..a0eedfb --- /dev/null +++ b/public/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/README.md b/public/fonts/README.md new file mode 100644 index 0000000..4add13f --- /dev/null +++ b/public/fonts/README.md @@ -0,0 +1,10 @@ +# DM Sans + +`dm-sans-variable.ttf` is the variable DM Sans font distributed by Google Fonts. +It is self-hosted with Next.js so reading the application does not request fonts +from a third-party server. + +- Source: https://github.com/google/fonts/tree/main/ofl/dmsans +- License: [SIL Open Font License 1.1](OFL.txt) + +Georgia, where used for editorial headings, is a system-font fallback. diff --git a/public/fonts/dm-sans-variable.ttf b/public/fonts/dm-sans-variable.ttf new file mode 100644 index 0000000..c672f98 Binary files /dev/null and b/public/fonts/dm-sans-variable.ttf differ diff --git a/scripts/deploy-vercel.mjs b/scripts/deploy-vercel.mjs new file mode 100644 index 0000000..d01b622 --- /dev/null +++ b/scripts/deploy-vercel.mjs @@ -0,0 +1,162 @@ +// Deployment credentials stay in environment secrets, never in source or logs. +// PR jobs never execute this script with deployment credentials. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { waitForDeployment } from "./deployment-status.mjs"; + +const root = path.resolve(import.meta.dirname, ".."); +const localStaging = process.argv.includes("--local-staging"); +const branch = localStaging ? "dev" : process.env.GITHUB_REF_NAME; +const temporary = []; +const env = { ...process.env, DEBUG: "" }; +const cli = process.env.VERCEL_CLI; +const command = cli?.endsWith(".js") ? process.execPath : cli || "vercel"; +const prefix = cli?.endsWith(".js") ? [cli] : []; + +function requireValue(key) { + const value = process.env[key]; + if (!value?.trim()) throw new Error(`Missing deployment setting: ${key}`); + return value; +} + +function origin(key) { + const value = requireValue(key); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") { + throw new Error(`Expected an HTTPS origin for ${key}`); + } + return url.origin; +} + +function workspace() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "procoderji-deploy-")); + temporary.push(directory); + return directory; +} + +function vercel(args, cwd, project, token) { + const result = spawnSync(command, [ + ...prefix, ...args, + ...(token ? ["--token", token] : []), + ], { + cwd, env: { ...env, VERCEL_ORG_ID: requireValue("VERCEL_ORG_ID"), VERCEL_PROJECT_ID: project }, + encoding: "utf8", windowsHide: true, timeout: 12 * 60 * 1000, + maxBuffer: 8 * 1024 * 1024, + }); + let data; + try { data = JSON.parse(result.stdout); } catch { /* Never dump raw provider logs. */ } + if (result.status !== 0 || !data) throw new Error("Vercel deployment failed. Inspect its build in the project dashboard."); + return data; +} + +function authentication(tokenKey) { + if (localStaging && !process.env[tokenKey]) return undefined; + // Native CLI 65 requires its explicit token option; no shell or command logging is used. + return requireValue(tokenKey); +} + +async function confirmReady(result, cwd, project, token, label) { + return waitForDeployment(result.deployment?.id, { + label, + read: async id => { + if (!token) { + const inspected = vercel(["inspect", id, "--json"], cwd, project); + return inspected.deployment ?? inspected; + } + const endpoint = new URL(`https://api.vercel.com/v13/deployments/${id}`); + endpoint.searchParams.set("teamId", requireValue("VERCEL_ORG_ID")); + const response = await fetch(endpoint, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`${label} status lookup failed (${response.status}).`); + const deployment = await response.json(); + if (deployment.projectId !== project) throw new Error(`${label} deployment belongs to a different project.`); + return { id: deployment.id, readyState: deployment.readyState }; + }, + }); +} + +function deploySource() { + const result = spawnSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8", windowsHide: true }); + if (result.status !== 0) throw new Error("Cannot enumerate tracked deployment source."); + const directory = workspace(); + for (const file of result.stdout.split("\0").filter(Boolean)) { + const source = path.resolve(root, file); + if (!source.startsWith(root + path.sep) || fs.lstatSync(source).isSymbolicLink()) { + throw new Error("Deployment source must contain ordinary repository files."); + } + const target = path.join(directory, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + } + return directory; +} + +async function check(url, inspect) { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(30_000) }); + if (await inspect(response)) return; + } catch { /* Cold starts can need one retry. */ } + if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error("A deployed endpoint failed its release check."); +} + +async function main() { + if (localStaging && process.env.GITHUB_ACTIONS === "true") throw new Error("Local staging mode is unavailable in Actions."); + if (!["dev", "main"].includes(branch)) throw new Error("Only dev and main have deployment environments."); + if (!localStaging && (process.env.GITHUB_EVENT_NAME !== "push" || process.env.GITHUB_REPOSITORY !== "procoderji-com/procoderji")) { + throw new Error("Only checked pushes to the canonical repository can deploy."); + } + const publicOrigin = origin("PUBLIC_ORIGIN"); + const backendOrigin = origin("BACKEND_ORIGIN"); + if (branch === "main" && publicOrigin !== "https://procoderji.com") throw new Error("Unexpected production origin."); + if (branch === "dev" && publicOrigin === "https://procoderji.com") throw new Error("Staging must have its own origin."); + const sha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true }).stdout.trim(); + if (!/^[a-f0-9]{40}$/.test(sha) || (!localStaging && sha !== process.env.GITHUB_SHA)) throw new Error("The checkout must match the checked commit."); + const backendProject = requireValue("VERCEL_BACKEND_PROJECT_ID"); + const frontendProject = requireValue("VERCEL_FRONTEND_PROJECT_ID"); + if (backendProject === frontendProject) throw new Error("Backend and frontend projects must be separate."); + + const backendSource = deploySource(); + const frontendSource = deploySource(); + fs.copyFileSync(path.join(backendSource, "vercel.backend.json"), path.join(backendSource, "vercel.json")); + const deployArgs = ["deploy", "--prod", "--yes", "--json", "--meta", `sourceCommit=${sha}`, "--meta", `sourceBranch=${branch}`]; + const backendToken = authentication("VERCEL_BACKEND_TOKEN"); + const backend = vercel([...deployArgs, "--project", backendProject], backendSource, backendProject, backendToken); + await confirmReady(backend, backendSource, backendProject, backendToken, "Backend"); + await check(`${backendOrigin}/api/platform/health`, async r => r.ok && (await r.json()).status === "ok"); + await check(`${backendOrigin}/api/platform/auth/session`, async r => { + if (!r.ok) return false; + const session = await r.json(); + return !!session.csrfToken && (branch !== "main" || session.configured === true); + }); + console.log("Backend is ready; database-backed session check passed."); + + const frontendToken = authentication("VERCEL_FRONTEND_TOKEN"); + const frontend = vercel([...deployArgs, "--project", frontendProject], frontendSource, frontendProject, frontendToken); + await confirmReady(frontend, frontendSource, frontendProject, frontendToken, "Frontend"); + await check(publicOrigin, async r => r.ok && (branch === "main" || r.headers.get("x-robots-tag")?.includes("noindex"))); + console.log(`Frontend is ready: ${publicOrigin}`); + const summary = `### ${branch === "main" ? "Production" : "Staging"} deployment\n\nCommit: \`${sha}\`\n\n- Frontend: ${publicOrigin}\n- Backend: ${backendOrigin}\n- Health and database-backed session checks passed.\n`; + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : "Deployment failed."); + process.exitCode = 1; +} finally { + const parent = fs.realpathSync(os.tmpdir()); + for (const directory of temporary) { + const resolved = path.resolve(directory); + if (path.dirname(resolved) === parent && path.basename(resolved).startsWith("procoderji-deploy-")) { + fs.rmSync(resolved, { recursive: true, force: true }); + } + } +} diff --git a/scripts/deployment-status.mjs b/scripts/deployment-status.mjs new file mode 100644 index 0000000..78e53a9 --- /dev/null +++ b/scripts/deployment-status.mjs @@ -0,0 +1,20 @@ +/** Confirm provider state instead of trusting the CLI's deployment snapshot. */ +export async function waitForDeployment(id, { + read, + label, + maxAttempts = 60, + intervalMs = 5000, + sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), +}) { + if (!/^dpl_[a-zA-Z0-9]+$/.test(id ?? "")) throw new Error(`${label} deployment ID is missing or invalid.`); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const deployment = await read(id); + if (deployment.id !== id) throw new Error(`${label} status returned a different deployment.`); + if (deployment.readyState === "READY") return deployment; + if (["ERROR", "CANCELED", "CANCELLED"].includes(deployment.readyState)) { + throw new Error(`${label} deployment failed (${deployment.readyState}).`); + } + if (attempt + 1 < maxAttempts) await sleep(intervalMs); + } + throw new Error(`${label} deployment did not become ready before the timeout.`); +} diff --git a/scripts/deployment-status.test.mjs b/scripts/deployment-status.test.mjs new file mode 100644 index 0000000..5cc4dee --- /dev/null +++ b/scripts/deployment-status.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { waitForDeployment } from "./deployment-status.mjs"; + +const id = "dpl_test123"; +const options = { label: "Backend", intervalMs: 0, maxAttempts: 3 }; + +test("queued and building deployments are checked until the provider reports ready", async () => { + const states = ["QUEUED", "BUILDING", "READY"]; + const seen = []; + const result = await waitForDeployment(id, { ...options, read: async requested => { + seen.push(requested); + return { id, readyState: states.shift() }; + } }); + assert.equal(result.readyState, "READY"); + assert.deepEqual(seen, [id, id, id]); +}); + +test("failed or canceled builds cannot pass the readiness gate", async () => { + for (const readyState of ["ERROR", "CANCELED", "CANCELLED"]) { + await assert.rejects(waitForDeployment(id, { ...options, read: async () => ({ id, readyState }) }), /deployment failed/); + } +}); + +test("a deployment that stays queued fails after bounded polling", async () => { + let reads = 0; + await assert.rejects(waitForDeployment(id, { ...options, read: async () => { + reads++; + return { id, readyState: "QUEUED" }; + } }), /timeout/); + assert.equal(reads, options.maxAttempts); +}); + +test("missing IDs, mismatched records and unavailable status lookups fail closed", async () => { + await assert.rejects(waitForDeployment(undefined, { ...options, read: async () => assert.fail("unexpected lookup") }), /ID is missing/); + await assert.rejects(waitForDeployment(id, { ...options, read: async () => ({ id: "dpl_other", readyState: "READY" }) }), /different deployment/); + await assert.rejects(waitForDeployment(id, { ...options, read: async () => { throw new Error("Status unavailable"); } }), /Status unavailable/); +}); diff --git a/src/app/(student)/practice/page.tsx b/src/app/(student)/practice/page.tsx index e9db4db..6116225 100644 --- a/src/app/(student)/practice/page.tsx +++ b/src/app/(student)/practice/page.tsx @@ -14,44 +14,39 @@ export const metadata = pageMetadata({ export default function PracticePage() { return ( <> -

+

Prepare with purpose

-

- Campus placement practice -

-

+

Campus placement practice

+

Work through short topic sessions. Understand why an answer works, review mistakes, and use your own progress to decide what comes next.

-
+
- - -
-
+
{PRACTICE_TOPICS.map((topic, index) => ( -
- +
+ 0{index + 1}

{topic.title}

-

+

{topic.description}

-

+

{QUESTION_BANK.filter((q) => q.topicId === topic.id).length}{" "} questions · Easy & medium · Explanations included

@@ -65,9 +60,9 @@ export default function PracticePage() {
))}
-
+

Learn from every attempt

-

+

These starter sets build familiarity with common reasoning and aptitude skills. They are independent learning exercises, and the small question bank is not a calibrated employer assessment. Use diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 2027a3c..67f61c9 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,73 +1,111 @@ import Link from "next/link"; -import { EmailLink, H2, LegalLayout, Operator, P, UL, legalMetadata } from "@/components/legal/legal-layout"; +import { + EmailLink, + H2, + LegalLayout, + Operator, + P, + UL, + legalMetadata, +} from "@/components/legal/legal-layout"; export const metadata = legalMetadata( - "About", - "Learn about ProCoderJii, an open-source student platform for placement practice, explanations, free games and saved progress, and how to contribute.", - "/about" + "About", + "Learn about ProCoderJii, an open-source student platform for placement practice, explanations, free games and saved progress, and how to contribute.", + "/about", ); export default function AboutPage() { - return ( - -

- ProCoderJii is an open-source learning and campus-placement practice project for students. - It is operated by and grows through reviewed community contributions. -

+ return ( + +

+ ProCoderJii is a student learning brand built around concept videos, + course resources, placement practice and a community of people learning + together. Our YouTube channel, Telegram and WhatsApp communities connect + students beyond this website. The open-source platform is operated by{" "} + and grows through reviewed community contributions. +

-

Practise and understand your answers

-
    -
  • - Placement practice covers - quantitative aptitude, logical reasoning, verbal ability and programming foundations. - Each session includes correct answers and explanations. -
  • -
  • - Free practice games include - Pattern Sprint for number patterns and Digit Recall for short-term memory. -
  • -
  • - The learner workspace shows - question-practice history, topic progress and a suggested next step. -
  • -
+

Practise and understand your answers

+
    +
  • + + Placement practice + {" "} + covers quantitative aptitude, logical reasoning, verbal ability and + programming foundations. Each session includes correct answers and + explanations. +
  • +
  • + + Free practice games + {" "} + include Pattern Sprint for number patterns and Digit Recall for + short-term memory. +
  • +
  • + The{" "} + + learner workspace + {" "} + shows question-practice history, topic progress and a suggested next + step. +
  • +
-

Free practice and your progress

-

- Practice is free and works without an account. Question-practice history is saved in this - browser; clearing site data removes it. Optional Google sign-in can synchronize that - history when account services are available. Game scores are shown at the end of a round - and are not yet saved. -

+

Free practice and your progress

+

+ Practice is free and works without an account. Question-practice history + is saved in this browser; clearing site data removes it. Optional Google + sign-in can synchronize that history when account services are + available. Game scores are shown at the end of a round and are not yet + saved. +

-

Learning content in development

-

- Course, branch and semester navigation is in place. Reviewed course notes, guides and job - listings are awaiting publication. The current learning exercises are independent practice - for building skills; they do not predict or guarantee a placement result. -

+

Learning content in development

+

+ Course, branch and semester navigation is in place. Reviewed course + notes, guides and job listings are awaiting publication. The current + learning exercises are independent practice for building skills; they do + not predict or guarantee a placement result. +

-

How to contribute

-

- Students and developers can help with original questions, explanations, accessibility, - documentation and code. Visit the{" "} - - GitHub repository and contribution guide - {" "} - to find a focused task and the checks it needs. -

-
    -
  • Learning material is reviewed for correctness and clarity before publication.
  • -
  • Where an official syllabus or question paper exists, we link to the university's own page instead of copying it.
  • -
  • We do not republish other people's notes, papers or PDFs without permission.
  • -
+

How to contribute

+

+ Students and developers can help with original questions, explanations, + accessibility, documentation and code. Visit the{" "} + + GitHub repository and contribution guide + {" "} + to find a focused task and the checks it needs. +

+
    +
  • + Learning material is reviewed for correctness and clarity before + publication. +
  • +
  • + Where an official syllabus or question paper exists, we link to the + university's own page instead of copying it. +
  • +
  • + We do not republish other people's notes, papers or PDFs without + permission. +
  • +
-

Corrections and requests

-

- Found a mistake, or want a subject covered? Email or open a reproducible issue - in the repository. See the Disclaimer{" "} - for the limits of the learning material. -

-
- ); +

Corrections and requests

+

+ Found a mistake, or want a subject covered? Email or open + a reproducible issue in the repository. See the{" "} + + Disclaimer + {" "} + for the limits of the learning material. +

+ + ); } diff --git a/src/app/api/platform/[...path]/route.ts b/src/app/api/platform/[...path]/route.ts index 40c6737..b6de8c8 100644 --- a/src/app/api/platform/[...path]/route.ts +++ b/src/app/api/platform/[...path]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { isBackendPath, parseBackendOrigin } from "@/features/account/config"; +import { canonicalGoogleChallenge, isBackendPath, parseBackendOrigin } from "@/features/account/config"; import { revalidateTag } from "next/cache"; import { portfolioCacheTag } from "@/features/portfolio/public-model"; import { validateUsername } from "@/features/portfolio/validation"; @@ -90,6 +90,13 @@ async function proxy( signal: AbortSignal.timeout(20_000), }, ); + if (path === "auth/google" && request.method === "GET" && upstream.status === 302) { + const canonical = canonicalGoogleChallenge(request.url, upstream.headers.get("location")); + if (canonical) { + // Restart on the callback host before forwarding a host-only correlation cookie. + return new Response(null, { status: 307, headers: { ...privateHeaders, Location: canonical } }); + } + } const responseHeaders = new Headers(privateHeaders); for (const name of ["content-type", "location", "retry-after"]) { const value = upstream.headers.get(name); diff --git a/src/app/dashboard/dashboard-layout-client.tsx b/src/app/dashboard/dashboard-layout-client.tsx index d2d382f..a99a604 100644 --- a/src/app/dashboard/dashboard-layout-client.tsx +++ b/src/app/dashboard/dashboard-layout-client.tsx @@ -1,94 +1,76 @@ "use client"; -import React, { useEffect, useState } from "react"; +import { useEffect, useRef } from "react"; import { Menu, X } from "lucide-react"; -import { AppSidebar } from "@/components/app-sidebar"; import { usePathname } from "next/navigation"; -import { motion, AnimatePresence } from "framer-motion"; - -export function DashboardClientLayout({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); - // Remember which route the drawer was opened on: navigating elsewhere closes it - // without needing an effect. - const [openOnPath, setOpenOnPath] = useState(null); - const isSidebarOpen = openOnPath === pathname; - const closeSidebar = () => setOpenOnPath(null); - - // Escape closes the mobile drawer. - useEffect(() => { - if (!isSidebarOpen) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpenOnPath(null); - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [isSidebarOpen]); - - return ( -
- {/* Mobile Header */} -
-
ProCoderJii
- -
+import { AppSidebar } from "@/components/app-sidebar"; +import { Brand } from "@/components/brand"; - {/* Mobile Sidebar Overlay */} - - {isSidebarOpen && ( - <> - +export function DashboardClientLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + const drawer = useRef(null); + const toggle = useRef(null); - {/* Desktop Sidebar */} -
- -
+ useEffect(() => { + drawer.current?.close(); + }, [pathname]); + useEffect(() => { + const desktop = window.matchMedia("(min-width: 768px)"); + const closeOnDesktop = () => { + if (desktop.matches) drawer.current?.close(); + }; + desktop.addEventListener("change", closeOnDesktop); + return () => desktop.removeEventListener("change", closeOnDesktop); + }, []); - {/* Main Content */} -
-
-
{children}
-
-
+ return ( +
+
+ + +
+ toggle.current?.focus()} + onClick={(event) => { + if (event.target === event.currentTarget) drawer.current?.close(); + }} + className="notes-drawer fixed inset-y-0 left-0 m-0 h-dvh max-h-none w-80 max-w-[90vw] border-r border-border bg-background text-foreground backdrop:bg-black/40" + > + + + +
+ +
+
+
+ {children}
- ); +
+
+ ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index ab33597..1a12c67 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,20 +1,15 @@ import { DashboardClientLayout } from "./dashboard-layout-client"; export default function DashboardLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( - <> -