diff --git a/__tests__/root-static-csp.test.ts b/__tests__/root-static-csp.test.ts
index d6b4b9d..2ad57da 100644
--- a/__tests__/root-static-csp.test.ts
+++ b/__tests__/root-static-csp.test.ts
@@ -37,6 +37,7 @@ function loadWorker() {
console,
openNext: { fetch: openNextFetch },
withTiming: (handler: RootWorker['fetch']) => handler,
+ withPublicReadability: (handler: RootWorker['fetch']) => handler,
handleAgentEdge: () => null,
handleRolePatchAgentRoutes: () => null,
isDocumentRequest: () => true,
diff --git a/biome.json b/biome.json
index 590a467..e99c196 100644
--- a/biome.json
+++ b/biome.json
@@ -32,7 +32,8 @@
"!**/.pages-deploy",
"!**/extension",
"!**/*.css",
- "!**/design.html"
+ "!**/design.html",
+ "!src/lib/agent-surfaces"
]
},
"formatter": {
diff --git a/knip.json b/knip.json
index 8ba91aa..e354593 100644
--- a/knip.json
+++ b/knip.json
@@ -20,7 +20,7 @@
"agent-edge.mjs",
"worker.mjs"
],
- "ignore": ["open-next.config.ts"],
+ "ignore": ["open-next.config.ts", "src/lib/agent-surfaces/**"],
"ignoreIssues": { "src/components/ui/**": ["exports", "types"] },
"ignoreDependencies": [
"@mozilla/readability",
diff --git a/landing-astro/src/layouts/Layout.astro b/landing-astro/src/layouts/Layout.astro
index 38ee683..a7c3306 100644
--- a/landing-astro/src/layouts/Layout.astro
+++ b/landing-astro/src/layouts/Layout.astro
@@ -28,6 +28,7 @@ const jsonLd = productJsonLd(SITE_URL);
{title}
+
@@ -38,7 +39,7 @@ const jsonLd = productJsonLd(SITE_URL);
-
+
diff --git a/landing-astro/src/lib/product.ts b/landing-astro/src/lib/product.ts
index acd67c3..28ca9ac 100644
--- a/landing-astro/src/lib/product.ts
+++ b/landing-astro/src/lib/product.ts
@@ -1,3 +1,4 @@
+import { publicIdentity } from '../../../src/lib/public-identity';
const PRODUCT_DESCRIPTION =
'Tailor a resume to one job using claims supported by your experience, then review every proposed change before it becomes part of the application.';
@@ -39,8 +40,11 @@ export const PRODUCT_FAQS = [
export const productJsonLd = (siteUrl: string) => ({
'@context': 'https://schema.org',
'@graph': [
+ publicIdentity['@graph'][0],
{
'@type': 'WebSite',
+ '@id': `${siteUrl}/#app`,
+ publisher: { '@id': 'https://sarthakagrawal.dev/#person' },
name: 'RolePatch',
alternateName: ['Role Patch', 'rolepatch.com'],
url: siteUrl,
diff --git a/landing-astro/src/pages/changelog.astro b/landing-astro/src/pages/changelog.astro
index 9588797..e370124 100644
--- a/landing-astro/src/pages/changelog.astro
+++ b/landing-astro/src/pages/changelog.astro
@@ -1,4 +1,5 @@
---
+import { PublicChangelog } from '../../../src/components/public-changelog';
import Layout from '@/layouts/Layout.astro';
import SiteFooter from '@/components/SiteFooter.astro';
import SiteHeader from '@/components/SiteHeader.astro';
@@ -6,74 +7,13 @@ import SiteHeader from '@/components/SiteHeader.astro';
const TITLE = 'RolePatch changelog — Verified product updates';
const DESCRIPTION =
'Verified RolePatch updates to guest workflows, job discovery, review-first application assistance, receipts, proof, and release checks.';
-const sourceUrl = 'https://github.com/Significant-Hobbies/rolepatch';
-const roadmapUrl = `${sourceUrl}/issues`;
-const entries = [
- {
- date: 'July 11, 2026',
- title: 'Guest workflows became more predictable',
- body: 'The test environment now resets browser-local state between runs. That protects the same boundary unsigned users rely on when they keep resumes, searches, and application drafts on one device.',
- },
- {
- date: 'July 4, 2026',
- title: 'Job discovery moved into the product',
- body: 'A LinkedIn-focused job browser added quick searches, posting signals, resume-aware ranking, visible match evidence, and reviewed batch queueing. Guests can search and prepare locally without being forced to sign in.',
- },
- {
- date: 'July 4, 2026',
- title: 'Application assistance became receipt-first',
- body: 'The reviewed queue added Chrome-assisted ATS fill, guarded submit attempts, provider-aware blockers, field-level details, retry timelines, and copyable audit summaries. Unattended bulk apply remains out of scope.',
- },
- {
- date: 'July 4, 2026',
- title: 'Proof and public release checks shipped',
- body: 'RolePatch added a source-backed Proof surface and a release preflight covering type safety, formatting, tests, the extension build, the Cloudflare build, public routes, and responsive browser journeys.',
- },
-];
---
-
-
-
-
Verified product history
-
What changed, and why it matters.
-
- Shipped changes live here. Planned work stays in the public issue tracker, where it
- cannot be mistaken for a finished feature.
-
-
-
-
-
-
-
- {entries.map((entry) => (
-
- {entry.date}
-
-
{entry.title}
-
{entry.body}
-
-
- ))}
-
-
-
-
Inspect the work behind the claims.
-
The canonical repository and its issue tracker are public.
-
-
-
-
-
-
+
diff --git a/landing-astro/src/pages/docs.astro b/landing-astro/src/pages/docs.astro
index 535468d..1711ab1 100644
--- a/landing-astro/src/pages/docs.astro
+++ b/landing-astro/src/pages/docs.astro
@@ -1,4 +1,5 @@
---
+import { PublicDocs } from '../../../src/components/public-docs';
import Layout from '@/layouts/Layout.astro';
import SiteFooter from '@/components/SiteFooter.astro';
import SiteHeader from '@/components/SiteHeader.astro';
@@ -7,94 +8,13 @@ const TITLE = 'RolePatch developer docs — Public discovery surfaces';
const DESCRIPTION =
'Documentation for RolePatch’s read-only public catalog, LLM indexes, Markdown negotiation, OpenAPI schema, typed errors, and version policy.';
-const endpoints = [
- ['/api/ai', 'JSON catalog of every public HTML surface and same-origin Markdown alternate.'],
- ['/api/public/v1/surfaces/{id}', 'Versioned JSON lookup for one catalog surface. This link uses the home surface as a working example; an unknown id returns a typed problem response.', '/api/public/v1/surfaces/home'],
- ['/llms.txt', 'Compact product guidance, best-fit use cases, boundaries, access, and discovery links.'],
- ['/llms-full.txt', 'Expanded product brief for agents that need the full product contract.'],
- ['/index.md', 'Canonical Markdown explanation of product purpose, evidence rules, access, and state.'],
- ['/openapi.json', 'OpenAPI 3.1 description of these read-only discovery routes.'],
- ['/sitemap.xml', 'Canonical public route inventory for crawlers.'],
-] as const;
+
---
-
-
-
-
Public discovery interface
-
Read the product contract. Do not automate the applicant.
-
- RolePatch publishes a small, unauthenticated, read-only interface so agents can
- discover the product and understand its limits. Resume tailoring and application
- actions remain inside the reviewed user workflow.
-
-
-
-
-
-
-
-
Endpoints
-
Seven public routes. No mutation API.
-
- Use GET or HEAD. These routes require no token or account. They describe RolePatch;
- they do not accept a resume, spend credits, fill an ATS, or submit an application.
-
-
-
- {endpoints.map(([path, body, href = path]) => (
-
- {path}
- {body}
-
- ))}
-
-
-
-
-
-
-
- Markdown negotiation
- Ask for Markdown at the page URL.
-
- Send Accept: text/markdown to any cataloged public page, or request its
- explicit .md alternate. Negotiated responses include{' '}
- Vary: Accept so HTML and Markdown cannot poison one another in a cache.
-
-
-
- Error model
- Discovery errors are structured.
-
- Unsupported methods and missing discovery routes return{' '}
- application/problem+json with a stable code, status, detail, and
- resolution hint. Ordinary product routes keep their own application contracts.
-
-
-
- Version policy
- Breaking changes get a new major contract.
-
- The OpenAPI info.version follows semantic versioning. A breaking change
- gets a new major version; the previous contract remains available for at least 90
- days with Deprecation and Sunset response headers.
-
-
-
- Non-goals
- No public agent can apply on your behalf.
-
- There is no public mutation API, MCP tool, payment-by-agent rail, CAPTCHA bypass, or
- unattended file upload. Those absences preserve the review-first product boundary.
-
-
-
-
-
+
diff --git a/landing-astro/src/pages/faq.astro b/landing-astro/src/pages/faq.astro
index 962d09f..cf9ef7d 100644
--- a/landing-astro/src/pages/faq.astro
+++ b/landing-astro/src/pages/faq.astro
@@ -1,4 +1,5 @@
---
+import { PublicFaq } from '../../../src/components/public-faq';
import Layout from '@/layouts/Layout.astro';
import SiteFooter from '@/components/SiteFooter.astro';
import SiteHeader from '@/components/SiteHeader.astro';
@@ -22,38 +23,7 @@ const faqJsonLd = {
-
-
-
-
RolePatch FAQ
-
Straight answers before you trust it with a real application.
-
- What RolePatch may change, what it must leave alone, where your records live, what
- costs money, and where application assistance stops.
-
-
-
-
-
-
-
- {PRODUCT_FAQS.map((faq) => (
-
- {faq.q}
- {faq.a}
-
- ))}
-
-
-
-
Bring the job. Keep the claims yours.
-
Start with a guest workspace, then decide whether account-backed persistence belongs in your search.
-
-
Open RolePatch ↗
-
-
-
-
+
diff --git a/landing-astro/src/pages/index.astro b/landing-astro/src/pages/index.astro
index ab2f4cb..28b2982 100644
--- a/landing-astro/src/pages/index.astro
+++ b/landing-astro/src/pages/index.astro
@@ -30,6 +30,7 @@ content.page.jsonLd = [jsonLd, {
+
diff --git a/package.json b/package.json
index 6dc3e53..c3b1309 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,6 @@
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7",
"brace-expansion@>=4.0.0 <5.0.12": "5.0.12",
"brace-expansion@>=5.0.0 <5.0.12": "5.0.12",
- "undici@>=7.0.0 <7.29.1": "7.29.1",
"devalue@<=5.9.2": "5.9.3",
"js-yaml@>=4.0.0 <4.3.2": "4.3.2",
"svgo@>=4.0.0 <4.1.0": "4.1.0",
@@ -26,7 +25,11 @@
"astro@7.2.8>vite": "8.2.4",
"dompurify@<3.4.17": "3.4.17",
"http-cache-semantics": "4.3.0",
- "glob@>=10.2.0 <10.5.0": "10.5.0"
+ "glob@>=10.2.0 <10.5.0": "10.5.0",
+ "undici@>=8.0.0 <8.12.2": "8.12.2",
+ "zod@>=4.0.0 <4.6.6": "4.6.6",
+ "body-parser@>=2.0.0 <2.4.0": "2.4.0",
+ "undici@>=7.0.0 <7.30.1": "7.30.1"
},
"patchedDependencies": {
"@saas-maker/templates@0.1.18": "patches/saas-maker-templates-footer-retry-0.1.18.patch",
@@ -103,7 +106,7 @@
"next": "16.4.1",
"pdf-parse": "^2.4.5",
"posthog-js": "^1.407.3",
- "puppeteer-core": "^24.42.0",
+ "puppeteer-core": "25.16.0",
"react": "19.2.8",
"react-diff-viewer-continued": "^4.4.0",
"react-dom": "19.2.8",
@@ -113,7 +116,7 @@
"uuid": "^13.0.1",
"web-vitals": "^4.2.4",
"workers-ai-provider": "3.3.1",
- "zod": "^4.4.3"
+ "zod": "4.6.6"
},
"devDependencies": {
"@biomejs/biome": "2.5.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c463d29..1fdc537 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,7 +11,6 @@ overrides:
brace-expansion@>=2.0.0 <2.1.7: 2.1.7
brace-expansion@>=4.0.0 <5.0.12: 5.0.12
brace-expansion@>=5.0.0 <5.0.12: 5.0.12
- undici@>=7.0.0 <7.29.1: 7.29.1
devalue@<=5.9.2: 5.9.3
js-yaml@>=4.0.0 <4.3.2: 4.3.2
svgo@>=4.0.0 <4.1.0: 4.1.0
@@ -26,6 +25,10 @@ overrides:
dompurify@<3.4.17: 3.4.17
http-cache-semantics: 4.3.0
glob@>=10.2.0 <10.5.0: 10.5.0
+ undici@>=8.0.0 <8.12.2: 8.12.2
+ zod@>=4.0.0 <4.6.6: 4.6.6
+ body-parser@>=2.0.0 <2.4.0: 2.4.0
+ undici@>=7.0.0 <7.30.1: 7.30.1
patchedDependencies:
'@saas-maker/templates@0.1.18':
@@ -41,10 +44,10 @@ importers:
dependencies:
'@ai-sdk/openai-compatible':
specifier: 2.0.41
- version: 2.0.41(zod@4.4.3)
+ version: 2.0.41(zod@4.6.6)
'@better-auth/mcp':
specifier: 1.7.7
- version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))
+ version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.6.6))
'@cloudflare/puppeteer':
specifier: ^1.1.0
version: 1.1.0(yauzl@2.10.0)
@@ -101,7 +104,7 @@ importers:
version: 147.0.2
ai:
specifier: 6.0.168
- version: 6.0.168(zod@4.4.3)
+ version: 6.0.168(zod@4.6.6)
better-auth:
specifier: 1.7.7
version: 1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11)
@@ -139,8 +142,8 @@ importers:
specifier: ^1.407.3
version: 1.407.3
puppeteer-core:
- specifier: ^24.42.0
- version: 24.42.0(yauzl@2.10.0)
+ specifier: 25.16.0
+ version: 25.16.0(yauzl@2.10.0)
react:
specifier: 19.2.8
version: 19.2.8
@@ -167,10 +170,10 @@ importers:
version: 4.2.4
workers-ai-provider:
specifier: 3.3.1
- version: 3.3.1(@ai-sdk/provider@3.0.12)(ai@6.0.168(zod@4.4.3))
+ version: 3.3.1(@ai-sdk/provider@3.0.12)(ai@6.0.168(zod@4.6.6))
zod:
- specifier: ^4.4.3
- version: 4.4.3
+ specifier: 4.6.6
+ version: 4.6.6
devDependencies:
'@biomejs/biome':
specifier: 2.5.5
@@ -318,19 +321,19 @@ packages:
resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==}
engines: {node: '>=18'}
peerDependencies:
- zod: ^3.25.76 || ^4.1.8
+ zod: 4.6.6
'@ai-sdk/openai-compatible@2.0.41':
resolution: {integrity: sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g==}
engines: {node: '>=18'}
peerDependencies:
- zod: ^3.25.76 || ^4.1.8
+ zod: 4.6.6
'@ai-sdk/provider-utils@4.0.33':
resolution: {integrity: sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA==}
engines: {node: '>=18'}
peerDependencies:
- zod: ^3.25.76 || ^4.1.8
+ zod: 4.6.6
'@ai-sdk/provider@3.0.12':
resolution: {integrity: sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q==}
@@ -2412,6 +2415,19 @@ packages:
yauzl:
optional: true
+ '@puppeteer/browsers@3.2.6':
+ resolution: {integrity: sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==}
+ engines: {node: '>=22.12.0'}
+ hasBin: true
+ peerDependencies:
+ proxy-agent: '>=8.0.1'
+ yauzl: ^2.10.0 || ^3.4.0
+ peerDependenciesMeta:
+ proxy-agent:
+ optional: true
+ yauzl:
+ optional: true
+
'@radix-ui/number@1.1.3':
resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
@@ -4084,7 +4100,7 @@ packages:
resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==}
engines: {node: '>=18'}
peerDependencies:
- zod: ^3.25.76 || ^4.1.8
+ zod: 4.6.6
am-i-vibing@0.4.0:
resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==}
@@ -4308,7 +4324,7 @@ packages:
better-call@1.4.0:
resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==}
peerDependencies:
- zod: ^4.0.0
+ zod: 4.6.6
peerDependenciesMeta:
zod:
optional: true
@@ -4322,8 +4338,8 @@ packages:
bluebird@3.4.7:
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
- body-parser@2.3.0:
- resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
+ body-parser@2.4.0:
+ resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==}
engines: {node: '>=18'}
boolbase@1.0.0:
@@ -4395,8 +4411,9 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
- chromium-bidi@14.0.0:
- resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==}
+ chromium-bidi@157.0.8090-0:
+ resolution: {integrity: sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==}
+ engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'}
peerDependencies:
devtools-protocol: '*'
@@ -4481,6 +4498,10 @@ packages:
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
engines: {node: '>=18'}
+ content-type@2.1.0:
+ resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
+ engines: {node: '>=18'}
+
convert-source-map@1.9.0:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
@@ -4625,8 +4646,8 @@ packages:
devtools-protocol@0.0.1299070:
resolution: {integrity: sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg==}
- devtools-protocol@0.0.1595872:
- resolution: {integrity: sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==}
+ devtools-protocol@0.0.1704951:
+ resolution: {integrity: sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==}
diff@9.0.0:
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
@@ -5644,6 +5665,10 @@ packages:
resolution: {integrity: sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==}
engines: {node: '>=18.0.0'}
+ modern-tar@0.8.6:
+ resolution: {integrity: sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==}
+ engines: {node: '>=18.0.0'}
+
motion-dom@13.5.1:
resolution: {integrity: sha512-/7CVCD20Mz1KobktorpdgvowV0V41J7UxqE+qWS3GkSJqrRr5ft+2RloOYtEKv+wvBp3JaSFBclhxt4ja8ovHg==}
@@ -5967,14 +5992,18 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- puppeteer-core@24.42.0:
- resolution: {integrity: sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg==}
- engines: {node: '>=18'}
+ puppeteer-core@25.16.0:
+ resolution: {integrity: sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==}
+ engines: {node: '>=22.12.0'}
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
+ qs@6.17.0:
+ resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==}
+ engines: {node: '>=0.6'}
+
query-selector-shadow-dom@1.0.1:
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
@@ -6468,8 +6497,8 @@ packages:
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
engines: {node: '>= 18'}
- typed-query-selector@2.12.2:
- resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==}
+ typed-query-selector@2.12.3:
+ resolution: {integrity: sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
@@ -6520,12 +6549,12 @@ packages:
resolution: {integrity: sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==}
engines: {node: '>=18.17'}
- undici@7.29.1:
- resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
+ undici@7.30.1:
+ resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==}
engines: {node: '>=20.18.1'}
- undici@8.10.2:
- resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
+ undici@8.12.2:
+ resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==}
engines: {node: '>=22.19.0'}
unenv@2.0.0-rc.24:
@@ -6819,8 +6848,8 @@ packages:
web-vitals@5.3.0:
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
- webdriver-bidi-protocol@0.4.1:
- resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
+ webdriver-bidi-protocol@1.0.1:
+ resolution: {integrity: sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -6927,6 +6956,18 @@ packages:
utf-8-validate:
optional: true
+ ws@8.22.0:
+ resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
xdg-app-paths@5.5.1:
resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==}
engines: {node: '>= 6.0'}
@@ -7000,17 +7041,8 @@ packages:
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
- zod@3.25.76:
- resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
-
- zod@4.1.11:
- resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
-
- zod@4.4.3:
- resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
-
- zod@4.5.4:
- resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
+ zod@4.6.6:
+ resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -7019,25 +7051,25 @@ snapshots:
'@adobe/css-tools@4.4.4': {}
- '@ai-sdk/gateway@3.0.104(zod@4.4.3)':
+ '@ai-sdk/gateway@3.0.104(zod@4.6.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.33(zod@4.4.3)
+ '@ai-sdk/provider-utils': 4.0.33(zod@4.6.6)
'@vercel/oidc': 3.2.0
- zod: 4.4.3
+ zod: 4.6.6
- '@ai-sdk/openai-compatible@2.0.41(zod@4.4.3)':
+ '@ai-sdk/openai-compatible@2.0.41(zod@4.6.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.33(zod@4.4.3)
- zod: 4.4.3
+ '@ai-sdk/provider-utils': 4.0.33(zod@4.6.6)
+ zod: 4.6.6
- '@ai-sdk/provider-utils@4.0.33(zod@4.4.3)':
+ '@ai-sdk/provider-utils@4.0.33(zod@4.6.6)':
dependencies:
'@ai-sdk/provider': 3.0.12
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.8
- zod: 4.4.3
+ zod: 4.6.6
'@ai-sdk/provider@3.0.12':
dependencies:
@@ -7226,7 +7258,7 @@ snapshots:
dependencies:
sitemap: 9.0.1
stream-replace-string: 2.0.0
- zod: 4.4.3
+ zod: 4.6.6
'@astrojs/telemetry@3.3.3':
dependencies:
@@ -7845,71 +7877,71 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
- '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)':
+ '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)':
dependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
'@opentelemetry/semantic-conventions': 1.43.0
'@standard-schema/spec': 1.1.0
- better-call: 1.4.0(zod@4.4.3)
+ better-call: 1.4.0(zod@4.6.6)
jose: 6.2.4
kysely: 0.29.4
nanostores: 1.4.1
- zod: 4.5.4
+ zod: 4.6.6
optionalDependencies:
'@opentelemetry/api': 1.9.0
- '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)':
+ '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
'@better-auth/utils': 0.4.2
- '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)':
+ '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)':
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
'@better-auth/utils': 0.4.2
optionalDependencies:
kysely: 0.29.4
- '@better-auth/mcp@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))':
+ '@better-auth/mcp@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.6.6))':
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
- '@better-auth/oauth-provider': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/oauth-provider': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.6.6))
better-auth: 1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11)
- better-call: 1.4.0(zod@4.4.3)
+ better-call: 1.4.0(zod@4.6.6)
jose: 6.2.4
transitivePeerDependencies:
- '@better-auth/utils'
- '@better-fetch/fetch'
- '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)':
+ '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
'@better-auth/utils': 0.4.2
- '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)':
+ '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
'@better-auth/utils': 0.4.2
- '@better-auth/oauth-provider@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))':
+ '@better-auth/oauth-provider@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.6.6))':
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
better-auth: 1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11)
- better-call: 1.4.0(zod@4.4.3)
+ better-call: 1.4.0(zod@4.6.6)
jose: 6.2.4
- zod: 4.5.4
+ zod: 4.6.6
- '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)':
+ '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
'@better-auth/utils': 0.4.2
- '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)':
+ '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)':
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
@@ -9108,6 +9140,13 @@ snapshots:
optionalDependencies:
yauzl: 2.10.0
+ '@puppeteer/browsers@3.2.6(yauzl@2.10.0)':
+ dependencies:
+ modern-tar: 0.8.6
+ yargs: 18.2.0
+ optionalDependencies:
+ yauzl: 2.10.0
+
'@radix-ui/number@1.1.3': {}
'@radix-ui/primitive@1.1.7': {}
@@ -10877,7 +10916,7 @@ snapshots:
'@vercel/cli-config@0.2.1':
dependencies:
xdg-app-paths: 5.5.1
- zod: 4.1.11
+ zod: 4.6.6
optional: true
'@vercel/cli-exec@1.0.0':
@@ -10988,13 +11027,13 @@ snapshots:
dependencies:
humanize-ms: 1.2.1
- ai@6.0.168(zod@4.4.3):
+ ai@6.0.168(zod@4.6.6):
dependencies:
- '@ai-sdk/gateway': 3.0.104(zod@4.4.3)
+ '@ai-sdk/gateway': 3.0.104(zod@4.6.6)
'@ai-sdk/provider': 3.0.8
- '@ai-sdk/provider-utils': 4.0.33(zod@4.4.3)
+ '@ai-sdk/provider-utils': 4.0.33(zod@4.6.6)
'@opentelemetry/api': 1.9.0
- zod: 4.4.3
+ zod: 4.6.6
am-i-vibing@0.4.0:
dependencies:
@@ -11101,7 +11140,7 @@ snapshots:
vitefu: 1.1.3(vite@8.2.4(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.36.0)(yaml@2.9.1))
xxhash-wasm: 1.1.0
yargs-parser: 22.0.0
- zod: 4.5.4
+ zod: 4.6.6
optionalDependencies:
sharp: 0.35.5(@types/node@24.12.4)
transitivePeerDependencies:
@@ -11211,23 +11250,23 @@ snapshots:
better-auth@1.7.7(@opentelemetry/api@1.9.0)(next@16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11):
dependencies:
- '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
- '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)
- '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)
- '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)
- '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)
- '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)
- '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)
+ '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)
+ '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)
+ '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)
+ '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)
+ '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)
+ '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)
+ '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.6.6))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
'@noble/ciphers': 2.2.0
'@noble/hashes': 2.2.0
- better-call: 1.4.0(zod@4.5.4)
+ better-call: 1.4.0(zod@4.6.6)
defu: 6.1.7
jose: 6.2.4
kysely: 0.29.4
nanostores: 1.4.1
- zod: 4.5.4
+ zod: 4.6.6
optionalDependencies:
next: 16.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.62.0)(@types/node@20.19.33)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
@@ -11236,23 +11275,14 @@ snapshots:
transitivePeerDependencies:
- '@opentelemetry/api'
- better-call@1.4.0(zod@4.4.3):
+ better-call@1.4.0(zod@4.6.6):
dependencies:
'@better-auth/utils': 0.5.0
'@better-fetch/fetch': 1.3.2
rou3: 0.9.2
set-cookie-parser: 3.1.2
optionalDependencies:
- zod: 4.4.3
-
- better-call@1.4.0(zod@4.5.4):
- dependencies:
- '@better-auth/utils': 0.5.0
- '@better-fetch/fetch': 1.3.2
- rou3: 0.9.2
- set-cookie-parser: 3.1.2
- optionalDependencies:
- zod: 4.5.4
+ zod: 4.6.6
bidi-js@1.0.3:
dependencies:
@@ -11262,15 +11292,15 @@ snapshots:
bluebird@3.4.7: {}
- body-parser@2.3.0:
+ body-parser@2.4.0:
dependencies:
bytes: 3.1.2
- content-type: 2.0.0
+ content-type: 2.1.0
debug: 4.4.3
http-errors: 2.0.1
iconv-lite: 0.7.3
on-finished: 2.4.1
- qs: 6.16.0
+ qs: 6.17.0
raw-body: 3.0.2
type-is: 2.1.0
transitivePeerDependencies:
@@ -11329,11 +11359,11 @@ snapshots:
dependencies:
readdirp: 5.1.1
- chromium-bidi@14.0.0(devtools-protocol@0.0.1595872):
+ chromium-bidi@157.0.8090-0(devtools-protocol@0.0.1704951):
dependencies:
- devtools-protocol: 0.0.1595872
+ devtools-protocol: 0.0.1704951
mitt: 3.0.1
- zod: 3.25.76
+ zod: 4.6.6
ci-info@4.4.0: {}
@@ -11420,6 +11450,8 @@ snapshots:
content-type@2.0.0: {}
+ content-type@2.1.0: {}
+
convert-source-map@1.9.0: {}
convert-source-map@2.0.0: {}
@@ -11543,7 +11575,7 @@ snapshots:
devtools-protocol@0.0.1299070: {}
- devtools-protocol@0.0.1595872: {}
+ devtools-protocol@0.0.1704951: {}
diff@9.0.0: {}
@@ -11798,7 +11830,7 @@ snapshots:
express@5.2.1:
dependencies:
accepts: 2.0.0
- body-parser: 2.3.0
+ body-parser: 2.4.0
content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
@@ -12244,7 +12276,7 @@ snapshots:
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.2
- undici: 7.29.1
+ undici: 7.30.1
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -12284,7 +12316,7 @@ snapshots:
tinyglobby: 0.2.17
unbash: 4.0.3
yaml: 2.9.0
- zod: 4.4.3
+ zod: 4.6.6
kysely@0.29.4: {}
@@ -12713,7 +12745,7 @@ snapshots:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.35.5(@types/node@24.12.4)
- undici: 7.29.1
+ undici: 7.30.1
workerd: 1.20260722.1
ws: 8.21.0
youch: 4.1.0-beta.10
@@ -12726,7 +12758,7 @@ snapshots:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.35.5(@types/node@20.19.33)
- undici: 7.29.1
+ undici: 7.30.1
workerd: 1.20261006.1
ws: 8.21.0
youch: 4.1.0-beta.10
@@ -12755,6 +12787,8 @@ snapshots:
modern-tar@0.8.5: {}
+ modern-tar@0.8.6: {}
+
motion-dom@13.5.1:
dependencies:
motion-utils: 13.5.0
@@ -13089,19 +13123,17 @@ snapshots:
punycode@2.3.1: {}
- puppeteer-core@24.42.0(yauzl@2.10.0):
+ puppeteer-core@25.16.0(yauzl@2.10.0):
dependencies:
- '@puppeteer/browsers': 3.2.2(yauzl@2.10.0)
- chromium-bidi: 14.0.0(devtools-protocol@0.0.1595872)
- debug: 4.4.3
- devtools-protocol: 0.0.1595872
- typed-query-selector: 2.12.2
- webdriver-bidi-protocol: 0.4.1
- ws: 8.21.0
+ '@puppeteer/browsers': 3.2.6(yauzl@2.10.0)
+ chromium-bidi: 157.0.8090-0(devtools-protocol@0.0.1704951)
+ devtools-protocol: 0.0.1704951
+ typed-query-selector: 2.12.3
+ webdriver-bidi-protocol: 1.0.1
+ ws: 8.22.0
transitivePeerDependencies:
- bufferutil
- proxy-agent
- - supports-color
- utf-8-validate
- yauzl
@@ -13110,6 +13142,11 @@ snapshots:
es-define-property: 1.0.1
side-channel: 1.1.1
+ qs@6.17.0:
+ dependencies:
+ es-define-property: 1.0.1
+ side-channel: 1.1.1
+
query-selector-shadow-dom@1.0.1: {}
radix-ui@1.6.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
@@ -13819,7 +13856,7 @@ snapshots:
media-typer: 1.1.1
mime-types: 3.0.2
- typed-query-selector@2.12.2: {}
+ typed-query-selector@2.12.3: {}
typescript@5.9.3: {}
@@ -13837,7 +13874,7 @@ snapshots:
jsonc-parser: 3.3.1
nypm: 0.6.9
yaml: 2.9.0
- zod: 4.4.3
+ zod: 4.6.6
ultrahtml@1.7.0: {}
@@ -13855,9 +13892,9 @@ snapshots:
undici@6.29.1: {}
- undici@7.29.1: {}
+ undici@7.30.1: {}
- undici@8.10.2: {}
+ undici@8.12.2: {}
unenv@2.0.0-rc.24:
dependencies:
@@ -13877,7 +13914,7 @@ snapshots:
dependencies:
css-tree: 3.2.1
ohash: 2.0.12
- undici: 8.10.2
+ undici: 8.12.2
unist-util-is@6.0.1:
dependencies:
@@ -14063,7 +14100,7 @@ snapshots:
web-vitals@5.3.0: {}
- webdriver-bidi-protocol@0.4.1: {}
+ webdriver-bidi-protocol@1.0.1: {}
webidl-conversions@3.0.1: {}
@@ -14109,10 +14146,10 @@ snapshots:
'@cloudflare/workerd-linux-arm64': 1.20261006.1
'@cloudflare/workerd-windows-64': 1.20261006.1
- workers-ai-provider@3.3.1(@ai-sdk/provider@3.0.12)(ai@6.0.168(zod@4.4.3)):
+ workers-ai-provider@3.3.1(@ai-sdk/provider@3.0.12)(ai@6.0.168(zod@4.6.6)):
dependencies:
'@ai-sdk/provider': 3.0.12
- ai: 6.0.168(zod@4.4.3)
+ ai: 6.0.168(zod@4.6.6)
wrangler@4.114.0(@types/node@24.12.4):
dependencies:
@@ -14175,6 +14212,8 @@ snapshots:
ws@8.21.0: {}
+ ws@8.22.0: {}
+
xdg-app-paths@5.5.1:
dependencies:
os-paths: 4.4.0
@@ -14249,13 +14288,6 @@ snapshots:
cookie: 1.1.1
youch-core: 0.3.3
- zod@3.25.76: {}
-
- zod@4.1.11:
- optional: true
-
- zod@4.4.3: {}
-
- zod@4.5.4: {}
+ zod@4.6.6: {}
zwitch@2.0.4: {}
diff --git a/public-readability-policy.mjs b/public-readability-policy.mjs
new file mode 100644
index 0000000..c54aa9c
--- /dev/null
+++ b/public-readability-policy.mjs
@@ -0,0 +1,83 @@
+import { PUBLIC_SURFACES, markdownPathForSurface } from './public-surfaces.mjs';
+import { discoveryLinkHeader } from './src/lib/agent-surfaces/handler.mjs';
+
+const origin = 'https://rolepatch.com';
+export const privatePaths = [
+ '/api/',
+ '/dashboard',
+ '/jobs',
+ '/evidence',
+ '/resume-builder',
+ '/editor',
+ '/tailor',
+ '/cover-letter',
+ '/library',
+ '/login',
+ '/settings',
+];
+export const publicCrawlers = [
+ 'GPTBot',
+ 'ClaudeBot',
+ 'Google-Extended',
+ 'Applebot-Extended',
+ 'CCBot',
+ 'Bytespider',
+ 'OAI-SearchBot',
+ 'ChatGPT-User',
+ 'Claude-SearchBot',
+ 'Claude-User',
+ 'PerplexityBot',
+ 'Perplexity-User',
+];
+export function publicRobots() {
+ return `${[...['*', ...publicCrawlers].map((agent) => `User-agent: ${agent}\nAllow: /\nAllow: /api/ai\nAllow: /api/md\n${privatePaths.map((path) => `Disallow: ${path}`).join('\n')}`), `Sitemap: ${origin}/sitemap.xml`].join('\n\n')}\n`;
+}
+export function withPublicReadability(next) {
+ return async (request, env, ctx) => {
+ const response = await next(request, env, ctx);
+ const path = new URL(request.url).pathname.replace(/\/$/, '') || '/';
+ const surface = PUBLIC_SURFACES.find((item) => item.path === path);
+ if (
+ !surface ||
+ response.status !== 200 ||
+ !response.headers.get('Content-Type')?.includes('text/html')
+ )
+ return response;
+ const headers = new Headers(response.headers);
+ headers.append('Vary', 'Accept');
+ headers.append(
+ 'Link',
+ `<${origin}${markdownPathForSurface(path)}>; rel="alternate"; type="text/markdown", ${discoveryLinkHeader(origin)}`
+ );
+ headers.delete('Content-Length');
+ if (request.method === 'HEAD') return new Response(null, { status: response.status, headers });
+ // The homepage can already be compressed by the inner worker. Its built
+ // HTML includes discovery metadata; never parse encoded bytes as HTML.
+ if (headers.has('Content-Encoding'))
+ return new Response(response.body, {
+ status: response.status,
+ headers,
+ encodeBody: 'manual',
+ });
+ return new globalThis.HTMLRewriter()
+ .on('link[rel="alternate"][type="text/markdown"]', {
+ element(element) {
+ element.remove();
+ },
+ })
+ .on('meta[name="ai-summary"]', {
+ element(element) {
+ element.remove();
+ },
+ })
+ .on('head', {
+ element(element) {
+ element.append(
+ ` `,
+ { html: true }
+ );
+ },
+ })
+ .transform(new Response(response.body, { status: response.status, headers }));
+ };
+}
diff --git a/public-surfaces.mjs b/public-surfaces.mjs
index 257a7b6..089aa78 100644
--- a/public-surfaces.mjs
+++ b/public-surfaces.mjs
@@ -18,20 +18,6 @@ export const PUBLIC_SURFACES = [
changeFrequency: 'weekly',
priority: 1,
},
- {
- id: 'jobs',
- path: '/jobs',
- title: 'Browse jobs',
- description:
- 'Browse observed job openings, search for live roles, save promising matches, and queue jobs for reviewed application preparation.',
- details: [
- 'The public feed shows recently observed roles and their source links.',
- 'Guest users can search, shortlist, and queue jobs using browser storage.',
- 'Queueing prepares a job for review; it does not submit an application.',
- ],
- changeFrequency: 'hourly',
- priority: 0.95,
- },
{
id: 'pricing',
path: '/pricing',
@@ -60,20 +46,6 @@ export const PUBLIC_SURFACES = [
changeFrequency: 'monthly',
priority: 0.9,
},
- {
- id: 'evidence',
- path: '/evidence',
- title: 'Achievement evidence bank',
- description:
- 'Store reusable, quantified achievement evidence for resumes, cover letters, interview stories, and recruiter replies.',
- details: [
- 'Evidence records capture a situation, action, result, metric, scope, and relevant skills.',
- 'Guest records stay in the browser; signed-in records use the user account.',
- 'Readiness checks distinguish reusable claims from proof that still needs support.',
- ],
- changeFrequency: 'monthly',
- priority: 0.85,
- },
{
id: 'tools',
path: '/tools',
@@ -303,20 +275,3 @@ export const PUBLIC_SURFACES = [
export function markdownPathForSurface(path) {
return path === '/' ? '/index.md' : `${path}.md`;
}
-
-export function renderSurfaceMarkdown(surface, origin = 'https://rolepatch.com') {
- const pageUrl = new URL(surface.path, origin).toString();
- const details = surface.details.map((detail) => `- ${detail}`).join('\n');
- return `# ${surface.title}
-
-${surface.description}
-
-## What this page provides
-
-${details}
-
-## Public page
-
-- ${pageUrl}
-`;
-}
diff --git a/public/api-ai.json b/public/api-ai.json
index 3019752..e7c8d73 100644
--- a/public/api-ai.json
+++ b/public/api-ai.json
@@ -10,7 +10,9 @@
"openapi": "https://rolepatch.com/openapi.json",
"markdown": {
"suffix": ".md",
- "negotiation": true
+ "negotiation": true,
+ "direct": "https://rolepatch.com/api/md/{path}",
+ "catalog": "https://rolepatch.com/api/md/_catalog"
},
"surfaces": [
{
@@ -18,32 +20,137 @@
"url": "https://rolepatch.com/",
"md": "https://rolepatch.com/index.md",
"kind": "static",
- "description": "Product purpose, evidence rule, access model, and review-first boundaries"
+ "description": "Evidence-bound resume tailoring for one specific job, followed by review-first supporting materials, application assistance, and receipts."
},
{
"id": "pricing",
"url": "https://rolepatch.com/pricing",
"md": "https://rolepatch.com/pricing.md",
"kind": "static",
- "description": "Free tools, guest use, account persistence, and exact token packs"
+ "description": "Compare free browser tools, guest-local use, account persistence, and the implemented token packs for signed-in AI work."
+ },
+ {
+ "id": "proof",
+ "url": "https://rolepatch.com/proof",
+ "md": "https://rolepatch.com/proof.md",
+ "kind": "static",
+ "description": "Build a user-controlled proof packet from quantified achievements and source-backed work for a specific application."
},
{
"id": "tools",
"url": "https://rolepatch.com/tools",
"md": "https://rolepatch.com/tools.md",
"kind": "static",
- "description": "Public browser-local resume tools"
+ "description": "Use RolePatch resume utilities for keyword matching, text comparison, ATS formatting, bullet quality, word counts, and reusable snippets."
+ },
+ {
+ "id": "resume-diff",
+ "url": "https://rolepatch.com/tools/diff",
+ "md": "https://rolepatch.com/tools/diff.md",
+ "kind": "static",
+ "description": "Compare two resume drafts word by word in split or inline views without uploading the text."
+ },
+ {
+ "id": "keyword-checker",
+ "url": "https://rolepatch.com/tools/keywords",
+ "md": "https://rolepatch.com/tools/keywords.md",
+ "kind": "static",
+ "description": "Compare a resume with a job description to see a keyword match score, matched terms, and missing terms."
+ },
+ {
+ "id": "ats-checker",
+ "url": "https://rolepatch.com/tools/ats-check",
+ "md": "https://rolepatch.com/tools/ats-check.md",
+ "kind": "static",
+ "description": "Check seven resume structure and formatting signals for free in your browser with no sign-up, upload, or network request."
+ },
+ {
+ "id": "bullet-checker",
+ "url": "https://rolepatch.com/tools/bullet-check",
+ "md": "https://rolepatch.com/tools/bullet-check.md",
+ "kind": "static",
+ "description": "Grade resume bullets for action verbs, quantified outcomes, readable length, tense, and first-person wording."
+ },
+ {
+ "id": "word-count",
+ "url": "https://rolepatch.com/tools/word-count",
+ "md": "https://rolepatch.com/tools/word-count.md",
+ "kind": "static",
+ "description": "Count words, characters, lines, sentences, paragraphs, and bullets, then estimate recruiter skim time."
+ },
+ {
+ "id": "snippets",
+ "url": "https://rolepatch.com/tools/snippets",
+ "md": "https://rolepatch.com/tools/snippets.md",
+ "kind": "static",
+ "description": "Save reusable accomplishment bullets and cover-letter paragraphs in the current browser."
+ },
+ {
+ "id": "blog",
+ "url": "https://rolepatch.com/blog",
+ "md": "https://rolepatch.com/blog.md",
+ "kind": "static",
+ "description": "Read RolePatch guides about resume tailoring, ATS scores, resume keywords, and job-search preparation."
+ },
+ {
+ "id": "tailored-resumes-article",
+ "url": "https://rolepatch.com/blog/why-tailoring-resume-matters",
+ "md": "https://rolepatch.com/blog/why-tailoring-resume-matters.md",
+ "kind": "static",
+ "description": "A practical guide to keyword alignment, resume prioritization, bullet tuning, and reviewing AI-assisted changes."
+ },
+ {
+ "id": "ats-score-article",
+ "url": "https://rolepatch.com/blog/ats-score-explained",
+ "md": "https://rolepatch.com/blog/ats-score-explained.md",
+ "kind": "static",
+ "description": "A practical guide to resume match scores, parsing and coverage metrics, and the limits of universal ATS thresholds."
+ },
+ {
+ "id": "resume-keywords-article",
+ "url": "https://rolepatch.com/blog/resume-keywords-guide",
+ "md": "https://rolepatch.com/blog/resume-keywords-guide.md",
+ "kind": "static",
+ "description": "A guide to hard skills, action verbs, filler language, and finding relevant terms in a job description."
},
{
"id": "faq",
"url": "https://rolepatch.com/faq",
"md": "https://rolepatch.com/faq.md",
"kind": "static",
- "description": "Evidence, storage, pricing, extension, safety, and current-state answers"
+ "description": "Answers about evidence-bound tailoring, storage, exact token pricing, the Chrome extension, product state, and review-first application safety."
+ },
+ {
+ "id": "docs",
+ "url": "https://rolepatch.com/docs",
+ "md": "https://rolepatch.com/docs.md",
+ "kind": "static",
+ "description": "Read the supported machine-readable discovery routes, content negotiation behavior, error model, version policy, and explicit non-goals."
+ },
+ {
+ "id": "changelog",
+ "url": "https://rolepatch.com/changelog",
+ "md": "https://rolepatch.com/changelog.md",
+ "kind": "static",
+ "description": "Read verified RolePatch product updates and follow planned work through the public GitHub issue tracker."
+ },
+ {
+ "id": "privacy",
+ "url": "https://rolepatch.com/privacy",
+ "md": "https://rolepatch.com/privacy.md",
+ "kind": "static",
+ "description": "Read how RolePatch handles account data, resume content, job descriptions, payments, guest storage, and deletion requests."
+ },
+ {
+ "id": "terms",
+ "url": "https://rolepatch.com/terms",
+ "md": "https://rolepatch.com/terms.md",
+ "kind": "static",
+ "description": "Read the service, payment, content ownership, AI-output, acceptable-use, availability, and contact terms for RolePatch."
}
],
"auth": {
"public": true,
- "notes": "Only cataloged pages are public agent surfaces. User-specific app and API routes are excluded."
+ "notes": "Only the cataloged pages are public agent surfaces. User-specific app and API routes are excluded."
}
}
diff --git a/public/robots.txt b/public/robots.txt
index 5a37c8d..06f59d8 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -1,9 +1,209 @@
User-agent: *
Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
-Sitemap: https://rolepatch.com/sitemap.xml
-# Agent indexing
-Allow: /llms.txt
-Allow: /index.md
+User-agent: GPTBot
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: ClaudeBot
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: Google-Extended
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: Applebot-Extended
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: CCBot
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: Bytespider
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: OAI-SearchBot
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: ChatGPT-User
+Allow: /
Allow: /api/ai
-Allow: /llms-full.txt
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: Claude-SearchBot
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: Claude-User
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: PerplexityBot
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+User-agent: Perplexity-User
+Allow: /
+Allow: /api/ai
+Allow: /api/md
+Disallow: /api/
+Disallow: /dashboard
+Disallow: /jobs
+Disallow: /evidence
+Disallow: /resume-builder
+Disallow: /editor
+Disallow: /tailor
+Disallow: /cover-letter
+Disallow: /library
+Disallow: /login
+Disallow: /settings
+
+Sitemap: https://rolepatch.com/sitemap.xml
diff --git a/rolepatch-agent-routes.mjs b/rolepatch-agent-routes.mjs
index 7e7b2ec..76b3284 100644
--- a/rolepatch-agent-routes.mjs
+++ b/rolepatch-agent-routes.mjs
@@ -1,13 +1,7 @@
-import {
- markdownPathForSurface,
- PUBLIC_SURFACES,
- renderSurfaceMarkdown,
-} from './public-surfaces.mjs';
-
-const surfaceByPath = new Map(PUBLIC_SURFACES.map((surface) => [surface.path, surface]));
-const surfaceByMarkdownPath = new Map(
- PUBLIC_SURFACES.map((surface) => [markdownPathForSurface(surface.path), surface])
-);
+import { createAgentSurfaceHandler } from './src/lib/agent-surfaces/handler.mjs';
+import { markdownResponse, wantsMarkdown } from './src/lib/agent-surfaces/http.mjs';
+import { publicRobots } from './public-readability-policy.mjs';
+import { markdownPathForSurface, PUBLIC_SURFACES } from './public-surfaces.mjs';
function buildCatalog(origin) {
return {
@@ -24,6 +18,8 @@ function buildCatalog(origin) {
markdown: {
suffix: '.md',
negotiation: true,
+ direct: `${origin}/api/md/{path}`,
+ catalog: `${origin}/api/md/_catalog`,
},
surfaces: PUBLIC_SURFACES.map((surface) => ({
id: surface.id,
@@ -64,91 +60,71 @@ ${rows}
`;
}
-/**
- * RolePatch-specific public route coverage that runs before the generated
- * Fleet discovery handler. Keeping this separate means registry refreshes can
- * safely regenerate agent-edge.mjs without deleting curated route Markdown.
- *
- * @param {Request} request
- * @returns {Response | null}
- */
-export function handleRolePatchAgentRoutes(request) {
- if (request.method !== 'GET' && request.method !== 'HEAD') return null;
- const url = new URL(request.url);
- const path = normalizePath(url.pathname);
-
- if (path === '/api/ai') {
- if (wantsMarkdown(request)) {
- return markdown(renderCatalogMarkdown(url.origin), '/api/ai.md');
- }
- return json(buildCatalog(url.origin));
- }
- if (path === '/api/ai.md') {
- return markdown(renderCatalogMarkdown(url.origin), '/api/ai.md');
- }
-
- const explicitSurface = surfaceByMarkdownPath.get(path);
- if (explicitSurface) {
- return markdown(renderSurfaceMarkdown(explicitSurface, url.origin), path);
- }
- if (path.endsWith('.md')) {
- return text(
- `# Not found\n\nNo public RolePatch Markdown surface exists at ${path}.\n`,
- 'text/markdown; charset=utf-8',
- { status: 404 }
+export async function handleRolePatchAgentRoutes(request, loadMarkdown) {
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method)) return null;
+ const manifest = {
+ name: 'RolePatch',
+ origin: 'https://rolepatch.com',
+ summary: PUBLIC_SURFACES[0].description,
+ openapi: 'https://rolepatch.com/openapi.json',
+ pages: {},
+ catalog: buildCatalog('https://rolepatch.com'),
+ negotiation: true,
+ trainingAllowed: true,
+ excludedPaths: [
+ '/api',
+ '/dashboard',
+ '/jobs',
+ '/evidence',
+ '/resume-builder',
+ '/editor',
+ '/tailor',
+ '/cover-letter',
+ '/library',
+ '/login',
+ '/settings',
+ ],
+ };
+ const path = new URL(request.url).pathname;
+ if (path === '/robots.txt' && ['GET', 'HEAD'].includes(request.method))
+ return new Response(request.method === 'HEAD' ? null : publicRobots(), {
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+ });
+ if (path === '/api/ai.md' || (path === '/api/ai' && wantsMarkdown(request)))
+ return markdownResponse(renderCatalogMarkdown(manifest.origin), request, {
+ 'Content-Location': `${manifest.origin}/api/ai`,
+ });
+ const handle = createAgentSurfaceHandler({
+ manifest,
+ listMarkdownRoutes: () => PUBLIC_SURFACES.map((surface) => ({ ...surface, kind: 'static' })),
+ loadMarkdown,
+ });
+ let response;
+ try {
+ response = await handle(request);
+ } catch (error) {
+ console.error(
+ 'Public Markdown rendering failed',
+ error instanceof Error ? error.message : 'Unknown error'
);
+ const unavailable = markdownResponse('# Markdown temporarily unavailable\n', request, {
+ 'Cache-Control': 'no-store',
+ });
+ return new Response(unavailable.body, { status: 503, headers: unavailable.headers });
}
-
- if (wantsMarkdown(request)) {
- const surface = surfaceByPath.get(path);
- if (surface) {
- return markdown(
- renderSurfaceMarkdown(surface, url.origin),
- markdownPathForSurface(surface.path)
- );
- }
- }
-
+ if (response) return response;
+ if (!path.startsWith('/api/') && wantsMarkdown(request) && !/\.[a-z0-9]+$/i.test(path))
+ return new Response(
+ request.method === 'HEAD' ? null : '# Not found\n\nNo public document exists at this path.\n',
+ {
+ status: 404,
+ headers: {
+ 'Content-Type': 'text/markdown; charset=utf-8',
+ 'Cache-Control': 'no-store',
+ 'X-Robots-Tag': 'noindex, follow',
+ 'Access-Control-Allow-Origin': '*',
+ },
+ }
+ );
return null;
}
-
-function normalizePath(pathname) {
- if (!pathname || pathname === '/') return '/';
- return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
-}
-
-function wantsMarkdown(request) {
- const accept = (request.headers.get('accept') || '').toLowerCase();
- if (!accept.includes('text/markdown')) return false;
- if (!accept.includes('text/html')) return true;
- return accept.indexOf('text/markdown') < accept.indexOf('text/html');
-}
-
-function markdown(body, alternatePath) {
- return text(body, 'text/markdown; charset=utf-8', {
- Link: `<${alternatePath}>; rel="alternate"; type="text/markdown"`,
- Vary: 'Accept',
- });
-}
-
-function text(body, type, extra = {}) {
- const { status = 200, ...headers } = extra;
- return new Response(body, {
- status,
- headers: {
- 'Content-Type': type,
- 'Cache-Control': 'public, max-age=300',
- ...headers,
- },
- });
-}
-
-function json(data) {
- return new Response(`${JSON.stringify(data, null, 2)}\n`, {
- status: 200,
- headers: {
- 'Content-Type': 'application/json; charset=utf-8',
- 'Cache-Control': 'public, max-age=300',
- },
- });
-}
diff --git a/scripts/check-code-health.mjs b/scripts/check-code-health.mjs
index 43f63eb..4258a2e 100644
--- a/scripts/check-code-health.mjs
+++ b/scripts/check-code-health.mjs
@@ -152,6 +152,8 @@ function checkComplexity() {
'scripts/check-code-health.mjs',
'-x',
'**/*.d.ts',
+ '-x',
+ 'src/lib/agent-surfaces/**',
'--csv',
]);
const rows = result.stdout
@@ -189,7 +191,7 @@ function checkDuplication() {
'--mode',
'strict',
'--ignore',
- '**/*.test.*,**/__tests__/**,**/*.d.ts,**/node_modules/**,**/dist/**,**/coverage/**,scripts/check-code-health.mjs',
+ '**/*.test.*,**/__tests__/**,**/*.d.ts,src/lib/agent-surfaces/**,**/node_modules/**,**/dist/**,**/coverage/**,scripts/check-code-health.mjs',
'--reporters',
'json',
'--output',
diff --git a/scripts/overlay-astro-landing.mjs b/scripts/overlay-astro-landing.mjs
index 096ecdc..917ffce 100644
--- a/scripts/overlay-astro-landing.mjs
+++ b/scripts/overlay-astro-landing.mjs
@@ -1,4 +1,4 @@
-import { copyFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
+import { copyFile, mkdir, readdir, readFile, writeFile, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
@@ -54,6 +54,11 @@ export async function runOverlay(opts = {}) {
return;
}
+ // These legacy summaries must not shadow the source-backed Worker routes.
+ for (const name of ['index.md', 'llms.txt', 'llms-full.txt']) {
+ const file = join(target, name);
+ if (existsSync(file)) await unlink(file);
+ }
const files = await walk(astroDist);
let copied = 0;
let skipped = 0;
diff --git a/src/__tests__/agent-edge.test.ts b/src/__tests__/agent-edge.test.ts
index 4723840..b6e01f7 100644
--- a/src/__tests__/agent-edge.test.ts
+++ b/src/__tests__/agent-edge.test.ts
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest';
import { markdownPathForSurface, PUBLIC_SURFACES } from '../../public-surfaces.mjs';
import { handleRolePatchAgentRoutes } from '../../rolepatch-agent-routes.mjs';
+import { publicSourceMarkdown } from '../lib/public-source';
+const serve = (request: Request) => handleRolePatchAgentRoutes(request, publicSourceMarkdown);
import sitemap from '../app/sitemap';
const ORIGIN = 'https://preview.rolepatch.test';
@@ -15,7 +17,7 @@ function request(path: string, accept?: string) {
describe('RolePatch agent surfaces', () => {
it('links the documented surface template to a real catalog example', () => {
- const docs = readFileSync('landing-astro/src/pages/docs.astro', 'utf8');
+ const docs = readFileSync('src/components/public-docs.tsx', 'utf8');
expect(docs).toContain("'/api/public/v1/surfaces/home'");
expect(PUBLIC_SURFACES.some((surface) => surface.id === 'home')).toBe(true);
expect(docs).toContain('href={href}');
@@ -23,16 +25,14 @@ describe('RolePatch agent surfaces', () => {
it('serves truthful Markdown for every cataloged HTML route', async () => {
for (const surface of PUBLIC_SURFACES) {
- const negotiated = handleRolePatchAgentRoutes(
- request(surface.path, 'text/markdown, text/html;q=0.1')
- );
+ const negotiated = await serve(request(surface.path, 'text/markdown, text/html;q=0.1'));
expect(negotiated, surface.path).toBeInstanceOf(Response);
expect(negotiated?.status, surface.path).toBe(200);
expect(negotiated?.headers.get('content-type'), surface.path).toContain('text/markdown');
- expect(await negotiated?.text(), surface.path).toContain(`# ${surface.title}`);
+ expect(await negotiated?.text(), surface.path).toMatch(/# .+/);
const markdownPath = markdownPathForSurface(surface.path);
- const explicit = handleRolePatchAgentRoutes(request(markdownPath));
+ const explicit = await serve(request(markdownPath));
expect(explicit, markdownPath).toBeInstanceOf(Response);
expect(explicit?.status, markdownPath).toBe(200);
expect(explicit?.headers.get('content-type'), markdownPath).toContain('text/markdown');
@@ -40,7 +40,7 @@ describe('RolePatch agent surfaces', () => {
});
it('catalogs every public surface with same-origin readable Markdown', async () => {
- const response = handleRolePatchAgentRoutes(request('/api/ai'));
+ const response = await serve(request('/api/ai'));
expect(response).toBeInstanceOf(Response);
expect(response?.headers.get('content-type')).toContain('application/json');
@@ -48,10 +48,10 @@ describe('RolePatch agent surfaces', () => {
expect(catalog.surfaces).toHaveLength(PUBLIC_SURFACES.length);
for (const surface of catalog.surfaces) {
- expect(new URL(surface.url).origin).toBe(ORIGIN);
- expect(new URL(surface.md).origin).toBe(ORIGIN);
+ expect(new URL(surface.url).origin).toBe('https://rolepatch.com');
+ expect(new URL(surface.md).origin).toBe('https://rolepatch.com');
- const markdown = handleRolePatchAgentRoutes(request(new URL(surface.md).pathname));
+ const markdown = await serve(request(new URL(surface.md).pathname));
expect(markdown?.status, surface.id).toBe(200);
expect(markdown?.headers.get('content-type'), surface.id).toContain('text/markdown');
}
@@ -64,28 +64,26 @@ describe('RolePatch agent surfaces', () => {
expect(sitemapUrls.has(surface.path), surface.path).toBe(true);
}
- expect(sitemapUrls).toHaveLength(PUBLIC_SURFACES.length + 3);
- expect(sitemapUrls).toContain('/llms.txt');
- expect(sitemapUrls).toContain('/index.md');
- expect(sitemapUrls).toContain('/api/ai');
+ expect(sitemapUrls).toHaveLength(PUBLIC_SURFACES.length);
+ expect(sitemapUrls).not.toContain('/llms.txt');
+ expect(sitemapUrls).not.toContain('/index.md');
+ expect(sitemapUrls).not.toContain('/api/ai');
});
it('serves the catalog itself as Markdown when negotiated', async () => {
- const response = handleRolePatchAgentRoutes(
- request('/api/ai', 'text/markdown, application/json;q=0.1')
- );
+ const response = await serve(request('/api/ai', 'text/markdown, application/json;q=0.1'));
expect(response?.status).toBe(200);
expect(response?.headers.get('content-type')).toContain('text/markdown');
expect(await response?.text()).toContain('# RolePatch public agent catalog');
});
it('never falls through to HTML for an unknown Markdown path', async () => {
- const response = handleRolePatchAgentRoutes(request('/missing.md'));
+ const response = await serve(request('/missing.md'));
expect(response?.status).toBe(404);
expect(response?.headers.get('content-type')).toContain('text/markdown');
});
- it('leaves ordinary HTML requests to the application', () => {
- expect(handleRolePatchAgentRoutes(request('/pricing', 'text/html'))).toBeNull();
+ it('leaves ordinary HTML requests to the application', async () => {
+ expect(await serve(request('/pricing', 'text/html'))).toBeNull();
});
});
diff --git a/src/__tests__/public-readability-wrapper.test.ts b/src/__tests__/public-readability-wrapper.test.ts
new file mode 100644
index 0000000..16d3d6b
--- /dev/null
+++ b/src/__tests__/public-readability-wrapper.test.ts
@@ -0,0 +1,92 @@
+import { gzipSync, gunzipSync } from 'node:zlib';
+import { afterEach, expect, test, vi } from 'vitest';
+import { withPublicReadability } from '../../public-readability-policy.mjs';
+
+afterEach(() => vi.unstubAllGlobals());
+const html =
+ ' Public home';
+
+test('compressed homepage keeps its bytes and advertises discovery without parsing gzip', async () => {
+ const rewrite = vi.fn(() => {
+ throw new Error('Encoded HTML must not be parsed');
+ });
+ vi.stubGlobal('HTMLRewriter', rewrite);
+ const encoded = gzipSync(html);
+ const handle = withPublicReadability(
+ async () =>
+ new Response(encoded, {
+ headers: {
+ 'content-type': 'text/html',
+ 'content-encoding': 'gzip',
+ 'content-length': String(encoded.length),
+ },
+ })
+ );
+ const response = await handle(new Request('https://rolepatch.com/'), {}, {});
+ expect(Buffer.from(await response.arrayBuffer())).toEqual(encoded);
+ expect(gunzipSync(encoded).toString()).toBe(html);
+ expect(response.headers.get('content-encoding')).toBe('gzip');
+ expect(response.headers.get('link')).toContain('https://rolepatch.com/index.md');
+ expect(response.headers.get('vary')).toContain('Accept');
+ expect(response.headers.has('content-length')).toBe(false);
+ expect(rewrite).not.toHaveBeenCalled();
+});
+
+test('HEAD has no body and unrelated, non-HTML, or non-200 responses pass through', async () => {
+ const response = new Response(html, { headers: { 'content-type': 'text/html' } });
+ const head = await withPublicReadability(async () => response)(
+ new Request('https://rolepatch.com/', { method: 'HEAD' }),
+ {},
+ {}
+ );
+ expect(await head.text()).toBe('');
+ expect(head.headers.get('link')).toContain('text/markdown');
+ for (const [path, status, type] of [
+ ['/dashboard', 200, 'text/html'],
+ ['/', 404, 'text/html'],
+ ['/', 200, 'application/json'],
+ ] as const) {
+ const original = new Response('payload', { status, headers: { 'content-type': type } });
+ expect(
+ await withPublicReadability(async () => original)(
+ new Request(`https://rolepatch.com${path}`),
+ {},
+ {}
+ )
+ ).toBe(original);
+ }
+});
+
+test('identity HTML replaces old metadata and preserves the body', async () => {
+ const selectors: string[] = [];
+ let added = '';
+ const removed = vi.fn();
+ class Rewriter {
+ on(selector: string, handler: { element: (element: object) => void }) {
+ selectors.push(selector);
+ handler.element({
+ remove: removed,
+ append: (text: string) => {
+ added = text;
+ },
+ });
+ return this;
+ }
+ transform(response: Response) {
+ return response;
+ }
+ }
+ vi.stubGlobal('HTMLRewriter', Rewriter);
+ const response = await withPublicReadability(
+ async () => new Response(html, { headers: { 'content-type': 'text/html' } })
+ )(new Request('https://rolepatch.com/'), {}, {});
+ expect(selectors).toEqual([
+ 'link[rel="alternate"][type="text/markdown"]',
+ 'meta[name="ai-summary"]',
+ 'head',
+ ]);
+ expect(removed).toHaveBeenCalledTimes(2);
+ expect(added).toContain('https://rolepatch.com/index.md');
+ expect(added).toContain('name="ai-summary"');
+ expect(await response.text()).toBe(html);
+});
diff --git a/src/__tests__/public-readability.test.ts b/src/__tests__/public-readability.test.ts
new file mode 100644
index 0000000..3382fed
--- /dev/null
+++ b/src/__tests__/public-readability.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from 'vitest';
+import { PUBLIC_SURFACES } from '../../public-surfaces.mjs';
+import { handleRolePatchAgentRoutes } from '../../rolepatch-agent-routes.mjs';
+import { publicRobots, publicCrawlers } from '../../public-readability-policy.mjs';
+import { publicSourceMarkdown } from '../lib/public-source';
+import { absoluteMarkdownLinks } from '../lib/agent-surfaces/format.mjs';
+import { blogPosts } from '../lib/blog-posts';
+
+const origin = 'https://rolepatch.com';
+const serve = (path: string, init?: RequestInit) =>
+ handleRolePatchAgentRoutes(new Request(origin + path, init), publicSourceMarkdown);
+describe('source-backed public readability', () => {
+ it('preserves complete authored article bodies and anonymous source copy', () => {
+ for (const post of blogPosts)
+ expect(publicSourceMarkdown(`/blog/${post.slug}`)).toContain(
+ absoluteMarkdownLinks(post.content, `${origin}/blog/${post.slug}`)
+ );
+ expect(publicSourceMarkdown('/pricing')).toContain('$30');
+ expect(publicSourceMarkdown('/proof')).toContain('No proof evidence yet');
+ expect(publicSourceMarkdown('/tools/bullet-check')).toContain('analyzed');
+ expect(publicSourceMarkdown('/')).toContain('PDF · DOCX · Markdown');
+ expect(publicSourceMarkdown('/')).toContain('One base resume. One specific role.');
+ });
+ it('never reads saved browser state or includes personal workspace routes', async () => {
+ localStorage.setItem('resume-tailor-snippets', JSON.stringify([{ body: 'PRIVATE_CANARY' }]));
+ for (const surface of PUBLIC_SURFACES)
+ expect(publicSourceMarkdown(surface.path)).not.toContain('PRIVATE_CANARY');
+ for (const path of ['/jobs', '/evidence', '/resume-builder', '/dashboard', '/login']) {
+ expect(publicSourceMarkdown(path)).toBeNull();
+ expect((await serve(`${path}.md`))?.status).toBe(404);
+ }
+ });
+ it('implements canonical direct URLs, HEAD, CORS, token estimates and quality-aware negotiation', async () => {
+ const direct = await serve('/api/md/tools/ats-check');
+ const suffix = await serve('/tools/ats-check.md');
+ expect(await direct?.text()).toBe(await suffix?.text());
+ expect(direct?.headers.get('content-location')).toBe(`${origin}/tools/ats-check`);
+ expect(direct?.headers.get('x-robots-tag')).toBe('noindex, follow');
+ expect(direct?.headers.get('access-control-allow-origin')).toBe('*');
+ expect(Number(direct?.headers.get('x-markdown-tokens'))).toBeGreaterThan(1);
+ const head = await serve('/tools/ats-check.md', { method: 'HEAD' });
+ expect(await head?.text()).toBe('');
+ expect(head?.headers.get('x-markdown-tokens')).toBe(direct?.headers.get('x-markdown-tokens'));
+ for (const accept of [
+ '*/*',
+ 'text/markdown;q=0',
+ 'text/html,text/markdown',
+ 'text/html;q=1,text/markdown;q=0.5',
+ ])
+ expect(await serve('/tools', { headers: { accept } })).toBeNull();
+ expect((await serve('/api/md/tools', { method: 'OPTIONS' }))?.status).toBe(204);
+ });
+ it('exports full public content with bounded coverage and explicit crawler policy', async () => {
+ const full = await (await serve('/llms-full.txt'))?.text();
+ expect(full).toContain(`Included ${PUBLIC_SURFACES.length} of ${PUBLIC_SURFACES.length}`);
+ expect(full).toContain('No proof evidence yet');
+ for (const crawler of publicCrawlers)
+ expect(publicRobots()).toContain(`User-agent: ${crawler}\nAllow: /`);
+ expect(publicRobots()).toContain('Disallow: /resume-builder');
+ });
+});
+
+it('returns uncached service errors when a registered source loader fails', async () => {
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ for (const path of ['/index.md', '/llms-full.txt']) {
+ for (const method of ['GET', 'HEAD']) {
+ const response = await handleRolePatchAgentRoutes(
+ new Request(origin + path, { method }),
+ async () => {
+ throw new Error('fixture source failure');
+ }
+ );
+ expect(response?.status).toBe(503);
+ expect(response?.headers.get('cache-control')).toBe('no-store');
+ expect(response?.headers.get('content-type')).toContain('text/markdown');
+ expect(await response?.text()).toBe(
+ method === 'HEAD' ? '' : '# Markdown temporarily unavailable\n'
+ );
+ }
+ }
+ expect(log).toHaveBeenCalledTimes(4);
+ } finally {
+ log.mockRestore();
+ }
+});
diff --git a/src/app/api/ai/source/route.ts b/src/app/api/ai/source/route.ts
new file mode 100644
index 0000000..7c3446f
--- /dev/null
+++ b/src/app/api/ai/source/route.ts
@@ -0,0 +1,17 @@
+import { publicSourceMarkdown } from '@/lib/public-source';
+import { markdownResponse } from '@/lib/agent-surfaces/http.mjs';
+
+export const dynamic = 'force-dynamic';
+export function GET(request: Request) {
+ const path = new URL(request.url).searchParams.get('path') ?? '';
+ const body = publicSourceMarkdown(path);
+ const response = markdownResponse(
+ body ?? '# Not found\n\nNo public document exists at this path.\n',
+ request,
+ {
+ 'Content-Location': new URL(body ? path : '/', 'https://rolepatch.com').href,
+ 'Cache-Control': body ? 'public, max-age=300' : 'no-store',
+ }
+ );
+ return body ? response : new Response(response.body, { status: 404, headers: response.headers });
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 568c082..df24c7b 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -6,13 +6,13 @@
@source "./error.tsx";
@source "./global-error.tsx";
@source "./not-found.tsx";
-@source "./pricing/page.tsx";
+@source "../components/public-pricing-view.tsx";
@source "./tools/page.tsx";
@source "../components/site-nav.tsx";
@source "../components/rolepatch-mark.tsx";
@source "../components/token-balance.tsx";
@source "../components/user-menu.tsx";
-@source "../components/pricing-cards.tsx";
+@source "../components/pricing-cards-view.tsx";
@source "../components/saasmaker-feedback.tsx";
@source "../components/saasmaker-feedback-widget.tsx";
@source "../components/ui/button.tsx";
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ced1bdc..ff886be 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,3 +1,4 @@
+import { publicIdentity } from '@/lib/public-identity';
import './globals.css';
import type { Metadata, Viewport } from 'next';
@@ -118,7 +119,7 @@ export default function RootLayout({
{/* fleet-jsonld:end */}
diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx
index 67889ba..870fa97 100644
--- a/src/app/pricing/page.tsx
+++ b/src/app/pricing/page.tsx
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import { preload } from 'react-dom';
-import { PricingCards } from '@/components/pricing-cards';
+import { PublicPricingView } from '@/components/public-pricing-view';
import { getTokenBalance } from '@/lib/actions/token-actions';
import { getCurrentUserId } from '@/lib/auth-utils';
import { db } from '@/lib/db';
@@ -15,21 +15,6 @@ export const metadata: Metadata = {
alternates: { canonical: 'https://rolepatch.com/pricing' },
};
-const FAQ = [
- {
- q: 'What can I do with tokens?',
- a: 'Tokens cover AI work: resume tailoring, cover letters, fit scoring, interview prep, outreach drafts, and bulk fit scoring. The reviewed apply-agent queue, packets, extension fill, and receipts are included.',
- },
- {
- q: 'Do tokens expire?',
- a: 'No. Tokens never expire -- use them whenever you need them.',
- },
- {
- q: 'Can I get a refund?',
- a: 'Yes. Contact us within 14 days of purchase for a full refund.',
- },
-];
-
async function hasRecentPayment(): Promise {
const userId = await getCurrentUserId();
if (!userId) return false;
@@ -48,35 +33,5 @@ export default async function PricingPage() {
});
const [balance, paymentVerified] = await Promise.all([getTokenBalance(), hasRecentPayment()]);
- return (
-
-
-
Get Tokens
-
- Each token lets you tailor a resume, score job fit, generate interview stories, or write a
- cover letter.
-
-
- {balance}
- tokens remaining
-
-
-
-
-
- {/* FAQ */}
-
-
FAQ
- {FAQ.map((faq) => (
-
- ))}
-
-
- );
+ return ;
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
index 651eff9..02dad52 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -1,14 +1,12 @@
import type { MetadataRoute } from 'next';
-
+import { privatePaths, publicCrawlers } from '../../public-readability-policy.mjs';
export default function robots(): MetadataRoute.Robots {
return {
- rules: [
- {
- userAgent: '*',
- allow: '/',
- disallow: ['/api/', '/editor/', '/tailor/', '/cover-letter/'],
- },
- ],
+ rules: ['*', ...publicCrawlers].map((userAgent) => ({
+ userAgent,
+ allow: ['/', '/api/ai', '/api/md'],
+ disallow: privatePaths,
+ })),
sitemap: 'https://rolepatch.com/sitemap.xml',
};
}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 611dc1f..6dac125 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -8,16 +8,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
// Public HTML routes share one source of truth with /api/ai so a catalog
// entry cannot drift out of the sitemap. User-specific app routes stay out.
- const machineSurfaces: Array<{
- path: string;
- changeFrequency: NonNullable;
- priority: number;
- }> = [
- { path: '/llms.txt', changeFrequency: 'weekly', priority: 0.45 },
- { path: '/index.md', changeFrequency: 'weekly', priority: 0.45 },
- { path: '/api/ai', changeFrequency: 'weekly', priority: 0.4 },
- ];
-
return [
...PUBLIC_SURFACES.map((surface) => ({
url: new URL(surface.path, baseUrl).toString(),
@@ -27,11 +17,5 @@ export default function sitemap(): MetadataRoute.Sitemap {
>,
priority: surface.priority,
})),
- ...machineSurfaces.map(({ path, changeFrequency, priority }) => ({
- url: `${baseUrl}${path}`,
- lastModified: now,
- changeFrequency,
- priority,
- })),
];
}
diff --git a/src/app/tools/ats-check/page-state.tsx b/src/app/tools/ats-check/page-state.tsx
new file mode 100644
index 0000000..0dbb4c7
--- /dev/null
+++ b/src/app/tools/ats-check/page-state.tsx
@@ -0,0 +1,156 @@
+'use client';
+
+import { AtsCheckPageView } from './page-view';
+
+import { useMemo, useState } from 'react';
+
+interface Finding {
+ severity: 'good' | 'warn' | 'fail';
+ label: string;
+ detail: string;
+}
+
+function analyze(text: string): { score: number; findings: Finding[] } {
+ const findings: Finding[] = [];
+ const t = text.trim();
+ const words = t.split(/\s+/).filter(Boolean);
+ const lines = t.split(/\n/);
+
+ // 1. Length sanity — most one-pager resumes are 350-900 words.
+ if (words.length < 200) {
+ findings.push({
+ severity: 'warn',
+ label: 'Too short',
+ detail: `Only ${words.length} words — most readable resumes land in 400–900 words.`,
+ });
+ } else if (words.length > 1300) {
+ findings.push({
+ severity: 'warn',
+ label: 'Probably too long',
+ detail: `${words.length} words. ATS happily ingests long text, but recruiters skim — trim to under 1000 if you can.`,
+ });
+ } else {
+ findings.push({
+ severity: 'good',
+ label: 'Word count looks sane',
+ detail: `${words.length} words.`,
+ });
+ }
+
+ // 2. Section headers — heuristic check for Experience / Education / Skills.
+ const lower = t.toLowerCase();
+ const sections = ['experience', 'education', 'skills', 'projects'];
+ const missing = sections.filter((s) => !lower.includes(s));
+ if (missing.length === 0) {
+ findings.push({
+ severity: 'good',
+ label: 'All four standard sections present',
+ detail: 'Experience · Education · Skills · Projects.',
+ });
+ } else {
+ findings.push({
+ severity: missing.length >= 3 ? 'fail' : 'warn',
+ label: `Missing section${missing.length === 1 ? '' : 's'}`,
+ detail: `Couldn't find: ${missing.join(', ')}. ATS parsers depend on these labels.`,
+ });
+ }
+
+ // 3. Contact info — at least an email and either a phone or a LinkedIn-like URL.
+ const hasEmail = /[\w.+-]+@[\w-]+\.[\w.-]+/.test(t);
+ const hasPhone = /(?:\+?\d[\s-]?){7,}\d/.test(t);
+ const hasLinkedIn = /linkedin\.com\/in\//i.test(t);
+ if (hasEmail && (hasPhone || hasLinkedIn)) {
+ findings.push({
+ severity: 'good',
+ label: 'Reachable',
+ detail: `Email${hasPhone ? ' + phone' : ''}${hasLinkedIn ? ' + LinkedIn' : ''}.`,
+ });
+ } else {
+ findings.push({
+ severity: 'fail',
+ label: 'Missing contact info',
+ detail: hasEmail
+ ? 'Email present but no phone or LinkedIn URL detected.'
+ : 'No email detected. ATS reject queues often filter on missing email.',
+ });
+ }
+
+ // 4. Bullet density — bullets should make up at least 30% of lines if Experience is structured.
+ const bulletLines = lines.filter((l) => /^\s*[-*•]/.test(l)).length;
+ if (lines.length > 12 && bulletLines / lines.length < 0.2) {
+ findings.push({
+ severity: 'warn',
+ label: 'Few bullet points',
+ detail: `Only ${bulletLines} of ${lines.length} lines start with a bullet. ATS skims achievement bullets — convert prose to bullets where you can.`,
+ });
+ } else if (bulletLines > 0) {
+ findings.push({
+ severity: 'good',
+ label: 'Bulleted achievements present',
+ detail: `${bulletLines} bullet point${bulletLines === 1 ? '' : 's'} detected.`,
+ });
+ }
+
+ // 5. Quantification — does the resume have any numbers?
+ const numbers = (t.match(/\d+%|\$\s?\d|\d+\s?(k|m|b)\b|\d{2,}/gi) ?? []).length;
+ if (numbers < 4) {
+ findings.push({
+ severity: 'warn',
+ label: 'Not enough quantification',
+ detail: `Only ${numbers} numeric mention${numbers === 1 ? '' : 's'} detected. Adding "$3M saved", "12 engineers", "p99 by 40%" lifts both ATS keyword density and recruiter scan signal.`,
+ });
+ } else {
+ findings.push({
+ severity: 'good',
+ label: 'Numbers and outcomes present',
+ detail: `${numbers} quantified mentions.`,
+ });
+ }
+
+ // 6. ATS-hostile constructs (tables, images, columns hinted by pipes).
+ const pipeLines = lines.filter((l) => l.includes('|') && l.split('|').length >= 3).length;
+ if (pipeLines >= 3) {
+ findings.push({
+ severity: 'warn',
+ label: 'Many table-like rows',
+ detail: `${pipeLines} pipe-delimited rows detected. Some ATS parse them, many flatten them — prefer plain bullets for guaranteed parsing.`,
+ });
+ }
+
+ // 7. Date formatting — at least one year present.
+ const years = (t.match(/\b(19|20)\d{2}\b/g) ?? []).length;
+ if (years < 2) {
+ findings.push({
+ severity: 'warn',
+ label: 'Few dates',
+ detail: `Only ${years} year${years === 1 ? '' : 's'} mentioned. Roles without start–end dates often get filtered.`,
+ });
+ } else {
+ findings.push({
+ severity: 'good',
+ label: 'Dates present',
+ detail: `${years} years referenced.`,
+ });
+ }
+
+ // Score: start at 100, deduct for warnings (5) and fails (15).
+ let score = 100;
+ for (const f of findings) {
+ if (f.severity === 'warn') score -= 5;
+ if (f.severity === 'fail') score -= 15;
+ }
+ score = Math.max(0, Math.min(100, score));
+
+ return { score, findings };
+}
+
+export function useAtsCheckPageSourceState() {
+ const [text, setText] = useState('');
+ const result = useMemo(() => (text.trim() ? analyze(text) : null), [text]);
+
+ return { text, setText, result };
+}
+
+export default function AtsCheckPage() {
+ return ;
+}
diff --git a/src/app/tools/ats-check/page-view.tsx b/src/app/tools/ats-check/page-view.tsx
new file mode 100644
index 0000000..c7b8552
--- /dev/null
+++ b/src/app/tools/ats-check/page-view.tsx
@@ -0,0 +1,61 @@
+import Link from 'next/link';
+import type { useAtsCheckPageSourceState } from './page-state';
+
+export function AtsCheckPageView(view: ReturnType) {
+ 'use no memo';
+ return (
+
+
+ ← RolePatch
+
+ Free ATS Resume Checker
+
+ Paste the text of your resume for an immediate, browser-local check of seven visible
+ structure and formatting signals. No sign-up, file upload, or network request is required.
+ The result is a transparent heuristic report, not a prediction that a particular employer or
+ applicant-tracking system will accept your resume.
+
+
+
+ );
+}
diff --git a/src/app/tools/ats-check/page.tsx b/src/app/tools/ats-check/page.tsx
index 31c54a2..9ef10a3 100644
--- a/src/app/tools/ats-check/page.tsx
+++ b/src/app/tools/ats-check/page.tsx
@@ -1,205 +1 @@
-'use client';
-
-import Link from 'next/link';
-import { useMemo, useState } from 'react';
-
-interface Finding {
- severity: 'good' | 'warn' | 'fail';
- label: string;
- detail: string;
-}
-
-function analyze(text: string): { score: number; findings: Finding[] } {
- const findings: Finding[] = [];
- const t = text.trim();
- const words = t.split(/\s+/).filter(Boolean);
- const lines = t.split(/\n/);
-
- // 1. Length sanity — most one-pager resumes are 350-900 words.
- if (words.length < 200) {
- findings.push({
- severity: 'warn',
- label: 'Too short',
- detail: `Only ${words.length} words — most readable resumes land in 400–900 words.`,
- });
- } else if (words.length > 1300) {
- findings.push({
- severity: 'warn',
- label: 'Probably too long',
- detail: `${words.length} words. ATS happily ingests long text, but recruiters skim — trim to under 1000 if you can.`,
- });
- } else {
- findings.push({
- severity: 'good',
- label: 'Word count looks sane',
- detail: `${words.length} words.`,
- });
- }
-
- // 2. Section headers — heuristic check for Experience / Education / Skills.
- const lower = t.toLowerCase();
- const sections = ['experience', 'education', 'skills', 'projects'];
- const missing = sections.filter((s) => !lower.includes(s));
- if (missing.length === 0) {
- findings.push({
- severity: 'good',
- label: 'All four standard sections present',
- detail: 'Experience · Education · Skills · Projects.',
- });
- } else {
- findings.push({
- severity: missing.length >= 3 ? 'fail' : 'warn',
- label: `Missing section${missing.length === 1 ? '' : 's'}`,
- detail: `Couldn't find: ${missing.join(', ')}. ATS parsers depend on these labels.`,
- });
- }
-
- // 3. Contact info — at least an email and either a phone or a LinkedIn-like URL.
- const hasEmail = /[\w.+-]+@[\w-]+\.[\w.-]+/.test(t);
- const hasPhone = /(?:\+?\d[\s-]?){7,}\d/.test(t);
- const hasLinkedIn = /linkedin\.com\/in\//i.test(t);
- if (hasEmail && (hasPhone || hasLinkedIn)) {
- findings.push({
- severity: 'good',
- label: 'Reachable',
- detail: `Email${hasPhone ? ' + phone' : ''}${hasLinkedIn ? ' + LinkedIn' : ''}.`,
- });
- } else {
- findings.push({
- severity: 'fail',
- label: 'Missing contact info',
- detail: hasEmail
- ? 'Email present but no phone or LinkedIn URL detected.'
- : 'No email detected. ATS reject queues often filter on missing email.',
- });
- }
-
- // 4. Bullet density — bullets should make up at least 30% of lines if Experience is structured.
- const bulletLines = lines.filter((l) => /^\s*[-*•]/.test(l)).length;
- if (lines.length > 12 && bulletLines / lines.length < 0.2) {
- findings.push({
- severity: 'warn',
- label: 'Few bullet points',
- detail: `Only ${bulletLines} of ${lines.length} lines start with a bullet. ATS skims achievement bullets — convert prose to bullets where you can.`,
- });
- } else if (bulletLines > 0) {
- findings.push({
- severity: 'good',
- label: 'Bulleted achievements present',
- detail: `${bulletLines} bullet point${bulletLines === 1 ? '' : 's'} detected.`,
- });
- }
-
- // 5. Quantification — does the resume have any numbers?
- const numbers = (t.match(/\d+%|\$\s?\d|\d+\s?(k|m|b)\b|\d{2,}/gi) ?? []).length;
- if (numbers < 4) {
- findings.push({
- severity: 'warn',
- label: 'Not enough quantification',
- detail: `Only ${numbers} numeric mention${numbers === 1 ? '' : 's'} detected. Adding "$3M saved", "12 engineers", "p99 by 40%" lifts both ATS keyword density and recruiter scan signal.`,
- });
- } else {
- findings.push({
- severity: 'good',
- label: 'Numbers and outcomes present',
- detail: `${numbers} quantified mentions.`,
- });
- }
-
- // 6. ATS-hostile constructs (tables, images, columns hinted by pipes).
- const pipeLines = lines.filter((l) => l.includes('|') && l.split('|').length >= 3).length;
- if (pipeLines >= 3) {
- findings.push({
- severity: 'warn',
- label: 'Many table-like rows',
- detail: `${pipeLines} pipe-delimited rows detected. Some ATS parse them, many flatten them — prefer plain bullets for guaranteed parsing.`,
- });
- }
-
- // 7. Date formatting — at least one year present.
- const years = (t.match(/\b(19|20)\d{2}\b/g) ?? []).length;
- if (years < 2) {
- findings.push({
- severity: 'warn',
- label: 'Few dates',
- detail: `Only ${years} year${years === 1 ? '' : 's'} mentioned. Roles without start–end dates often get filtered.`,
- });
- } else {
- findings.push({
- severity: 'good',
- label: 'Dates present',
- detail: `${years} years referenced.`,
- });
- }
-
- // Score: start at 100, deduct for warnings (5) and fails (15).
- let score = 100;
- for (const f of findings) {
- if (f.severity === 'warn') score -= 5;
- if (f.severity === 'fail') score -= 15;
- }
- score = Math.max(0, Math.min(100, score));
-
- return { score, findings };
-}
-
-export default function AtsCheckPage() {
- const [text, setText] = useState('');
- const result = useMemo(() => (text.trim() ? analyze(text) : null), [text]);
-
- return (
-
-
- ← RolePatch
-
- Free ATS Resume Checker
-
- Paste the text of your resume for an immediate, browser-local check of seven visible
- structure and formatting signals. No sign-up, file upload, or network request is required.
- The result is a transparent heuristic report, not a prediction that a particular employer or
- applicant-tracking system will accept your resume.
-
-
-
- );
-}
+export { default } from './page-state';
diff --git a/src/app/tools/bullet-check/analysis.ts b/src/app/tools/bullet-check/analysis.ts
new file mode 100644
index 0000000..b78d6db
--- /dev/null
+++ b/src/app/tools/bullet-check/analysis.ts
@@ -0,0 +1,129 @@
+const WEAK_VERBS = new Set([
+ 'did',
+ 'made',
+ 'worked',
+ 'helped',
+ 'assisted',
+ 'responsible',
+ 'involved',
+ 'participated',
+ 'tasked',
+ 'duties',
+ 'handled',
+ 'managed', // ambiguous, but flag for review
+ 'was',
+ 'were',
+ 'had',
+]);
+
+const STRONG_VERBS = [
+ 'shipped',
+ 'led',
+ 'built',
+ 'architected',
+ 'designed',
+ 'scaled',
+ 'reduced',
+ 'increased',
+ 'automated',
+ 'drove',
+ 'delivered',
+ 'launched',
+ 'spearheaded',
+ 'negotiated',
+ 'owned',
+ 'migrated',
+ 'decomposed',
+ 'rewrote',
+ 'instrumented',
+ 'open-sourced',
+];
+
+interface Check {
+ ok: boolean;
+ label: string;
+ detail: string;
+}
+
+interface BulletReport {
+ raw: string;
+ checks: Check[];
+ score: number;
+}
+
+const QUANTIFIER_RE =
+ /(\d+(\.\d+)?\s*%|\$\s*\d+|[0-9]+(?:[,.][0-9]+)*\+?|\b(?:million|thousand|k|m|b)\b)/i;
+const FIRST_PERSON_RE = /\b(i|me|my|mine)\b/i;
+const VERB_TENSE_HINT_RE = /\b(ing)\b/i; // "managing" instead of "managed"
+
+function analyzeBullet(raw: string): BulletReport {
+ const text = raw.trim();
+ const _lower = text.toLowerCase();
+ const firstWord = (text.split(/\s+/)[0] ?? '').replace(/[^a-zA-Z-]/g, '').toLowerCase();
+
+ const checks: Check[] = [];
+
+ const startsWithStrong = STRONG_VERBS.includes(firstWord);
+ const startsWithWeak = WEAK_VERBS.has(firstWord);
+ checks.push({
+ ok: startsWithStrong || !startsWithWeak,
+ label: 'Starts with a strong action verb',
+ detail: startsWithStrong
+ ? `“${firstWord}” is a great opener.`
+ : startsWithWeak
+ ? `“${firstWord}” is vague — try a verb like ${STRONG_VERBS.slice(0, 4).join(', ')}.`
+ : `“${firstWord}” is fine but consider stronger options.`,
+ });
+
+ checks.push({
+ ok: QUANTIFIER_RE.test(text),
+ label: 'Contains a quantified outcome',
+ detail: QUANTIFIER_RE.test(text)
+ ? 'A measurable number anchors the impact.'
+ : 'No numbers / percentages / dollar figures — add a metric if possible.',
+ });
+
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
+ const lenOk = wordCount >= 6 && wordCount <= 24;
+ checks.push({
+ ok: lenOk,
+ label: 'Length within sweet spot (6–24 words)',
+ detail: `${wordCount} word${wordCount === 1 ? '' : 's'} — ${
+ wordCount < 6 ? 'too short, add context' : wordCount > 24 ? 'too long, trim filler' : 'good'
+ }.`,
+ });
+
+ const noFirstPerson = !FIRST_PERSON_RE.test(text);
+ checks.push({
+ ok: noFirstPerson,
+ label: 'No first-person pronouns',
+ detail: noFirstPerson
+ ? "Clean — resume convention drops 'I' / 'my'."
+ : "Drop 'I' / 'my' — resume convention is implied first person.",
+ });
+
+ const presentParticiple = /\b\w+ing\b/.test(firstWord) || VERB_TENSE_HINT_RE.test(firstWord);
+ checks.push({
+ ok: !presentParticiple,
+ label: 'Uses past tense (or present for current role)',
+ detail: presentParticiple
+ ? `“${firstWord}” is -ing form — past tense reads stronger.`
+ : 'Past tense reads decisive and complete.',
+ });
+
+ const score = Math.round((checks.filter((c) => c.ok).length / checks.length) * 100);
+
+ // de-duplicate: if no input, all checks fail and score is 0 by design
+ if (!text) {
+ return { raw, score: 0, checks: [] };
+ }
+
+ return { raw, score, checks };
+}
+
+const PLACEHOLDER = `Built dashboard for support team
+Managed deployment pipeline
+Reduced API p99 latency from 410ms to 95ms (-77%) by adding read replicas
+I was responsible for handling team standups`;
+
+export { PLACEHOLDER, analyzeBullet };
diff --git a/src/app/tools/bullet-check/page-state.tsx b/src/app/tools/bullet-check/page-state.tsx
new file mode 100644
index 0000000..5e64ab0
--- /dev/null
+++ b/src/app/tools/bullet-check/page-state.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { BulletCheckPageView } from './page-view';
+
+import { useMemo, useState } from 'react';
+
+import { PLACEHOLDER, analyzeBullet } from './analysis';
+
+export function useBulletCheckPageSourceState() {
+ const [text, setText] = useState(PLACEHOLDER);
+
+ const bullets = useMemo(() => {
+ return text
+ .split(/\n+/)
+ .map((line) => line.replace(/^[-•*\s]+/, '').trim())
+ .filter((line) => line.length > 0)
+ .map(analyzeBullet);
+ }, [text]);
+
+ const avg =
+ bullets.length === 0
+ ? 0
+ : Math.round(bullets.reduce((s, b) => s + b.score, 0) / bullets.length);
+
+ return { text, setText, bullets, avg };
+}
+
+export default function BulletCheckPage() {
+ return ;
+}
diff --git a/src/app/tools/bullet-check/page-view.tsx b/src/app/tools/bullet-check/page-view.tsx
new file mode 100644
index 0000000..78a61dc
--- /dev/null
+++ b/src/app/tools/bullet-check/page-view.tsx
@@ -0,0 +1,79 @@
+import { AlertCircle, ArrowLeft, CheckCircle2 } from 'lucide-react';
+import Link from 'next/link';
+import type { useBulletCheckPageSourceState } from './page-state';
+
+function scoreColor(score: number): string {
+ 'use no memo';
+ if (score >= 80) return 'text-emerald-500';
+ if (score >= 60) return 'text-amber-500';
+ return 'text-red-500';
+}
+
+export function BulletCheckPageView(view: ReturnType) {
+ 'use no memo';
+ return (
+
+
+ All tools
+
+
+
+ Bullet Strength Checker
+
+ Paste your resume bullets one per line. Each line gets graded on verb strength, quantified
+ outcome, length, tense, and first-person leakage. Everything runs in your browser.
+
+
+
+
+ );
+}
diff --git a/src/app/tools/bullet-check/page.tsx b/src/app/tools/bullet-check/page.tsx
index f24972d..9ef10a3 100644
--- a/src/app/tools/bullet-check/page.tsx
+++ b/src/app/tools/bullet-check/page.tsx
@@ -1,220 +1 @@
-'use client';
-
-import { AlertCircle, ArrowLeft, CheckCircle2 } from 'lucide-react';
-import Link from 'next/link';
-import { useMemo, useState } from 'react';
-
-const WEAK_VERBS = new Set([
- 'did',
- 'made',
- 'worked',
- 'helped',
- 'assisted',
- 'responsible',
- 'involved',
- 'participated',
- 'tasked',
- 'duties',
- 'handled',
- 'managed', // ambiguous, but flag for review
- 'was',
- 'were',
- 'had',
-]);
-
-const STRONG_VERBS = [
- 'shipped',
- 'led',
- 'built',
- 'architected',
- 'designed',
- 'scaled',
- 'reduced',
- 'increased',
- 'automated',
- 'drove',
- 'delivered',
- 'launched',
- 'spearheaded',
- 'negotiated',
- 'owned',
- 'migrated',
- 'decomposed',
- 'rewrote',
- 'instrumented',
- 'open-sourced',
-];
-
-interface Check {
- ok: boolean;
- label: string;
- detail: string;
-}
-
-interface BulletReport {
- raw: string;
- checks: Check[];
- score: number;
-}
-
-const QUANTIFIER_RE =
- /(\d+(\.\d+)?\s*%|\$\s*\d+|[0-9]+(?:[,.][0-9]+)*\+?|\b(?:million|thousand|k|m|b)\b)/i;
-const FIRST_PERSON_RE = /\b(i|me|my|mine)\b/i;
-const VERB_TENSE_HINT_RE = /\b(ing)\b/i; // "managing" instead of "managed"
-
-function analyzeBullet(raw: string): BulletReport {
- const text = raw.trim();
- const _lower = text.toLowerCase();
- const firstWord = (text.split(/\s+/)[0] ?? '').replace(/[^a-zA-Z-]/g, '').toLowerCase();
-
- const checks: Check[] = [];
-
- const startsWithStrong = STRONG_VERBS.includes(firstWord);
- const startsWithWeak = WEAK_VERBS.has(firstWord);
- checks.push({
- ok: startsWithStrong || !startsWithWeak,
- label: 'Starts with a strong action verb',
- detail: startsWithStrong
- ? `“${firstWord}” is a great opener.`
- : startsWithWeak
- ? `“${firstWord}” is vague — try a verb like ${STRONG_VERBS.slice(0, 4).join(', ')}.`
- : `“${firstWord}” is fine but consider stronger options.`,
- });
-
- checks.push({
- ok: QUANTIFIER_RE.test(text),
- label: 'Contains a quantified outcome',
- detail: QUANTIFIER_RE.test(text)
- ? 'A measurable number anchors the impact.'
- : 'No numbers / percentages / dollar figures — add a metric if possible.',
- });
-
- const wordCount = text.split(/\s+/).filter(Boolean).length;
- const lenOk = wordCount >= 6 && wordCount <= 24;
- checks.push({
- ok: lenOk,
- label: 'Length within sweet spot (6–24 words)',
- detail: `${wordCount} word${wordCount === 1 ? '' : 's'} — ${
- wordCount < 6 ? 'too short, add context' : wordCount > 24 ? 'too long, trim filler' : 'good'
- }.`,
- });
-
- const noFirstPerson = !FIRST_PERSON_RE.test(text);
- checks.push({
- ok: noFirstPerson,
- label: 'No first-person pronouns',
- detail: noFirstPerson
- ? "Clean — resume convention drops 'I' / 'my'."
- : "Drop 'I' / 'my' — resume convention is implied first person.",
- });
-
- const presentParticiple = /\b\w+ing\b/.test(firstWord) || VERB_TENSE_HINT_RE.test(firstWord);
- checks.push({
- ok: !presentParticiple,
- label: 'Uses past tense (or present for current role)',
- detail: presentParticiple
- ? `“${firstWord}” is -ing form — past tense reads stronger.`
- : 'Past tense reads decisive and complete.',
- });
-
- const score = Math.round((checks.filter((c) => c.ok).length / checks.length) * 100);
-
- // de-duplicate: if no input, all checks fail and score is 0 by design
- if (!text) {
- return { raw, score: 0, checks: [] };
- }
-
- return { raw, score, checks };
-}
-
-function scoreColor(score: number): string {
- if (score >= 80) return 'text-emerald-500';
- if (score >= 60) return 'text-amber-500';
- return 'text-red-500';
-}
-
-const PLACEHOLDER = `Built dashboard for support team
-Managed deployment pipeline
-Reduced API p99 latency from 410ms to 95ms (-77%) by adding read replicas
-I was responsible for handling team standups`;
-
-export default function BulletCheckPage() {
- const [text, setText] = useState(PLACEHOLDER);
-
- const bullets = useMemo(() => {
- return text
- .split(/\n+/)
- .map((line) => line.replace(/^[-•*\s]+/, '').trim())
- .filter((line) => line.length > 0)
- .map(analyzeBullet);
- }, [text]);
-
- const avg =
- bullets.length === 0
- ? 0
- : Math.round(bullets.reduce((s, b) => s + b.score, 0) / bullets.length);
-
- return (
-
-
- All tools
-
-
-
- Bullet Strength Checker
-
- Paste your resume bullets one per line. Each line gets graded on verb strength, quantified
- outcome, length, tense, and first-person leakage. Everything runs in your browser.
-
-
-
-
- );
-}
+export { default } from './page-state';
diff --git a/src/app/tools/diff/page-state.tsx b/src/app/tools/diff/page-state.tsx
new file mode 100644
index 0000000..347f8ae
--- /dev/null
+++ b/src/app/tools/diff/page-state.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { DiffToolPageView } from './page-view';
+
+import { useState } from 'react';
+
+export function useDiffToolPageSourceState() {
+ const [versionA, setVersionA] = useState('');
+ const [versionB, setVersionB] = useState('');
+ const [compared, setCompared] = useState(false);
+ const [splitView, setSplitView] = useState(true);
+
+ const canCompare = versionA.trim().length > 0 && versionB.trim().length > 0;
+
+ const aLines = versionA.split('\n');
+ const bLines = versionB.split('\n');
+ const additions = compared ? bLines.filter((l, i) => l !== aLines[i]).length : 0;
+ const removals = compared ? aLines.filter((l, i) => l !== bLines[i]).length : 0;
+
+ return {
+ versionA,
+ setVersionA,
+ setCompared,
+ versionB,
+ setVersionB,
+ canCompare,
+ compared,
+ additions,
+ removals,
+ setSplitView,
+ splitView,
+ };
+}
+
+export default function DiffToolPage() {
+ return ;
+}
diff --git a/src/app/tools/diff/page-view.tsx b/src/app/tools/diff/page-view.tsx
new file mode 100644
index 0000000..6281a2b
--- /dev/null
+++ b/src/app/tools/diff/page-view.tsx
@@ -0,0 +1,134 @@
+import Link from 'next/link';
+import { ResumeDiffView } from '@/components/resume-diff-view';
+import type { useDiffToolPageSourceState } from './page-state';
+
+const darkTheme = {
+ variables: {
+ dark: {
+ diffViewerBackground: 'var(--background)',
+ diffViewerColor: '#e5e5e5',
+ addedBackground: '#0d2818',
+ addedColor: 'var(--accent)',
+ removedBackground: '#2d1215',
+ removedColor: '#f87171',
+ wordAddedBackground: '#166534',
+ wordRemovedBackground: '#991b1b',
+ addedGutterBackground: '#0d2818',
+ removedGutterBackground: '#2d1215',
+ gutterBackground: '#111111',
+ gutterBackgroundDark: '#0d0d0d',
+ highlightBackground: '#1a1a1a',
+ highlightGutterBackground: '#1a1a1a',
+ codeFoldGutterBackground: '#111111',
+ codeFoldBackground: '#111111',
+ emptyLineBackground: 'var(--background)',
+ gutterColor: '#525252',
+ addedGutterColor: 'var(--accent)',
+ removedGutterColor: '#f87171',
+ codeFoldContentColor: '#737373',
+ diffViewerTitleBackground: '#111111',
+ diffViewerTitleColor: '#a3a3a3',
+ diffViewerTitleBorderColor: '#262626',
+ },
+ },
+};
+
+export function DiffToolPageView(view: ReturnType) {
+ 'use no memo';
+ return (
+
+
+
+ Free Resume Diff Tool
+
+
+ Compare two versions of your resume side by side. See exactly what changed, word by word.
+ Free, no sign-up required.
+
+
+
+
+
+
+ Version A
+
+
+
+
+ Version B
+
+
+
+
+
+ view.setCompared(true)}
+ disabled={!view.canCompare}
+ className="bg-[var(--accent)] hover:bg-[var(--accent)]/90 disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold px-8 py-3 rounded-xl text-lg transition-colors"
+ >
+ Compare
+
+
+
+ {view.compared && (
+
+
+
+ +{view.additions} additions
+ -{view.removals} removals
+
+
view.setSplitView(!view.splitView)}
+ className="text-xs px-3 py-1.5 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] hover:text-foreground hover:border-[var(--muted-foreground)] transition-colors"
+ >
+ {view.splitView ? 'Inline view' : 'Split view'}
+
+
+
+
+
+
+ )}
+
+
+
+
+ Want AI to tailor your resume automatically?
+
+
+ Try RolePatch free →
+
+
+
+
+ );
+}
diff --git a/src/app/tools/diff/page.tsx b/src/app/tools/diff/page.tsx
index 0c8749f..9ef10a3 100644
--- a/src/app/tools/diff/page.tsx
+++ b/src/app/tools/diff/page.tsx
@@ -1,148 +1 @@
-'use client';
-
-import Link from 'next/link';
-import { useState } from 'react';
-import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued';
-
-const darkTheme = {
- variables: {
- dark: {
- diffViewerBackground: 'var(--background)',
- diffViewerColor: '#e5e5e5',
- addedBackground: '#0d2818',
- addedColor: 'var(--accent)',
- removedBackground: '#2d1215',
- removedColor: '#f87171',
- wordAddedBackground: '#166534',
- wordRemovedBackground: '#991b1b',
- addedGutterBackground: '#0d2818',
- removedGutterBackground: '#2d1215',
- gutterBackground: '#111111',
- gutterBackgroundDark: '#0d0d0d',
- highlightBackground: '#1a1a1a',
- highlightGutterBackground: '#1a1a1a',
- codeFoldGutterBackground: '#111111',
- codeFoldBackground: '#111111',
- emptyLineBackground: 'var(--background)',
- gutterColor: '#525252',
- addedGutterColor: 'var(--accent)',
- removedGutterColor: '#f87171',
- codeFoldContentColor: '#737373',
- diffViewerTitleBackground: '#111111',
- diffViewerTitleColor: '#a3a3a3',
- diffViewerTitleBorderColor: '#262626',
- },
- },
-};
-
-export default function DiffToolPage() {
- const [versionA, setVersionA] = useState('');
- const [versionB, setVersionB] = useState('');
- const [compared, setCompared] = useState(false);
- const [splitView, setSplitView] = useState(true);
-
- const canCompare = versionA.trim().length > 0 && versionB.trim().length > 0;
-
- const aLines = versionA.split('\n');
- const bLines = versionB.split('\n');
- const additions = compared ? bLines.filter((l, i) => l !== aLines[i]).length : 0;
- const removals = compared ? aLines.filter((l, i) => l !== bLines[i]).length : 0;
-
- return (
-
-
-
- Free Resume Diff Tool
-
-
- Compare two versions of your resume side by side. See exactly what changed, word by word.
- Free, no sign-up required.
-
-
-
-
-
-
- Version A
-
-
-
-
- Version B
-
-
-
-
-
- setCompared(true)}
- disabled={!canCompare}
- className="bg-[var(--accent)] hover:bg-[var(--accent)]/90 disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold px-8 py-3 rounded-xl text-lg transition-colors"
- >
- Compare
-
-
-
- {compared && (
-
-
-
- +{additions} additions
- -{removals} removals
-
-
setSplitView(!splitView)}
- className="text-xs px-3 py-1.5 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] hover:text-foreground hover:border-[var(--muted-foreground)] transition-colors"
- >
- {splitView ? 'Inline view' : 'Split view'}
-
-
-
-
-
-
- )}
-
-
-
-
- Want AI to tailor your resume automatically?
-
-
- Try RolePatch free →
-
-
-
-
- );
-}
+export { default } from './page-state';
diff --git a/src/app/tools/keywords/page-state.tsx b/src/app/tools/keywords/page-state.tsx
new file mode 100644
index 0000000..c44e40d
--- /dev/null
+++ b/src/app/tools/keywords/page-state.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { KeywordsToolPageView } from './page-view';
+import { useState } from 'react';
+
+import { type ATSResult, calculateATSScore } from '@/lib/ats-score';
+
+export function useKeywordsToolPageSourceState() {
+ const [resumeText, setResumeText] = useState('');
+ const [jdText, setJdText] = useState('');
+ const [result, setResult] = useState(null);
+
+ const canAnalyze = resumeText.trim().length > 0 && jdText.trim().length > 0;
+
+ function handleAnalyze() {
+ const r = calculateATSScore(resumeText, jdText);
+ setResult(r);
+ }
+
+ return {
+ resumeText,
+ setResumeText,
+ setResult,
+ jdText,
+ setJdText,
+ handleAnalyze,
+ canAnalyze,
+ result,
+ };
+}
+
+export default function KeywordsToolPage() {
+ return ;
+}
diff --git a/src/app/tools/keywords/page-view.tsx b/src/app/tools/keywords/page-view.tsx
new file mode 100644
index 0000000..c7ae86f
--- /dev/null
+++ b/src/app/tools/keywords/page-view.tsx
@@ -0,0 +1,178 @@
+import Link from 'next/link';
+import type { useKeywordsToolPageSourceState } from './page-state';
+
+function scoreColor(score: number) {
+ 'use no memo';
+ if (score > 70) return { stroke: 'var(--accent)', text: 'text-[var(--accent)]' };
+ if (score >= 40) return { stroke: '#f59e0b', text: 'text-amber-400' };
+ return { stroke: '#ef4444', text: 'text-red-400' };
+}
+
+function ScoreCircle({ score }: { score: number }) {
+ 'use no memo';
+ const color = scoreColor(score);
+ const radius = 54;
+ const circumference = 2 * Math.PI * radius;
+ const offset = circumference - (score / 100) * circumference;
+
+ return (
+
+
+
+
+
+
+ {score}%
+
+ Keyword coverage
+
+
+
+ );
+}
+
+export function KeywordsToolPageView(view: ReturnType) {
+ 'use no memo';
+ return (
+
+
+
+ Free Resume Keyword Checker
+
+
+ Check how well your resume matches a job description. See matched and missing keywords
+ instantly. Free, no sign-up required.
+
+
+
+
+
+
+ Your Resume
+
+
+
+
+ Job Description
+
+
+
+
+
+
+ Analyze
+
+
+
+ {view.result && (
+
+
+ {view.result.totalKeywords > 0 ? (
+
+ ) : (
+
No usable keywords found in this job description.
+ )}
+
+ {view.result.matchedKeywords.length} of {view.result.totalKeywords} keywords matched
+
+
+ Matched terms divided by extracted terms. This uses literal text matches, including
+ substrings, and can miss synonyms or count partial words. It does not predict an
+ employer’s ATS decision, job fit, or interview chances. Add a missing term only if
+ your experience supports it.
+
+
+
+
+ {view.result.matchedKeywords.length > 0 && (
+
+
+ Matched keywords ({view.result.matchedKeywords.length})
+
+
+ {view.result.matchedKeywords.map((kw) => (
+
+ {kw}
+
+ ))}
+
+
+ )}
+
+ {view.result.missingKeywords.length > 0 && (
+
+
+ Missing keywords ({view.result.missingKeywords.length})
+
+
+ {view.result.missingKeywords.map((kw) => (
+
+ {kw}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+
+
+
+ Review suggested edits against your actual experience.
+
+
+ Try RolePatch free →
+
+
+
+
+ );
+}
diff --git a/src/app/tools/keywords/page.tsx b/src/app/tools/keywords/page.tsx
index d5aa8a8..9ef10a3 100644
--- a/src/app/tools/keywords/page.tsx
+++ b/src/app/tools/keywords/page.tsx
@@ -1,188 +1 @@
-'use client';
-
-import Link from 'next/link';
-import { useState } from 'react';
-
-import { type ATSResult, calculateATSScore } from '@/lib/ats-score';
-
-function scoreColor(score: number) {
- if (score > 70) return { stroke: 'var(--accent)', text: 'text-[var(--accent)]' };
- if (score >= 40) return { stroke: '#f59e0b', text: 'text-amber-400' };
- return { stroke: '#ef4444', text: 'text-red-400' };
-}
-
-function ScoreCircle({ score }: { score: number }) {
- const color = scoreColor(score);
- const radius = 54;
- const circumference = 2 * Math.PI * radius;
- const offset = circumference - (score / 100) * circumference;
-
- return (
-
-
-
-
-
-
- {score}%
-
- Keyword coverage
-
-
-
- );
-}
-
-export default function KeywordsToolPage() {
- const [resumeText, setResumeText] = useState('');
- const [jdText, setJdText] = useState('');
- const [result, setResult] = useState(null);
-
- const canAnalyze = resumeText.trim().length > 0 && jdText.trim().length > 0;
-
- function handleAnalyze() {
- const r = calculateATSScore(resumeText, jdText);
- setResult(r);
- }
-
- return (
-
-
-
- Free Resume Keyword Checker
-
-
- Check how well your resume matches a job description. See matched and missing keywords
- instantly. Free, no sign-up required.
-
-
-
-
-
-
- Your Resume
-
-
-
-
- Job Description
-
-
-
-
-
-
- Analyze
-
-
-
- {result && (
-
-
- {result.totalKeywords > 0 ? (
-
- ) : (
-
No usable keywords found in this job description.
- )}
-
- {result.matchedKeywords.length} of {result.totalKeywords} keywords matched
-
-
- Matched terms divided by extracted terms. This uses literal text matches, including
- substrings, and can miss synonyms or count partial words. It does not predict an
- employer’s ATS decision, job fit, or interview chances. Add a missing term only if
- your experience supports it.
-
-
-
-
- {result.matchedKeywords.length > 0 && (
-
-
- Matched keywords ({result.matchedKeywords.length})
-
-
- {result.matchedKeywords.map((kw) => (
-
- {kw}
-
- ))}
-
-
- )}
-
- {result.missingKeywords.length > 0 && (
-
-
- Missing keywords ({result.missingKeywords.length})
-
-
- {result.missingKeywords.map((kw) => (
-
- {kw}
-
- ))}
-
-
- )}
-
-
- )}
-
-
-
-
- Review suggested edits against your actual experience.
-
-
- Try RolePatch free →
-
-
-
-
- );
-}
+export { default } from './page-state';
diff --git a/src/app/tools/snippets/page-state.tsx b/src/app/tools/snippets/page-state.tsx
new file mode 100644
index 0000000..1d5ed3a
--- /dev/null
+++ b/src/app/tools/snippets/page-state.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import { SnippetsPageView } from './page-view';
+
+import { useCallback, useEffect, useState } from 'react';
+
+const STORAGE_KEY = 'resume-tailor-snippets';
+
+interface Snippet {
+ id: string;
+ title: string;
+ body: string;
+ updatedAt: number;
+}
+
+function loadAll(): Snippet[] {
+ if (typeof window === 'undefined') return [];
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveAll(snips: Snippet[]) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(snips));
+}
+
+export function useSnippetsPageSourceState() {
+ const [snips, setSnips] = useState([]);
+ const [title, setTitle] = useState('');
+ const [body, setBody] = useState('');
+ const [editingId, setEditingId] = useState(null);
+ const [copiedId, setCopiedId] = useState(null);
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setSnips(loadAll());
+ }, []);
+
+ const submit = useCallback(() => {
+ const t = title.trim();
+ const b = body.trim();
+ if (!t || !b) return;
+ setSnips((prev) => {
+ const next = editingId
+ ? prev.map((s) =>
+ s.id === editingId ? { ...s, title: t, body: b, updatedAt: Date.now() } : s
+ )
+ : [{ id: crypto.randomUUID(), title: t, body: b, updatedAt: Date.now() }, ...prev];
+ saveAll(next);
+ return next;
+ });
+ setTitle('');
+ setBody('');
+ setEditingId(null);
+ }, [title, body, editingId]);
+
+ const edit = (s: Snippet) => {
+ setTitle(s.title);
+ setBody(s.body);
+ setEditingId(s.id);
+ };
+
+ const remove = (id: string) => {
+ setSnips((prev) => {
+ const next = prev.filter((s) => s.id !== id);
+ saveAll(next);
+ return next;
+ });
+ };
+
+ const copy = (s: Snippet) => {
+ navigator.clipboard.writeText(s.body).then(() => {
+ setCopiedId(s.id);
+ window.setTimeout(() => setCopiedId(null), 1200);
+ });
+ };
+
+ return {
+ editingId,
+ title,
+ setTitle,
+ body,
+ setBody,
+ submit,
+ setEditingId,
+ snips,
+ copy,
+ copiedId,
+ edit,
+ remove,
+ };
+}
+
+export default function SnippetsPage() {
+ return ;
+}
diff --git a/src/app/tools/snippets/page-view.tsx b/src/app/tools/snippets/page-view.tsx
new file mode 100644
index 0000000..48da4d3
--- /dev/null
+++ b/src/app/tools/snippets/page-view.tsx
@@ -0,0 +1,102 @@
+import Link from 'next/link';
+import type { useSnippetsPageSourceState } from './page-state';
+
+export function SnippetsPageView(view: ReturnType) {
+ 'use no memo';
+ return (
+
+
+ ← RolePatch
+
+ Snippet library
+
+ Save reusable cover-letter or resume paragraphs once, paste them anywhere. Everything lives
+ in this browser's localStorage — nothing is sent server-side.
+
+
+
+
+
+ {view.snips.length === 0 ? (
+ No snippets yet. Save your first one above.
+ ) : (
+ view.snips.map((s) => (
+
+
+
+ {s.body}
+
+
+ updated {new Date(s.updatedAt).toISOString().slice(0, 10)}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/app/tools/snippets/page.tsx b/src/app/tools/snippets/page.tsx
index ade2645..9ef10a3 100644
--- a/src/app/tools/snippets/page.tsx
+++ b/src/app/tools/snippets/page.tsx
@@ -1,170 +1 @@
-'use client';
-
-import Link from 'next/link';
-import { useCallback, useEffect, useState } from 'react';
-
-const STORAGE_KEY = 'resume-tailor-snippets';
-
-interface Snippet {
- id: string;
- title: string;
- body: string;
- updatedAt: number;
-}
-
-function loadAll(): Snippet[] {
- if (typeof window === 'undefined') return [];
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return [];
- const parsed = JSON.parse(raw);
- return Array.isArray(parsed) ? parsed : [];
- } catch {
- return [];
- }
-}
-
-function saveAll(snips: Snippet[]) {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(snips));
-}
-
-export default function SnippetsPage() {
- const [snips, setSnips] = useState([]);
- const [title, setTitle] = useState('');
- const [body, setBody] = useState('');
- const [editingId, setEditingId] = useState(null);
- const [copiedId, setCopiedId] = useState(null);
-
- useEffect(() => {
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setSnips(loadAll());
- }, []);
-
- const submit = useCallback(() => {
- const t = title.trim();
- const b = body.trim();
- if (!t || !b) return;
- setSnips((prev) => {
- const next = editingId
- ? prev.map((s) =>
- s.id === editingId ? { ...s, title: t, body: b, updatedAt: Date.now() } : s
- )
- : [{ id: crypto.randomUUID(), title: t, body: b, updatedAt: Date.now() }, ...prev];
- saveAll(next);
- return next;
- });
- setTitle('');
- setBody('');
- setEditingId(null);
- }, [title, body, editingId]);
-
- const edit = (s: Snippet) => {
- setTitle(s.title);
- setBody(s.body);
- setEditingId(s.id);
- };
-
- const remove = (id: string) => {
- setSnips((prev) => {
- const next = prev.filter((s) => s.id !== id);
- saveAll(next);
- return next;
- });
- };
-
- const copy = (s: Snippet) => {
- navigator.clipboard.writeText(s.body).then(() => {
- setCopiedId(s.id);
- window.setTimeout(() => setCopiedId(null), 1200);
- });
- };
-
- return (
-
-
- ← RolePatch
-
- Snippet library
-
- Save reusable cover-letter or resume paragraphs once, paste them anywhere. Everything lives
- in this browser's localStorage — nothing is sent server-side.
-
-
-
-
-
- {snips.length === 0 ? (
- No snippets yet. Save your first one above.
- ) : (
- snips.map((s) => (
-
-
-
- {s.body}
-
-
- updated {new Date(s.updatedAt).toISOString().slice(0, 10)}
-
-
- ))
- )}
-
-
- );
-}
+export { default } from './page-state';
diff --git a/src/app/tools/word-count/page-state.tsx b/src/app/tools/word-count/page-state.tsx
new file mode 100644
index 0000000..feb7395
--- /dev/null
+++ b/src/app/tools/word-count/page-state.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { WordCountPageView } from './page-view';
+import { useMemo, useState } from 'react';
+
+interface Stats {
+ characters: number;
+ charactersNoSpace: number;
+ words: number;
+ lines: number;
+ sentences: number;
+ paragraphs: number;
+ bullets: number;
+ readingSeconds: number;
+}
+
+function analyze(text: string): Stats {
+ const characters = text.length;
+ const charactersNoSpace = text.replace(/\s+/g, '').length;
+ const words = (text.match(/\S+/g) ?? []).length;
+ const lines = text.split(/\n/).length;
+ const sentences = (text.match(/[^.!?\n]+[.!?]+/g) ?? []).length;
+ const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length;
+ const bullets = text.split(/\n/).filter((l) => /^\s*[-*•]/.test(l)).length;
+ // 230 words/min is the common skim rate for resumes / bullet copy.
+ const readingSeconds = Math.round((words / 230) * 60);
+ return {
+ characters,
+ charactersNoSpace,
+ words,
+ lines,
+ sentences,
+ paragraphs,
+ bullets,
+ readingSeconds,
+ };
+}
+
+export function useWordCountPageSourceState() {
+ const [text, setText] = useState('');
+ const stats = useMemo(() => (text.length > 0 ? analyze(text) : null), [text]);
+
+ return { text, setText, stats };
+}
+
+export default function WordCountPage() {
+ return ;
+}
diff --git a/src/app/tools/word-count/page-view.tsx b/src/app/tools/word-count/page-view.tsx
new file mode 100644
index 0000000..91035d6
--- /dev/null
+++ b/src/app/tools/word-count/page-view.tsx
@@ -0,0 +1,62 @@
+import Link from 'next/link';
+import type { useWordCountPageSourceState } from './page-state';
+
+function formatSeconds(s: number): string {
+ 'use no memo';
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rem = s % 60;
+ if (rem === 0) return `${m}m`;
+ return `${m}m ${rem}s`;
+}
+
+function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
+ 'use no memo';
+ return (
+
+
{label}
+
{value}
+ {hint &&
{hint}
}
+
+ );
+}
+
+export function WordCountPageView(view: ReturnType) {
+ 'use no memo';
+ return (
+
+
+ ← RolePatch
+
+ Word count
+
+ Paste anything — resume, cover letter, bio — to get word / line / sentence / paragraph
+ counts and an estimated recruiter-skim time. Local only; no network call.
+
+
+
+ );
+}
diff --git a/src/app/tools/word-count/page.tsx b/src/app/tools/word-count/page.tsx
index 0792aed..9ef10a3 100644
--- a/src/app/tools/word-count/page.tsx
+++ b/src/app/tools/word-count/page.tsx
@@ -1,93 +1 @@
-'use client';
-
-import Link from 'next/link';
-import { useMemo, useState } from 'react';
-
-interface Stats {
- characters: number;
- charactersNoSpace: number;
- words: number;
- lines: number;
- sentences: number;
- paragraphs: number;
- bullets: number;
- readingSeconds: number;
-}
-
-function analyze(text: string): Stats {
- const characters = text.length;
- const charactersNoSpace = text.replace(/\s+/g, '').length;
- const words = (text.match(/\S+/g) ?? []).length;
- const lines = text.split(/\n/).length;
- const sentences = (text.match(/[^.!?\n]+[.!?]+/g) ?? []).length;
- const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length;
- const bullets = text.split(/\n/).filter((l) => /^\s*[-*•]/.test(l)).length;
- // 230 words/min is the common skim rate for resumes / bullet copy.
- const readingSeconds = Math.round((words / 230) * 60);
- return {
- characters,
- charactersNoSpace,
- words,
- lines,
- sentences,
- paragraphs,
- bullets,
- readingSeconds,
- };
-}
-
-function formatSeconds(s: number): string {
- if (s < 60) return `${s}s`;
- const m = Math.floor(s / 60);
- const rem = s % 60;
- if (rem === 0) return `${m}m`;
- return `${m}m ${rem}s`;
-}
-
-export default function WordCountPage() {
- const [text, setText] = useState('');
- const stats = useMemo(() => (text.length > 0 ? analyze(text) : null), [text]);
-
- return (
-
-
- ← RolePatch
-
- Word count
-
- Paste anything — resume, cover letter, bio — to get word / line / sentence / paragraph
- counts and an estimated recruiter-skim time. Local only; no network call.
-
-
-
- );
-}
-
-function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
- return (
-
-
{label}
-
{value}
- {hint &&
{hint}
}
-
- );
-}
+export { default } from './page-state';
diff --git a/src/components/pricing-cards-view.tsx b/src/components/pricing-cards-view.tsx
new file mode 100644
index 0000000..cd3d5db
--- /dev/null
+++ b/src/components/pricing-cards-view.tsx
@@ -0,0 +1,127 @@
+import type { usePricingCardsSourceState } from './pricing-cards';
+
+const TOKEN_PACKS = [
+ {
+ id: 'starter' as const,
+ name: 'Starter',
+ tokens: 10,
+ priceLabel: '$5',
+ perToken: '$0.50',
+ },
+ {
+ id: 'pro' as const,
+ name: 'Pro',
+ tokens: 30,
+ priceLabel: '$12',
+ perToken: '$0.40',
+ popular: true,
+ },
+ {
+ id: 'bulk' as const,
+ name: 'Bulk',
+ tokens: 100,
+ priceLabel: '$30',
+ perToken: '$0.30',
+ },
+];
+
+export function PricingCardsView(view: ReturnType) {
+ 'use no memo';
+ return (
+
+ {view.paymentVerified && (
+
+
+ Payment successful! Your tokens have been added to your account.
+
+
+ )}
+
+ {view.error && (
+
+ )}
+
+
+ {[
+ {
+ title: 'Free reviewed workflow',
+ detail:
+ 'Queue jobs, prepare packets, fill with the extension, and record receipts without spending tokens.',
+ },
+ {
+ title: 'Tokens fund AI work',
+ detail:
+ 'Spend tokens on tailoring, cover letters, fit scoring, interview prep, outreach drafts, and bulk fit scoring.',
+ },
+ {
+ title: 'No subscription',
+ detail:
+ 'Buy packs when needed. Guarded submit stays review-first and does not become unattended bulk apply.',
+ },
+ ].map((item) => (
+
+
+ {item.title}
+
+
{item.detail}
+
+ ))}
+
+
+
+ {TOKEN_PACKS.map((pack) => (
+
+ {pack.popular && (
+
+ Popular
+
+ )}
+
+
{pack.name}
+
+ {pack.priceLabel}
+
+
+ {pack.tokens} tokens · {pack.perToken} each
+
+
+
+
+ {view.isGuest ? (
+
+ Sign in to purchase
+
+ ) : (
+
view.handleBuy(pack.id)}
+ disabled={view.loadingPack !== null}
+ className={`w-full min-h-[44px] py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 ${
+ pack.popular
+ ? 'bg-[var(--accent)] text-white hover:bg-[var(--accent)]/90'
+ : 'bg-white text-gray-900 hover:bg-gray-200'
+ }`}
+ >
+ {view.loadingPack === pack.id ? 'Redirecting...' : 'Buy'}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/pricing-cards.tsx b/src/components/pricing-cards.tsx
index 38fa985..7ed84a9 100644
--- a/src/components/pricing-cards.tsx
+++ b/src/components/pricing-cards.tsx
@@ -1,37 +1,14 @@
'use client';
+import { PricingCardsView } from './pricing-cards-view';
+
import { useState } from 'react';
import { useAuth } from '@/components/auth-provider';
import { authClient } from '@/lib/auth-client';
import { captureAuthFailure } from '@/lib/foundry-monitoring';
-const TOKEN_PACKS = [
- {
- id: 'starter' as const,
- name: 'Starter',
- tokens: 10,
- priceLabel: '$5',
- perToken: '$0.50',
- },
- {
- id: 'pro' as const,
- name: 'Pro',
- tokens: 30,
- priceLabel: '$12',
- perToken: '$0.40',
- popular: true,
- },
- {
- id: 'bulk' as const,
- name: 'Bulk',
- tokens: 100,
- priceLabel: '$30',
- perToken: '$0.30',
- },
-];
-
-export function PricingCards({ paymentVerified }: { paymentVerified: boolean }) {
+export function usePricingCardsSourceState({ paymentVerified }: { paymentVerified: boolean }) {
const { isGuest } = useAuth();
const [loadingPack, setLoadingPack] = useState(null);
const [error, setError] = useState(null);
@@ -82,101 +59,9 @@ export function PricingCards({ paymentVerified }: { paymentVerified: boolean })
});
}
- return (
-
- {paymentVerified && (
-
-
- Payment successful! Your tokens have been added to your account.
-
-
- )}
-
- {error && (
-
- )}
-
-
- {[
- {
- title: 'Free reviewed workflow',
- detail:
- 'Queue jobs, prepare packets, fill with the extension, and record receipts without spending tokens.',
- },
- {
- title: 'Tokens fund AI work',
- detail:
- 'Spend tokens on tailoring, cover letters, fit scoring, interview prep, outreach drafts, and bulk fit scoring.',
- },
- {
- title: 'No subscription',
- detail:
- 'Buy packs when needed. Guarded submit stays review-first and does not become unattended bulk apply.',
- },
- ].map((item) => (
-
-
- {item.title}
-
-
{item.detail}
-
- ))}
-
-
-
- {TOKEN_PACKS.map((pack) => (
-
- {pack.popular && (
-
- Popular
-
- )}
-
-
{pack.name}
-
- {pack.priceLabel}
-
-
- {pack.tokens} tokens · {pack.perToken} each
-
-
-
+ return { paymentVerified, error, isGuest, handleGuestSignIn, handleBuy, loadingPack };
+}
- {isGuest ? (
-
- Sign in to purchase
-
- ) : (
-
handleBuy(pack.id)}
- disabled={loadingPack !== null}
- className={`w-full min-h-[44px] py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 ${
- pack.popular
- ? 'bg-[var(--accent)] text-white hover:bg-[var(--accent)]/90'
- : 'bg-white text-gray-900 hover:bg-gray-200'
- }`}
- >
- {loadingPack === pack.id ? 'Redirecting...' : 'Buy'}
-
- )}
-
- ))}
-
-
- );
+export function PricingCards(props: Parameters[0]) {
+ return ;
}
diff --git a/src/components/proof-packet-preview-view.tsx b/src/components/proof-packet-preview-view.tsx
new file mode 100644
index 0000000..525ad57
--- /dev/null
+++ b/src/components/proof-packet-preview-view.tsx
@@ -0,0 +1,158 @@
+import Link from 'next/link';
+import type { buildProofPacketPreview } from '@/lib/achievement-evidence';
+import type { useProofPacketPreviewSourceState } from './proof-packet-preview';
+
+function ProofMetric({ label, value }: { label: string; value: number }) {
+ 'use no memo';
+ return (
+
+
{value}
+
+ {label}
+
+
+ );
+}
+
+function ProofItem({
+ item,
+ compact = false,
+}: {
+ item: ReturnType['shareable'][number];
+ compact?: boolean;
+}) {
+ 'use no memo';
+ return (
+
+
+
+ {item.readiness.label}
+
+
+ {item.impact}
+
+
+ User-provided
+
+
+ {item.title}
+ {item.claim}
+ {!compact && item.tags.length > 0 && (
+
+ {item.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+ {item.source_url && (
+
+ View source
+
+ )}
+ {item.readiness.missing.length > 0 && (
+
+ Missing: {item.readiness.missing.join(', ')}
+
+ )}
+
+ );
+}
+
+export function ProofPacketPreviewView(view: ReturnType) {
+ 'use no memo';
+ return (
+
+
+
+
+
+ Your proof packet
+
+
+ Preview what can be shared.
+
+
+ This preview uses your existing achievement evidence. Items remain private and
+ user-provided until you attach them to an application packet or a later verification
+ flow ships.
+
+
+
+
+
+
+ {view.copyState === 'copied'
+ ? 'Copied proof'
+ : view.copyState === 'failed'
+ ? 'Copy failed'
+ : 'Copy proof profile'}
+
+
+
+
+ {!view.hasEvidence ? (
+
+
No proof evidence yet
+
+ Add quantified achievements first, then return here to preview candidate proof
+ packets.
+
+
+ Add evidence
+
+
+ ) : (
+
+
+
Ready for packet review
+
+ {view.preview.shareable.length === 0 ? (
+
+ No evidence is packet-ready yet. Add metrics, context, and tags in the evidence
+ bank.
+
+ ) : (
+ view.preview.shareable.map((item) =>
)
+ )}
+
+
+
+
+
Needs proof cleanup
+
+ {view.preview.needsWork.length === 0 ? (
+
+ All saved evidence is ready for packet review.
+
+ ) : (
+ view.preview.needsWork.map((item) => (
+
+ ))
+ )}
+
+
+
+ )}
+
+
+ );
+}
+export { ProofMetric };
diff --git a/src/components/proof-packet-preview.tsx b/src/components/proof-packet-preview.tsx
index 18f6ccd..bbc3313 100644
--- a/src/components/proof-packet-preview.tsx
+++ b/src/components/proof-packet-preview.tsx
@@ -1,6 +1,6 @@
'use client';
-import Link from 'next/link';
+import { ProofPacketPreviewView } from './proof-packet-preview-view';
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '@/components/auth-provider';
@@ -12,7 +12,7 @@ interface Props {
serverEntries: AchievementEvidence[];
}
-export function ProofPacketPreview({ serverEntries }: Props) {
+export function useProofPacketPreviewSourceState({ serverEntries }: Props) {
const { isGuest } = useAuth();
const [entries, setEntries] = useState(serverEntries);
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
@@ -36,88 +36,7 @@ export function ProofPacketPreview({ serverEntries }: Props) {
}
}
- return (
-
-
-
-
-
- Your proof packet
-
-
- Preview what can be shared.
-
-
- This preview uses your existing achievement evidence. Items remain private and
- user-provided until you attach them to an application packet or a later verification
- flow ships.
-
-
-
-
-
-
- {copyState === 'copied'
- ? 'Copied proof'
- : copyState === 'failed'
- ? 'Copy failed'
- : 'Copy proof profile'}
-
-
-
-
- {!hasEvidence ? (
-
-
No proof evidence yet
-
- Add quantified achievements first, then return here to preview candidate proof
- packets.
-
-
- Add evidence
-
-
- ) : (
-
-
-
Ready for packet review
-
- {preview.shareable.length === 0 ? (
-
- No evidence is packet-ready yet. Add metrics, context, and tags in the evidence
- bank.
-
- ) : (
- preview.shareable.map((item) =>
)
- )}
-
-
-
-
-
Needs proof cleanup
-
- {preview.needsWork.length === 0 ? (
-
- All saved evidence is ready for packet review.
-
- ) : (
- preview.needsWork.map((item) =>
)
- )}
-
-
-
- )}
-
-
- );
+ return { shareableCount, needsWorkCount, copyProofProfile, copyState, hasEvidence, preview };
}
function formatProofProfileSummary(
@@ -144,66 +63,6 @@ function formatProofProfileSummary(
return lines.join('\n');
}
-function ProofMetric({ label, value }: { label: string; value: number }) {
- return (
-
-
{value}
-
- {label}
-
-
- );
-}
-
-function ProofItem({
- item,
- compact = false,
-}: {
- item: ReturnType['shareable'][number];
- compact?: boolean;
-}) {
- return (
-
-
-
- {item.readiness.label}
-
-
- {item.impact}
-
-
- User-provided
-
-
- {item.title}
- {item.claim}
- {!compact && item.tags.length > 0 && (
-
- {item.tags.map((tag) => (
-
- {tag}
-
- ))}
-
- )}
- {item.source_url && (
-
- View source
-
- )}
- {item.readiness.missing.length > 0 && (
-
- Missing: {item.readiness.missing.join(', ')}
-
- )}
-
- );
+export function ProofPacketPreview(props: Parameters[0]) {
+ return ;
}
diff --git a/src/components/proof-project-overview.tsx b/src/components/proof-project-overview.tsx
index 1c5cff1..794a8ad 100644
--- a/src/components/proof-project-overview.tsx
+++ b/src/components/proof-project-overview.tsx
@@ -304,3 +304,5 @@ function ProofRow({ label, value, detail }: { label: string; value: string; deta
);
}
+
+export { ProofMetric as OverviewProofMetric, ProofRow };
diff --git a/src/components/public-changelog.tsx b/src/components/public-changelog.tsx
new file mode 100644
index 0000000..e1fef61
--- /dev/null
+++ b/src/components/public-changelog.tsx
@@ -0,0 +1,72 @@
+const sourceUrl = 'https://github.com/Significant-Hobbies/rolepatch';
+const roadmapUrl = `${sourceUrl}/issues`;
+
+const entries = [
+ {
+ date: 'July 11, 2026',
+ title: 'Guest workflows became more predictable',
+ body: 'The test environment now resets browser-local state between runs. That protects the same boundary unsigned users rely on when they keep resumes, searches, and application drafts on one device.',
+ },
+ {
+ date: 'July 4, 2026',
+ title: 'Job discovery moved into the product',
+ body: 'A LinkedIn-focused job browser added quick searches, posting signals, resume-aware ranking, visible match evidence, and reviewed batch queueing. Guests can search and prepare locally without being forced to sign in.',
+ },
+ {
+ date: 'July 4, 2026',
+ title: 'Application assistance became receipt-first',
+ body: 'The reviewed queue added Chrome-assisted ATS fill, guarded submit attempts, provider-aware blockers, field-level details, retry timelines, and copyable audit summaries. Unattended bulk apply remains out of scope.',
+ },
+ {
+ date: 'July 4, 2026',
+ title: 'Proof and public release checks shipped',
+ body: 'RolePatch added a source-backed Proof surface and a release preflight covering type safety, formatting, tests, the extension build, the Cloudflare build, public routes, and responsive browser journeys.',
+ },
+];
+export function PublicChangelog() {
+ 'use no memo';
+ return (
+
+
+
+
Verified product history
+
What changed, and why it matters.
+
+ Shipped changes live here. Planned work stays in the public issue tracker, where it
+ cannot be mistaken for a finished feature.
+
+
+
+
+
+
+
+ {entries.map((entry) => (
+
+ {entry.date}
+
+
{entry.title}
+
{entry.body}
+
+
+ ))}
+
+
+
+
Inspect the work behind the claims.
+
The canonical repository and its issue tracker are public.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/public-docs.tsx b/src/components/public-docs.tsx
new file mode 100644
index 0000000..ca29a13
--- /dev/null
+++ b/src/components/public-docs.tsx
@@ -0,0 +1,100 @@
+const endpoints = [
+ ['/api/ai', 'JSON catalog of every public HTML surface and same-origin Markdown alternate.'],
+ [
+ '/api/public/v1/surfaces/{id}',
+ 'Versioned JSON lookup for one catalog surface. This link uses the home surface as a working example; an unknown id returns a typed problem response.',
+ '/api/public/v1/surfaces/home',
+ ],
+ [
+ '/llms.txt',
+ 'Compact product guidance, best-fit use cases, boundaries, access, and discovery links.',
+ ],
+ ['/llms-full.txt', 'Expanded product brief for agents that need the full product contract.'],
+ [
+ '/index.md',
+ 'Canonical Markdown explanation of product purpose, evidence rules, access, and state.',
+ ],
+ ['/openapi.json', 'OpenAPI 3.1 description of these read-only discovery routes.'],
+ ['/sitemap.xml', 'Canonical public route inventory for crawlers.'],
+] as const;
+export function PublicDocs() {
+ 'use no memo';
+ return (
+
+
+
+
Public discovery interface
+
Read the product contract. Do not automate the applicant.
+
+ RolePatch publishes a small, unauthenticated, read-only interface so agents can discover
+ the product and understand its limits. Resume tailoring and application actions remain
+ inside the reviewed user workflow.
+
+
+
+
+
+
+
+
Endpoints
+
Seven public routes. No mutation API.
+
+ Use GET or HEAD. These routes require no token or account. They describe RolePatch;
+ they do not accept a resume, spend credits, fill an ATS, or submit an application.
+
+
+
+ {endpoints.map(([path, body, href = path]) => (
+
+
+ {path}
+
+ {body}
+
+ ))}
+
+
+
+
+
+
+
+ Markdown negotiation
+ Ask for Markdown at the page URL.
+
+ Send Accept: text/markdown to any cataloged public page, or request its
+ explicit .md alternate. Negotiated responses include{' '}
+ Vary: Accept so HTML and Markdown cannot poison one another in a cache.
+
+
+
+ Error model
+ Discovery errors are structured.
+
+ Unsupported methods and missing discovery routes return{' '}
+ application/problem+json with a stable code, status, detail, and
+ resolution hint. Ordinary product routes keep their own application contracts.
+
+
+
+ Version policy
+ Breaking changes get a new major contract.
+
+ The OpenAPI info.version follows semantic versioning. A breaking change
+ gets a new major version; the previous contract remains available for at least 90 days
+ with Deprecation and Sunset response headers.
+
+
+
+ Non-goals
+ No public agent can apply on your behalf.
+
+ There is no public mutation API, MCP tool, payment-by-agent rail, CAPTCHA bypass, or
+ unattended file upload. Those absences preserve the review-first product boundary.
+
+
+
+
+
+ );
+}
diff --git a/src/components/public-faq.tsx b/src/components/public-faq.tsx
new file mode 100644
index 0000000..3463579
--- /dev/null
+++ b/src/components/public-faq.tsx
@@ -0,0 +1,44 @@
+import { PRODUCT_FAQS } from '../../landing-astro/src/lib/product';
+
+export function PublicFaq() {
+ 'use no memo';
+ return (
+
+
+
+
RolePatch FAQ
+
Straight answers before you trust it with a real application.
+
+ What RolePatch may change, what it must leave alone, where your records live, what costs
+ money, and where application assistance stops.
+
+
+
+
+
+
+
+ {PRODUCT_FAQS.map((faq) => (
+
+ {faq.q}
+ {faq.a}
+
+ ))}
+
+
+
+
Bring the job. Keep the claims yours.
+
+ Start with a guest workspace, then decide whether account-backed persistence belongs
+ in your search.
+
+
+
+ Open RolePatch ↗
+
+
+
+
+
+ );
+}
diff --git a/src/components/public-pricing-view.tsx b/src/components/public-pricing-view.tsx
new file mode 100644
index 0000000..46f9a5e
--- /dev/null
+++ b/src/components/public-pricing-view.tsx
@@ -0,0 +1,56 @@
+import { PricingCards } from './pricing-cards';
+const FAQ = [
+ {
+ q: 'What can I do with tokens?',
+ a: 'Tokens cover AI work: resume tailoring, cover letters, fit scoring, interview prep, outreach drafts, and bulk fit scoring. The reviewed apply-agent queue, packets, extension fill, and receipts are included.',
+ },
+ {
+ q: 'Do tokens expire?',
+ a: 'No. Tokens never expire -- use them whenever you need them.',
+ },
+ {
+ q: 'Can I get a refund?',
+ a: 'Yes. Contact us within 14 days of purchase for a full refund.',
+ },
+];
+
+export function PublicPricingView({
+ balance,
+ paymentVerified,
+}: {
+ balance: number;
+ paymentVerified: boolean;
+}) {
+ 'use no memo';
+ return (
+
+
+
Get Tokens
+
+ Each token lets you tailor a resume, score job fit, generate interview stories, or write a
+ cover letter.
+
+
+ {balance}
+ tokens remaining
+
+
+
+
+
+ {/* FAQ */}
+
+
FAQ
+ {FAQ.map((faq) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/resume-diff-view.tsx b/src/components/resume-diff-view.tsx
new file mode 100644
index 0000000..5c548d2
--- /dev/null
+++ b/src/components/resume-diff-view.tsx
@@ -0,0 +1,10 @@
+'use client';
+
+import type { ComponentProps } from 'react';
+import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued';
+
+export function ResumeDiffView(
+ props: Omit, 'compareMethod'>
+) {
+ return ;
+}
diff --git a/src/components/truehire-proof-preview-view.tsx b/src/components/truehire-proof-preview-view.tsx
new file mode 100644
index 0000000..caeb60e
--- /dev/null
+++ b/src/components/truehire-proof-preview-view.tsx
@@ -0,0 +1,343 @@
+import type { TrueHireProofItem, TrueHireRoleFitRequirement } from '@/lib/truehire-proof';
+import type { useTrueHireProofPreviewSourceState } from './truehire-proof-preview';
+
+function PreviewMetric({ label, value }: { label: string; value: number }) {
+ 'use no memo';
+ return (
+
+
{value.toLocaleString()}
+
+ {label}
+
+
+ );
+}
+
+function RoleFitList({
+ title,
+ empty,
+ items,
+}: {
+ title: string;
+ empty: string;
+ items: TrueHireRoleFitRequirement[];
+}) {
+ 'use no memo';
+ return (
+
+
{title}
+
+ {items.length === 0 ? (
+
+ {empty}
+
+ ) : (
+ items.map((item) =>
)
+ )}
+
+
+ );
+}
+
+function RoleFitItem({ item }: { item: TrueHireRoleFitRequirement }) {
+ 'use no memo';
+ return (
+
+
+
+ {item.score}/100
+
+
+ {item.category}
+
+
+ {item.label}
+ {item.remediation}
+ {item.strengths.length > 0 && (
+
+ {item.strengths.map((strength) => (
+
+
{strength.repo_full_name}
+
+ {[
+ strength.primary_language,
+ strength.commits > 0 ? `${strength.commits.toLocaleString()} commits` : '',
+ strength.merged_prs > 0
+ ? `${strength.merged_prs.toLocaleString()} merged PRs`
+ : '',
+ strength.stars > 0 ? `${strength.stars.toLocaleString()} stars` : '',
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function TrueHireItem({ item }: { item: TrueHireProofItem }) {
+ 'use no memo';
+ return (
+
+
+
+ {item.readiness}
+
+
+ {item.source_label}
+
+
+ {item.title}
+ {item.claim}
+
+
+ );
+}
+
+export function TrueHireProofPreviewView(
+ view: ReturnType
+) {
+ 'use no memo';
+ return (
+
+
+
+
+
+ TrueHire import preview
+
+
+ Inspect proof before any merge.
+
+
+ Paste a TrueHire handle to preview public score evidence and confirmed work history as
+ RolePatch proof items. Previewing is read-only; importing saves private evidence for
+ your review and still does not share anything.
+
+
+
+
+
+
+ {view.error && (
+
+ {view.error}
+
+ )}
+ {view.notice && (
+
+ {view.notice} Review imported evidence before attaching it to application packets.
+
+ )}
+
+ {view.preview?.profile && (
+
+
+
+
+
+ @{view.preview.profile.handle}
+
+
+ Open TrueHire profile
+
+
+ {view.isImporting ? 'Importing...' : 'Import to evidence'}
+
+
+
+
+ {view.preview.profile.overall_score ?? '-'}
+
+
+ Score
+
+
+
+
+
+
+
+
+
Mapped proof candidates
+
+ {view.preview.items && view.preview.items.length > 0 ? (
+ view.preview.items.map((item) =>
)
+ ) : (
+
+ This profile does not expose packet-ready proof items yet.
+
+ )}
+
+
+
+ )}
+
+
+
+
+
+ Role-fit JSON preview
+
+
+ Consume TrueHire fit without sharing.
+
+
+ Paste a job description to read TrueHire's public role-fit JSON for this
+ handle. This report stays preview-only in RolePatch; it is not imported into
+ evidence, attached to packets, or sent to employers.
+
+
+
+
+
+
+ {view.roleFitError && (
+
+ {view.roleFitError}
+
+ )}
+
+ {view.roleFit?.role_fit && (
+
+
+
+
+ @{view.roleFit.role_fit.handle}
+
+
+ TrueHire role-fit report
+
+
+ {view.roleFit.boundary ??
+ 'Role-fit preview is read-only. Nothing is shared automatically.'}
+
+
+
+
+ {view.roleFit.role_fit.fit_score}
+
+
+ Fit score
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/truehire-proof-preview.tsx b/src/components/truehire-proof-preview.tsx
index 9173eb3..fa2918c 100644
--- a/src/components/truehire-proof-preview.tsx
+++ b/src/components/truehire-proof-preview.tsx
@@ -1,5 +1,7 @@
'use client';
+import { TrueHireProofPreviewView } from './truehire-proof-preview-view';
+
import type { FormEvent } from 'react';
import { useState } from 'react';
@@ -11,7 +13,6 @@ import {
type TrueHireProofItem,
type TrueHireProofProfile,
type TrueHireRoleFitPreview,
- type TrueHireRoleFitRequirement,
} from '@/lib/truehire-proof';
import { localCreateAchievementEvidence, localListAchievementEvidence } from '@/lib/local-storage';
@@ -31,7 +32,7 @@ interface RoleFitResponse {
role_fit?: TrueHireRoleFitPreview;
}
-export function TrueHireProofPreview() {
+export function useTrueHireProofPreviewSourceState() {
const { isGuest } = useAuth();
const [handle, setHandle] = useState('');
const [jobDescription, setJobDescription] = useState('');
@@ -149,333 +150,25 @@ export function TrueHireProofPreview() {
}
}
- return (
-
-
-
-
-
- TrueHire import preview
-
-
- Inspect proof before any merge.
-
-
- Paste a TrueHire handle to preview public score evidence and confirmed work history as
- RolePatch proof items. Previewing is read-only; importing saves private evidence for
- your review and still does not share anything.
-
-
-
-
- setHandle(event.target.value)}
- placeholder="@github-handle"
- aria-label="TrueHire handle"
- className="min-h-[44px] min-w-0 flex-1 rounded-lg border border-[var(--border)] bg-background px-3 text-sm text-foreground outline-none transition-colors placeholder:text-[var(--muted-foreground)] focus:border-[var(--accent)]"
- />
-
- {isLoading ? 'Previewing...' : 'Preview'}
-
-
-
-
- {error && (
-
- {error}
-
- )}
- {notice && (
-
- {notice} Review imported evidence before attaching it to application packets.
-
- )}
-
- {preview?.profile && (
-
-
-
-
-
-
- {preview.profile.overall_score ?? '-'}
-
-
- Score
-
-
-
-
-
-
-
-
-
Mapped proof candidates
-
- {preview.items && preview.items.length > 0 ? (
- preview.items.map((item) =>
)
- ) : (
-
- This profile does not expose packet-ready proof items yet.
-
- )}
-
-
-
- )}
-
-
-
-
-
- Role-fit JSON preview
-
-
- Consume TrueHire fit without sharing.
-
-
- Paste a job description to read TrueHire's public role-fit JSON for this
- handle. This report stays preview-only in RolePatch; it is not imported into
- evidence, attached to packets, or sent to employers.
-
-
-
-
- setJobDescription(event.target.value)}
- rows={5}
- minLength={40}
- placeholder="Paste the role description..."
- aria-label="Role-fit job description"
- className="min-h-32 w-full resize-y rounded-lg border border-[var(--border)] bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-[var(--muted-foreground)] focus:border-[var(--accent)]"
- />
-
-
- Uses the handle above. No application packet changes are made.
-
-
- {isRoleFitLoading ? 'Analyzing...' : 'Analyze role fit'}
-
-
-
-
-
- {roleFitError && (
-
- {roleFitError}
-
- )}
-
- {roleFit?.role_fit && (
-
-
-
-
- @{roleFit.role_fit.handle}
-
-
- TrueHire role-fit report
-
-
- {roleFit.boundary ??
- 'Role-fit preview is read-only. Nothing is shared automatically.'}
-
-
-
-
- {roleFit.role_fit.fit_score}
-
-
- Fit score
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
-
- );
-}
-
-function PreviewMetric({ label, value }: { label: string; value: number }) {
- return (
-
-
{value.toLocaleString()}
-
- {label}
-
-
- );
+ return {
+ loadPreview,
+ handle,
+ setHandle,
+ isLoading,
+ error,
+ notice,
+ preview,
+ importPreview,
+ isImporting,
+ loadRoleFit,
+ jobDescription,
+ setJobDescription,
+ isRoleFitLoading,
+ roleFitError,
+ roleFit,
+ };
}
-function RoleFitList({
- title,
- empty,
- items,
-}: {
- title: string;
- empty: string;
- items: TrueHireRoleFitRequirement[];
-}) {
- return (
-
-
{title}
-
- {items.length === 0 ? (
-
- {empty}
-
- ) : (
- items.map((item) =>
)
- )}
-
-
- );
-}
-
-function RoleFitItem({ item }: { item: TrueHireRoleFitRequirement }) {
- return (
-
-
-
- {item.score}/100
-
-
- {item.category}
-
-
- {item.label}
- {item.remediation}
- {item.strengths.length > 0 && (
-
- {item.strengths.map((strength) => (
-
-
{strength.repo_full_name}
-
- {[
- strength.primary_language,
- strength.commits > 0 ? `${strength.commits.toLocaleString()} commits` : '',
- strength.merged_prs > 0
- ? `${strength.merged_prs.toLocaleString()} merged PRs`
- : '',
- strength.stars > 0 ? `${strength.stars.toLocaleString()} stars` : '',
- ]
- .filter(Boolean)
- .join(' · ')}
-
-
- ))}
-
- )}
-
- );
-}
-
-function TrueHireItem({ item }: { item: TrueHireProofItem }) {
- return (
-
-
-
- {item.readiness}
-
-
- {item.source_label}
-
-
- {item.title}
- {item.claim}
-
-
- );
+export function TrueHireProofPreview() {
+ return ;
}
diff --git a/src/lib/agent-surfaces/adapters/static-assets.mjs b/src/lib/agent-surfaces/adapters/static-assets.mjs
new file mode 100644
index 0000000..93cd8c4
--- /dev/null
+++ b/src/lib/agent-surfaces/adapters/static-assets.mjs
@@ -0,0 +1,115 @@
+// Generated from sass-maker/saas-maker tooling/lib/agent-surfaces; use sync-agent-readability.mjs.
+import { createAgentSurfaceManifest } from '../manifest.mjs';
+import { createAgentSurfaceHandler, discoveryLinkHeader } from '../handler.mjs';
+import { wantsMarkdown } from '../http.mjs';
+
+// Workers Assets can omit MIME for extensionless JSON. Parsing and the
+// manifest/registry shape checks remain mandatory; HTML never qualifies.
+function metadataType(response) {
+ const type = response.headers.get('content-type');
+ return !type || /^(?:application\/(?:json|octet-stream)|text\/plain)(?:;|$)/i.test(type);
+}
+
+/**
+ * Serve only build-registered public Markdown assets, never the HTML fallback.
+ * @param {{ origin?: string, catalogPath?: string, identity?: object, preserveMarkdownPaths?: string[], publicReservedPaths?: string[], prebuiltFullPath?: string, loadMarkdown?: (path: string, request: Request, route: any) => Promise | string | null | undefined }} options
+ */
+export function createStaticAssetHandler({ origin, catalogPath = '/api/md/_catalog', identity, preserveMarkdownPaths = [], publicReservedPaths = [], prebuiltFullPath, loadMarkdown }) {
+ if (prebuiltFullPath && (!prebuiltFullPath.startsWith('/') || prebuiltFullPath.startsWith('//') || /[?#\\]/.test(prebuiltFullPath) || new URL(prebuiltFullPath, 'https://example.invalid').pathname !== prebuiltFullPath)) throw new TypeError('Invalid prebuilt full-export asset path');
+ const handlers = new WeakMap();
+ return async function handle(request, assets) {
+ if (!assets?.fetch) return null;
+ const pathname = new URL(request.url).pathname;
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method)) return null;
+ const direct = pathname === '/api/md' || pathname.startsWith('/api/md/');
+ const discovery = ['/llms.txt', '/llms-full.txt', '/ai.txt'].includes(pathname);
+ if (!direct && !discovery) {
+ if (preserveMarkdownPaths.includes(pathname) || ['/auth.md', '/skill.md', '/SKILL.md', '/agents.md', '/AGENTS.md'].includes(pathname)) return null;
+ if (/^\/(?:api|admin|auth|dashboard|_next|_astro|\.well-known)(?:\/|$)/.test(pathname) && !publicReservedPaths.includes(pathname)) return null;
+ // Public entity slugs can contain dots (for example /launchdesk/example.io).
+ if (/\.(?:css|m?js|map|json|xml|txt|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|mp[34]|webm|pdf|zip|wasm)$/i.test(pathname)) return null;
+ }
+ if (!pathname.endsWith('.md') && !pathname.startsWith('/api/md') && !['/llms.txt', '/llms-full.txt', '/ai.txt'].includes(pathname) && !wantsMarkdown(request)) return null;
+ let handler = handlers.get(assets);
+ if (!handler) {
+ handler = (async () => {
+ const assetOrigin = origin || new URL(request.url).origin;
+ let config = identity;
+ if (!config) {
+ const response = await assets.fetch(new Request(new URL('/api/md/_identity', assetOrigin)));
+ if (!response.ok || !metadataType(response)) throw new Error('Public identity unavailable');
+ config = await response.json();
+ }
+ const canonicalOrigin = config.url || origin;
+ return createAgentSurfaceHandler({
+ preserveMarkdownPaths,
+ publicReservedPaths,
+ manifest: createAgentSurfaceManifest({ ...config, url: canonicalOrigin, pages: {} }),
+ async listMarkdownRoutes() {
+ const response = await assets.fetch(new Request(new URL(catalogPath, assetOrigin)));
+ if (!response.ok || !metadataType(response)) throw new Error('Public Markdown catalog unavailable');
+ const catalog = await response.json();
+ if (!Array.isArray(catalog)) throw new TypeError('Invalid public Markdown catalog');
+ return catalog;
+ },
+ ...(prebuiltFullPath ? { async loadFullMarkdown() {
+ const response = await assets.fetch(new Request(new URL(prebuiltFullPath, assetOrigin)));
+ if (!response.ok || !/^text\/(?:plain|markdown)(?:;|$)/i.test(response.headers.get('content-type') || '')) throw new Error('Prebuilt public export unavailable');
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error('Empty prebuilt public export');
+ const decoder = new TextDecoder('utf-8', { fatal: true });
+ let body = '';
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ body += done ? decoder.decode() : decoder.decode(value, { stream: true });
+ if (body.length > 500000) throw new Error('Prebuilt public export exceeds 500000 characters');
+ if (done) break;
+ }
+ } catch (error) { await reader.cancel().catch(() => {}); throw error; }
+ if (!/^#\s+\S/.test(body) || /^\s*<(?:!doctype|html)/i.test(body)) throw new Error('Invalid prebuilt public Markdown export');
+ return body;
+ } } : {}),
+ async loadMarkdown(path, publicRequest, route) {
+ const rendered = await loadMarkdown?.(path, publicRequest, route);
+ if (rendered !== undefined) {
+ if (rendered !== null && typeof rendered !== 'string') throw new TypeError('Public renderer must return Markdown text or null');
+ return rendered;
+ }
+ const md = route?.assetPath || (path === '/' ? '/index.md' : `${path.replace(/\/$/, '')}.md`);
+ if (!md.startsWith('/') || md.startsWith('//') || new URL(md, assetOrigin).origin !== new URL(assetOrigin).origin) throw new Error('Invalid public asset path');
+ const response = await assets.fetch(new Request(new URL(md, assetOrigin)));
+ if (response.status === 404) return null;
+ if (!response.ok || !response.headers.get('content-type')?.includes('text/markdown')) throw new Error('Public Markdown asset unavailable');
+ const body = await response.text();
+ if (/^\s*<(?:!doctype|html)/i.test(body)) throw new Error('HTML fallback is not Markdown');
+ return body;
+ },
+ });
+ })().catch((error) => { handlers.delete(assets); throw error; });
+ handlers.set(assets, handler);
+ }
+ try {
+ return await (await handler)(request);
+ } catch {
+ return new Response(request.method === 'HEAD' ? null : '# Markdown temporarily unavailable\n', {
+ status: 503, headers: {
+ 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'no-store',
+ 'CDN-Cache-Control': 'no-store', 'Cloudflare-CDN-Cache-Control': 'no-store',
+ 'Vary': 'Accept', 'X-Robots-Tag': 'noindex, follow',
+ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+ },
+ });
+ }
+ };
+}
+
+export function advertiseMarkdown(response, origin, mdPath) {
+ const headers = new Headers(response.headers);
+ headers.append('Link', discoveryLinkHeader(origin));
+ if (mdPath) headers.append('Link', `<${new URL(mdPath, origin).href}>; rel="alternate"; type="text/markdown"`);
+ const vary = new Set((headers.get('Vary') || '').split(',').map((value) => value.trim()).filter(Boolean));
+ if (!vary.has('*') && ![...vary].some((value) => value.toLowerCase() === 'accept')) vary.add('Accept');
+ headers.set('Vary', [...vary].join(', '));
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
+}
diff --git a/src/lib/agent-surfaces/catalog.mjs b/src/lib/agent-surfaces/catalog.mjs
new file mode 100644
index 0000000..6692adc
--- /dev/null
+++ b/src/lib/agent-surfaces/catalog.mjs
@@ -0,0 +1,97 @@
+// Generated from sass-maker/saas-maker tooling/lib/agent-surfaces; use sync-agent-readability.mjs.
+/**
+ * @typedef {{
+ * id: string,
+ * url: string,
+ * md?: string | null,
+ * kind: 'static' | 'collection' | 'dynamic' | 'api' | 'feed' | 'spa',
+ * description?: string,
+ * }} AgentSurface
+ *
+ * @typedef {{
+ * name: string,
+ * version?: string,
+ * url: string,
+ * llms?: string,
+ * llmsFull?: string | null,
+ * sitemap?: string | null,
+ * markdown?: { suffix?: string, negotiation?: boolean },
+ * surfaces: AgentSurface[],
+ * auth?: { public?: boolean, notes?: string },
+ * }} ApiAiCatalog
+ */
+
+function mapSurface(origin, s) {
+ return {
+ id: s.id,
+ url: absolutize(origin, s.url),
+ md: s.md == null ? null : absolutize(origin, s.md),
+ kind: s.kind,
+ ...(s.description ? { description: s.description } : {}),
+ };
+}
+
+/**
+ * @param {ApiAiCatalog} input
+ * @returns {ApiAiCatalog}
+ */
+export function buildApiAiCatalog(input) {
+ if (!input?.name) throw new TypeError('buildApiAiCatalog: name required');
+ if (!input?.url) throw new TypeError('buildApiAiCatalog: url required');
+ if (!Array.isArray(input.surfaces)) {
+ throw new TypeError('buildApiAiCatalog: surfaces[] required');
+ }
+
+ const origin = input.url.replace(/\/$/, '');
+ const catalog = {
+ name: input.name,
+ version: input.version || '1',
+ url: origin,
+ llms: input.llms || `${origin}/llms.txt`,
+ llmsFull: input.llmsFull === undefined ? null : input.llmsFull,
+ sitemap: input.sitemap === undefined ? `${origin}/sitemap.xml` : input.sitemap,
+ markdown: {
+ suffix: input.markdown?.suffix ?? '.md',
+ negotiation: input.markdown?.negotiation ?? true,
+ },
+ surfaces: input.surfaces.map((s) => mapSurface(origin, s)),
+ auth: {
+ public: input.auth?.public ?? true,
+ notes: input.auth?.notes ?? '',
+ },
+ };
+
+ assertApiAiCatalog(catalog);
+ return catalog;
+}
+
+/**
+ * Lightweight runtime validation for audits and tests.
+ * @param {unknown} catalog
+ */
+function assertApiAiCatalog(catalog) {
+ if (!catalog || typeof catalog !== 'object') {
+ throw new TypeError('catalog must be an object');
+ }
+ const c = /** @type {Record} */ (catalog);
+ for (const key of ['name', 'version', 'url', 'llms', 'markdown', 'surfaces']) {
+ if (c[key] == null) throw new TypeError(`catalog missing ${key}`);
+ }
+ if (!Array.isArray(c.surfaces)) throw new TypeError('surfaces must be an array');
+ const md = /** @type {Record} */ (c.markdown);
+ if (typeof md.suffix !== 'string' || typeof md.negotiation !== 'boolean') {
+ throw new TypeError('markdown.suffix and markdown.negotiation required');
+ }
+ return true;
+}
+
+/**
+ * @param {string} origin
+ * @param {string} pathOrUrl
+ */
+function absolutize(origin, pathOrUrl) {
+ if (!pathOrUrl) return pathOrUrl;
+ if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) return pathOrUrl;
+ const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
+ return `${origin}${path}`;
+}
diff --git a/src/lib/agent-surfaces/documents.mjs b/src/lib/agent-surfaces/documents.mjs
new file mode 100644
index 0000000..fb78b89
--- /dev/null
+++ b/src/lib/agent-surfaces/documents.mjs
@@ -0,0 +1,83 @@
+// Generated from sass-maker/saas-maker tooling/lib/agent-surfaces; use sync-agent-readability.mjs.
+import { htmlPathFromMarkdown, markdownPathFor } from './http.mjs';
+
+/** Registry entries are explicitly public. Never populate them from user state. */
+export function listMarkdownRoutes(manifest) {
+ const paths = new Map();
+ for (const [key, body] of Object.entries(manifest.pages)) {
+ const path = key.endsWith('.md') ? htmlPathFromMarkdown(key) : key;
+ if (paths.has(path)) continue;
+ const surface = manifest.catalog.surfaces.find((item) => new URL(item.url).pathname === path);
+ paths.set(path, {
+ path,
+ url: new URL(path, manifest.origin).href,
+ md: new URL(markdownPathFor(path), manifest.origin).href,
+ title: body.match(/^#\s+(.+)$/m)?.[1] || surface?.id || path,
+ description: surface?.description || body.match(/^>\s+(.+)$/m)?.[1] || '',
+ });
+ }
+ return [...paths.values()];
+}
+
+export function buildMarkdownIndex(manifest, routes) {
+ const lines = [`# ${manifest.name}`, '', `> ${manifest.summary}`, '',
+ ...(manifest.notes || []), '',
+ '', '', '## Public pages', ''];
+ for (const route of routes.slice(0, 50)) {
+ lines.push(`- [${escapeLabel(route.title)}](${route.md}): ${route.description || route.title}`);
+ }
+ if (routes.length > 50) lines.push('', `Showing 50 of ${routes.length} pages; the catalog lists all public routes.`);
+ lines.push('', '## Machine-readable', '',
+ `- [Full content](${manifest.origin}/llms-full.txt): Bounded public page content`,
+ `- [Markdown catalog](${manifest.origin}/api/md/_catalog): All registered public routes`,
+ `- [Agent catalog](${manifest.origin}/api/ai): Existing discovery contract`,
+ `- [AI usage policy](${manifest.origin}/ai.txt): Permissions and attribution`);
+ if (manifest.catalog.sitemap) lines.push(`- [Sitemap](${manifest.catalog.sitemap}): Canonical HTML pages`);
+ if (manifest.openapi) lines.push(`- [OpenAPI](${manifest.openapi}): Public API contract`);
+ return `${lines.join('\n')}\n`;
+}
+
+export function buildAiTxt(manifest, routes) {
+ const contact = manifest.contact;
+ return [
+ '# Keep in sync with robots.txt and llms.txt.',
+ '[identity]', `name: ${manifest.name}`, `url: ${manifest.origin}`, `description: ${manifest.summary}`,
+ ...(contact ? [`contact: ${contact}`] : []), '',
+ '[permissions]', 'allowed: summarise, quote with attribution, cite, index for search/answers',
+ ...(manifest.trainingAllowed ? ['training: allowed on public pages only'] : []), '',
+ '[restrictions]', 'No fabricated quotes or reviews/ratings.',
+ 'Do not present this content as your own.', 'No training on private user data.', '',
+ '[attribution]', `cite: ${manifest.name} and the canonical HTML URL`, '',
+ '[scope]', `included: public routes listed at ${manifest.origin}/api/md/_catalog`,
+ `public-route-count: ${routes.length}`,
+ `excluded: ${(manifest.excludedPaths || ['/admin', '/api', '/dashboard', '/auth']).join(', ')}`,
+ 'public-machine-endpoints: /api/ai, /api/md/*', '',
+ ...(contact ? ['[contact]', `contact: ${contact}`, ''] : []),
+ ...(manifest.updatedAt ? [`Last updated: ${manifest.updatedAt}`, ''] : []),
+ ].join('\n');
+}
+
+export async function buildFullMarkdown(manifest, routes, render, { maxChars = 500000, maxPages = 50 } = {}) {
+ const header = `# ${manifest.name} - full content\n\n> ${manifest.summary}\n\nGenerated: ${new Date().toISOString().slice(0, 10)}\n\n`;
+ const footer = `\nFull catalog: ${manifest.origin}/api/md/_catalog\n`;
+ let body = header;
+ let included = 0;
+ for (const route of routes) {
+ if (route.full === false) continue;
+ if (included >= maxPages) break;
+ const markdown = await render(route.path);
+ if (markdown == null) continue;
+ const section = `---\n\n${markdown}\n\n`;
+ if (body.length + section.length + footer.length + 150 > maxChars) {
+ console.warn(`Full Markdown export reached its ${maxChars}-character limit after ${included} pages.`);
+ break;
+ }
+ body += section;
+ included++;
+ }
+ return `${body}Included ${included} of ${routes.length} registered public pages (limit ${maxPages} pages / ${maxChars} characters).\n${footer}`;
+}
+
+function escapeLabel(value) {
+ return value.replace(/[\\[\]]/g, '\\$&').replace(/[\r\n]/g, ' ');
+}
diff --git a/src/lib/agent-surfaces/format.mjs b/src/lib/agent-surfaces/format.mjs
new file mode 100644
index 0000000..69ba7cf
--- /dev/null
+++ b/src/lib/agent-surfaces/format.mjs
@@ -0,0 +1,34 @@
+// Generated from sass-maker/saas-maker tooling/lib/agent-surfaces; use sync-agent-readability.mjs.
+/**
+ * Absolutize authored links without changing code examples or real .md routes.
+ * @param {string} markdown
+ * @param {string} canonical
+ * @param {{htmlPathsByMarkdown?: Record, resolveLink?: (href: string) => string | undefined}} options
+ */
+export function absoluteMarkdownLinks(markdown, canonical, { htmlPathsByMarkdown = {}, resolveLink } = {}) {
+ const code = [];
+ const protect = (text) => {
+ const token = `\u0000markdown-code-${code.length}\u0000`;
+ const newline = text.endsWith('\n') ? '\n' : '';
+ code.push(newline ? text.slice(0, -1) : text);
+ return token + newline;
+ };
+ const source = markdown
+ .replace(/^( {0,3})(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1\2[^\n]*(?:\n|$)/gm, protect)
+ .replace(/(`+)([^`]|(?!\1)`)*?\1/g, protect);
+ function destination(raw) {
+ const angle = raw.startsWith('<') && raw.endsWith('>');
+ const href = angle ? raw.slice(1, -1) : raw;
+ const resolved = resolveLink?.(href);
+ if (resolved !== undefined) return angle ? `<${resolved}>` : resolved;
+ const url = new URL(href, canonical);
+ if (url.origin === new URL(canonical).origin && Object.hasOwn(htmlPathsByMarkdown, url.pathname)) {
+ url.pathname = htmlPathsByMarkdown[url.pathname];
+ } else if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return raw;
+ return angle ? `<${url.href}>` : url.href;
+ }
+ return source
+ .replace(/(!?\[[^\]\n]*\]\()(\s*)(<[^>\n]+>|[^\s)]+)(?=[\s)])/g, (_match, lead, space, href) => `${lead}${space}${destination(href)}`)
+ .replace(/^( {0,3}\[[^\]\n]+\]:\s*)(<[^>\n]+>|\S+)/gm, (_match, lead, href) => `${lead}${destination(href)}`)
+ .replace(/\u0000markdown-code-(\d+)\u0000/g, (_match, index) => code[Number(index)]);
+}
diff --git a/src/lib/agent-surfaces/handler.mjs b/src/lib/agent-surfaces/handler.mjs
new file mode 100644
index 0000000..3916f4b
--- /dev/null
+++ b/src/lib/agent-surfaces/handler.mjs
@@ -0,0 +1,110 @@
+// Generated from sass-maker/saas-maker tooling/lib/agent-surfaces; use sync-agent-readability.mjs.
+import { wantsMarkdown, markdownPathFor, htmlPathFromMarkdown, markdownResponse } from './http.mjs';
+import { listMarkdownRoutes, buildMarkdownIndex, buildAiTxt, buildFullMarkdown } from './documents.mjs';
+
+const reserved = /^\/(?:api|admin|auth|dashboard|_next|_astro|\.well-known)(?:\/|$)/;
+const nativeMarkdown = new Set(['/auth.md', '/skill.md', '/SKILL.md', '/agents.md', '/AGENTS.md']);
+const documents = new Set(['/llms.txt', '/llms-full.txt', '/ai.txt', '/api/ai', '/api/md/_catalog']);
+
+/** A loader is reachable only through the explicitly enumerated public registry. */
+export function createAgentSurfaceHandler(options) {
+ const { manifest, loadMarkdown } = options;
+ if (!manifest) throw new TypeError('createAgentSurfaceHandler: manifest required');
+ // Some public documentation sites use names such as /dashboard. Exceptions
+ // are exact, source-reviewed HTML routes, never API or infrastructure paths.
+ const publicReservedPaths = new Set(options.publicReservedPaths || []);
+ for (const path of publicReservedPaths) {
+ if (!/^\/(?:admin|auth|dashboard)(?:\/[^*?#\\]*)?$/.test(path)) throw new TypeError(`Invalid public route exception: ${path}`);
+ }
+ let snapshot;
+ let expires = 0;
+ let full;
+
+ async function registry() {
+ if (!snapshot || Date.now() >= expires) {
+ full = undefined;
+ expires = Date.now() + 86400000;
+ snapshot = Promise.resolve(options.listMarkdownRoutes?.() ?? listMarkdownRoutes(manifest)).then((routes) => {
+ const seen = new Set();
+ return routes.map((route) => {
+ const path = route.path;
+ if (!path?.startsWith('/') || path.startsWith('//') || (reserved.test(path) && !publicReservedPaths.has(path)) || /[?#\\]/.test(path) || new URL(path, manifest.origin).pathname !== path) {
+ throw new TypeError(`Invalid public Markdown route: ${path}`);
+ }
+ if (seen.has(path)) throw new TypeError(`Duplicate Markdown route: ${path}`);
+ seen.add(path);
+ return { ...route, url: new URL(path, manifest.origin).href, md: new URL(markdownPathFor(path), manifest.origin).href };
+ });
+ }).catch((error) => { snapshot = undefined; throw error; });
+ }
+ return snapshot;
+ }
+
+ async function render(path) {
+ const pages = manifest.pages;
+ if (Object.hasOwn(pages, path)) return pages[path];
+ const mdPath = markdownPathFor(path);
+ if (Object.hasOwn(pages, mdPath)) return pages[mdPath];
+ // Public, cacheable renderers never inherit cookies, authorization or query state.
+ return loadMarkdown ? loadMarkdown(path, new Request(new URL(path, manifest.origin)), (await registry()).find((route) => route.path === path)) : null;
+ }
+
+ return async function handleAgentSurface(request) {
+ const path = new URL(request.url).pathname;
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method)) return null;
+ if (nativeMarkdown.has(path) || options.preserveMarkdownPaths?.includes(path)) return null;
+ const direct = path === '/api/md' || path.startsWith('/api/md/');
+ const suffix = path.endsWith('.md') && !reserved.test(path);
+ if (!direct && !suffix && !documents.has(path) && (!manifest.negotiation || !wantsMarkdown(request))) return null;
+ const routes = await registry();
+ const htmlPath = direct ? '/' + path.replace(/^\/api\/md\/?/, '') : suffix ? htmlPathFromMarkdown(path) : path;
+ const route = routes.find((entry) => entry.path.replace(/\/$/, '') === htmlPath.replace(/\/$/, '') || entry.aliases?.includes(path));
+ if (!documents.has(path) && !direct && !suffix && !route) return null;
+ if (request.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: {
+ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Accept', 'Access-Control-Max-Age': '86400',
+ } });
+ }
+ let response;
+ if (path === '/llms.txt') response = text(buildMarkdownIndex(manifest, routes));
+ else if (path === '/ai.txt') response = text(buildAiTxt(manifest, routes));
+ else if (path === '/api/ai') response = text(JSON.stringify(manifest.catalog), 'application/json; charset=utf-8');
+ else if (path === '/api/md/_catalog') response = text(JSON.stringify(routes), 'application/json; charset=utf-8');
+ else if (path === '/llms-full.txt') {
+ full ??= Promise.resolve().then(() => options.loadFullMarkdown ? options.loadFullMarkdown() : buildFullMarkdown(manifest, routes, render, options.fullLimits)).catch((error) => { full = undefined; throw error; });
+ response = text(await full);
+ } else {
+ const body = route ? await render(route.path) : null;
+ const fallback = new URL(manifest.origin);
+ fallback.pathname = htmlPath;
+ const canonical = route?.url || fallback.href;
+ response = markdownResponse(body ?? '# Not found\n\nNo public Markdown page exists at this path.\n', request, {
+ 'Content-Location': canonical,
+ Link: `<${canonical}>; rel="canonical"`,
+ ...(body == null ? { 'Cache-Control': 'no-store' } : {}),
+ ...(route?.lastModified ? { 'Last-Modified': new Date(route.lastModified).toUTCString() } : {}),
+ });
+ if (body == null) response = new Response(response.body, { status: 404, headers: response.headers });
+ }
+ response.headers.append('Link', discoveryLinkHeader(manifest.origin));
+ if (!suffix && !direct && !documents.has(path)) {
+ // Cloudflare's URL cache does not partition on Vary: Accept.
+ response.headers.set('CDN-Cache-Control', 'no-store');
+ response.headers.set('Cloudflare-CDN-Cache-Control', 'no-store');
+ }
+ return request.method === 'HEAD' ? new Response(null, { status: response.status, headers: response.headers }) : response;
+ };
+}
+
+export function discoveryLinkHeader(origin) {
+ return ['/llms.txt', '/llms-full.txt', '/ai.txt'].map((path) => `<${new URL(path, origin).href}>; rel="describedby"`).join(', ');
+}
+
+function text(body, type = 'text/plain; charset=utf-8') {
+ return new Response(body, { headers: {
+ 'Content-Type': type, 'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
+ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+ 'X-Content-Type-Options': 'nosniff',
+ } });
+}
diff --git a/src/lib/agent-surfaces/http.mjs b/src/lib/agent-surfaces/http.mjs
new file mode 100644
index 0000000..df21779
--- /dev/null
+++ b/src/lib/agent-surfaces/http.mjs
@@ -0,0 +1,187 @@
+// Generated from sass-maker/saas-maker tooling/lib/agent-surfaces; use sync-agent-readability.mjs.
+/** Paths that must never fall through to an SPA HTML shell. */
+const AGENT_PATHS = new Set([
+ '/llms.txt',
+ '/llms-full.txt',
+ '/ai.txt',
+ '/api/ai',
+ '/skill.md',
+ '/agents.md',
+ '/humans.txt',
+ '/.well-known/skills/index.json',
+ '/.well-known/agent.json',
+ '/.well-known/mcp.json',
+]);
+
+/**
+ * @param {string} pathname
+ * @returns {boolean}
+ */
+export function isAgentPath(pathname) {
+ if (!pathname) return false;
+ const path = normalizePath(pathname);
+ if (AGENT_PATHS.has(path)) return true;
+ if (path.endsWith('.md')) return true;
+ if (path.startsWith('/.well-known/skills/')) return true;
+ return false;
+}
+
+/**
+ * @param {string} htmlPath e.g. "/" or "/pricing"
+ * @returns {string} e.g. "/index.md" or "/pricing.md"
+ */
+export function markdownPathFor(htmlPath) {
+ const path = normalizePath(htmlPath);
+ if (path === '/') return '/index.md';
+ if (path.endsWith('.md')) return path;
+ return `${path.replace(/\/$/, '')}.md`;
+}
+
+/**
+ * @param {string} mdPath e.g. "/index.md" or "/pricing.md"
+ * @returns {string}
+ */
+export function htmlPathFromMarkdown(mdPath) {
+ const path = normalizePath(mdPath);
+ if (path === '/index.md') return '/';
+ if (path.endsWith('.md')) return path.slice(0, -3);
+ return path;
+}
+
+/**
+ * Prefer markdown when Accept ranks text/markdown over text/html
+ * (or when only markdown/plain is offered).
+ *
+ * @param {Request | { headers?: Headers | Record }} request
+ * @returns {boolean}
+ */
+export function wantsMarkdown(request) {
+ const headers = request?.headers;
+ const accept =
+ typeof headers?.get === 'function'
+ ? headers.get('accept') || ''
+ : headers?.accept || headers?.Accept || '';
+ if (!accept) return false;
+ const ranges = accept.toLowerCase().split(',').map((part) => {
+ const [type, ...parameters] = part.trim().split(';');
+ const weights = parameters.map((value) => value.trim()).filter((value) => /^q\s*=/.test(value));
+ const raw = weights[0]?.split('=')[1]?.trim();
+ const q = weights.length > 1 || (raw !== undefined && !/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(raw))
+ ? 0 : raw === undefined ? 1 : Number(raw);
+ return { type: type.trim(), q };
+ });
+ // Wildcards never opt into Markdown. Specific media ranges override them.
+ const markdown = ranges.filter(({ type }) => type === 'text/markdown');
+ if (!markdown.length) return false;
+ const quality = (types) => {
+ for (const type of types) {
+ const matches = ranges.filter((range) => range.type === type);
+ if (matches.length) return Math.min(...matches.map((range) => range.q));
+ }
+ return 0;
+ };
+ return quality(['text/markdown']) > quality(['text/html', 'text/*', '*/*']);
+}
+
+/**
+ * Detect SPA / HTML shell responses that fake agent endpoints.
+ * @param {string | ArrayBuffer | Uint8Array} body
+ * @param {string} [contentType]
+ */
+export function isHtmlShell(body, contentType = '') {
+ const ct = (contentType || '').toLowerCase();
+ if (ct.includes('text/html') || ct.includes('application/xhtml')) return true;
+ const text = bodyToPreview(body);
+ if (!text) return false;
+ const head = text.slice(0, 200).toLowerCase();
+ return (
+ head.includes('} [extraHeaders]
+ */
+export function textResponse(body, contentType = 'text/plain; charset=utf-8', extraHeaders = {}) {
+ return new Response(body, {
+ status: 200,
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=300',
+ ...extraHeaders,
+ },
+ });
+}
+
+/**
+ * @param {unknown} data
+ * @param {Record} [extraHeaders]
+ */
+export function jsonResponse(data, extraHeaders = {}) {
+ return new Response(JSON.stringify(data, null, 2) + '\n', {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'public, max-age=300',
+ ...extraHeaders,
+ },
+ });
+}
+
+/**
+ * @param {string} body
+ * @param {Request | { headers?: Headers }} [request] for Accept negotiation
+ * @param {Record} [extraHeaders]
+ */
+export function markdownResponse(body, request, extraHeaders = {}) {
+ return new Response(request?.method === 'HEAD' ? null : body, {
+ headers: {
+ 'Content-Type': 'text/markdown; charset=utf-8',
+ 'X-Markdown-Tokens': String(Math.ceil(body.length / 4)),
+ 'X-Robots-Tag': 'noindex, follow',
+ 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+ 'X-Content-Type-Options': 'nosniff',
+ Vary: 'Accept',
+ ...extraHeaders,
+ },
+ });
+}
+
+/**
+ * @param {string} mdPath absolute path starting with /
+ */
+export function alternateLinkHeader(mdPath) {
+ const path = normalizePath(mdPath);
+ return `<${path}>; rel="alternate"; type="text/markdown"`;
+}
+
+function normalizePath(pathname) {
+ if (!pathname) return '/';
+ try {
+ if (pathname.startsWith('http://') || pathname.startsWith('https://')) {
+ pathname = new URL(pathname).pathname;
+ }
+ } catch {
+ /* keep raw */
+ }
+ let path = pathname.split('?')[0].split('#')[0];
+ if (!path.startsWith('/')) path = `/${path}`;
+ if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
+ return path || '/';
+}
+
+function bodyToPreview(body) {
+ if (body == null) return '';
+ if (typeof body === 'string') return body;
+ if (body instanceof Uint8Array) {
+ return new TextDecoder().decode(body.slice(0, 256));
+ }
+ return '';
+}
diff --git a/src/lib/agent-surfaces/llms.mjs b/src/lib/agent-surfaces/llms.mjs
new file mode 100644
index 0000000..ec53774
--- /dev/null
+++ b/src/lib/agent-surfaces/llms.mjs
@@ -0,0 +1,64 @@
+// Generated from sass-maker/saas-maker tooling/lib/agent-surfaces; use sync-agent-readability.mjs.
+/**
+ * Build llmstxt.org-style index documents.
+ *
+ * @typedef {{ title: string, url: string, description?: string }} LlmsLink
+ * @typedef {{
+ * name: string,
+ * summary: string,
+ * url?: string,
+ * product?: LlmsLink[],
+ * docs?: LlmsLink[],
+ * feeds?: LlmsLink[],
+ * optional?: LlmsLink[],
+ * notes?: string[],
+ * }} LlmsMeta
+ */
+
+/**
+ * @param {LlmsMeta} meta
+ * @returns {string}
+ */
+export function buildLlmsTxt(meta) {
+ if (!meta?.name) throw new TypeError('buildLlmsTxt: meta.name required');
+ if (!meta?.summary) throw new TypeError('buildLlmsTxt: meta.summary required');
+
+ const lines = [`# ${meta.name}`, '', `> ${meta.summary.trim()}`, ''];
+
+ if (meta.notes?.length) {
+ for (const note of meta.notes) lines.push(note);
+ lines.push('');
+ }
+
+ appendSection(lines, 'Product', meta.product);
+ appendSection(lines, 'Docs', meta.docs);
+ appendSection(lines, 'Feeds', meta.feeds);
+ appendSection(lines, 'Optional', meta.optional);
+
+ // Always point agents at the machine catalog when present.
+ if (meta.url) {
+ const origin = meta.url.replace(/\/$/, '');
+ lines.push('## Machine surfaces');
+ lines.push('');
+ lines.push(`- [Agent catalog](${origin}/api/ai): JSON inventory of public surfaces`);
+ lines.push(`- [This index](${origin}/llms.txt)`);
+ lines.push('');
+ }
+
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n');
+}
+
+/**
+ * @param {string[]} lines
+ * @param {string} heading
+ * @param {LlmsLink[] | undefined} links
+ */
+function appendSection(lines, heading, links) {
+ if (!links?.length) return;
+ lines.push(`## ${heading}`, '');
+ for (const link of links) {
+ const desc = link.description ? `: ${link.description}` : '';
+ lines.push(`- [${link.title}](${link.url})${desc}`);
+ }
+ lines.push('');
+}
diff --git a/src/lib/agent-surfaces/manifest.mjs b/src/lib/agent-surfaces/manifest.mjs
new file mode 100644
index 0000000..4d8e412
--- /dev/null
+++ b/src/lib/agent-surfaces/manifest.mjs
@@ -0,0 +1,134 @@
+// Generated from sass-maker/saas-maker tooling/lib/agent-surfaces; use sync-agent-readability.mjs.
+import { buildLlmsTxt } from './llms.mjs';
+import { listMarkdownRoutes, buildMarkdownIndex } from './documents.mjs';
+import { buildApiAiCatalog } from './catalog.mjs';
+import { markdownPathFor } from './http.mjs';
+
+/**
+ * Single source of truth for a product's agent surfaces.
+ *
+ * @typedef {import('./llms.mjs').LlmsMeta} LlmsMeta
+ * @typedef {import('./catalog.mjs').ApiAiCatalog} ApiAiCatalog
+ * @typedef {import('./catalog.mjs').AgentSurface} AgentSurface
+ *
+ * @typedef {{
+ * name: string,
+ * url: string,
+ * summary: string,
+ * contact?: string,
+ * openapi?: string,
+ * updatedAt?: string,
+ * trainingAllowed?: boolean,
+ * excludedPaths?: string[],
+ * mode?: 'static' | 'collection' | 'dynamic' | 'spa',
+ * product?: import('./llms.mjs').LlmsLink[],
+ * docs?: import('./llms.mjs').LlmsLink[],
+ * feeds?: import('./llms.mjs').LlmsLink[],
+ * optional?: import('./llms.mjs').LlmsLink[],
+ * notes?: string[],
+ * surfaces?: AgentSurface[],
+ * pages?: Record,
+ * llmsFull?: string | null,
+ * sitemap?: string | null,
+ * auth?: { public?: boolean, notes?: string },
+ * negotiation?: boolean,
+ * }} AgentSurfaceConfig
+ *
+ * pages: map of pathname → markdown body (for static/runtime serve)
+ */
+
+/**
+ * @param {AgentSurfaceConfig} config
+ */
+export function createAgentSurfaceManifest(config) {
+ if (!config?.name || !config?.url || !config?.summary) {
+ throw new TypeError('createAgentSurfaceManifest requires name, url, summary');
+ }
+
+ const origin = config.url.replace(/\/$/, '');
+ const pages = { ...(config.pages || {}) };
+
+ // Ensure homepage has an index.md key if pages has "/" content.
+ if (pages['/'] && !pages['/index.md']) {
+ pages['/index.md'] = pages['/'];
+ }
+
+ const surfaces =
+ config.surfaces ||
+ deriveSurfacesFromPages(pages, config.mode || 'static');
+
+ const catalog = buildApiAiCatalog({
+ name: config.name,
+ url: origin,
+ llmsFull: `${origin}/llms-full.txt`,
+ sitemap: config.sitemap,
+ markdown: { suffix: '.md', negotiation: config.negotiation ?? true },
+ surfaces,
+ auth: config.auth,
+ });
+
+ const llmsTxt = buildLlmsTxt({
+ name: config.name,
+ summary: config.summary,
+ url: origin,
+ product: config.product,
+ docs: config.docs,
+ feeds: config.feeds,
+ optional: config.optional,
+ notes: config.notes,
+ });
+
+ const manifest = {
+ origin,
+ name: config.name,
+ summary: config.summary,
+ contact: config.contact,
+ openapi: config.openapi,
+ notes: config.notes,
+ updatedAt: config.updatedAt,
+ trainingAllowed: config.trainingAllowed ?? false,
+ excludedPaths: config.excludedPaths,
+ mode: config.mode || 'static',
+ llmsTxt,
+ llmsFull: config.llmsFull ?? null,
+ catalog,
+ pages,
+ negotiation: config.negotiation ?? true,
+ };
+ if (Object.keys(pages).length) manifest.llmsTxt = buildMarkdownIndex(manifest, listMarkdownRoutes(manifest));
+ return manifest;
+}
+
+/**
+ * @param {Record} pages
+ * @param {string} kind
+ * @returns {import('./catalog.mjs').AgentSurface[]}
+ */
+function deriveSurfacesFromPages(pages, kind) {
+ const surfaces = [];
+ const seen = new Set();
+
+ for (const key of Object.keys(pages)) {
+ let htmlPath = key;
+ if (key.endsWith('.md')) {
+ htmlPath = key === '/index.md' ? '/' : key.slice(0, -3);
+ }
+ if (seen.has(htmlPath)) continue;
+ seen.add(htmlPath);
+ const id =
+ htmlPath === '/'
+ ? 'home'
+ : htmlPath.replace(/^\//, '').replace(/\//g, '-') || 'page';
+ surfaces.push({
+ id,
+ url: htmlPath,
+ md: markdownPathFor(htmlPath),
+ kind: /** @type {any} */ (kind === 'spa' ? 'spa' : kind === 'collection' ? 'collection' : kind === 'dynamic' ? 'dynamic' : 'static'),
+ });
+ }
+
+ if (surfaces.length === 0) {
+ surfaces.push({ id: 'home', url: '/', md: '/index.md', kind: 'static' });
+ }
+ return surfaces;
+}
diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts
index 4f50c5c..8070ccb 100644
--- a/src/lib/pdf.ts
+++ b/src/lib/pdf.ts
@@ -61,10 +61,12 @@ export async function renderPdf(html: string): Promise {
const chromium = chromiumMod.default ?? chromiumMod;
const puppeteer = puppeteerMod.default ?? puppeteerMod;
- const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || (await chromium.executablePath());
+ const customExecutablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
+ const executablePath = customExecutablePath || (await chromium.executablePath());
const browser = await puppeteer.launch({
- args: chromium.args,
+ // Sparticuz flags target its bundled Linux binary, not a caller-supplied browser.
+ args: customExecutablePath ? [] : chromium.args,
executablePath,
headless: true,
});
diff --git a/src/lib/public-editorial-source.ts b/src/lib/public-editorial-source.ts
new file mode 100644
index 0000000..5e56cb5
--- /dev/null
+++ b/src/lib/public-editorial-source.ts
@@ -0,0 +1,67 @@
+import { createElement } from 'react';
+import home from '../../landing-astro/src/content/home.json';
+import { PRODUCT_FAQS } from '../../landing-astro/src/lib/product';
+import { PublicFaq } from '@/components/public-faq';
+import { PublicDocs } from '@/components/public-docs';
+import { PublicChangelog } from '@/components/public-changelog';
+import { reactSourceMarkdown } from './react-source-markdown';
+
+type Action = { label: string; href: string };
+const action = (value?: Action) => (value ? `[${value.label}](${value.href})` : '');
+function homeMarkdown() {
+ const hero = home.hero;
+ const lines = [
+ `# ${hero.title}`,
+ hero.eyebrow,
+ hero.lede,
+ action(hero.primary),
+ action(hero.secondary),
+ hero.note,
+ ``,
+ hero.caption,
+ ];
+ for (const section of home.sections) {
+ if ('header' in section && section.header)
+ lines.push(section.header.eyebrow, `## ${section.header.title}`, section.header.lede);
+ for (const block of section.blocks) {
+ if (block.type === 'cta' && 'primary' in block) {
+ lines.push(
+ `## ${block.title}`,
+ 'lede' in block ? (block.lede ?? '') : '',
+ action(block.primary),
+ block.note ?? ''
+ );
+ } else if (block.type === 'statement' && 'points' in block) {
+ lines.push(block.eyebrow ?? '', `## ${block.title}`, block.lede ?? '');
+ for (const point of block.points ?? []) lines.push(`### ${point.title}`, point.body);
+ } else if (block.type === 'steps' && 'items' in block) {
+ for (const [index, step] of (block.items ?? []).entries())
+ lines.push(
+ `### ${index + 1}. ${step.title}`,
+ step.body,
+ 'code' in step && step.code ? `\`\`\`text\n${step.code}\n\`\`\`` : ''
+ );
+ } else if (block.type === 'faq' && 'title' in block) {
+ lines.push(`## ${block.title}`);
+ for (const faq of PRODUCT_FAQS.slice(0, 4)) lines.push(`### ${faq.q}`, faq.a);
+ } else throw new Error(`Unmapped public Workbench block: ${block.type}`);
+ }
+ }
+ return lines.filter(Boolean).join('\n\n');
+}
+
+export function publicEditorialMarkdown(path: string): string | null {
+ if (path === '/') return homeMarkdown();
+ const component = (
+ { '/faq': PublicFaq, '/docs': PublicDocs, '/changelog': PublicChangelog } as Record<
+ string,
+ () => React.ReactNode
+ >
+ )[path];
+ return component
+ ? reactSourceMarkdown(createElement(component), {
+ origin: 'https://rolepatch.com',
+ components: [component],
+ })
+ : null;
+}
diff --git a/src/lib/public-identity.ts b/src/lib/public-identity.ts
new file mode 100644
index 0000000..f63020f
--- /dev/null
+++ b/src/lib/public-identity.ts
@@ -0,0 +1,48 @@
+export const publicIdentity = {
+ '@context': 'https://schema.org',
+ '@graph': [
+ {
+ '@type': 'Person',
+ '@id': 'https://sarthakagrawal.dev/#person',
+ name: 'Sarthak Agrawal',
+ jobTitle: 'AI Infrastructure & Product Engineer',
+ url: 'https://sarthakagrawal.dev',
+ image: 'https://avatars.githubusercontent.com/u/43884471?v=4',
+ sameAs: [
+ 'https://sarthakagrawal.dev',
+ 'https://www.linkedin.com/in/sarthakagrawal927',
+ 'https://github.com/sarthakagrawal927',
+ 'https://x.com/sarthakcodes',
+ 'https://huggingface.co/sarthakagrawal927',
+ ],
+ knowsAbout: [
+ 'AI infrastructure',
+ 'Local-first software',
+ 'Post-training language models',
+ 'AI code review',
+ 'Cloudflare Workers',
+ 'TypeScript',
+ 'Rust',
+ ],
+ affiliation: {
+ '@type': 'Organization',
+ '@id': 'https://sassmaker.com/#app',
+ name: 'SaaS Maker',
+ url: 'https://sassmaker.com',
+ },
+ },
+ {
+ '@type': 'WebSite',
+ '@id': 'https://rolepatch.com/#app',
+ name: 'RolePatch',
+ alternateName: ['Role Patch', 'rolepatch.com'],
+ url: 'https://rolepatch.com',
+ description:
+ 'AI-powered resume tailoring. Score fit against a job description, rewrite bullets for the role, and prep interviews.',
+ publisher: {
+ '@id': 'https://sarthakagrawal.dev/#person',
+ },
+ sameAs: ['https://github.com/Significant-Hobbies/rolepatch'],
+ },
+ ],
+};
diff --git a/src/lib/public-source.tsx b/src/lib/public-source.tsx
new file mode 100644
index 0000000..5df00de
--- /dev/null
+++ b/src/lib/public-source.tsx
@@ -0,0 +1,222 @@
+import {
+ AlertCircle,
+ ArrowLeft,
+ ArrowRight,
+ CheckCircle2,
+ CheckSquare,
+ FileCheck2,
+ GitCompare,
+ Hash,
+ Link2,
+ ListChecks,
+ Search,
+ ShieldCheck,
+ Sparkles,
+} from 'lucide-react';
+import Link from 'next/link';
+import { createElement, type ReactNode } from 'react';
+import { PUBLIC_SURFACES } from '../../public-surfaces.mjs';
+import BlogPage from '@/app/blog/page';
+import PrivacyPage from '@/app/privacy/page';
+import TermsPage from '@/app/terms/page';
+import ToolsPage from '@/app/tools/page';
+import { DiffToolPageView } from '@/app/tools/diff/page-view';
+import { KeywordsToolPageView } from '@/app/tools/keywords/page-view';
+import { AtsCheckPageView } from '@/app/tools/ats-check/page-view';
+import { BulletCheckPageView } from '@/app/tools/bullet-check/page-view';
+import { PLACEHOLDER, analyzeBullet } from '@/app/tools/bullet-check/analysis';
+import { WordCountPageView } from '@/app/tools/word-count/page-view';
+import { SnippetsPageView } from '@/app/tools/snippets/page-view';
+import {
+ ProofProjectOverview,
+ OverviewProofMetric,
+ ProofRow,
+} from '@/components/proof-project-overview';
+import { ProofPacketPreviewView, ProofMetric } from '@/components/proof-packet-preview-view';
+import { TrueHireProofPreviewView } from '@/components/truehire-proof-preview-view';
+import { PublicPricingView } from '@/components/public-pricing-view';
+import { PricingCards } from '@/components/pricing-cards';
+import { PricingCardsView } from '@/components/pricing-cards-view';
+import { blogPostBySlug } from '@/lib/blog-posts';
+import { buildProofPacketPreview } from '@/lib/achievement-evidence';
+import { absoluteMarkdownLinks } from './agent-surfaces/format.mjs';
+import { reactSourceMarkdown } from './react-source-markdown';
+import { publicEditorialMarkdown } from './public-editorial-source';
+
+const origin = 'https://rolepatch.com';
+const noop = () => {};
+const asyncNoop = async () => {};
+const bullets = PLACEHOLDER.split(/\n+/)
+ .map((line) => line.replace(/^[-•*\s]+/, '').trim())
+ .filter(Boolean)
+ .map(analyzeBullet);
+const toolNodes: Record ReactNode> = {
+ '/tools/diff': () => (
+
+ ),
+ '/tools/keywords': () => (
+
+ ),
+ '/tools/ats-check': () => ,
+ '/tools/bullet-check': () => (
+ sum + bullet.score, 0) / bullets.length)}
+ setText={noop}
+ />
+ ),
+ '/tools/word-count': () => ,
+ '/tools/snippets': () => (
+
+ ),
+};
+const pricing = () => (
+
+);
+const proof = () => (
+
+
+
+ >
+ }
+ />
+);
+
+const components = [
+ BlogPage,
+ PrivacyPage,
+ TermsPage,
+ ToolsPage,
+ DiffToolPageView,
+ KeywordsToolPageView,
+ AtsCheckPageView,
+ BulletCheckPageView,
+ WordCountPageView,
+ SnippetsPageView,
+ ProofProjectOverview,
+ OverviewProofMetric,
+ ProofRow,
+ ProofPacketPreviewView,
+ ProofMetric,
+ TrueHireProofPreviewView,
+ PublicPricingView,
+ PricingCardsView,
+];
+const adapters = new Map<
+ unknown,
+ (props: Record & { children?: ReactNode }) => ReactNode
+>([
+ [Link, (props) => createElement('a', props, props.children)],
+ [PricingCards, pricing],
+ ...[
+ AlertCircle,
+ ArrowLeft,
+ ArrowRight,
+ CheckCircle2,
+ CheckSquare,
+ FileCheck2,
+ GitCompare,
+ Hash,
+ Link2,
+ ListChecks,
+ Search,
+ ShieldCheck,
+ Sparkles,
+ ].map((icon) => [icon, () => null] as const),
+]);
+
+/** Only empty anonymous views and authored public source are reachable here. */
+export function publicSourceMarkdown(path: string): string | null {
+ const surface = PUBLIC_SURFACES.find((item) => item.path === path);
+ if (!surface) return null;
+ let body = publicEditorialMarkdown(path);
+ if (path.startsWith('/blog/')) {
+ const post = blogPostBySlug(path.slice('/blog/'.length));
+ if (!post) return null;
+ body = `# ${post.title}\n\n${new Date(post.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} · ${post.readTime} read\n\n${post.content}`;
+ }
+ if (!body) {
+ const node =
+ toolNodes[path]?.() ??
+ (
+ {
+ '/privacy': ,
+ '/terms': ,
+ '/tools': ,
+ '/blog': ,
+ '/proof': proof(),
+ '/pricing': ,
+ } as Record
+ )[path];
+ if (!node) return null;
+ body = reactSourceMarkdown(node, { origin, components, adapters, includeFormDefaults: true });
+ }
+ return absoluteMarkdownLinks(`Canonical: ${origin}${path}\n\n${body}\n`, `${origin}${path}`);
+}
diff --git a/src/lib/react-source-markdown.ts b/src/lib/react-source-markdown.ts
new file mode 100644
index 0000000..be3d165
--- /dev/null
+++ b/src/lib/react-source-markdown.ts
@@ -0,0 +1,260 @@
+import { Fragment, isValidElement, type ReactElement, type ReactNode } from 'react';
+
+type Props = Record & { children?: ReactNode };
+type SourceElement = ReactElement;
+type PureComponent = (props: never) => ReactNode;
+interface Options {
+ origin: string;
+ includeFormDefaults?: boolean;
+ components: readonly PureComponent[];
+ adapters?: ReadonlyMap ReactNode>;
+}
+interface Context extends Options {
+ allowed: Set;
+ code: string[];
+ quoted?: boolean;
+}
+
+// Only reviewed, pure authored components are evaluated. No DOM or HTML parser,
+// effects, browser state, or rendered-HTML roundtrip participates in this path.
+// Reviewed components use "use no memo" so React Compiler cannot inject hooks
+// into these functions when the production build is evaluated outside React.
+export function reactSourceMarkdown(node: ReactNode, options: Options): string {
+ const context: Context = {
+ ...options,
+ allowed: new Set(options.components),
+ code: [],
+ };
+ let main = node;
+ while (isSourceElement(main) && typeof main.type === 'function') {
+ main = invoke(main.type as PureComponent, main.props, context);
+ }
+ if (!isValidElement(main) || main.type !== 'main')
+ throw new Error('Public source must have one main root');
+ return renderNode(main, context)
+ .replace(/[ \t]+\n/g, '\n')
+ .replace(/\n[ \t]+/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .replace(/[ \t]{2,}/g, ' ')
+ .trim()
+ .replace(/\uE000CODE(\d+)\uE000/g, (_, index: string) => context.code[Number(index)] ?? '');
+}
+
+function isSourceElement(value: ReactNode): value is SourceElement {
+ return isValidElement(value);
+}
+
+function invoke(component: PureComponent, props: Props, context: Context): ReactNode {
+ if (!context.allowed.has(component))
+ throw new Error(`Unreviewed public component: ${component.name}`);
+ return component(props as never);
+}
+
+const markdownSymbols = new Set(['\\', '`', '*', '_', '[', ']', '#']);
+function escapeText(value: string): string {
+ return [...value]
+ .map((character) => (markdownSymbols.has(character) ? `\\${character}` : character))
+ .join('');
+}
+
+function renderNode(value: ReactNode, context: Context): string {
+ if (value == null || typeof value === 'boolean') return '';
+ if (typeof value === 'string' || typeof value === 'number') return escapeText(String(value));
+ if (Array.isArray(value)) return value.map((child) => renderNode(child, context)).join('');
+ if (!isSourceElement(value)) throw new Error('Unsupported public source node');
+ return renderElement(value, context);
+}
+
+function renderElement(value: SourceElement, context: Context): string {
+ const { type, props } = value;
+ if (excluded(props)) {
+ return '';
+ }
+ if (type === Fragment) {
+ return renderNode(props.children, context);
+ }
+ const adapter = context.adapters?.get(type);
+ if (adapter) {
+ return renderNode(adapter(props), context);
+ }
+ if (typeof type === 'function') {
+ const component = type as PureComponent;
+ return renderNode(invoke(component, props, context), context);
+ }
+ if (typeof type !== 'string') {
+ throw new Error('Unsupported public component wrapper');
+ }
+ return renderIntrinsic(type, props, context);
+}
+
+function renderIntrinsic(type: string, props: Props, context: Context): string {
+ if (type === 'style') return '';
+ if (type === 'nav' || (type === 'footer' && !context.quoted)) return '';
+ if (type === 'blockquote')
+ return `\n\n> ${renderNode(props.children, { ...context, quoted: true })
+ .trim()
+ .replace(/\n/g, '\n> ')}\n\n`;
+ if (type === 'svg') return typeof props['aria-label'] === 'string' ? props['aria-label'] : '';
+ if (type === 'script' && props.type === 'application/ld+json') return '';
+ if (props.dangerouslySetInnerHTML)
+ throw new Error('Raw HTML is not a public source representation');
+ if (type === 'pre' || type === 'code') return renderCode(type, props.children, context);
+ return renderTag(
+ type,
+ props,
+ (child) => renderNode(child, context),
+ context.origin,
+ context.includeFormDefaults
+ );
+}
+
+function renderCode(tag: string, node: ReactNode, context: Context): string {
+ const source = plainCode(node);
+ const fence = '`'.repeat(
+ Math.max(
+ tag === 'pre' ? 3 : 1,
+ ...[...source.matchAll(/\x60+/g)].map((match) => match[0].length + 1)
+ )
+ );
+ context.code.push(
+ tag === 'pre' ? `\n\n${fence}\n${source}\n${fence}\n\n` : `${fence}${source}${fence}`
+ );
+ return `\uE000CODE${context.code.length - 1}\uE000`;
+}
+
+function excluded(props: Props): boolean {
+ const style = props.style as { display?: string; visibility?: string } | undefined;
+ return (
+ props.hidden === true ||
+ props['aria-hidden'] === true ||
+ props['aria-hidden'] === 'true' ||
+ style?.display === 'none' ||
+ style?.visibility === 'hidden'
+ );
+}
+
+const blocks = new Set([
+ 'main',
+ 'figure',
+ 'figcaption',
+ 'button',
+ 'form',
+ 'select',
+ 'option',
+ 'footer',
+ 'section',
+ 'article',
+ 'aside',
+ 'header',
+ 'div',
+ 'p',
+ 'caption',
+ 'details',
+ 'summary',
+ 'ul',
+ 'ol',
+ 'dl',
+ 'dt',
+ 'dd',
+]);
+const inline = new Set(['cite', 'span', 'time', 'small', 'label', 'sup', 'sub']);
+type Render = (node: ReactNode) => string;
+
+function renderTag(
+ tag: string,
+ props: Props,
+ render: Render,
+ origin: string,
+ includeFormDefaults = false
+): string {
+ if (tag === 'iframe')
+ return `[${String(props.title || 'Embedded media')}](${publicUrl(props.src, origin)})`;
+ if (tag === 'table') return renderTable(props.children, render);
+ if (tag === 'input' || tag === 'textarea')
+ return `${String(props['aria-label'] || '')} ${String(props.placeholder || '')}${includeFormDefaults && ['string', 'number'].includes(typeof props.value) ? `: ${String(props.value)}` : ''}`;
+ const body = render(props.children);
+ if (blocks.has(tag)) return `\n\n${body.trim()}\n\n`;
+ if (tag === 'span') return ` ${body} `;
+ if (inline.has(tag)) return body;
+ if (/^h[1-6]$/.test(tag))
+ return `\n\n${'#'.repeat(Number(tag[1]))} ${body.trim().replace(/\s+/g, ' ')}\n\n`;
+ if (tag === 'a')
+ return ` [${
+ body
+ .trim()
+ .replace(/(?:^|\n)#{1,6}\s+/g, '')
+ .replace(/\s+/g, ' ') || String(props['aria-label'] || '')
+ }](${publicUrl(props.href, origin)}) `;
+ if (tag === 'img') return props.alt ? `})` : '';
+ const decorated = decorations[tag];
+ if (decorated) return decorated(body);
+ throw new Error(`Unsupported public source element: ${tag}`);
+}
+
+const decorations: Record string> = {
+ li: (body) => `\n- ${body.trim()}\n`,
+ br: () => '\n',
+ hr: () => '\n\n---\n\n',
+ strong: (body) => `**${body}**`,
+ b: (body) => `**${body}**`,
+ em: (body) => `*${body}*`,
+ i: (body) => `*${body}*`,
+};
+
+function publicUrl(value: unknown, origin: string): string {
+ if (typeof value !== 'string') throw new Error('Public source link has no URL');
+ const url = new URL(value, origin);
+ if (!['https:', 'http:', 'mailto:'].includes(url.protocol))
+ throw new Error('Unsupported public source URL');
+ return url.href;
+}
+
+function plainCode(node: ReactNode): string {
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
+ if (Array.isArray(node)) return node.map(plainCode).join('');
+ if (isSourceElement(node) && node.type === 'code') return plainCode(node.props.children);
+ throw new Error('Unsupported public code source');
+}
+
+function renderTable(node: ReactNode, render: Render): string {
+ const rows: { cells: string[]; header: boolean }[] = [];
+ const captions: string[] = [];
+ function visit(value: ReactNode) {
+ if (value == null || typeof value === 'boolean' || (typeof value === 'string' && !value.trim()))
+ return;
+ if (Array.isArray(value)) {
+ value.forEach(visit);
+ return;
+ }
+ if (!isSourceElement(value)) throw new Error('Unsupported table source');
+ if (excluded(value.props)) return;
+ if (['thead', 'tbody', 'tfoot'].includes(String(value.type))) {
+ visit(value.props.children);
+ return;
+ }
+ if (value.type === 'caption') {
+ captions.push(render(value.props.children));
+ return;
+ }
+ if (value.type !== 'tr') throw new Error('Unsupported table row source');
+ const children = (
+ Array.isArray(value.props.children) ? value.props.children : [value.props.children]
+ ).filter((child) => !(typeof child === 'string' && !child.trim()));
+ const cells = children.map((cell) => {
+ if (!isSourceElement(cell) || !['td', 'th'].includes(String(cell.type)))
+ throw new Error('Unsupported table cell source');
+ return render(cell.props.children)
+ .trim()
+ .replaceAll('|', '\\|')
+ .replace(/\s*\n\s*/g, ' ');
+ });
+ rows.push({
+ cells,
+ header: children.every((cell) => isValidElement(cell) && cell.type === 'th'),
+ });
+ }
+ visit(node);
+ if (!rows.length) return '';
+ if (!rows[0]?.header) rows.unshift({ cells: rows[0].cells.map(() => ''), header: true });
+ return `\n\n${captions.join('\n')}\n${rows.map((row, index) => `| ${row.cells.join(' | ')} |${index === 0 ? `\n| ${row.cells.map(() => '---').join(' | ')} |` : ''}`).join('\n')}\n\n`;
+}
diff --git a/worker.mjs b/worker.mjs
index f04a216..4954de3 100644
--- a/worker.mjs
+++ b/worker.mjs
@@ -16,6 +16,7 @@ import { ensureLeadingHtmlDoctype } from './html-document.mjs';
import { withTiming } from './timing.mjs';
import { CONTENT_SECURITY_POLICY } from './security-policy.mjs';
import { handleAgentEdge } from './agent-edge.mjs';
+import { withPublicReadability } from './public-readability-policy.mjs';
import { handleRolePatchAgentRoutes } from './rolepatch-agent-routes.mjs';
import {
documentCacheRequest,
@@ -146,175 +147,192 @@ export default {
}
},
- fetch: withTiming(async function fetch(request, env, ctx) {
- // Curated Markdown for every public RolePatch page. This precedes the
- // generated Fleet handler because it also owns the complete /api/ai catalog.
- {
- const rolePatchAgentRoute = handleRolePatchAgentRoutes(request);
- if (rolePatchAgentRoute) return rolePatchAgentRoute;
- }
- // Agent / LLM indexing surfaces (fleet GEO standard)
- {
- const agent = handleAgentEdge(request);
- if (agent) return agent;
- }
- try {
- const url = new URL(request.url);
- if (url.pathname.startsWith(INTERNAL_PATH_PREFIX) && !isInternalWorkerRequest(request)) {
- return new Response('Not found', { status: 404 });
+ fetch: withTiming(
+ withPublicReadability(async function fetch(request, env, ctx) {
+ // Curated Markdown for every public RolePatch page. This precedes the
+ // generated Fleet handler because it also owns the complete /api/ai catalog.
+ {
+ const rolePatchAgentRoute = await handleRolePatchAgentRoutes(request, async (path) => {
+ const url = new URL('/api/ai/source', request.url);
+ url.searchParams.set('path', path);
+ const response = await openNext.fetch(
+ new Request(url, { headers: { Accept: 'text/markdown' } }),
+ env,
+ ctx
+ );
+ if (response.status === 404) return null;
+ if (!response.ok) throw new Error(`Public Markdown source failed: ${response.status}`);
+ return response.text();
+ });
+ if (rolePatchAgentRoute) return rolePatchAgentRoute;
}
- if (request.method !== 'GET') {
- return openNext.fetch(request, env, ctx);
+ // Agent / LLM indexing surfaces (fleet GEO standard)
+ {
+ const legacyHeaders = new Headers(request.headers);
+ legacyHeaders.set('Accept', 'text/html');
+ const agent = ['GET', 'HEAD'].includes(request.method)
+ ? handleAgentEdge(new Request(request, { headers: legacyHeaders }))
+ : null;
+ if (agent) return agent;
}
- if (!isCacheableDocumentPath(url.pathname)) {
- return openNext.fetch(request, env, ctx);
- }
- // Auth-bearing requests pass straight through; the user is likely
- // going to be redirected by middleware to /library or /dashboard.
- if (hasAuthCookie(request) || !isDocumentRequest(request)) {
- return openNext.fetch(request, env, ctx);
- }
-
- // Short-circuit: the Astro landing is overlaid into
- // `.open-next/assets/index.html` by `scripts/overlay-astro-landing.mjs`.
- // For anon GET /, serve straight from the assets binding instead of
- // booting the full OpenNext stack (next-server, middleware handler,
- // Beasties pipeline, etc.). Cuts TTFB from ~250ms to ~30ms.
- // Marketing subpages fall through to caches.default + OpenNext below.
- //
- // The Workers Static Assets binding does NOT auto-compress its
- // responses (Lighthouse flagged ~80 KB wasted on uncompressed HTML
- // even with CF Edge cache HIT). Compress with gzip here so the
- // response — and the downstream CF Edge cache entry — is small.
- // Only Astro overlay at `/` is static; marketing pages use edge HTML cache.
- if (env.ASSETS && url.pathname === '/') {
- // Fetch an identity representation from the binding. Compression is
- // negotiated below for the caller; forwarding its Accept-Encoding can
- // otherwise make local and edge runtimes hand us encoded bytes without
- // a stable Content-Encoding contract, which risks double compression.
- const assetHeaders = new Headers(request.headers);
- assetHeaders.set('accept-encoding', 'identity');
- const assetResp = await env.ASSETS.fetch(new Request(request, { headers: assetHeaders }));
- // The assets binding answers If-None-Match revalidations with 304.
- // Pass those through — falling through would serve the wrong page.
- if (assetResp.status === 304) {
- const headers = new Headers(assetResp.headers);
- headers.set('Content-Security-Policy', CONTENT_SECURITY_POLICY);
- headers.set('Cache-Control', CACHE_CONTROL);
- headers.set('x-edge-cache', 'ASSET');
- return new Response(null, { status: 304, headers });
+ try {
+ const url = new URL(request.url);
+ if (url.pathname.startsWith(INTERNAL_PATH_PREFIX) && !isInternalWorkerRequest(request)) {
+ return new Response('Not found', { status: 404 });
+ }
+ if (request.method !== 'GET') {
+ return openNext.fetch(request, env, ctx);
+ }
+ if (!isCacheableDocumentPath(url.pathname)) {
+ return openNext.fetch(request, env, ctx);
+ }
+ // Auth-bearing requests pass straight through; the user is likely
+ // going to be redirected by middleware to /library or /dashboard.
+ if (hasAuthCookie(request) || !isDocumentRequest(request)) {
+ return openNext.fetch(request, env, ctx);
}
- if (assetResp.ok && assetResp.body) {
- const acceptEnc = request.headers.get('accept-encoding') ?? '';
- const wantsGzip = acceptEnc.includes('gzip');
- const headers = new Headers(assetResp.headers);
- headers.set('Content-Security-Policy', CONTENT_SECURITY_POLICY);
- headers.set('Cache-Control', CACHE_CONTROL);
- headers.set('x-edge-cache', 'ASSET');
- // Static Assets can expose already-compressed bytes through a Worker
- // without a matching Content-Encoding header (notably in local
- // qualification). Normalize the small HTML asset before negotiating
- // the caller's representation so neither identity nor gzip can be
- // served with an incorrect number of encoding layers.
- let payload = new Uint8Array(await assetResp.arrayBuffer());
- let encodingLayers = 0;
- while (payload[0] === 0x1f && payload[1] === 0x8b && encodingLayers < 4) {
- const decoded = new Blob([payload])
- .stream()
- .pipeThrough(new DecompressionStream('gzip'));
- payload = new Uint8Array(await new Response(decoded).arrayBuffer());
- encodingLayers += 1;
+ // Short-circuit: the Astro landing is overlaid into
+ // `.open-next/assets/index.html` by `scripts/overlay-astro-landing.mjs`.
+ // For anon GET /, serve straight from the assets binding instead of
+ // booting the full OpenNext stack (next-server, middleware handler,
+ // Beasties pipeline, etc.). Cuts TTFB from ~250ms to ~30ms.
+ // Marketing subpages fall through to caches.default + OpenNext below.
+ //
+ // The Workers Static Assets binding does NOT auto-compress its
+ // responses (Lighthouse flagged ~80 KB wasted on uncompressed HTML
+ // even with CF Edge cache HIT). Compress with gzip here so the
+ // response — and the downstream CF Edge cache entry — is small.
+ // Only Astro overlay at `/` is static; marketing pages use edge HTML cache.
+ if (env.ASSETS && url.pathname === '/') {
+ // Fetch an identity representation from the binding. Compression is
+ // negotiated below for the caller; forwarding its Accept-Encoding can
+ // otherwise make local and edge runtimes hand us encoded bytes without
+ // a stable Content-Encoding contract, which risks double compression.
+ const assetHeaders = new Headers(request.headers);
+ assetHeaders.set('accept-encoding', 'identity');
+ const assetResp = await env.ASSETS.fetch(new Request(request, { headers: assetHeaders }));
+ // The assets binding answers If-None-Match revalidations with 304.
+ // Pass those through — falling through would serve the wrong page.
+ if (assetResp.status === 304) {
+ const headers = new Headers(assetResp.headers);
+ headers.set('Content-Security-Policy', CONTENT_SECURITY_POLICY);
+ headers.set('Cache-Control', CACHE_CONTROL);
+ headers.set('x-edge-cache', 'ASSET');
+ return new Response(null, { status: 304, headers });
}
- headers.delete('content-encoding');
- headers.delete('content-length');
+ if (assetResp.ok && assetResp.body) {
+ const acceptEnc = request.headers.get('accept-encoding') ?? '';
+ const wantsGzip = acceptEnc.includes('gzip');
+ const headers = new Headers(assetResp.headers);
+ headers.set('Content-Security-Policy', CONTENT_SECURITY_POLICY);
+ headers.set('Cache-Control', CACHE_CONTROL);
+ headers.set('x-edge-cache', 'ASSET');
- if (wantsGzip) {
- headers.set('content-encoding', 'gzip');
- // `Vary: Accept-Encoding` so a future no-encoding client
- // gets a separately negotiated entry.
- const vary = headers.get('vary');
- headers.set('vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');
- return new Response(
- new Blob([payload]).stream().pipeThrough(new CompressionStream('gzip')),
- {
- status: assetResp.status,
- statusText: assetResp.statusText,
- headers,
- encodeBody: 'manual',
- }
- );
- }
+ // Static Assets can expose already-compressed bytes through a Worker
+ // without a matching Content-Encoding header (notably in local
+ // qualification). Normalize the small HTML asset before negotiating
+ // the caller's representation so neither identity nor gzip can be
+ // served with an incorrect number of encoding layers.
+ let payload = new Uint8Array(await assetResp.arrayBuffer());
+ let encodingLayers = 0;
+ while (payload[0] === 0x1f && payload[1] === 0x8b && encodingLayers < 4) {
+ const decoded = new Blob([payload])
+ .stream()
+ .pipeThrough(new DecompressionStream('gzip'));
+ payload = new Uint8Array(await new Response(decoded).arrayBuffer());
+ encodingLayers += 1;
+ }
+ headers.delete('content-encoding');
+ headers.delete('content-length');
+
+ if (wantsGzip) {
+ headers.set('content-encoding', 'gzip');
+ // `Vary: Accept-Encoding` so a future no-encoding client
+ // gets a separately negotiated entry.
+ const vary = headers.get('vary');
+ headers.set('vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');
+ return new Response(
+ new Blob([payload]).stream().pipeThrough(new CompressionStream('gzip')),
+ {
+ status: assetResp.status,
+ statusText: assetResp.statusText,
+ headers,
+ encodeBody: 'manual',
+ }
+ );
+ }
- return new Response(payload, {
- status: assetResp.status,
- statusText: assetResp.statusText,
- headers,
- encodeBody: 'manual',
- });
+ return new Response(payload, {
+ status: assetResp.status,
+ statusText: assetResp.statusText,
+ headers,
+ encodeBody: 'manual',
+ });
+ }
}
- }
- // A deploy replaces hashed JS chunks. Never pair new assets with HTML
- // left in a URL-only cache by an earlier build.
- const cacheRequest = await documentCacheRequest(request, env.ASSETS);
- if (!cacheRequest) return openNext.fetch(request, env, ctx);
- const cache = caches.default;
- const cached = await cache.match(cacheRequest);
- if (cached) {
- const hit = new Response(cached.body, cached);
- hit.headers.set('Cache-Control', DOCUMENT_CLIENT_CACHE_CONTROL);
- hit.headers.set('x-edge-cache', 'HIT');
- return hit;
- }
+ // A deploy replaces hashed JS chunks. Never pair new assets with HTML
+ // left in a URL-only cache by an earlier build.
+ const cacheRequest = await documentCacheRequest(request, env.ASSETS);
+ if (!cacheRequest) return openNext.fetch(request, env, ctx);
+ const cache = caches.default;
+ const cached = await cache.match(cacheRequest);
+ if (cached) {
+ const hit = new Response(cached.body, cached);
+ hit.headers.set('Cache-Control', DOCUMENT_CLIENT_CACHE_CONTROL);
+ hit.headers.set('x-edge-cache', 'HIT');
+ return hit;
+ }
- const response = await openNext.fetch(request, env, ctx);
+ const response = await openNext.fetch(request, env, ctx);
- // Only cache 2xx HTML responses — never error pages or redirects.
- const contentType = response.headers.get('content-type') ?? '';
- if (
- response.status !== 200 ||
- !contentType.includes('text/html') ||
- response.headers.has('set-cookie')
- ) {
- return response;
- }
+ // Only cache 2xx HTML responses — never error pages or redirects.
+ const contentType = response.headers.get('content-type') ?? '';
+ if (
+ response.status !== 200 ||
+ !contentType.includes('text/html') ||
+ response.headers.has('set-cookie')
+ ) {
+ return response;
+ }
- // Read the body into memory once so we can hand the same bytes to
- // both the client response and the cache.put. The earlier pattern
- // (`new Response(response.body, response)` then `.clone()`) was
- // silently dropping the inlined critical-CSS chunk somewhere in the
- // stream-fork; reading once and constructing both responses from
- // the same Uint8Array sidesteps the streaming edge case entirely.
- const body = ensureLeadingHtmlDoctype(await response.arrayBuffer());
- const headers = new Headers(response.headers);
- headers.set('Cache-Control', DOCUMENT_EDGE_CACHE_CONTROL);
+ // Read the body into memory once so we can hand the same bytes to
+ // both the client response and the cache.put. The earlier pattern
+ // (`new Response(response.body, response)` then `.clone()`) was
+ // silently dropping the inlined critical-CSS chunk somewhere in the
+ // stream-fork; reading once and constructing both responses from
+ // the same Uint8Array sidesteps the streaming edge case entirely.
+ const body = ensureLeadingHtmlDoctype(await response.arrayBuffer());
+ const headers = new Headers(response.headers);
+ headers.set('Cache-Control', DOCUMENT_EDGE_CACHE_CONTROL);
- const cacheable = new Response(body, {
- status: response.status,
- statusText: response.statusText,
- headers,
- });
- ctx.waitUntil(cache.put(cacheRequest, cacheable.clone()));
+ const cacheable = new Response(body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+ ctx.waitUntil(cache.put(cacheRequest, cacheable.clone()));
- const clientResponse = new Response(body, {
- status: response.status,
- statusText: response.statusText,
- headers,
- });
- clientResponse.headers.set('Cache-Control', DOCUMENT_CLIENT_CACHE_CONTROL);
- clientResponse.headers.set('x-edge-cache', 'MISS');
- return clientResponse;
- } catch (err) {
- console.error(
- `[error] ${request.method} ${new URL(request.url).pathname}:`,
- err.message,
- err.stack
- );
- return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- });
- }
- }),
+ const clientResponse = new Response(body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+ clientResponse.headers.set('Cache-Control', DOCUMENT_CLIENT_CACHE_CONTROL);
+ clientResponse.headers.set('x-edge-cache', 'MISS');
+ return clientResponse;
+ } catch (err) {
+ console.error(
+ `[error] ${request.method} ${new URL(request.url).pathname}:`,
+ err.message,
+ err.stack
+ );
+ return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ })
+ ),
};