From b9847bad0987e09abf1f1abbfc1affffc0db35d2 Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Wed, 11 Nov 2026 09:46:06 +0530
Subject: [PATCH] feat: complete Phase 1 student portfolios
---
README.md | 30 +-
backend/ProCoderJi.slnx | 1 +
backend/README.md | 74 ++-
.../Data/Mongo/MongoPlatformDatabase.cs | 18 +-
.../Data/Mongo/MongoPortfolioStore.cs | 18 +-
.../ProCoderJi.Api/Data/PlatformDbContext.cs | 5 +
.../Features/Portfolio/IPortfolioStore.cs | 8 +-
.../Features/Portfolio/PortfolioEndpoints.cs | 80 ++-
.../Portfolio/PortfolioModerationCommand.cs | 168 +++++++
.../Portfolio/PortfolioPublishingPolicy.cs | 19 +
.../Features/Portfolio/PortfolioRules.cs | 11 +-
.../Features/Portfolio/PortfolioValidator.cs | 10 +-
.../Portfolio/RelationalPortfolioStore.cs | 18 +-
...0261111090000_PortfolioPublishingPolicy.cs | 24 +
.../PlatformDbContextModelSnapshot.cs | 3 +
...SqlServerPlatformDbContextModelSnapshot.cs | 3 +
backend/src/ProCoderJi.Api/Program.cs | 8 +-
.../PortfolioIntegrationTests.cs | 8 +
.../PortfolioModerationTests.cs | 129 +++++
.../PortfolioPublicationTests.cs | 240 +++++++++
.../PortfolioRelationalTests.cs | 5 +-
.../ProCoderJi.BrowserHost/BrowserProgram.cs | 247 +++++++++
.../ProCoderJi.BrowserHost.csproj | 14 +
.../ProCoderJi.BrowserHost/packages.lock.json | 470 ++++++++++++++++++
docs/README.md | 6 +-
docs/architecture.md | 59 ++-
docs/decisions/0002-student-portfolios.md | 122 +++--
docs/development.md | 37 +-
docs/portfolio-api.md | 110 ++--
docs/portfolio-markdown.md | 31 ++
docs/portfolio-workspace.md | 83 ++++
e2e/checks.mjs | 9 +
e2e/launch.mjs | 12 +-
e2e/portfolio-checks.mjs | 418 ++++++++++++++++
e2e/run.mjs | 29 +-
public/templates/portfolio.md | 46 ++
shared/portfolio/README.md | 28 +-
shared/portfolio/fixtures.json | 4 +
shared/portfolio/rules.json | 4 +-
src/app/(student)/learn/portfolio/page.tsx | 13 +
src/app/api/platform/[...path]/route.ts | 10 +
src/app/not-found.tsx | 4 +-
src/app/privacy/page.tsx | 107 +++-
src/app/sitemap.ts | 8 +-
src/app/terms/page.tsx | 76 ++-
src/app/u/[username]/page.tsx | 25 +
src/components/legal/legal-layout.tsx | 9 +-
src/features/portfolio/client.test.ts | 73 +++
src/features/portfolio/client.ts | 157 ++++++
.../portfolio/editor/portfolio-editor.tsx | 430 ++++++++++++++++
src/features/portfolio/markdown.test.ts | 120 +++++
src/features/portfolio/markdown.ts | 313 ++++++++++++
src/features/portfolio/policy.ts | 10 +
src/features/portfolio/public-loader.test.ts | 28 ++
src/features/portfolio/public-loader.ts | 25 +
src/features/portfolio/public-model.test.ts | 19 +
src/features/portfolio/public-model.ts | 31 ++
src/features/portfolio/public-page.tsx | 33 ++
.../portfolio/public-presentation.test.ts | 34 ++
src/features/portfolio/public-presentation.ts | 48 ++
src/features/portfolio/rules.ts | 2 +
src/features/portfolio/server.ts | 70 +++
.../portfolio/themes/developer-theme.tsx | 5 +
.../portfolio/themes/portfolio-preview.tsx | 13 +
.../portfolio/themes/presentation.test.ts | 37 ++
src/features/portfolio/themes/presentation.ts | 47 ++
.../portfolio/themes/simple-theme.tsx | 5 +
src/features/portfolio/themes/theme-frame.tsx | 187 +++++++
src/features/portfolio/types.ts | 1 +
src/features/portfolio/validation.ts | 7 +
.../practice/client/learning-shell.tsx | 1 +
src/lib/legal.test.ts | 19 +-
src/lib/legal.ts | 11 +-
73 files changed, 4395 insertions(+), 192 deletions(-)
create mode 100644 backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioModerationCommand.cs
create mode 100644 backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioPublishingPolicy.cs
create mode 100644 backend/src/ProCoderJi.Api/Migrations/20261111090000_PortfolioPublishingPolicy.cs
create mode 100644 backend/tests/ProCoderJi.Api.Tests/PortfolioModerationTests.cs
create mode 100644 backend/tests/ProCoderJi.Api.Tests/PortfolioPublicationTests.cs
create mode 100644 backend/tests/ProCoderJi.BrowserHost/BrowserProgram.cs
create mode 100644 backend/tests/ProCoderJi.BrowserHost/ProCoderJi.BrowserHost.csproj
create mode 100644 backend/tests/ProCoderJi.BrowserHost/packages.lock.json
create mode 100644 docs/portfolio-markdown.md
create mode 100644 docs/portfolio-workspace.md
create mode 100644 e2e/portfolio-checks.mjs
create mode 100644 public/templates/portfolio.md
create mode 100644 src/app/(student)/learn/portfolio/page.tsx
create mode 100644 src/app/u/[username]/page.tsx
create mode 100644 src/features/portfolio/client.test.ts
create mode 100644 src/features/portfolio/client.ts
create mode 100644 src/features/portfolio/editor/portfolio-editor.tsx
create mode 100644 src/features/portfolio/markdown.test.ts
create mode 100644 src/features/portfolio/markdown.ts
create mode 100644 src/features/portfolio/policy.ts
create mode 100644 src/features/portfolio/public-loader.test.ts
create mode 100644 src/features/portfolio/public-loader.ts
create mode 100644 src/features/portfolio/public-model.test.ts
create mode 100644 src/features/portfolio/public-model.ts
create mode 100644 src/features/portfolio/public-page.tsx
create mode 100644 src/features/portfolio/public-presentation.test.ts
create mode 100644 src/features/portfolio/public-presentation.ts
create mode 100644 src/features/portfolio/server.ts
create mode 100644 src/features/portfolio/themes/developer-theme.tsx
create mode 100644 src/features/portfolio/themes/portfolio-preview.tsx
create mode 100644 src/features/portfolio/themes/presentation.test.ts
create mode 100644 src/features/portfolio/themes/presentation.ts
create mode 100644 src/features/portfolio/themes/simple-theme.tsx
create mode 100644 src/features/portfolio/themes/theme-frame.tsx
diff --git a/README.md b/README.md
index 81a9784..8e1dee3 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ 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 and account progress when configured. Billing and checkout are not enabled. Course notes, guides, and job listings are still awaiting published content.
+> 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.

@@ -49,6 +49,8 @@ their answers. Run the app locally to explore the implemented routes:
| `/practice/[topic]` | Multiple-choice practice sessions with scores, correct answers, and explanations. |
| `/games` | Pattern Sprint and Digit Recall: short reasoning and memory exercises. |
| `/account` | Optional Google sign-in and cloud progress controls when the backend is configured. |
+| `/learn/portfolio` | In-memory guest preview; signed-in private drafts, Markdown import, theme choice, and explicit publication when enabled. |
+| `/u/[username]` | A student's published snapshot; drafts, suspended or withdrawn pages return 404. |
| `/dashboard` | Engineering course, branch, and semester navigation for future reviewed notes. |
Questions are original learning exercises. They are independent of employer assessments and do
@@ -56,9 +58,16 @@ not predict or guarantee hiring outcomes. The first question bank is intentional
can be reviewed and improved before expanding.
The expanded [placement-game catalogue](https://github.com/procoderji-com/procoderji/issues/24)
-and [student portfolios](https://github.com/procoderji-com/procoderji/issues/40) are tracked as
-separate work. Check those issues for delivery status; the table above describes the current
-student experience.
+remains separate work. For portfolios, start with the [student journey and deployment
+runbook](docs/portfolio-workspace.md): guests can try the form, Google accounts save drafts,
+and students aged 18 or older can explicitly accept the publishing policy. Usernames stay
+fixed while the portfolio exists; draft edits remain private until published again. Search
+indexing is opt-in. Import the [Markdown template](public/templates/portfolio.md) or fill the
+same fields directly; both Simple and Developer themes use that model.
+
+Repository features do not establish that the live site has been updated.
+[Issue #74](https://github.com/procoderji-com/procoderji/issues/74) tracks the outstanding
+production hosting work; see the [deployment guide](docs/deployment.md).
## Progress and optional accounts
@@ -86,6 +95,7 @@ does not currently enable them.
| --- | --- |
| Placement practice | 24 questions across four topics, explanations, and saved session history. |
| Short games | Pattern Sprint and Digit Recall are playable without login; their scores are not saved. |
+| Student portfolios | Form/live preview, Simple and Developer themes, Markdown import, authenticated drafts, and gated public pages with moderation. |
| Course catalogue | Course, branch, and semester navigation exists; no subjects or notes are published yet. |
| Guides | No reviewed guides published yet. |
| Jobs and internships | No reviewed listings published yet. |
@@ -131,7 +141,7 @@ troubleshooting, [backend guide](backend/README.md) for accounts, and
| `npm run dev` | Start the local development server. |
| `npm run lint` | Run ESLint. |
| `npm run typecheck` | Check TypeScript types. |
-| `npm test` | Run unit tests, including practice-domain, progress-model/store, and content-validation tests. |
+| `npm test` | Run practice/progress, portfolio rules/import/client, content-validation and helper tests. |
| `npm run build` | Create the production build. |
| `npm run e2e` | Build with synthetic content and check frontend browser behavior; opt into the backend with `E2E_DOTNET=1`. |
| `npm run launch-check` | Build the real site and crawl links, sitemap, metadata, and SEO rules. |
@@ -140,7 +150,7 @@ troubleshooting, [backend guide](backend/README.md) for accounts, and
| CI job | Coverage |
| --- | --- |
| Lint, typecheck, tests and build | TypeScript, code quality, domain/store tests, and the production build. |
-| Browser and SEO checks | Desktop/mobile flows, a real .NET process and frontend proxy, the Google sign-in challenge, and a crawl of links/metadata. |
+| Browser and SEO checks | Desktop/mobile flows, test-only .NET host using the real API/BFF, portfolio publication/moderation, Google challenge, and links/metadata. |
| Backend build and tests | The .NET solution build and tests for backend rules and access behavior. |
| Database contracts and container smoke | PostgreSQL, SQL Server and MongoDB contracts, Compose configuration, and the packaged API image. |
| Dependency review | Pull-request dependency changes checked for newly introduced high or critical vulnerabilities. |
@@ -149,7 +159,9 @@ These jobs need no Google credentials or other external secrets. Browser screens
retained for seven days when the site-quality job fails. A deployed Google sign-in flow still
needs the configured-provider checks in [Backend setup](backend/README.md). Browser CI verifies
the challenge and public callback URL with test client configuration; it does not complete real
-Google consent or token exchange.
+Google consent or token exchange. Full-stack portfolio checks use disposable accounts and
+loopback-only fixtures in `backend/tests/ProCoderJi.BrowserHost`, which is excluded from the
+production API image. The production API has no fixture login or moderation HTTP endpoint.
The E2E runner refuses to run when `src/data` or `src/content` has local changes because it
temporarily installs synthetic fixtures. See [Development](docs/development.md#checks).
@@ -161,7 +173,7 @@ src/app/ Next.js routes, metadata, and backend-for-frontend API
src/features/practice/ Pure practice rules, question-bank adapter, and practice UI
src/features/progress/ Validated attempt records and browser progress storage
src/features/account/ Optional account-provider clients and controls
-src/features/portfolio/ Shared-rule validation for portfolio fields and usernames
+src/features/portfolio/ Portfolio rules, editor, Markdown import, themes, and public delivery
src/components/ Shared UI and landing-page sections
src/content/ Markdown and MDX learning content
src/data/ Course, guide, and job catalogues
@@ -193,7 +205,7 @@ The [maintainer guide](docs/maintainer-guide.md) explains triage, review and dep
| Saved progress behavior | [Progress feature](src/features/progress/) and its model/store tests. |
| Student interface and accessibility | [Practice views](src/features/practice/client/) and shared components. |
| Optional accounts, synchronization, or persistence | [Backend setup](backend/README.md), [account feature](src/features/account/), and the Next.js `/api/platform/*` proxy. |
-| Portfolio field and username validation | [Shared rules](shared/portfolio/README.md) and the [portfolio roadmap](https://github.com/procoderji-com/procoderji/issues/40). |
+| Portfolio editor, themes, import, or publication | [Workspace guide](docs/portfolio-workspace.md), [shared rules](shared/portfolio/README.md), and [API contract](docs/portfolio-api.md). |
| Original notes, guides, and listings | [Content guide](docs/content-guide.md). |
| Setup and contributor guidance | `docs/` and the [contribution guide](CONTRIBUTING.md). |
diff --git a/backend/ProCoderJi.slnx b/backend/ProCoderJi.slnx
index 6711b58..db097b3 100644
--- a/backend/ProCoderJi.slnx
+++ b/backend/ProCoderJi.slnx
@@ -4,5 +4,6 @@
+
diff --git a/backend/README.md b/backend/README.md
index 775f7a3..be7c78b 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,6 +1,8 @@
-# ProCoderJii account and progress API
+# ProCoderJii account, progress and portfolio API
-ASP.NET Core 10 provides Google sign-in, server-managed cookie sessions, and durable learner progress. The Next.js app forwards `/api/platform/*` to this service through its server-only BFF. Anonymous browser practice stays available when the backend or Google credentials are not configured.
+ASP.NET Core 10 provides Google sign-in, server-managed cookie sessions, durable learner progress,
+and portfolio drafts/publication. Next.js forwards `/api/platform/*` through its server-only
+BFF. Guest practice and the in-memory portfolio preview work without backend credentials.
## Run locally
@@ -39,8 +41,12 @@ Users are linked by Google's stable provider subject, never by matching email ad
## API contract
Portfolio storage and authenticated/public endpoints are documented in the
-[portfolio API guide](../docs/portfolio-api.md). Public publishing is disabled by default until
-the owner decisions and launch work in #47/#48 are complete.
+[portfolio API guide](../docs/portfolio-api.md). The editor, two themes, Markdown import,
+moderation and 18+ publishing policy are implemented. `Portfolio__PublishingEnabled` remains
+false by default; use the [workspace launch runbook](../docs/portfolio-workspace.md) before
+enabling it. Frontend production hosting remains separate work under
+[#74](https://github.com/procoderji-com/procoderji/issues/74); this guide does not establish a
+deployed production service.
| Endpoint | Behavior |
| --- | --- |
@@ -101,8 +107,9 @@ dotnet run --project backend/src/ProCoderJi.Api --no-launch-profile -- --migrate
```
This command uses the current environment's database/provider settings and exits after
-provisioning. It applies EF migrations for relational databases and creates MongoDB's unique
-Google-subject index for MongoDB. Development and Testing provision automatically; production
+provisioning. It applies EF migrations, including portfolio publishing-policy fields, for
+relational databases and creates MongoDB's Google-subject and portfolio-username indexes.
+Development and Testing provision automatically; production
does so only for `--migrate` or explicit `Database__MigrateOnStartup=true`. Back up the database
before schema changes. Shared relational migrations contain provider-specific SQL where needed;
keep both the default and SQL Server model snapshots current and run the provider checks.
@@ -120,6 +127,53 @@ replica set or multi-document transactions. See [ADR 0003](../docs/decisions/000
Razorpay or Paytm integration is planned for a later billing slice. No payment, subscription activation, or live checkout is implemented in this API yet.
+## Portfolio moderation
+
+Each public portfolio's **Report this page** link opens an email to `procoderji@gmail.com` with
+its canonical address. Operators review reports and can run these commands using the deployed
+API's database environment and authorised database credentials:
+
+```bash
+dotnet ProCoderJi.Api.dll --portfolio suspend --username riya-student
+dotnet ProCoderJi.Api.dll --portfolio restore --username riya-student
+```
+
+For a local development database, use
+`dotnet run --project backend/src/ProCoderJi.Api --no-launch-profile -- --portfolio suspend --username riya-student`.
+Run `--migrate` separately before these commands when the schema needs updating. Moderation
+does not start an HTTP server or migrate a database. It prints only username, status and new
+revision, excluding student content and provider connection details. Exit codes are 0 success
+(including an already-applied state), 2 invalid arguments, 3 missing portfolio, 1 operation
+failure, and 130 cancellation. `--portfolio help` prints syntax.
+
+Suspend removes the public snapshot, search visibility and publishing acceptance while
+retaining the owner's private draft. Owners may edit/unpublish, but cannot publish or delete
+to bypass suspension. Restore returns a private **draft**, not a live page: the student must
+review and explicitly accept the current policy and publish again. The CLI uses revision/status
+checks and supports relational providers and MongoDB. There is no production moderation HTTP
+endpoint or student-supplied admin flag.
+
+Next.js checks fresh publication state before serving cached snapshots, so suspension becomes
+404 on the next request without a CLI cache-invalidation callback. Verify the public URL and
+sitemap after changes. Keep report decisions and operator access in the deployment's operating
+process. Requests to remove a suspended portfolio or an account go to the operator contact;
+practice-history deletion is separate.
+
+## Portfolio publishing configuration
+
+Phase 1 fixes usernames while the portfolio exists and requires 18+ self-declaration plus
+current Terms/Privacy acceptance for every publication. The server privately stores policy
+version `2026-11-11` and confirmation time; it collects no birth date. This product policy does
+not establish independent age verification or qualified legal review. Existing snapshots
+without current acceptance stay unavailable until the student republishes.
+
+After configuring production Google OAuth, HTTPS `PublicOrigin`, BFF connectivity, durable
+storage, migrations, backup/restore, reporting and operator access, set
+`Portfolio__PublishingEnabled=true` on the backend to enable publication. Keep it false while
+preparing drafts or investigating a public-content incident; disabling it makes public
+snapshot/state reads return 404 and empties the public index without deleting drafts. Consult
+the [runbook](../docs/portfolio-workspace.md#deployment-runbook) for release checks.
+
## Tests
```bash
@@ -128,6 +182,14 @@ dotnet test backend/ProCoderJi.slnx --no-restore
The default suite creates isolated SQLite databases and uses test-only authentication registered by `WebApplicationFactory` in the test assembly. The API has no test-header login path. Tests cover anonymous behavior, Google challenge URL construction, CSRF/origin checks, account isolation, immutable sync, generation resets, stale requests, question validation, and retention.
+Full-stack frontend checks (`E2E_DOTNET=1 npm run e2e`) build the separate
+`backend/tests/ProCoderJi.BrowserHost` executable. It hosts the real API on loopback with an
+owned temporary SQLite database, seeded disposable-account cookies and fixture-only publishing
+controls. Origin, CSRF, ownership and persistence still use the real application pipeline.
+Its `/__e2e__` endpoints and authentication scheme exist only in the test executable; the
+production API Dockerfile publishes `backend/src/ProCoderJi.Api` only. Never deploy BrowserHost.
+See [browser checks](../docs/development.md#browser-checks) for coverage and configuration.
+
To run the same API contract tests against a disposable server, set exactly one of
`PROCODERJI_TEST_POSTGRES`, `PROCODERJI_TEST_SQLSERVER`, or `PROCODERJI_TEST_MONGODB` to its
administrative test connection string. Each factory creates and drops only its randomly named
diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs
index f453bb5..50e41b7 100644
--- a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs
+++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs
@@ -29,6 +29,16 @@ await Users.Indexes.CreateManyAsync([
Unique = true,
Name = "IX_users_Portfolio_Username",
PartialFilterExpression = Builders.Filter.Type("Portfolio.Username", BsonType.String)
+ }),
+ new CreateIndexModel(Builders.IndexKeys
+ .Ascending("Portfolio.Status").Ascending("Portfolio.PublishedSearchVisible")
+ .Ascending("Portfolio.PublishingPolicyVersion").Ascending("Portfolio.Username"),
+ new CreateIndexOptions
+ {
+ Name = "IX_users_Portfolio_PublicIndex",
+ PartialFilterExpression = Builders.Filter.And(
+ Builders.Filter.Eq(user => user.Portfolio!.Status, PortfolioStatus.Published),
+ Builders.Filter.Eq(user => user.Portfolio!.PublishedSearchVisible, true))
})
], cancellationToken: cancellationToken);
}
@@ -93,16 +103,20 @@ public sealed class MongoPortfolioDocument
public long CreatedAtUtc { get; set; }
public long UpdatedAtUtc { get; set; }
public long? PublishedAtUtc { get; set; }
+ [BsonIgnoreIfNull] public string? PublishingPolicyVersion { get; set; }
+ [BsonIgnoreIfNull] public long? EligibilityConfirmedAtUtc { get; set; }
public PortfolioRecord ToRecord(Guid userId) => new(userId, Username, DraftJson, PublishedJson,
- SearchVisible, PublishedSearchVisible, Status, Guid.Parse(Revision), CreatedAtUtc, UpdatedAtUtc, PublishedAtUtc);
+ SearchVisible, PublishedSearchVisible, Status, Guid.Parse(Revision), CreatedAtUtc, UpdatedAtUtc, PublishedAtUtc,
+ PublishingPolicyVersion, EligibilityConfirmedAtUtc);
public static MongoPortfolioDocument From(PortfolioRecord record) => new()
{
Username = record.Username, DraftJson = record.DraftJson, PublishedJson = record.PublishedJson,
SearchVisible = record.SearchVisible, PublishedSearchVisible = record.PublishedSearchVisible,
Status = record.Status, Revision = record.Revision.ToString("D"), CreatedAtUtc = record.CreatedAtUtc,
- UpdatedAtUtc = record.UpdatedAtUtc, PublishedAtUtc = record.PublishedAtUtc
+ UpdatedAtUtc = record.UpdatedAtUtc, PublishedAtUtc = record.PublishedAtUtc,
+ PublishingPolicyVersion = record.PublishingPolicyVersion, EligibilityConfirmedAtUtc = record.EligibilityConfirmedAtUtc
};
}
diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs
index f29568b..950a995 100644
--- a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs
+++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs
@@ -38,7 +38,7 @@ public Task ClaimUsername(Guid userId, Guid? revision, string u
return current with { Username = username };
}, cancellationToken);
- public Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken) =>
+ public Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken, string? policyVersion = null) =>
Write(userId, revision, allowCreate: false, (current, now) =>
{
if (current.Status == PortfolioStatus.Suspended) throw new PortfolioConflictException("suspended");
@@ -46,10 +46,24 @@ public Task Publish(Guid userId, Guid revision, CancellationTok
return current with
{
Status = PortfolioStatus.Published, PublishedJson = current.DraftJson,
- PublishedSearchVisible = current.SearchVisible, PublishedAtUtc = now
+ PublishedSearchVisible = current.SearchVisible, PublishedAtUtc = now,
+ PublishingPolicyVersion = policyVersion, EligibilityConfirmedAtUtc = policyVersion is null ? null : now
};
}, cancellationToken);
+ public async Task> ListSearchVisible(string? after, int limit, CancellationToken cancellationToken)
+ {
+ var filter = Builders.Filter.Where(row => row.Portfolio != null
+ && row.Portfolio.Status == PortfolioStatus.Published && row.Portfolio.PublishedJson != null
+ && row.Portfolio.Username != null && row.Portfolio.PublishedSearchVisible
+ && row.Portfolio.PublishingPolicyVersion == PortfolioPublishingPolicy.Version
+ && row.Portfolio.EligibilityConfirmedAtUtc != null && row.Portfolio.PublishedAtUtc != null);
+ if (after is not null) filter &= Builders.Filter.Gt("Portfolio.Username", after);
+ return await database.Users.Find(filter).SortBy(row => row.Portfolio!.Username).Limit(limit)
+ .Project(row => new PortfolioIndexEntry(row.Portfolio!.Username!, row.Portfolio.PublishedAtUtc!.Value))
+ .ToListAsync(cancellationToken);
+ }
+
public Task Unpublish(Guid userId, Guid revision, CancellationToken cancellationToken) =>
Write(userId, revision, allowCreate: false, (current, _) => current with
{
diff --git a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
index 78dc7bb..eeb0834 100644
--- a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
+++ b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
@@ -54,6 +54,9 @@ protected override void OnModelCreating(ModelBuilder model)
.HasFilter(Database.IsSqlServer() ? "[Username] IS NOT NULL" : null);
entity.Property(portfolio => portfolio.DraftJson).HasMaxLength(262144);
entity.Property(portfolio => portfolio.PublishedJson).HasMaxLength(262144);
+ entity.Property(portfolio => portfolio.PublishingPolicyVersion).HasMaxLength(16);
+ entity.HasIndex(portfolio => new { portfolio.Status, portfolio.PublishedSearchVisible, portfolio.PublishingPolicyVersion, portfolio.Username })
+ .HasDatabaseName("IX_Portfolios_PublicIndex");
entity.Property(portfolio => portfolio.Status).HasConversion().HasMaxLength(16).IsConcurrencyToken();
entity.Property(portfolio => portfolio.Revision).IsConcurrencyToken();
entity.HasOne().WithOne().HasForeignKey(portfolio => portfolio.UserId).OnDelete(DeleteBehavior.Cascade);
@@ -107,4 +110,6 @@ public sealed class PortfolioEntity
public long CreatedAtUtc { get; set; }
public long UpdatedAtUtc { get; set; }
public long? PublishedAtUtc { get; set; }
+ public string? PublishingPolicyVersion { get; set; }
+ public long? EligibilityConfirmedAtUtc { get; set; }
}
diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs
index 4aea1bd..6f6d73c 100644
--- a/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs
+++ b/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs
@@ -6,7 +6,10 @@ public enum PortfolioStatus { Draft, Published, Suspended }
public sealed record PortfolioRecord(
Guid UserId, string? Username, string DraftJson, string? PublishedJson,
bool SearchVisible, bool PublishedSearchVisible, PortfolioStatus Status,
- Guid Revision, long CreatedAtUtc, long UpdatedAtUtc, long? PublishedAtUtc);
+ Guid Revision, long CreatedAtUtc, long UpdatedAtUtc, long? PublishedAtUtc,
+ string? PublishingPolicyVersion = null, long? EligibilityConfirmedAtUtc = null);
+
+public sealed record PortfolioIndexEntry(string Username, long PublishedAtUtc);
public sealed class PortfolioConflictException(string code) : Exception(code)
{
@@ -18,10 +21,11 @@ public interface IPortfolioStore
{
Task GetOwner(Guid userId, CancellationToken cancellationToken);
Task GetPublished(string username, CancellationToken cancellationToken);
+ Task> ListSearchVisible(string? after, int limit, CancellationToken cancellationToken);
Task IsUsernameAvailable(string username, Guid userId, CancellationToken cancellationToken);
Task SaveDraft(Guid userId, Guid? revision, string dataJson, bool searchVisible, CancellationToken cancellationToken);
Task ClaimUsername(Guid userId, Guid? revision, string username, CancellationToken cancellationToken);
- Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken);
+ Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken, string? policyVersion = null);
Task Unpublish(Guid userId, Guid revision, CancellationToken cancellationToken);
Task Delete(Guid userId, Guid revision, CancellationToken cancellationToken);
}
diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs
index 9e82469..0b36c4b 100644
--- a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs
+++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs
@@ -23,6 +23,16 @@ public sealed record ClaimPortfolioUsernameRequest(
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record PortfolioRevisionRequest([property: JsonRequired] Guid Revision);
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public sealed record PortfolioEligibility(
+ [property: JsonRequired] bool AtLeast18,
+ [property: JsonRequired] string PolicyVersion);
+
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public sealed record PublishPortfolioRequest(
+ [property: JsonRequired] Guid Revision,
+ [property: JsonRequired] PortfolioEligibility? Eligibility);
+
public static class PortfolioEndpoints
{
private const int MaxBytes = 256 * 1024;
@@ -37,6 +47,8 @@ public static void MapPortfolioEndpoints(this WebApplication app)
app.MapGet("/api/platform/portfolio/username", CheckUsername)
.RequireRateLimiting(PortfolioApiConfiguration.UsernamePolicy);
app.MapGet("/api/platform/portfolio/public/{username}", GetPublic);
+ app.MapGet("/api/platform/portfolio/public/{username}/state", GetPublicState);
+ app.MapGet("/api/platform/portfolio/public", GetPublicIndex);
app.MapPost("/api/platform/portfolio/draft", SaveDraft)
.RequireRateLimiting(PortfolioApiConfiguration.WritePolicy);
app.MapPost("/api/platform/portfolio/username", ClaimUsername)
@@ -54,7 +66,8 @@ private static async Task GetOwner(HttpContext context, IPortfolioStore
var error = CheckOwner(context, out var userId);
if (error is not null) return error;
var portfolio = await store.GetOwner(userId, context.RequestAborted);
- return Results.Json(new { portfolio = portfolio is null ? null : Owner(portfolio), publishingEnabled = PublishingEnabled(configuration) });
+ return Results.Json(new { portfolio = portfolio is null ? null : Owner(portfolio), publishingEnabled = PublishingEnabled(configuration),
+ policyVersion = PortfolioPublishingPolicy.Version, minimumPublishingAge = PortfolioPublishingPolicy.MinimumAge });
}
private static async Task CheckUsername(HttpContext context, string? username, IPortfolioStore store, PortfolioValidator validator)
@@ -72,8 +85,11 @@ private static async Task GetPublic(string username, HttpContext contex
if (!PublishingEnabled(configuration) || validator.ValidateUsername(username).Count != 0)
return Results.NotFound();
var portfolio = await store.GetPublished(username, context.RequestAborted);
- if (portfolio is null || portfolio.Status != PortfolioStatus.Published || portfolio.PublishedJson is null)
+ if (portfolio is null || portfolio.Status != PortfolioStatus.Published || portfolio.PublishedJson is null || !PortfolioPublishingPolicy.IsAccepted(portfolio))
return Results.NotFound();
+ var version = PortfolioPublishingPolicy.PublicVersion(portfolio);
+ var expected = context.Request.Query["version"].ToString();
+ if (expected.Length != 0 && expected != version) return Results.NotFound();
// This whitelist intentionally excludes drafts, internal identity, revision, and
// the account's Google profile. Search visibility comes from the published version.
return Results.Json(new
@@ -81,10 +97,36 @@ private static async Task GetPublic(string username, HttpContext contex
username = portfolio.Username,
data = JsonNode.Parse(portfolio.PublishedJson),
searchVisible = portfolio.PublishedSearchVisible,
- publishedAt = Timestamp(portfolio.PublishedAtUtc)
+ publishedAt = Timestamp(portfolio.PublishedAtUtc), version
});
}
+ private static async Task GetPublicState(string username, HttpContext context, IPortfolioStore store, PortfolioValidator validator, IConfiguration configuration)
+ {
+ context.Response.Headers["X-Robots-Tag"] = "noindex";
+ if (!PublishingEnabled(configuration) || validator.ValidateUsername(username).Count != 0) return Results.NotFound();
+ var portfolio = await store.GetPublished(username, context.RequestAborted);
+ if (portfolio is null || !PortfolioPublishingPolicy.IsAccepted(portfolio)) return Results.NotFound();
+ return Results.Json(new { username, version = PortfolioPublishingPolicy.PublicVersion(portfolio),
+ searchVisible = portfolio.PublishedSearchVisible, publishedAt = Timestamp(portfolio.PublishedAtUtc) });
+ }
+
+ private static async Task GetPublicIndex(HttpContext context, IPortfolioStore store, PortfolioValidator validator, IConfiguration configuration)
+ {
+ context.Response.Headers["X-Robots-Tag"] = "noindex";
+ if (!PublishingEnabled(configuration)) return Results.Json(new { portfolios = Array.Empty
-
+
Browse notes
-
+
Go to home
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index 37bd748..1bae6da 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -1,16 +1,17 @@
import { EmailLink, H2, LegalLayout, Operator, P, UL, legalMetadata } from "@/components/legal/legal-layout";
-import { LEGAL } from "@/lib/legal";
+import { PORTFOLIO_MINIMUM_AGE, PORTFOLIO_POLICY_VERSION } from "@/features/portfolio/policy";
+import { LEGAL, policyEffectiveDates } from "@/lib/legal";
import { getYoutubeChannelId } from "@/lib/youtube";
export const metadata = legalMetadata(
"Privacy Policy",
- "How ProCoderJii Notes handles visitor data.",
+ "How ProCoderJii handles learning accounts, practice history, private portfolio drafts, and published portfolios.",
"/privacy"
);
export default function PrivacyPage() {
return (
-
+
This policy explains what information is handled when you use ProCoderJii (procoderji.com).
The site is operated by . You can reach us at .
@@ -35,6 +36,21 @@ export default function PrivacyPage() {
create and maintain your learning account. ProCoderJii does not receive your Google password.
We do not ask for your college, phone number, or resume to use practice.
+
+ Private portfolio drafts. When you explicitly save a draft while signed in,
+ we store it in the learning service's database with your account and any username you claim.
+ Drafts can contain your chosen name, headline, about text, skills, projects, education,
+ experience, achievements, links, optional public email address, and theme. A guest can try
+ the form, but that draft stays only in the open page and is lost when the page is left or
+ the account changes.
+
+
+ Publishing confirmation. Before publishing, you must confirm that you are
+ at least {PORTFOLIO_MINIMUM_AGE} and have read the portfolio Terms and Privacy Policy. We
+ privately store the accepted policy version (currently {PORTFOLIO_POLICY_VERSION}) and the
+ confirmation time with your portfolio. We do not collect a date of birth or identity
+ document for this step; it is your declaration, not independent age verification.
+
Messages you send us. The contact form on the home page does not send anything
by itself. It opens your own email app with a message addressed to {LEGAL.contactEmail}. If you
@@ -50,14 +66,44 @@ export default function PrivacyPage() {
-
2. Cookies and similar technologies
+
2. What publishing makes public
+
+ Saving a draft does not publish it. When you choose Publish, we save a separate public copy of
+ the portfolio details and search preference you chose. Anyone who knows its /u/username address
+ can read that copy without an account. This includes any email address, education, experience,
+ project details, and links you put in it. Your Google sign-in email is not added to the public
+ page automatically. Later edits to your draft stay private until you publish again.
+
+
+ Search visibility is off by default: the page asks search engines not to index it and is left
+ out of our sitemap. You can opt in when publishing. This setting does not make the address
+ private or prevent visitors, search engines, or other services from copying its contents.
+ Search engines may take time to update, and we cannot recall copies held by third parties.
+ Share only information you want public; do not include another person's private information.
+
+
+ The portfolio has no fields for phone numbers, postal addresses, or dates of birth, and no
+ photo or other uploaded-file storage. Its avatar uses initials. Free-text fields are still
+ public if you publish them, so avoid putting sensitive information there.
+
+
+
3. Markdown import
+
+ You can paste Markdown or select a UTF-8 text file up to 50 KiB to fill the form. Parsing happens
+ in your browser; the file itself is not uploaded, stored, or rendered as a public page. Review
+ the imported fields before explicitly saving a private draft or publishing. We do not send
+ your text to an AI service. If you use your own AI tool to prepare it, that tool has its own
+ data practices.
+
+
+
4. Cookies and similar technologies
Browser storage keeps your learning progress on this device. If you sign in, essential authentication
cookies maintain your session, and a request-verification cookie protects account updates. These features are used to provide the learning service. The site does
not currently use analytics or advertising services.
-
3. Third-party content
+
5. Third-party content
The “About” section of the home page displays an animation. The animation file is
loaded from lottie.host (LottieFiles) and the player's WebAssembly file is loaded from the
@@ -69,23 +115,49 @@ export default function PrivacyPage() {
university websites) lead to other sites that have their own privacy practices.
-
4. Retention and deletion
+
6. Retention, unpublishing, and deletion
Guest history remains in your browser until you delete it, clear browser data, or older sessions fall
outside the 100-session limit. Cloud practice history keeps the most recent 100 sessions per account;
older sessions are removed as new sessions are synced. The Account & data page lets you export
- the history currently visible in your workspace and delete practice progress. Cloud deletion resets
- cached account history on other devices when they next sync. To request deletion of the sign-in
- account or other data associated with your email, contact from that email address.
+ the practice history currently visible in your workspace and delete practice progress. Cloud
+ deletion resets cached account history on other devices when they next sync. Clearing practice
+ history does not delete a portfolio or your Google sign-in account.
-
5. Future changes
+
+ Your saved portfolio draft and current public copy remain until you delete the portfolio or
+ request removal. Unpublish removes public access while keeping your private draft and username.
+ New requests to the site no longer receive the published page after unpublishing, deletion,
+ or operator suspension. Published copies can remain in an internal cache for up to one hour,
+ but each new page request checks that the portfolio is still published before serving a cached
+ copy. Copies already saved outside the site may remain.
+
+
+ Delete portfolio in the editor removes the saved draft, current public copy, and username claim.
+ A claimed username cannot be changed while that portfolio exists. Suspended portfolios cannot
+ be deleted and recreated through the editor to bypass moderation; contact for
+ a review or a data-removal request. Account deletion is currently handled by contacting us,
+ rather than an account-deletion button. To request removal of the sign-in account and associated
+ portfolio or other data, write from the email address used for that account.
+
+
+
7. Reports and moderation
+
+ The report link on a public portfolio opens your email app with the page address filled in.
+ Nothing is sent until you send the email. We receive the report and any details you include
+ through your email provider and use them to investigate and reply. The operator can suspend
+ a portfolio so it is no longer public. Restoring a suspended portfolio returns it to a private
+ draft; the student must confirm eligibility and publish again to make it public.
+
+
+
8. Future changes
If we add analytics, advertising (such as Google AdSense), embedded videos, a newsletter or anything
else that collects data or sets cookies, we will update this policy first, describe the third parties
involved, and ask for your consent where the law requires it.
-
6. Your choices and rights
+
9. Your choices and rights
Depending on where you live, you may have the right to ask what personal data we hold about you, to
have it corrected or deleted, and to object to its use. We handle personal data in line with
@@ -93,18 +165,21 @@ export default function PrivacyPage() {
force. To make a request, email . We will respond within a reasonable time.
-
7. Children
+
10. Age requirement for public portfolios
- The site is meant for students. We do not knowingly collect personal data from children through the
- site. If you believe a child has sent us personal data, email us and we will delete it.
+ Public portfolio publishing is available only to people aged {PORTFOLIO_MINIMUM_AGE} or older
+ under our product policy. Guest practice does not require an account or a portfolio. We rely
+ on the publishing confirmation described above. If you believe an underage person has published
+ a portfolio or sent us personal information, contact so we can investigate and
+ remove the information where appropriate.
-
8. Changes to this policy
+
11. Changes to this policy
We may update this policy. The effective date at the top shows when the current version took effect.
-
9. Contact
+
12. Contact
Questions about this policy:
{LEGAL.postalAddress ? <> · {LEGAL.postalAddress}> : null}.
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 3f27161..4285b37 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -6,16 +6,18 @@ import { jobsSitemapRoutes, todayIso } from "@/lib/jobs";
import { legalSitemapRoutes } from "@/lib/legal";
import { readNote } from "@/lib/notes";
import { buildSitemapEntries, getSiteUrl } from "@/lib/site";
+import { getSearchVisiblePortfolios } from "@/features/portfolio/server";
-// Regenerate daily so /jobs drops out as soon as nothing is open.
-export const revalidate = 86400;
+// Fresh visibility keeps unpublished/suspended pages out of the discovery list.
+export const dynamic = "force-dynamic";
-export default function sitemap(): MetadataRoute.Sitemap {
+export default async function sitemap(): Promise {
return buildSitemapEntries(getSiteUrl(), [
...getSitemapRoutes((unit) => readNote(unit.path) !== null),
...guideSitemapRoutes(getGuides(), (g) => readNote(g.path) !== null),
...jobsSitemapRoutes(getJobs(), todayIso()),
// Legal pages are listed only once the owner's required details are filled in.
...legalSitemapRoutes(),
+ ...await getSearchVisiblePortfolios(),
]);
}
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
index 0a020a1..c9fca13 100644
--- a/src/app/terms/page.tsx
+++ b/src/app/terms/page.tsx
@@ -1,23 +1,26 @@
import { EmailLink, Fill, H2, LegalLayout, Operator, P, UL, legalMetadata } from "@/components/legal/legal-layout";
+import { PORTFOLIO_MINIMUM_AGE, PORTFOLIO_POLICY_VERSION } from "@/features/portfolio/policy";
+import { policyEffectiveDates } from "@/lib/legal";
export const metadata = legalMetadata(
"Terms of Use",
- "Review the terms for using ProCoderJii Notes, including acceptable use, ownership of study material, and service limitations.",
+ "Terms for ProCoderJii learning resources and student portfolios, including publishing, content ownership, and moderation.",
"/terms"
);
export default function TermsPage() {
return (
-
+
- By using ProCoderJii Notes (procoderji.com), operated by , you agree to
+ By using ProCoderJii (procoderji.com), operated by , you agree to
these terms. If you do not agree, please do not use the site.
1. What the site is
- ProCoderJii Notes provides study notes and related learning material for engineering students,
- alongside the ProCoderJii YouTube channel. It is provided for general educational use.
+ ProCoderJii provides study notes, placement practice, learning accounts, and optional student
+ portfolios, alongside the ProCoderJii YouTube channel. It is provided for general educational
+ use. A portfolio is a way to present your work; it does not guarantee employment or recruitment.
2. Ownership of content
@@ -33,39 +36,86 @@ export default function TermsPage() {
parties belong to their owners. Where official syllabi or question papers exist, we link to the
official source and do not claim them as ours.
+
+ You retain ownership of the material you put in your portfolio. Publishing a portfolio does not
+ place your personal information or portfolio content under the repository's MIT License.
+ You give the operator permission to store and process your draft to provide the service and,
+ when you choose to publish, display your chosen public copy at its portfolio address. This
+ permission is limited to operating the portfolio service in accordance with your publishing
+ choices and the Privacy Policy. You must have the rights or permission needed for what you submit.
+
+
+
3. Portfolio publishing and your choices
+
+ You must be at least {PORTFOLIO_MINIMUM_AGE} to publish a public portfolio. Publishing requires
+ Google sign-in, a username claim, and your confirmation that you meet this age requirement and
+ have read the portfolio Terms and Privacy Policy. We record the accepted policy version
+ (currently {PORTFOLIO_POLICY_VERSION}) and confirmation time. This is a self-declaration;
+ we do not collect a date of birth or independently verify your age through this step.
+
+
+ One portfolio is available per account. Your username stays fixed while that portfolio exists,
+ so choose it carefully before sharing its /u/username address. Saving a draft keeps it private.
+ Publishing creates a public copy, and later draft edits do not change that copy until you
+ publish again. Anyone with the address can view a published portfolio. Search indexing is
+ off by default and can be enabled when publishing; it is not an access restriction.
+
+
+ Unpublish hides the public page and keeps your draft and username. Delete portfolio removes
+ the saved draft, current public copy, and username claim. Search engines and other third parties
+ may keep copies after you unpublish or delete. Clearing practice history does not delete your
+ portfolio. Contact to request deletion of your sign-in account and associated data.
+
-
3. Acceptable use
+
4. Acceptable use
Do not attempt to disrupt, overload or gain unauthorised access to the site.
-
Do not use automated tools to copy the site's content in bulk.
+
Do not impersonate another person or organisation or misrepresent the work you have done.
+
Do not publish phishing, scams, malicious links, harassment, or unlawful content.
+
Do not infringe copyright or publish another person's private information without permission.
+
Do not collect student contact details in bulk for spam or other abuse.
Do not use the site for anything unlawful.
-
4. Accuracy and no warranty
+
5. Reports and suspension
+
+ Use Report this page on a portfolio, or email with the page address and the
+ reason for your report. The report link only prepares a message in your own email app.
+ The operator reviews reports and may suspend a portfolio that violates these terms or puts
+ students or the service at risk. Suspension hides the public page and prevents republishing
+ or deleting and recreating the portfolio through the editor to evade that decision.
+
+
+ Contact the operator to request a review of a suspension or removal of your data. If the
+ operator restores a suspended portfolio, it returns to a private draft. Restoration does
+ not publish it automatically; you must confirm eligibility and publish again.
+
+
+
6. Accuracy and no warranty
We try to keep the content accurate, but it may contain mistakes or become out of date, for example
when a university changes its syllabus. The site and its content are provided “as is”
without warranties of any kind. See also the Disclaimer.
-
5. Links to other sites
+
7. Links to other sites
The site may link to websites we do not control. We are not responsible for their content or practices.
-
6. Limitation of liability
+
8. Limitation of liability
To the extent allowed by law, we are not liable for any loss arising from your use of, or inability
to use, the site or its content, including loss from relying on the notes in an examination.
-
7. Copyright complaints
+
9. Copyright complaints
If you believe content on the site infringes your rights, follow the steps on the Copyright &
Takedown page.
-
8. Changes and governing law
+
10. Changes and governing law
We may change these terms; the effective date at the top shows the current version. These terms are
governed by the laws of . Subject to any rights you have under law that
@@ -73,7 +123,7 @@ export default function TermsPage() {
dispute arising from them.
-
9. Contact
+
11. Contact
Questions about these terms:
diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx
new file mode 100644
index 0000000..4c276cd
--- /dev/null
+++ b/src/app/u/[username]/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { PublicPortfolioPage } from "@/features/portfolio/public-page";
+import { publicPortfolioMetadata } from "@/features/portfolio/public-presentation";
+import { getPublishedPortfolio } from "@/features/portfolio/server";
+
+// A fresh publication-state check runs before cached published content is displayed.
+// Unpublished or suspended records must never survive as a cached HTML page.
+export const dynamic = "force-dynamic";
+
+type Props = { params: Promise<{ username: string }> };
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { username } = await params;
+ const portfolio = await getPublishedPortfolio(username);
+ if (!portfolio) return { title: "Portfolio not found", robots: { index: false, follow: false } };
+ return publicPortfolioMetadata(portfolio);
+}
+
+export default async function PortfolioPage({ params }: Props) {
+ const { username } = await params;
+ const portfolio = await getPublishedPortfolio(username);
+ if (!portfolio) notFound();
+ return ;
+}
diff --git a/src/components/legal/legal-layout.tsx b/src/components/legal/legal-layout.tsx
index d08774b..f2c15e5 100644
--- a/src/components/legal/legal-layout.tsx
+++ b/src/components/legal/legal-layout.tsx
@@ -58,7 +58,12 @@ export function EmailLink() {
);
}
-export function LegalLayout({ title, children }: { title: string; children: React.ReactNode }) {
+export function LegalLayout({ title, children, effectiveDate }: {
+ title: string;
+ children: React.ReactNode;
+ /** A trusted publication date supplied by the policy page, never visitor input. */
+ effectiveDate?: string;
+}) {
const missing = missingLegalFields();
return (
@@ -92,7 +97,7 @@ export function LegalLayout({ title, children }: { title: string; children: Reac
+
+ ;
+ }
+
+ const importMarkdown = (source: string) => {
+ const result = parsePortfolioMarkdown(source);
+ if (!result.ok) { setImportErrors(result.problems.map(problem => `${problem.line ? `Line ${problem.line}: ` : ""}${problem.message}`)); return; }
+ if (dirtyRef.current && !window.confirm("Replace the current form with these imported details? Your saved portfolio stays unchanged until you save.")) return;
+ dirtyRef.current = true;
+ setData(result.data); setProblems([]); setError(""); setImportErrors([]);
+ setMessage("Markdown imported into the form. Review every section and the preview before saving.");
+ };
+
+ return <>
+
+
+
Your work, in one place
+
Portfolio builder
+
Introduce yourself, show your projects and choose a simple public page. Keep changes private until you are ready to publish.
Your guest draft stays only in this open page. It is cleared when you leave or change accounts. A Google account is needed to save a private draft, claim an address or publish.
+ {accountConfigured ? :
Account services are not available on this deployment. You can still try the preview. View account options.
{conflict ? "Another update was saved. Your unsaved edits are still here. Load the latest draft before trying to save again." : "Your saved draft could not be loaded. Retry before saving to your account."}
}
+ {suspended &&
Your public portfolio is suspended. You can edit your private draft or unpublish it. Contact {CONTACT_EMAIL} to request a review.
}
+
+
+
+
+
+
+ >;
+}
diff --git a/src/features/portfolio/markdown.test.ts b/src/features/portfolio/markdown.test.ts
new file mode 100644
index 0000000..eb4cfbf
--- /dev/null
+++ b/src/features/portfolio/markdown.test.ts
@@ -0,0 +1,120 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import {
+ parsePortfolioMarkdown,
+ portfolioToMarkdown,
+ PORTFOLIO_MARKDOWN_AI_PROMPT,
+ PORTFOLIO_MARKDOWN_MAX_BYTES,
+} from "./markdown.ts";
+import type { PortfolioData } from "./types.ts";
+
+const template = fs.readFileSync("public/templates/portfolio.md", "utf8");
+const example: PortfolioData = {
+ name: "Riya Student",
+ headline: "Computer science student building useful web applications",
+ about: "I enjoy building small applications that help other students learn. Replace these sample details with your own before saving.",
+ skills: ["TypeScript", "C#", "PostgreSQL"],
+ projects: [{ title: "Campus Notes", link: "https://github.com/example/campus-notes", technologies: ["TypeScript", "Next.js"], description: "Built a small notes catalogue with keyboard navigation." }],
+ education: [{ institution: "Example Institute", degree: "B.Tech in Computer Science", period: "2023–2027", details: "Studying databases and software engineering." }],
+ experience: [{ organization: "Student Computing Club", role: "Volunteer Developer", period: "2025–2026", description: "Helped classmates publish reviewed study material." }],
+ achievements: ["Built a student project with a small team."],
+ links: { github: "https://github.com/example", linkedin: "https://linkedin.com/in/example-student", website: "https://example.com", email: "riya@example.com" },
+};
+
+function mustParse(source: string): PortfolioData {
+ const result = parsePortfolioMarkdown(source);
+ assert.equal(result.ok, true, JSON.stringify(result.problems));
+ if (!result.ok) throw new Error("Expected valid import");
+ return result.data;
+}
+
+test("the downloadable template imports every supported portfolio section and round-trips", () => {
+ const expected = { ...example, theme: "simple" };
+ const parsed = mustParse(template);
+ assert.deepEqual(parsed, expected);
+ assert.deepEqual(mustParse(portfolioToMarkdown(parsed)), expected);
+ assert.deepEqual(mustParse(template.replaceAll("\n", "\r\n")), expected);
+});
+
+test("missing and unknown sections fail explicitly instead of dropping student content", () => {
+ const missing = parsePortfolioMarkdown(template.replace("## Education", "Education"));
+ assert.equal(missing.ok, false);
+ assert.ok(missing.problems.some((entry) => entry.code === "missing-section" && entry.path === "Education"));
+ const extra = parsePortfolioMarkdown(template + "\n## Awards\n- A prize\n");
+ assert.equal(extra.ok, false);
+ assert.ok(extra.problems.some((entry) => entry.code === "unknown-section" && entry.path === "Awards"));
+ assert.ok(extra.problems.some((entry) => entry.line !== undefined));
+ const duplicate = parsePortfolioMarkdown(template + "\n## Skills\n- Java\n");
+ assert.ok(duplicate.problems.some((entry) => entry.code === "duplicate-section"));
+});
+
+test("HTML, JSX, executable expressions and fenced code are rejected before creating data", () => {
+ for (const text of ["", "", "<>fragment>", "{process.env.TOKEN}", "```js\nalert(1)\n```", "import something from 'elsewhere'"]) {
+ const result = parsePortfolioMarkdown(template.replace("I enjoy building small applications", text + "\nI enjoy building small applications"));
+ assert.equal(result.ok, false, text);
+ assert.ok(result.problems.some((entry) => entry.code === "unsafe-markdown"), text);
+ assert.ok(!Object.hasOwn(result, "data"));
+ }
+});
+
+test("shared link, field, nested required-value and theme rules apply to imports", () => {
+ for (const [from, to, path, code] of [
+ ["https://github.com/example/campus-notes", "javascript:alert(1)", "projects[0].link", "invalid-link"],
+ ["https://github.com/example\n", "https://evil.example\n", "links.github", "invalid-host"],
+ ["Role: Volunteer Developer", "Role:", "experience[0].role", "required"],
+ ["theme: simple", "theme: custom", "theme", "invalid-format"],
+ ]) {
+ const result = parsePortfolioMarkdown(template.replace(from, to));
+ assert.equal(result.ok, false, to);
+ assert.ok(result.problems.some((entry) => entry.path === path && entry.code === code), JSON.stringify(result.problems));
+ }
+});
+
+test("empty optional sections import as a draft, and multiline entry text stays plain text", () => {
+ const source = "---\nname: Riya\nheadline: Student\n---\n\n## About\n\n## Skills\n\n## Projects\n\n## Education\n\n## Experience\n\n## Achievements\n";
+ assert.deepEqual(mustParse(source), { name: "Riya", headline: "Student" });
+ const result = mustParse(template.replace("Description: Built a small notes catalogue with keyboard navigation.", "Description: |\n Built a small catalogue.\n Supports keyboard navigation."));
+ assert.equal(result.projects?.[0].description, "Built a small catalogue.\nSupports keyboard navigation.");
+});
+
+test("import rejects malformed/duplicate metadata, unexpected fields and unsupported YAML without object construction", () => {
+ for (const source of [
+ template.replace("name: \"Riya Student\"", "name: \"Riya Student\"\nname: Other"),
+ template.replace("theme: simple", "theme: simple\n__proto__: bad"),
+ template.replace("theme: simple", "theme: !javascript bad"),
+ template.replace("Link: https://github.com/example/campus-notes", "Constructor: surprise"),
+ template.replace('Technologies: ["TypeScript", "Next.js"]', "Technologies: TypeScript, Next.js"),
+ template.replace('name: "Riya Student"', 'name: "unclosed'),
+ ]) {
+ const result = parsePortfolioMarkdown(source);
+ assert.equal(result.ok, false);
+ assert.ok(result.problems.length > 0);
+ }
+ assert.equal(({} as Record).bad, undefined);
+});
+
+test("the size cap counts UTF-8 bytes before parsing and malformed text is rejected", () => {
+ assert.equal(PORTFOLIO_MARKDOWN_MAX_BYTES, 50 * 1024);
+ for (const source of [" ".repeat(PORTFOLIO_MARKDOWN_MAX_BYTES + 1), "अ".repeat(18_000)]) {
+ const result = parsePortfolioMarkdown(source);
+ assert.equal(result.ok, false);
+ assert.equal(result.problems[0].code, "too-large");
+ }
+ for (const source of [template + "\0", template + String.fromCharCode(0xd800)]) {
+ const result = parsePortfolioMarkdown(source);
+ assert.equal(result.ok, false);
+ assert.equal(result.problems[0].code, "invalid-text");
+ }
+ assert.equal(parsePortfolioMarkdown(null).ok, false);
+ assert.ok(PORTFOLIO_MARKDOWN_AI_PROMPT.includes("review"));
+});
+
+test("serialization escapes literal Markdown structure and preserves quoted Unicode and braces", () => {
+ const data: PortfolioData = {
+ name: 'Riya "विद्यार्थी"', headline: "Student", about: "I use objects \\ and {values}.\n## This is text, not a section",
+ skills: ["C#", "CSS {grid}"],
+ projects: [{ title: "A project", description: "An object {value} in plain quoted text." }],
+ };
+ assert.deepEqual(mustParse(portfolioToMarkdown(data)), data);
+});
diff --git a/src/features/portfolio/markdown.ts b/src/features/portfolio/markdown.ts
new file mode 100644
index 0000000..52f6d58
--- /dev/null
+++ b/src/features/portfolio/markdown.ts
@@ -0,0 +1,313 @@
+import type {
+ PortfolioData,
+ PortfolioEducation,
+ PortfolioExperience,
+ PortfolioProject,
+} from "./types.ts";
+import { validatePortfolio } from "./validation.ts";
+
+export const PORTFOLIO_MARKDOWN_MAX_BYTES = 50 * 1024;
+
+export const PORTFOLIO_MARKDOWN_AI_PROMPT =
+ "Fill the ProCoderJii portfolio.md template using only the details I provide. " +
+ "Keep every front-matter key and section heading exactly as it is. " +
+ "Keep the name, headline and theme in front matter, and use the labelled fields in each entry. " +
+ "Use simple or developer for theme, https links, and a plain email address if I choose to share it. " +
+ "Keep Technologies as a JSON array of strings. Do not add HTML, JSX, JavaScript expressions, code fences or new sections. " +
+ "Remove sample entries that do not apply and leave their section headings empty. " +
+ "Do not invent experience, qualifications or achievements. Return only the completed Markdown file; I will review it before saving.";
+
+export interface PortfolioMarkdownProblem {
+ path: string;
+ code: string;
+ message: string;
+ line?: number;
+}
+
+export type PortfolioMarkdownResult =
+ | { ok: true; data: PortfolioData; problems: [] }
+ | { ok: false; problems: PortfolioMarkdownProblem[] };
+
+const SECTIONS = ["About", "Skills", "Projects", "Education", "Experience", "Achievements"] as const;
+type Section = (typeof SECTIONS)[number];
+const FRONT_FIELDS = ["name", "headline", "theme", "github", "linkedin", "website", "email"];
+const ENTRY_FIELDS = {
+ Projects: { link: "link", technologies: "technologies", description: "description" },
+ Education: { degree: "degree", period: "period", details: "details" },
+ Experience: { role: "role", period: "period", description: "description" },
+} as const;
+type EntrySection = keyof typeof ENTRY_FIELDS;
+interface Line { text: string; number: number }
+const MULTILINE = new Set(["description", "details"]);
+
+function problem(path: string, code: string, message: string, line?: number): PortfolioMarkdownProblem {
+ return { path, code, message, ...(line === undefined ? {} : { line }) };
+}
+
+function wellFormed(value: string): boolean {
+ for (let index = 0; index < value.length; index++) {
+ const code = value.charCodeAt(index);
+ if (code >= 0xd800 && code <= 0xdbff) {
+ const next = value.charCodeAt(++index);
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
+ } else if (code >= 0xdc00 && code <= 0xdfff) return false;
+ }
+ return true;
+}
+
+/** Decode only Markdown punctuation escapes, never HTML entities or executable markup. */
+function unescapeText(value: string): string {
+ return value.replace(/\\([\\{}#*\[\]_])/g, "$1");
+}
+
+function hasExpression(value: string): boolean {
+ for (let index = 0; index < value.length; index++) {
+ if (value[index] === "\\") { index++; continue; }
+ if (value[index] === "{" || value[index] === "}") return true;
+ }
+ return false;
+}
+
+function plainText(value: string, path: string, line: number, problems: PortfolioMarkdownProblem[]): string | undefined {
+ if (hasExpression(value)) {
+ problems.push(problem(path, "unsafe-markdown", "JavaScript expressions are not supported. Escape literal braces with a backslash.", line));
+ return undefined;
+ }
+ return unescapeText(value);
+}
+
+/** A deliberately small scalar grammar, not a YAML object loader. */
+function scalar(value: string, path: string, line: number, problems: PortfolioMarkdownProblem[]): string | undefined {
+ const trimmed = value.trim();
+ if (!trimmed) return undefined;
+ if (trimmed.startsWith('"')) {
+ try {
+ const parsed: unknown = JSON.parse(trimmed);
+ if (typeof parsed === "string") return parsed;
+ } catch { /* Report the format below. */ }
+ problems.push(problem(path, "invalid-format", "Use a plain value or a valid double-quoted JSON string.", line));
+ return undefined;
+ }
+ if (trimmed.startsWith("'")) {
+ if (/^'(?:[^']|'')*'$/.test(trimmed)) return trimmed.slice(1, -1).replaceAll("''", "'");
+ problems.push(problem(path, "invalid-format", "Close the quoted value; double a quote inside a single-quoted value.", line));
+ return undefined;
+ }
+ if (/^[!&*|>\[{]/.test(trimmed)) {
+ problems.push(problem(path, "unsupported-value", "Use a plain scalar value. YAML tags, aliases, objects and arrays are not supported here.", line));
+ return undefined;
+ }
+ return plainText(trimmed, path, line, problems);
+}
+
+function trimBlankLines(lines: Line[]): Line[] {
+ let start = 0;
+ let end = lines.length;
+ while (start < end && !lines[start].text.trim()) start++;
+ while (end > start && !lines[end - 1].text.trim()) end--;
+ return lines.slice(start, end);
+}
+
+function bullets(lines: Line[], path: string, problems: PortfolioMarkdownProblem[]): string[] | undefined {
+ const items: string[] = [];
+ for (const line of lines) {
+ if (!line.text.trim()) continue;
+ const match = /^- (.+)$/.exec(line.text);
+ if (!match) {
+ problems.push(problem(path, "invalid-format", "Use one '- item' line per entry.", line.number));
+ continue;
+ }
+ const value = scalar(match[1], `${path}[${items.length}]`, line.number, problems);
+ if (value !== undefined) items.push(value);
+ }
+ return items.length ? items : undefined;
+}
+
+function entries(lines: Line[], section: EntrySection, problems: PortfolioMarkdownProblem[]): Record[] | undefined {
+ const path = section.toLowerCase();
+ const titleKey = section === "Projects" ? "title" : section === "Education" ? "institution" : "organization";
+ const allowed: Readonly> = ENTRY_FIELDS[section];
+ const result: Record[] = [];
+ let item: Record | undefined;
+ let fields = new Set();
+ for (let index = 0; index < lines.length; index++) {
+ const line = lines[index];
+ if (!line.text.trim()) continue;
+ const heading = /^### (.+)$/.exec(line.text);
+ if (heading) {
+ const title = scalar(heading[1], `${path}[${result.length}].${titleKey}`, line.number, problems);
+ item = { [titleKey]: title ?? "" };
+ result.push(item);
+ fields = new Set();
+ continue;
+ }
+ const itemPath = `${path}[${Math.max(0, result.length - 1)}]`;
+ if (!item) {
+ problems.push(problem(path, "invalid-format", "Start each entry with a '### Title' heading.", line.number));
+ continue;
+ }
+ const field = /^([A-Za-z][A-Za-z ]*):(?:[ \t]*(.*))$/.exec(line.text);
+ if (!field) {
+ problems.push(problem(itemPath, "invalid-format", "Use the template's labelled fields; indent continuation text by two spaces.", line.number));
+ continue;
+ }
+ const fieldName = field[1].toLowerCase();
+ const key = Object.hasOwn(allowed, fieldName) ? allowed[fieldName] : undefined;
+ if (!key) {
+ problems.push(problem(`${itemPath}.${field[1]}`, "unknown-field", `Unknown field '${field[1]}' in ${section}.`, line.number));
+ continue;
+ }
+ if (fields.has(key)) {
+ problems.push(problem(`${itemPath}.${key}`, "duplicate-field", `The '${field[1]}' field appears more than once.`, line.number));
+ continue;
+ }
+ fields.add(key);
+ if (key === "technologies") {
+ if (!field[2].trim()) continue;
+ try {
+ const value: unknown = JSON.parse(field[2]);
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
+ item[key] = value;
+ continue;
+ }
+ } catch { /* Report an actionable array error. */ }
+ problems.push(problem(`${itemPath}.${key}`, "invalid-format", 'Write Technologies as a JSON list such as ["TypeScript", "C#"].', line.number));
+ continue;
+ }
+ if (MULTILINE.has(key) && (field[2].trim() === "|" || !field[2].trim())) {
+ const body: Line[] = [];
+ while (index + 1 < lines.length && (lines[index + 1].text.startsWith(" ") || !lines[index + 1].text.trim())) {
+ const next = lines[++index];
+ body.push({ text: next.text.startsWith(" ") ? next.text.slice(2) : "", number: next.number });
+ }
+ const text = trimBlankLines(body).map((entry) => entry.text).join("\n");
+ const value = text ? plainText(text, `${itemPath}.${key}`, line.number, problems) : undefined;
+ if (value !== undefined) item[key] = value;
+ } else {
+ const value = scalar(field[2], `${itemPath}.${key}`, line.number, problems);
+ if (value !== undefined) item[key] = value;
+ }
+ }
+ return result.length ? result : undefined;
+}
+
+/** Parse the import format into draft data only. Nothing is rendered, stored or published. */
+export function parsePortfolioMarkdown(source: unknown): PortfolioMarkdownResult {
+ const problems: PortfolioMarkdownProblem[] = [];
+ if (typeof source !== "string") return { ok: false, problems: [problem("$", "invalid-type", "Choose a UTF-8 Markdown text file.")] };
+ if (source.length > PORTFOLIO_MARKDOWN_MAX_BYTES || new TextEncoder().encode(source).byteLength > PORTFOLIO_MARKDOWN_MAX_BYTES)
+ return { ok: false, problems: [problem("$", "too-large", "The Markdown file must be 50 KiB or smaller.")] };
+ if (!wellFormed(source) || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(source))
+ return { ok: false, problems: [problem("$", "invalid-text", "Use well-formed UTF-8 text without control characters.")] };
+ if (/<(?:[A-Za-z/!?]|>)/.test(source))
+ return { ok: false, problems: [problem("$", "unsafe-markdown", "HTML and JSX are not allowed in portfolio imports.")] };
+ if (/^\s*(?:`{3,}|~{3,})/m.test(source) || /^\s*(?:import|export)\s/m.test(source))
+ return { ok: false, problems: [problem("$", "unsafe-markdown", "Code fences and JavaScript import/export statements are not supported.")] };
+
+ const lines = source.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n").map((text, index) => ({ text, number: index + 1 }));
+ if (lines[0]?.text !== "---")
+ return { ok: false, problems: [problem("$", "missing-front-matter", "Start with the template's --- front matter block.", 1)] };
+ const end = lines.findIndex((line, index) => index > 0 && line.text === "---");
+ if (end === -1)
+ return { ok: false, problems: [problem("$", "missing-front-matter", "Close the front matter with a second --- line.", 1)] };
+ const data: Record = {};
+ const links: Record = {};
+ const frontFields = new Set();
+ for (const line of lines.slice(1, end)) {
+ if (!line.text.trim()) continue;
+ const match = /^([a-z][a-z0-9]*):(?:[ \t]*(.*))$/.exec(line.text);
+ if (!match || !FRONT_FIELDS.includes(match[1])) {
+ problems.push(problem("frontMatter", "unknown-field", "Use only the front-matter keys from the template.", line.number));
+ continue;
+ }
+ const key = match[1];
+ if (frontFields.has(key)) {
+ problems.push(problem(key, "duplicate-field", `The '${key}' field appears more than once.`, line.number));
+ continue;
+ }
+ frontFields.add(key);
+ const value = scalar(match[2], key, line.number, problems);
+ if (value !== undefined) {
+ if (["github", "linkedin", "website", "email"].includes(key)) links[key] = value;
+ else data[key] = value;
+ }
+ }
+ if (Object.keys(links).length) data.links = links;
+
+ const sections = new Map();
+ let current: Line[] | undefined;
+ for (const line of lines.slice(end + 1)) {
+ if (line.text === "# Portfolio" && !current && sections.size === 0) continue;
+ const heading = /^## (.+)$/.exec(line.text);
+ if (heading) {
+ if (!SECTIONS.includes(heading[1] as Section)) {
+ problems.push(problem(heading[1], "unknown-section", `Unknown section '${heading[1]}'. Keep the template headings unchanged.`, line.number));
+ current = undefined;
+ } else if (sections.has(heading[1] as Section)) {
+ problems.push(problem(heading[1], "duplicate-section", `The '${heading[1]}' section appears more than once.`, line.number));
+ current = undefined;
+ } else {
+ current = [];
+ sections.set(heading[1] as Section, current);
+ }
+ } else if (/^#{1,6}(?:\s|$)/.test(line.text) && !/^### .+/.test(line.text)) {
+ problems.push(problem("$", "unknown-section", "Use the template's ## sections and ### entry headings.", line.number));
+ } else if (current) current.push(line);
+ else if (line.text.trim()) problems.push(problem("$", "outside-section", "Place this text inside a known section.", line.number));
+ }
+ for (const section of SECTIONS)
+ if (!sections.has(section)) problems.push(problem(section, "missing-section", `The '${section}' heading is missing. Leave it empty if it does not apply.`));
+ const aboutLines = trimBlankLines(sections.get("About") ?? []);
+ if (aboutLines.some((line) => /^### /.test(line.text)))
+ problems.push(problem("about", "unknown-section", "The About section does not have entry subheadings."));
+ if (aboutLines.length) {
+ const value = plainText(aboutLines.map((line) => line.text).join("\n"), "about", aboutLines[0].number, problems);
+ if (value !== undefined) data.about = value;
+ }
+ const skills = bullets(sections.get("Skills") ?? [], "skills", problems);
+ const achievements = bullets(sections.get("Achievements") ?? [], "achievements", problems);
+ if (skills) data.skills = skills;
+ if (achievements) data.achievements = achievements;
+ for (const section of ["Projects", "Education", "Experience"] as const) {
+ const values = entries(sections.get(section) ?? [], section, problems);
+ if (values) data[section.toLowerCase()] = values;
+ }
+ if (problems.length) return { ok: false, problems };
+ const validation = validatePortfolio(data, "draft");
+ if (validation.length) return { ok: false, problems: validation.map((entry) => problem(entry.path, entry.code, `Correct '${entry.path}': ${entry.code.replaceAll("-", " ")}.`)) };
+ return { ok: true, data: data as PortfolioData, problems: [] };
+}
+
+function escaped(value: string): string {
+ return value.replaceAll("\\", "\\\\").replace(/[{}]/g, "\\$&").replace(/^(#{1,6})(?=\s|$)/gm, "\\$1");
+}
+
+/** Serialize the supported fields into the documented review/import format. */
+export function portfolioToMarkdown(data: PortfolioData): string {
+ const front = ["---", `name: ${JSON.stringify(data.name ?? "")}`, `headline: ${JSON.stringify(data.headline ?? "")}`];
+ const theme = data.theme;
+ if (theme !== undefined) front.push(`theme: ${JSON.stringify(theme)}`);
+ for (const key of ["github", "linkedin", "website", "email"] as const)
+ if (data.links?.[key] !== undefined) front.push(`${key}: ${JSON.stringify(data.links[key])}`);
+ front.push("---", "", "# Portfolio", "", "## About", "", escaped(data.about ?? ""), "", "## Skills", "");
+ const output = [...front, ...(data.skills ?? []).map((value) => `- ${JSON.stringify(value)}`), "", "## Projects", ""];
+ function addEntries(values: (PortfolioProject | PortfolioEducation | PortfolioExperience)[], kind: EntrySection) {
+ for (const value of values) {
+ const record = value as unknown as Record;
+ const title = kind === "Projects" ? "title" : kind === "Education" ? "institution" : "organization";
+ output.push(`### ${JSON.stringify(record[title])}`);
+ for (const [label, key] of Object.entries(ENTRY_FIELDS[kind])) {
+ if (record[key] === undefined) continue;
+ output.push(`${label[0].toUpperCase() + label.slice(1)}: ${JSON.stringify(record[key])}`);
+ }
+ output.push("");
+ }
+ }
+ addEntries(data.projects ?? [], "Projects");
+ output.push("## Education", "");
+ addEntries(data.education ?? [], "Education");
+ output.push("## Experience", "");
+ addEntries(data.experience ?? [], "Experience");
+ output.push("## Achievements", "", ...(data.achievements ?? []).map((value) => `- ${JSON.stringify(value)}`), "");
+ return output.join("\n");
+}
diff --git a/src/features/portfolio/policy.ts b/src/features/portfolio/policy.ts
new file mode 100644
index 0000000..9ea0f6f
--- /dev/null
+++ b/src/features/portfolio/policy.ts
@@ -0,0 +1,10 @@
+import { PORTFOLIO_RULES } from "./rules.ts";
+
+/** Product publishing policy shared with the backend, not a claim of age verification. */
+export const PORTFOLIO_PUBLISHING_POLICY = Object.freeze({
+ policyVersion: PORTFOLIO_RULES.publishing.policyVersion,
+ minimumAge: PORTFOLIO_RULES.publishing.minimumAge,
+});
+
+export const PORTFOLIO_POLICY_VERSION = PORTFOLIO_PUBLISHING_POLICY.policyVersion;
+export const PORTFOLIO_MINIMUM_AGE = PORTFOLIO_PUBLISHING_POLICY.minimumAge;
diff --git a/src/features/portfolio/public-loader.test.ts b/src/features/portfolio/public-loader.test.ts
new file mode 100644
index 0000000..22717b7
--- /dev/null
+++ b/src/features/portfolio/public-loader.test.ts
@@ -0,0 +1,28 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { loadPublishedSnapshot, PublicationChangedError } from "./public-loader.ts";
+
+const state = { username: "riya-student", version: "a".repeat(64), searchVisible: false, publishedAt: "2026-11-11T09:00:00.000Z" };
+const payload = { ...state, data: { name: "Riya", headline: "Student", theme: "simple" } };
+
+test("a disappearing publication rejects the cache load and can recover with the same version", async () => {
+ await assert.rejects(loadPublishedSnapshot(async () => null, state), PublicationChangedError);
+ assert.deepEqual(await loadPublishedSnapshot(async () => payload, state), payload);
+});
+
+test("malformed snapshots fail as service errors instead of cacheable missing pages", async () => {
+ for (const invalid of [{}, { ...payload, data: {} }, { ...payload, version: "b".repeat(64) }]) {
+ await assert.rejects(loadPublishedSnapshot(async () => invalid, state), error =>
+ error instanceof Error && !(error instanceof PublicationChangedError) && /invalid public snapshot/.test(error.message));
+ }
+});
+
+test("snapshot indexing and publication time must match the fresh public state", async () => {
+ for (const changed of [{ ...payload, searchVisible: true }, { ...payload, publishedAt: "2026-11-11T10:00:00.000Z" }])
+ await assert.rejects(loadPublishedSnapshot(async () => changed, state), /invalid public snapshot/);
+});
+
+test("upstream failures propagate without becoming publication changes", async () => {
+ const unavailable = new Error("Unavailable");
+ await assert.rejects(loadPublishedSnapshot(async () => { throw unavailable; }, state), error => error === unavailable);
+});
diff --git a/src/features/portfolio/public-loader.ts b/src/features/portfolio/public-loader.ts
new file mode 100644
index 0000000..97935e4
--- /dev/null
+++ b/src/features/portfolio/public-loader.ts
@@ -0,0 +1,25 @@
+import { parsePublishedPortfolio, type PublishedPortfolio, type PublicPortfolioState } from "./public-model.ts";
+
+/** A publication changed or became unavailable between its state and snapshot reads. */
+export class PublicationChangedError extends Error {
+ constructor() {
+ super("The portfolio publication changed while it was being read.");
+ this.name = "PublicationChangedError";
+ }
+}
+
+/**
+ * Only a valid snapshot may resolve inside the persistent cache. A missing snapshot must
+ * reject, otherwise a brief publication-state race would cache a false 404 for a full hour.
+ */
+export async function loadPublishedSnapshot(
+ read: () => Promise,
+ state: PublicPortfolioState,
+): Promise {
+ const payload = await read();
+ if (payload === null) throw new PublicationChangedError();
+ const snapshot = parsePublishedPortfolio(payload, state.username, state.version);
+ if (!snapshot || snapshot.searchVisible !== state.searchVisible || snapshot.publishedAt !== state.publishedAt)
+ throw new Error("The portfolio service returned an invalid public snapshot.");
+ return snapshot;
+}
diff --git a/src/features/portfolio/public-model.test.ts b/src/features/portfolio/public-model.test.ts
new file mode 100644
index 0000000..1372693
--- /dev/null
+++ b/src/features/portfolio/public-model.test.ts
@@ -0,0 +1,19 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { parsePublicState, parsePublishedPortfolio, portfolioCacheTag } from "./public-model.ts";
+
+const state = { username: "riya-student", version: "a".repeat(64), searchVisible: false, publishedAt: "2026-11-11T09:00:00.000Z" };
+test("public snapshots are bound to their username and publication version", () => {
+ const payload = { ...state, data: { name: "Riya", headline: "Student", theme: "developer" }, userId: "private", revision: "private" };
+ assert.deepEqual(parsePublishedPortfolio(payload, state.username, state.version), { ...state, data: payload.data });
+ assert.equal(parsePublishedPortfolio(payload, "another-student", state.version), null);
+ assert.equal(parsePublishedPortfolio(payload, state.username, "b".repeat(64)), null);
+});
+test("invalid or untrusted portfolio data cannot enter the public view model", () => {
+ for (const data of [{}, { name: "Riya", headline: "Student", links: { website: "javascript:alert(1)" } }, { name: "Riya", headline: "Student", phone: "private" }])
+ assert.equal(parsePublishedPortfolio({ ...state, data }, state.username, state.version), null);
+ for (const patch of [{ version: "private-edit" }, { searchVisible: "yes" }, { publishedAt: "invalid" }])
+ assert.equal(parsePublicState({ ...state, ...patch }, state.username), null);
+ assert.throws(() => portfolioCacheTag("../account"));
+ assert.equal(portfolioCacheTag(state.username), "portfolio:riya-student");
+});
diff --git a/src/features/portfolio/public-model.ts b/src/features/portfolio/public-model.ts
new file mode 100644
index 0000000..d6c867a
--- /dev/null
+++ b/src/features/portfolio/public-model.ts
@@ -0,0 +1,31 @@
+import type { PortfolioData } from "./types.ts";
+import { validatePortfolio, validateUsername } from "./validation.ts";
+
+export interface PublicPortfolioState {
+ username: string;
+ version: string;
+ searchVisible: boolean;
+ publishedAt: string;
+}
+export interface PublishedPortfolio extends PublicPortfolioState { data: PortfolioData }
+
+function object(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+export function parsePublicState(value: unknown, username: string): PublicPortfolioState | null {
+ if (!object(value) || validateUsername(username).length || value.username !== username ||
+ typeof value.version !== "string" || !/^[a-f0-9]{64}$/.test(value.version) ||
+ typeof value.searchVisible !== "boolean" || typeof value.publishedAt !== "string" ||
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value.publishedAt) ||
+ !Number.isFinite(Date.parse(value.publishedAt))) return null;
+ return { username, version: value.version, searchVisible: value.searchVisible, publishedAt: value.publishedAt };
+}
+export function parsePublishedPortfolio(value: unknown, username: string, version: string): PublishedPortfolio | null {
+ const state = parsePublicState(value, username);
+ if (!state || state.version !== version || !object(value) || validatePortfolio(value.data, "publish").length) return null;
+ return { ...state, data: value.data as PortfolioData };
+}
+export function portfolioCacheTag(username: string): string {
+ if (validateUsername(username).length) throw new Error("Invalid portfolio username.");
+ return `portfolio:${username}`;
+}
diff --git a/src/features/portfolio/public-page.tsx b/src/features/portfolio/public-page.tsx
new file mode 100644
index 0000000..9e51791
--- /dev/null
+++ b/src/features/portfolio/public-page.tsx
@@ -0,0 +1,33 @@
+import Link from "next/link";
+import { PortfolioPreview } from "./themes/portfolio-preview";
+import { portfolioReportHref, type PublicPortfolioPresentation } from "./public-presentation";
+
+export function PublicPortfolioPage({ username, data }: PublicPortfolioPresentation) {
+ const developer = data.theme === "developer";
+ const chrome = developer ? "text-slate-300 hover:text-white focus-visible:outline-amber-300" : "text-slate-600 hover:text-slate-950 focus-visible:outline-amber-700";
+ return (
+