Compare commits

...
464 Commits
Author SHA1 Message Date
Amruth Pillai c288675b16 Release v5.2.9 (#3382)
* feat(ats): add ATS checker and replace resume analysis

Adds a public, browser-only ATS checker at /ats-checker and an ATS Check
section in the builder's right sidebar. PDFs are parsed locally: text
extraction, reading order, contact and date recovery, section detection,
and file-level readability are scored deterministically, with evidence
cited per finding and skipped checks reported rather than counted as
passes.

Removes the AI-scored resume analysis it supersedes: the resume_analysis
table (dropped via migration), the get_resume_analysis MCP tool, and
POST /ai/analyze-resume. The replacement, POST /ai/ats-review, reviews
extracted resume text and returns qualitative feedback with no score.

Also bumps the version to 5.2.9 and adds the changelog entry.

* chore(deps): bump workspace dependencies

* fix(ats-checker): keep negation inside each 'what this does not do' bullet

The three bullets were bare fragments whose negation came from the
section heading, which translators never see. A dozen locales rendered
them as affirmative assertions or imperatives, so the page claimed the
checker enforces a one-page rule and predicts rejection -- the opposite
of the source, and directly contradicted by the sentence beside it.

Each bullet now carries its own negation, so the polarity cannot be
lost in translation. Re-extracted and refilled across all 53 target
locales.
2026-08-27 03:37:01 +02:00
Santhi PrakashandAmruth Pillai e065a10824 fix(pdf): resolve bold text weight from the family's bold face (#3335)
* fix(pdf): resolve bold text weight from the family's bold face

Bold text (<strong>, rich-text bold, template bold styles) previously
rendered at the last stored body weight, which is ambiguous: families
are commonly stored as ["400","600"] (the typography picker's default
pairing), so bold rendered at SemiBold — nearly indistinguishable from
Regular for faces like Open Sans (#3310).

Add resolveBoldFontWeight() to the fonts package: keep a deliberate
stored bold-class choice (>= 700), else prefer the family's true Bold
face ("700"), else the heaviest >= 600 face; return null so callers
keep their existing fallback when the family has no bold-class face.

Wire it through use-register-fonts, the shared base-template-styles
builder, base-styles and the Scizor template. Default body IBM Plex
Serif ["400","500"] now renders bold at 700 (base-reset-fidelity
expectation updated accordingly).

Fixes #3310

* fix(pdf): register bold fallback faces for CJK glyph substitution

When resolveBoldFontWeight maps stored weights like ["400","600"] to the
family's 700 face, register that weight on each PDF fallback font too so
glyph-level substitution keeps bold glyphs instead of snapping to 600.

Also reorder @reactive-resume/fonts imports per Biome convention.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-27 03:16:46 +02:00
Santhi Prakashgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>Amruth Pillaiautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b47f805321 fix(pdf): render emoji via a Noto Emoji script fallback (#3351)
* fix(pdf): render emoji via a Noto Emoji script fallback

Emoji in resume content (flags, globe, pictographs) rendered as mojibake
in the preview and PDF export because the per-codepoint fallback chain
registered no emoji-capable font: every font in the stack lacked the
glyphs, so layout fell through to single-byte standard-font encoding —
each UTF-16 code unit truncated to its low byte (#3321).

Follows the #2986/#3190 script-fallback pattern: detect emoji content
(regional indicators unioned with Extended_Pictographic), map it to the
monochrome Noto Emoji web font (TrueType glyf outlines, PDF-embeddable),
and register it in the fallback stack for both serif and sans stacks.
Out-of-range weight requests alias to the nearest served weight (300-700)
so registration never falls back to the preview subset.

* fix(pdf): detect keycap emoji via the combining enclosing keycap

Greptile review on #3351: keycap sequences like 1\uFE0F\u20E3 carry no
regional indicator and no Extended_Pictographic codepoint, so they
bypassed the emoji detector and rendered garbled — the exact class of
bug #3321 fixes. Union U+20E3 into the detector; every valid keycap
sequence contains it.

* Update packages/utils/src/locale.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-27 03:15:34 +02:00
Santhi Prakash 2761bd6715 fix(fonts): register Vazirmatn in webfont catalog for JSON imports (#3331)
* fix(fonts): register Vazirmatn in webfont catalog for JSON imports

Imported resumes can set typography.fontFamily to Vazirmatn, but the
popularity-sorted Google Fonts slice omits it so PDF registration fell
back to IBM Plex Serif and Persian/Arabic glyphs stacked or tofu (#3098).

Add Vazirmatn as a locale-coverage manual entry (same pattern as Carlito)
and cover catalog resolution with unit tests.

* test(pdf): keep Vazirmatn as primary family for fa-IR registration

Prove JSON-imported Vazirmatn is handed to Font.register instead of
being rewritten to IBM Plex Serif (#3098).

* fix(fonts): address CodeRabbit review on Vazirmatn catalog

Assert getWebFontSource resolves files["400"] for Vazirmatn instead of
only matching the preview fallback, and split the font-generation log
line to satisfy the 120-column Biome limit.
2026-08-27 03:15:31 +02:00
Santhi Prakash a416d01112 fix(pdf): constrain bullet list content width within horizontal margin (#3367)
Change richListItemContent from flex: initial to flex: 1 with minWidth: 0.
This makes the content area fill remaining space after the marker and
columnGap, so text wraps within the user-set margin instead of overflowing.

Fixes #3336
2026-08-27 03:15:28 +02:00
github-actions[bot]andCrowdin Bot 7fac6f29c0 Sync Translations from Crowdin (#3381)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-26 16:25:56 +02:00
Amruth Pillai d3dddf229b Update .gitignore and AGENTS.md 2026-08-26 14:23:42 +00:00
Amruth Pillai 3c195dc3f8 Release v5.2.8 (#3375)
Upgrades to Better Auth 1.7, expands Custom Styles coverage of item headers, and adds a human-approval step to the AI agent's resume edits.

Breaking for self-hosters using a custom OAuth provider: the callback path changes from /api/auth/oauth2/callback/custom to /api/auth/callback/custom, and installs using OAUTH_DISCOVERY_URL need one additional UPDATE after upgrading. Both are documented in docs/self-hosting/sso.mdx.

- Better Auth 1.7, with the account issuer migration and the jwks alg/crv columns the 1.7 jwt plugin requires
- Agent edits gated behind an approval step, with crash-safe runs and context pruning
- item-header now covers every section header row on every template; adds the item-header-row part
- Fixes provider unlinking, auth error messages, and version conflicts on freshly created resumes
- New /auth/error page, translated across all 53 target locales
- DeepSeek Harness plugin moved into packages/dsh-plugin
- Dependency bumps across the workspace
2026-08-24 21:44:16 +02:00
github-actions[bot]andCrowdin Bot 3221afda9d Sync Translations from Crowdin (#3365)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-20 10:38:31 +02:00
Amruth Pillai 8ce899a04b feat(agent): omit resume documents from the copied conversation json 2026-08-20 09:40:57 +02:00
Amruth Pillai 39f36b4ac5 fix(resume): guard patch versions in the transaction, not in sql
Postgres defaultNow() stores microseconds while JS Dates are millisecond-truncated, so the SQL equality guard matched zero rows on freshly created resumes and every guarded agent patch failed with a permanent version conflict. The SELECT ... FOR UPDATE lock plus the in-transaction ms-precision check already provide the guarantee; drop the SQL predicate. Verified A/B against a live database.
2026-08-20 09:28:31 +02:00
Amruth Pillai 39590eaff6 fix(auth): allow unlinking providers after the session ages past a day (#3364)
Better Auth guards `/unlink-account` with `freshSessionMiddleware`, which
rejects any session whose `createdAt` is older than `freshAge` (one day by
default). Sessions here last a week and there is no re-authentication flow to
refresh that timestamp, so disconnecting a provider failed with
`SESSION_NOT_FRESH` for every user who signed in more than a day ago.

Disable the freshness gate, and teach `getReadableErrorMessage` to read plain
error objects: Better Auth client errors are `{ code, message, status }`
objects rather than `Error` instances, so every auth toast was collapsing to
its generic fallback instead of showing the real reason.
2026-08-20 08:20:18 +02:00
Amruth Pillai c8081ac2fe feat(agent): adopt AI SDK v7 — crash safety, context pruning, HITL approvals (#3362)
* docs(adr): propose agent AI SDK v7 adoption plan

* fix(ai): bind analyzeResume through aiService in service test

The test destructured analyzeResume as a named export that does not exist; main was red.

* test(agent): keep pure ai helpers real via spread-actual mock factory

* feat(agent): add run guards, patch version guard, run wall-clock timeout

* feat(agent): validate UI messages at the send boundary

* feat(agent): crash-safe draft-row persistence and server-side cancellation

* feat(agent): reap stale run claims at boot, on send, and on thread open

* feat(agent): fresh-document patch output and tiered context pruning

* feat(ai): shared agent tool contracts and message metadata schema

* feat(agent): add per-thread review-patches setting with update endpoint

* feat(agent): gate resume patches behind hmac-signed tool approval

* feat(agent): merge question answers and approval decisions before run claim

* feat(agent): approval ui with composed auto-send and fixture-driven tests

* feat(agent): usage metadata, tool activity cards, smoother streaming

* feat(agent): tool-call repair, input examples, structured step logging

* chore(i18n): translate new agent workspace strings across all locales

* fix(agent): gate stale-run draft cancellation on winning the claim clear

Snapshot streaming drafts before the conditional clear and skip the flip entirely when another reaper or a replacement run already cleared the claim. Also address review nits in eleven locale catalogs.

* fix(agent): flip reaped drafts only when their snapshotted state is unchanged

* fix(agent): address review findings across run lifecycle, context budget, and approval flow

- bind patches to the revision the model read via signed baseUpdatedAt
- claim the run before consuming a continuation; recorded-but-unexecuted approvals retry as pending continuations
- keep run ownership on stop() until cancellation persists; preserve the claim for the reaper when final persistence fails
- estimate tokens without serializing binary attachments (tokenx) and enforce the budget by dropping oldest whole turns
- mark crash-recovered patch results as snapshot boundaries; strip /data prefixes at execution time
- retry failed continuations without regenerate; mount a single AgentChat; disable response controls on read-only threads; freeze review toggle during runs (client+server)
- accumulate usage across continuations and match the SDK's nested usage shape; label-form token strings; reorderable source label; accessible note field; state-neutral web-search label

* chore(i18n): translate revised agent strings across all locales

* fix(agent): harden baseUpdatedAt validation and address review follow-ups

- bundle tokenx in the server runtime dependencies (e2e boot failure)
- strict ISO schema for baseUpdatedAt plus loud executor rejection of unparseable values
- it-IT source label consistency (Fonte)
- prove penultimate-turn retention in the context pruning test

* chore(deps): exempt tokenx from knip for the externalized server bundle
2026-08-20 08:06:53 +02:00
autofix-ci[bot] dbbab6fd76 [autofix.ci] apply automated fixes 2026-08-19 03:49:17 +00:00
Emanuele Tonello 8acde4c1ac fix(ai): provide current date to resume analysis (#3353) 2026-08-19 05:48:31 +02:00
Amruth Pillai 4d53a6d1de fix(stylesheet): apply item-header to every header row on every template (#3357)
`SectionItemHeader` only rendered its own box when a template opted into
`mainItemHeaderBorder` (only Ditgar did). Everywhere else it walked the
header children and attached the resolved `item-header` style to the first
descendant that happened to be a literal `View` or `InlineItemHeader`.

Sections whose header starts with anything else — certifications, awards,
projects, publications, references — matched nothing, so the style was
silently dropped; stacked headers such as experience matched only their
first row, so a second row went unstyled.

The header now always renders its own `Div`, so `item-header` covers the
whole header row of every section on every template. `Div` rather than
`View` keeps the base row gap the rows used to inherit from the item box,
and Ditgar keeps its tight header via `rowGap: 0` on its own
`sectionItemHeader` slot, so rendered output is unchanged apart from the
newly styled rows. `mainItemHeaderBorder` is now dead and removed.

Fixes #3349
2026-08-19 02:24:38 +02:00
Amruth Pillai ab811b5f10 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-08-18 20:42:47 +02:00
Amruth Pillai 65618a82a0 feat/dsh plugin (#3356)
* docs: remove .superpowers

* feat(dsh-plugin): bring the DeepSeek Harness plugin into the monorepo

Moves dsh-plugin-reactive-resume out of its own repository and into
packages/dsh-plugin. It stays a published, public npm package — the only
one here — but now builds, typechecks, tests, and lints under the same
turbo tasks as everything else.

The move pays for itself in the drift guard. Standalone, the plugin kept a
generated snapshot of the tool names scraped from the live server card at
https://rxresu.me, plus a weekly CI job to notice when that snapshot went
stale. Sitting next to packages/mcp, it reads MCP_TOOL_NAME directly, so a
tool rename breaks the prompt guide on the same pull request instead of
days later. The snapshot, the fetch script, and the scheduled job are gone.

packages/mcp gains a ./tool-names export so that import goes through the
public export map rather than another workspace's src.

Also flips autoInstallPeers off. The DeepSeek Harness rc packages declare
peers that are host-supplied and, in one case
(@deepseek-ai/dsh-type-meta), not published at all, so auto-install 404s
the whole workspace. Turning it off drops only optional peers elsewhere;
@neodrag/core was the single hard peer that had been arriving implicitly,
and it is now declared where it is used. Full typecheck and test suites
pass, and pnpm peers check reports nothing new beyond the pre-existing
drizzle-orm range mismatch.

Tests move from test/ to colocated src/*.test.ts and the build output from
lib/ to dist/ to match repository conventions.

* fix(dsh-plugin): ship a bundle manifest and target the current Harness

`dsh plugin add` warned that the package "declares no dsh.bundle — installed
as a plain dependency, not a profile layer", and it was right. Every other
Harness plugin, in-box and third-party, ships a cordis.patch.yml and points
dsh.bundle.patch at it; that declaration is what joins a package to a
profile's bundle stack. Without it the package installed and then sat inert,
and the README's hand-written insert row was a workaround for the gap rather
than the intended way in.

The peer ranges were also a generation behind. They asked for
@deepseek-ai/dsh-mcp-client and dsh-system-prompt at ^0.0.1-rc.1, which
cannot match the 0.1.0-rc.6 a current harness ships, so the plugin could
never have resolved against the thing it targets. Both APIs are unchanged
across the bump — StreamableHttpConfig still takes the same six fields and
PromptSection still takes name/order/text — so this is a range correction,
not a migration.

That bump pays for itself elsewhere. The old generation peer-depended on
@deepseek-ai/dsh-type-meta, which was never published, and working around
that 404 is why merging this package turned autoInstallPeers off for the
whole repository and pulled @neodrag/core in by hand. The new generation
dropped that peer and publishes every other one, so both changes are
reverted and pnpm-workspace.yaml is back to what it was.

Because a bundle patch mounts the plugin the moment it is installed, a
required apiKey would fail config validation and take the profile down
before the user ever had a chance to mint a key. It now defaults to empty
and apply() warns and mounts nothing, matching how dsh-honcho-memory
handles the same problem.

Verified by packing the tarball and installing it into a clean project with
default pnpm settings: it resolves, imports, and reports its exports.
2026-08-18 20:42:42 +02:00
Amruth Pillai 6f0c727770 docs: remove .superpowers 2026-08-18 19:50:36 +02:00
Amruth Pillai ebcaa4729f fix(stylesheet): stop item header titles overlapping the date under nowrap (#3355) 2026-08-18 17:47:23 +02:00
Amruth Pillai f14e120b00 Update star history chart links in README 2026-08-18 04:26:28 +02:00
Amruth Pillai d9da31e7bc fix(ci): pass issue_number when labeling new issues
context.issue spreads to { owner, repo, number }, but Octokit v9 requires
issue_number. The request hit /repos/.../issues//labels and returned 404,
so no opened issue was ever labeled.
2026-08-18 04:04:21 +02:00
github-actions[bot]andCrowdin Bot 128916b9a0 [skip ci] chore(i18n): sync translations from crowdin (#3346)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-18 03:50:35 +02:00
Amruth Pillai 00be67f702 feat(stylesheet): expose the item header row to Semantic CSS (#3345)
Section item headers render the title and its trailing date inside a shared
split row styled with `flex-wrap: wrap`. When the title is long the date wraps
onto its own line and left-aligns instead of staying pinned right, which reads
as inconsistent down a list of certifications.

That row had no selector. It is a bare View, so it never reached the semantic
tree: `item-header` matches the box around the row, and the title and date are
text nodes that layout properties do not apply to. There was no stylesheet that
could reach it.

Expose it as `template-part[name="item-header-row"]`, shared by every template
because the row comes from the shared section components. Awards,
certifications, projects and publications are covered; experience, education
and volunteer stack two rows and hand their headers to the
`inline-item-header-*` parts on some templates, so they are left alone.

Readers can now write:

    @version 1;
    template-part[name="item-header-row"] { flex-wrap: nowrap; }
2026-08-18 00:29:30 +02:00
Amruth Pillai 5392728f22 chore(ui): drop the orphaned next-themes dependency
The sonner wrapper was the only consumer of next-themes; the Base UI toast
that replaced it does not use the hook. knip flagged it as unused, and CI's
`knip --fix` step removed it and then failed `pnpm check` against a lockfile
that still listed it.
2026-08-17 23:08:33 +02:00
Amruth Pillai 0b0b4ef13b chore(release): v5.2.7
Bumps the version and adds the changelog entry for the changes since v5.2.6.
2026-08-17 22:54:57 +02:00
Amruth Pillai 24c15cd8cd chore(i18n): fill missing translations
Fills the 25 strings added this cycle by the toast migration, the account page
rename, the Custom Styles status labels and the job posting auto-fill, across
all 53 target catalogs. The zu-ZA pseudo-locale is intentionally left empty.
2026-08-17 22:54:57 +02:00
Amruth Pillai 6e3853fe13 chore(i18n): extract catalogs
Picks up the strings added and removed by the toast migration, the account page
rename and the autofill change.
2026-08-17 22:32:34 +02:00
Amruth Pillai b080fcddad docs: document intent skill loading
Adds the @tanstack/intent skill lookup step to AGENTS.md so agents check for a
matching local skill before editing files.
2026-08-17 22:32:33 +02:00
Amruth Pillai 9dc2aade46 chore(deps): update dependencies
Routine version bumps across the workspace. The @react-pdf/textkit patch is
renamed to drop the pinned version so it survives the next bump.
2026-08-17 22:32:33 +02:00
Amruth Pillai e2554c9be8 chore(ui): drop the sonner dependency
Every call site now uses the Base UI toast, so the sonner wrapper and its test
go with it.
2026-08-17 22:32:33 +02:00
Amruth Pillai eedf2faf02 feat(agent): let the assistant ask clarifying questions
Adds the questionnaire and empty-state primitives and renders the
ask_user_question tool call inline in the chat, so the agent can offer choices
instead of guessing when a request is ambiguous.
2026-08-17 22:32:33 +02:00
Amruth Pillai da2f1f8244 refactor(applications): autofill from a pasted posting instead of a URL
Fetching an arbitrary job URL server side meant owning SSRF defence, redirect
and size limits, and per-site scraping quirks. The autofill tool now takes only
pasted text, so the URL input, the fetch path and its MCP annotation are gone.

The sheet gates the call behind a tested AI provider and a minimum paste length
so a stray snippet does not spend an AI call.
2026-08-17 22:32:32 +02:00
Amruth Pillai 7a14b0dfbc refactor(settings): rename the danger zone page to account
The page now holds account-level actions rather than only destructive ones, so
it is reachable at /dashboard/settings/account and presented with a neutral
icon in the sidebar and command palette.
2026-08-17 22:32:32 +02:00
Amruth Pillai 23ceee2148 refactor(web): move toast call sites to the new component
Swaps sonner's toast.success/error/loading/dismiss for the new toast.add({ type,
description }) and toast.close across dialogs, auth pages, the builder, the
dashboard and the applications views. Behaviour is unchanged.
2026-08-17 22:32:32 +02:00
Amruth Pillai 170550ed59 feat(ui): add a Base UI toast component
Adds the toast primitive that replaces sonner, along with its design-sync card
mapping. Nothing consumes it yet; the call sites move over next.
2026-08-17 22:32:31 +02:00
Amruth Pillai ac062bbcbd test: cap turbo concurrency so suites stop timing out
Turbo defaults to ten concurrent tasks and each vitest sizes its pool to the
core count, so a ten-core machine ran roughly a hundred workers and a 1.6s test
blew its 15s budget. Different suites failed on every run. At concurrency four
the whole repo passed five runs straight with no wall-clock cost.
2026-08-17 22:19:52 +02:00
Amruth Pillai bfdd29f941 test(server): generate the OpenAPI spec once per suite
Each case rebuilt the whole spec, which walks every router and resume JSON
schema. The first case already carried a raised 15s timeout and still timed out
on a loaded machine. The spec is deterministic and only read here, so build it
once: the file drops from over 15s to 1.86s.
2026-08-17 22:19:52 +02:00
Amruth Pillai e8508e6d03 test: isolate test files to stop cross-file mock leakage
Without isolation the files in a worker share one module registry, so a
vi.mock of @reactive-resume/env/server in one file leaked into another and
whichever file imported the module first won. Measured on a clean cache,
isolate: false failed four of four whole-repo runs; with isolation, none.
2026-08-17 22:19:52 +02:00
Amruth Pillai 60d0440763 test: seed the required server env vars for every suite
Units that transitively import the validated server env threw at import time
whenever no .env was present, taking out packages/auth and packages/api. Seeding
the three required variables in the shared setup fixes every current and future
caller in one place. Real values still win.
2026-08-17 22:19:52 +02:00
Amruth Pillai f4bf6887b9 test(stylesheet): complete system variables at the end of the prefix
The case passed cursor position 5 into "--resume-", which lands mid-token and
reads as a selector context, so it received the selector list. Every other case
in the file uses source.length.
2026-08-17 22:19:52 +02:00
Amruth Pillai 817d4ef971 test(stylesheet): correct the malformed declaration offset
The expected offset disagreed with its own line and column: line 2 column 17 is
offset 28, which is where `red` starts. Offset 31 pointed at `; }`. The sibling
UTF-16 case in the same file already used the correct arithmetic.

Anchors the offset to the source it must point at so it cannot drift again.
2026-08-17 22:19:52 +02:00
Amruth Pillai 7c7dbaf21d fix(agent): keep the chat composer focused while streaming
Disabling the textarea for the duration of a response made the browser blur it,
so the caret left the composer on every send and had to be clicked back. send()
already ignores calls mid-stream, so Enter stays a no-op and type-ahead works.
2026-08-17 22:19:52 +02:00
Amruth Pillai 762b999d1e fix(agent): key chat message parts by index
Every step-start part serialises to the same JSON, so the content-derived key
collided for any multi-step assistant message and React warned about duplicate
keys on each incoming chunk. Two identical text parts collided the same way.

Parts are append-only and never reordered by the AI SDK, so the index is stable.
2026-08-17 22:19:52 +02:00
Amruth Pillai 9d0dc36706 feat(seo): render social card metadata for public resumes
Public resume pages only produced their OpenGraph and Twitter tags client side,
so a shared link had no card at all. The server now injects them into the shell
and swaps in the resume's own title and description.

The lookup is scoped to public, password-free resumes and deliberately avoids
resumeService.getBySlug: that counts a view and would expose a protected
resume's summary to an unauthenticated crawler. User-authored values are escaped
before they reach the HTML, and any lookup failure falls back to the plain shell.

getResumeSocialMeta is shared with the client route head so the two cannot drift.
2026-08-17 22:19:52 +02:00
Amruth Pillai d0fa9ae8da fix(seo): shorten the meta description for mobile search results
The 131 character description overflowed the three line snippet Google renders
on mobile. The replacement is 114 characters and keeps the same claims.
2026-08-17 22:19:52 +02:00
Amruth Pillai 1e23a453a0 fix(seo): declare Twitter card tags with name attributes
X reads twitter:* meta tags from the name attribute, not property, so the card
validator reported twitter:title and twitter:description as missing. Also adds
the og:type tag the root head was never emitting.
2026-08-17 22:19:52 +02:00
Amruth Pillai 36c35c9bd5 fix(seo): serve the root request through the web app handler
The static middleware was mounted ahead of the web app fallback, and Hono's
serveStatic resolves "/" to the directory and returns dist/index.html verbatim.
handleWebApp never ran for the root route, so the OpenGraph, Twitter, canonical
and JSON-LD markup it injects was missing in production - fetching
https://rxresu.me/ as Twitterbot returned zero og: tags.

Route "/" explicitly before the static middleware so the injection runs.
2026-08-17 22:19:52 +02:00
github-actions[bot]andCrowdin Bot 0c7c3ac4c4 [skip ci] chore(i18n): sync translations from crowdin (#3330)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-17 10:20:08 +02:00
Amruth Pillai 9509b5bc2e refactor(stylesheet): move Semantic CSS to the browser (#3329) 2026-08-16 16:50:27 +02:00
github-actions[bot]andCrowdin Bot f848e57436 Sync Translations from Crowdin (#3328)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-16 12:46:45 +02:00
a4bc2693be fix(ai): bound the provider test and explain why it failed (#3319)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-16 12:45:00 +02:00
github-actions[bot]andCrowdin Bot 104e954b77 Sync Translations from Crowdin (#3327)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-16 11:57:33 +02:00
Kaushik NandClaude Opus 5 118f3679a3 fix(lefthook): run the conflict-marker check on Windows (#3320)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:55:30 +02:00
Amruth Pillai 6c1280dca9 chore(github): organize issue triage (#3325) 2026-08-16 10:59:02 +02:00
Amruth Pillai 8affc567e3 fix: show non-expiring API keys (#3324) 2026-08-16 10:58:55 +02:00
Amruth Pillai 409d09809a chore: release v5.2.6 2026-08-14 05:24:08 +02:00
ignaciocarreandAmruth Pillai 6d9ebccc63 feat(mcp): add cover-letter PDF downloads (#3304)
* feat(mcp): add cover-letter PDF downloads

* fix(mcp): bind signed PDF targets

* test(mcp): cover unavailable cover letters

* fix(api): accept legacy PDF download targets

* fix(server): limit legacy PDF tokens to resumes

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:09:56 +02:00
Syed Ali Abbas ZaidiandAmruth Pillai 45303fb465 feat(resume): add a deterministic ATS parseability check (#3314)
* feat(resume): add a deterministic ATS parseability check

Adds an offline ATS linter that reports whether a parser can read a
resume, surfaced as an always-on panel in the builder.

The existing Resume Analysis panel needs a configured AI provider, so
users who never set one up get no feedback at all. These 22 rules run
as a pure function over ResumeData with no provider, no network and no
rendered PDF, so they work for everyone on every edit.

Rules cover contact details, date parseability, sections that hold
content but never render, column and sidebar placement, and typography
thresholds. The catalog mirrors the Semantic CSS diagnostic catalog:
stable codes carrying a severity, meaning and action, with no i18n
dependency so the web layer translates by code. Each finding carries a
JSON Pointer, which is what makes jump-to-field work.

Deliberately no second score. Resume Analysis owns overallScore, so
this reports "N of M checks passed" and counts by severity instead.

* fix(resume): accept localized ongoing periods and reject bare ones

Two period-parsing bugs found in review.

The ongoing-token set was English-only, so a German resume reading
"2020 - heute" was reported as unparseable and the panel told the user
to rewrite a perfectly valid range. Rather than guess translations for
55 locales, a range ending that carries no digits and is not a month
name in the resume's locale is now read as ongoing. That keeps a
genuinely incomplete ending such as "Jan 2020 - Feb" reported, since
"Feb" resolves as a month.

A bare "Present" also parsed as a valid period, so an experience entry
with no start date passed the check. A standalone ongoing token is now
rejected; ongoing tokens remain valid as the end of a range.

* feat(web): scroll ATS findings to the item they belong to

Findings for different items in one section all landed on the section
header, so a date problem on the third role gave no more help than
naming the section.

getAtsFindingTarget now resolves the offending item from the finding's
JSON Pointer against the resume, and SectionItem carries a matching DOM
id. The panel scrolls to that item and falls back to the section header
when the item is not mounted, which is what happens while its section
is collapsed.

* fix(resume): recognize ongoing periods by token, not by shape

The previous heuristic read any short, digit-free range ending as an
ongoing marker, so "2020 - unknown", "2020 - later" and "2020 - tbd"
parsed cleanly and suppressed the finding they should have raised.

Replaced with an explicit table of ongoing words keyed by language,
covering the locales the app ships. Matching is exact, so unrecognized
endings are reported again. A locale missing from the table falls back
to the earlier behaviour of reporting its ongoing periods, which is a
visible gap someone can close by adding a word rather than a silent
hole in detection.

Tests assert every listed token parses and that the table stays
lowercase, since lookups normalize that way.

* fix(ats): parse punctuated ongoing tokens

* fix(ats): parse Unicode punctuated ongoing tokens

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:07:55 +02:00
Santhi PrakashandAmruth Pillai f64d02df7f fix(pdf): ignore phantom gengar skill text nodes (#3289)
* docs(agents): align Redis compose commands with development guide

- Problem: AGENTS.md omitted Redis from dev infrastructure compose commands
  while docs/contributing/development.mdx starts redis for local dev.
- Fix: document full postgres/redis/seaweedfs compose command and note that
  REDIS_URL and ENCRYPTION_SECRET are required for AI agent features.
- Verification: preflight upstream fetch; manual diff against development.mdx
  and compose.dev.yml redis service; duplicate PR gate passed.

* fix(pdf): ignore phantom gengar skill text nodes

- Problem: gengar template resumes with skills keywords fail semantic CSS activation because the legacy renderer emits a harmless empty text node that parity treats as a mismatch.

- Fix: treat the specific empty text artifact as presentation-neutral in the legacy parity comparator and add a regression test for the phantom fontSize 9 node.

- Verification: pnpm test src/semantic/legacy-parity.test.ts in packages/pdf passed (31 tests).

* docs: clarify host and container Redis URLs

- Problem: the development guide only showed the Docker Redis hostname, which fails for host-run development.\n- Fix: document localhost for host execution and redis for Docker execution.\n- Verification: pnpm test src/semantic/legacy-parity.test.ts (31 passed).

* fix(pdf): omit empty skill proficiency text

* test(pdf): cover blank skill proficiency

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:07:49 +02:00
bad431b2fc fix(import): auto-detect JSON format and show readable errors (#1) (#3296)
* fix(import): auto-detect JSON format and show readable errors (#1)

Readable import errors, a fail-soft v4 parser, and auto-detect of the JSON format so uploads just work. The format dropdown becomes an optional override. PDF and DOCX (AI) paths are untouched.

* fix(import): address review feedback on the v4 guard and error message

- reactive-resume-v4-json.tsx: reject arrays in isRecord so array-valued
  basics, sections, or metadata no longer pass the v4 shape guard.
- reactive-resume-v4-json.tsx: reuse the guard's error instance in the
  catch arm instead of allocating a duplicate NOT_V4_MESSAGE.
- error.ts: use a singular "Problem" label for root-level Zod issues so
  the message stays grammatical.
- add a regression test for array-valued v4 branches.

* fix(import): preserve selected JSON format

* test(import): cover selected JSON parser

---------

Co-authored-by: MrTig-afk <MrTig-afk@users.noreply.github.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:01:06 +02:00
Syed Ali Abbas ZaidiandAmruth Pillai 9f13638eab fix(web): drop focus when panning the builder canvas (#3303)
react-zoom-pan-pinch calls preventDefault() on its window-level mousedown
listener so that dragging the canvas does not select text. That also cancels the
browser's native focus shift, so focus stays wherever it was before the pan --
typically the sidebar button that opened the last dialog, since closing a dialog
restores focus to its trigger. A focused button activates on Space keyup, so
holding space to pan and then releasing it reopened the most recent dialog.

Blur the focused element from onPanningStart, which reinstates exactly the focus
change the browser would have made on its own. onPanningStart only fires when the
mousedown target is inside the transform wrapper, so sidebar and dialog clicks are
unaffected, and keyboard-only users never trigger a pointer pan.

Closes #3300

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:54:23 +02:00
13e584d522 fix(icon-picker): allow selecting the empty/no-icon option (#3298)
The icon picker grid starts with an empty string entry that renders the
"no icon" (prohibit) symbol, but the onClick guard "if (icon)" treated
the empty string as falsy and ignored the click. Change the guard to
check for a defined string value so the empty/no-icon option can be
selected.

Closes #3252
Closes #3261

Co-authored-by: Devin <devin@example.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:54:16 +02:00
Santhi PrakashandAmruth Pillai 6035402832 docs(agents): align Redis compose commands with development guide (#3288)
* docs(agents): align Redis compose commands with development guide

- Problem: AGENTS.md omitted Redis from dev infrastructure compose commands
  while docs/contributing/development.mdx starts redis for local dev.
- Fix: document full postgres/redis/seaweedfs compose command and note that
  REDIS_URL and ENCRYPTION_SECRET are required for AI agent features.
- Verification: preflight upstream fetch; manual diff against development.mdx
  and compose.dev.yml redis service; duplicate PR gate passed.

* docs(agents): clarify Redis development URLs

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:54:10 +02:00
Shehraan HafizandAmruth Pillai 69961210bd fix(pdf): missing spaces around bold rich text in PDFs (#3273)
* fix(pdf): missing spaces around bold rich text in PDFs

* fix(pdf): make bold tag matching quote-aware

* fix(pdf): preserve quoted bold tag attributes

* fix(pdf): handle encoded non-breaking spaces in bold boundaries

* fix(pdf): preserve top-level bold boundary spaces

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:54:05 +02:00
Santhi PrakashandAmruth Pillai 7eb6d3bdbf fix(auth): use loopback URL for MCP OAuth JWKS verification (#3297)
* fix(auth): use loopback URL for MCP OAuth JWKS verification

Fetch the JWKS endpoint over the internal loopback address instead of the public APP_URL, so token verification works under Docker port-mapping, reverse proxies, and other deployments where the public URL does not loop back to the Node process.

Also log the specific MCP OAuth verification error instead of swallowing it with a bare catch.

Fixes #3077

* fix(auth): normalize internal JWKS URL and throttle MCP OAuth warnings

 - Problem: default loopback JWKS URL used PORT in dev where the server
   listens on SERVER_PORT (3001), and trailing-slash overrides produced
   //api/auth/jwks; unthrottled warn logs could flood on bad bearer tokens.
 - Fix: resolveInternalBaseUrl trims/normalizes BETTER_AUTH_INTERNAL_URL,
   mirrors apps/server listen-port selection, and MCP OAuth warnings are
   throttled to once per minute.
 - Verification: pnpm exec biome check on changed files; pnpm typecheck.

* fix(auth): declare BETTER_AUTH_INTERNAL_URL in turbo globalEnv

- Problem: Turborepo strict env mode strips undeclared BETTER_AUTH_INTERNAL_URL under pnpm dev, so the JWKS override silently falls back to loopback.
- Fix: add BETTER_AUTH_INTERNAL_URL to turbo.json globalEnv (required for any new env var per CLAUDE.md).
- Verification: python3 JSON parse of turbo.json; confirmed var was absent from globalEnv before this change.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:51:02 +02:00
Santhi PrakashandAmruth Pillai 5fc9c3ee04 fix(pdf): register Noto punctuation fallback for missing glyphs (#3294)
* fix(pdf): register Noto punctuation fallback for missing glyphs

- Problem: U+2022 bullet characters render as garbled glyphs when the body
  font (e.g. IBM Plex Serif) lacks the glyph and no PDF fallback is registered.
- Fix: append Noto Serif/Sans to the PDF fallback stack as a general-purpose
  punctuation source covering General Punctuation (U+2000–U+206F).
- Verification: pnpm --filter @reactive-resume/fonts test;
  pnpm --filter @reactive-resume/pdf test src/hooks/use-register-fonts.test.ts

* test(fonts): clarify zh-CN fallback test description

- Problem: getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-CN" }) now
  returns ["Noto Serif SC", "Noto Serif"] (the general-purpose punctuation
  fallback is appended), so the test description "returns only the Simplified
  Chinese font for zh-CN (unchanged behavior)" is no longer accurate.
- Fix: rename the test to describe that it uses the Simplified Chinese font
  plus the punctuation fallback. The assertion is unchanged.
- Verification: pnpm --filter @reactive-resume/fonts test -> 45/45 passing.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:50:58 +02:00
Santhi PrakashandAmruth Pillai a8d1f5a685 docs(contributing): align app names in guide frontmatter (#3287)
- Problem: development.mdx and architecture.mdx frontmatter still referenced
  removed client/worker/artboard apps even though the monorepo only ships
  apps/web and apps/server.
- Fix: update both descriptions to say web and server apps.
- Verification: docs-only; grep confirms only apps/web and apps/server exist.

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:50:53 +02:00
Santhi PrakashandAmruth Pillai dd9843172b docs(contributing): align development guide with dotenvx workflow (#3286)
* docs(contributing): align development guide with dotenvx workflow

- Problem: development.mdx told contributors to use a root `.env` file and
  export DATABASE_URL manually, while AGENTS.md and compose.dev.yml use
  `.env.local` loaded through dotenvx for dev and migration commands.
- Fix: update the setup, migration, dev-server, and database sections to
  match the dotenvx commands documented in AGENTS.md.
- Verification: preflight_ship.py (upstream bug marker present); duplicate
  PR check clean; docs-only change.

* docs(contributing): add cp command to env setup step

- Problem: setup step said to copy .env.example but the bash block only listed variable assignments.
- Fix: add explicit cp .env.example .env.local command and label the following block as edits.
- Verification: manual review of development.mdx; addresses CodeRabbit review on #3286.

* docs(contributing): align AGENTS.md env copy target with dotenvx

- Problem: AGENTS.md told contributors to copy .env.example to .env while all dev commands use .env.local.
- Fix: update the copy instruction to .env.local for consistency with the dotenvx workflow.
- Verification: manual review; folded into #3286 dotenvx alignment PR.

* docs(contributing): dotenvx-wrap remaining dev script references

- Problem: scripts table and troubleshooting still showed bare pnpm dev/db commands after the dotenvx workflow update.
- Fix: prefix dev, db, and port-override examples with dotenvx run -f .env.local --.
- Verification: manual review of development.mdx; folded into #3286.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 22:50:48 +02:00
Amruth Pillai 28d698635f build: use official pnpm image 2026-08-13 22:43:53 +02:00
Amruth Pillai 3635b3d578 fix(stylesheet): skip parity on explicit activation (#3316) 2026-08-13 15:53:28 +02:00
Amruth Pillai 5a75eda893 ci: remove semantic CSS acceptance test 2026-08-13 09:40:54 +02:00
github-actions[bot]andCrowdin Bot e4b28e9825 Sync Translations from Crowdin (#3315)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-13 09:36:15 +02:00
Amruth Pillai 2d6ea9ce8d fix(auth): allow implicit social signup 2026-08-13 09:23:04 +02:00
Amruth Pillai 0e463883af docs: design implicit social signup 2026-08-13 09:16:18 +02:00
Syed Ali Abbas Zaidi 3a5b12e2a4 fix(web): confirm before the import dialog's provider link navigates away (#3308) 2026-08-11 10:26:04 +02:00
Amruth Pillai 035d94183b fix(web): show static template previews in gallery (#3302) 2026-08-10 12:18:47 +02:00
Amruth Pillai efd950bd93 fix(server): log unhandled rejections instead of crashing the process
Node 24 terminates the whole process on an unhandled promise rejection, so a
single request's stray rejection could take the server down for every user
(as the USER_STOPPED agent-abort bug did). Add a process-level unhandledRejection
handler that logs and keeps serving. Uncaught exceptions are intentionally left
on Node's default crash-and-restart, since process state is unsafe afterward.

Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye
2026-08-09 16:01:23 +02:00
Amruth Pillai 04100aa9ef fix(agent): abort stopped runs with an AbortError, not a bare string
Stopping (or archiving) an agent run called controller.abort("USER_STOPPED")
with a plain string reason. The AI SDK only recognizes a cancellation when the
reason is an AbortError (err.name === "AbortError" / isAbortError); a bare
string is treated as a real stream error, and its rejection escaped the
background resumable-stream pump and crashed the whole server process with
ERR_UNHANDLED_REJECTION on every user Stop. Abort with a DOMException named
AbortError (label preserved as the message) so the SDK cancels the run
gracefully. Same fix for the USER_ARCHIVED path.

Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye
2026-08-09 15:46:31 +02:00
Amruth Pillai c292968314 chore: update translations 2026-08-09 15:10:09 +02:00
Amruth Pillai ba1f469950 fix(a11y): label 2FA copy button, restore template focus ring, guard API-key double-submit
- Add an sr-only "Copy secret" label to the icon-only copy button in the 2FA
  enable dialog; it was previously announced as an unlabeled button.
- Add a focus-visible ring to template gallery cards. The only ring was gated
  on the selected state, so keyboard focus was invisible while tabbing.
- Disable the API-key create submit button while the request is in flight to
  prevent duplicate keys from a double-click.

Surfaced by a shadscan UI audit. The remaining ~95 findings were false
positives from the auditor not understanding the pnpm monorepo and the
TanStack Start root-route shell, and were waived.

Claude-Session: https://claude.ai/code/session_01JYTniVDeA56o1kGhdoCUoD
2026-08-09 14:48:46 +02:00
Amruth Pillai b4f245a38e fix(deps): restore @react-pdf/textkit patch dropped by the 4.6.0 bump
The dependency-update commit bumped @react-pdf/renderer 4.5.1->4.6.0 (textkit
6.3.0->6.4.0) and silently dropped the pnpm patch that overrides font vertical
metrics to prefer OS/2 sTypo* over inflated hhea values, matching browser line
boxes. 6.4.0 did not upstream it, so the semantic-CSS template visual snapshots
(baselined with the patch, maxDiffPixelRatio 0) no longer matched and the E2E
job failed. Re-create the patch for textkit@6.4.0 and re-register it in
patchedDependencies; remove the orphaned 6.3.0 patch.

Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye
2026-08-09 14:41:01 +02:00
Amruth Pillai e6a31aab97 fix(stylesheet): warm and reuse the server PDF preflight worker
The server PDF preflight spawned a fresh worker per semantic-CSS edit, each
racing a 15s startup deadline to cold-load the ~721kB+5MB PDF runtime. That
load is super-linear in CPU (~3s at 1 vCPU, >15s on a throttled/shared vCPU),
so on a constrained box every edit hit the startup-timeout path and returned
STYLESHEET_PREFLIGHT_WORKER_FAILED. Since the service only advances the applied
stylesheet when preflight passes, custom styles never applied and the editor
stuck on Checking.

Warm one worker at boot and reuse it (message-based input, respawn on
crash/timeout), so the cold load is paid once instead of per edit. Raise the
render deadline 5s->30s (a rich resume renders ~5-18s on a slow box) and the
readiness ceiling to 120s so the one-time warm completes even when throttled.
Surface worker load failures instead of an unhandled-rejection crash, and log
runner-side failure paths so the previously opaque failure is diagnosable.
Verified in node:24-slim under --cpus=0.25/0.35/0.5: all reused requests pass.

Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye
2026-08-09 14:30:55 +02:00
Amruth Pillai 88a19619da chore: update dependencies 2026-08-09 12:17:09 +02:00
autofix-ci[bot] 36232b631d [autofix.ci] apply automated fixes 2026-07-31 15:25:43 +00:00
Amruth Pillai 9eec1520a1 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-07-31 17:24:40 +02:00
Amruth Pillai 131c1492cd chore: update dependencies 2026-07-31 17:24:32 +02:00
Amruth PillaiandCursor Agent ba8e1be2ab fix(stylesheet): harden PDF preflight and surface worker failures (#3284)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-31 07:57:54 +02:00
Amruth PillaiandCursor Agent 4a8f87ab8f fix(web): stop custom styles from sticking on Checking (#3283)
Concurrent compile requests from editor intelligence were rejecting
in-flight edit compiles as stale, and the store swallowed that rejection
without leaving compiling. Resolve all compile results and surface
compile failures as an error status so styles can apply again.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-31 01:00:45 +02:00
github-actions[bot]andCrowdin Bot 186c400ab7 Sync Translations from Crowdin (#3281)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-30 18:59:23 +02:00
Amruth Pillai d314361ad6 fix(ci): run current semantic CSS tests 2026-07-30 18:48:56 +02:00
Amruth Pillai b071a118a3 test: remove slow OpenAPI spec synchronization test 2026-07-30 16:37:03 +02:00
Amruth Pillai 3589b534f5 feat: enable semantic CSS by default 2026-07-30 16:28:44 +02:00
github-actions[bot]andCrowdin Bot 1ee24e5a9f Sync Translations from Crowdin (#3280)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-30 13:01:23 +02:00
Amruth Pillai 93bf1e882d docs: update spec.json 2026-07-30 13:00:30 +02:00
Amruth Pillai ae8d48bcee chore: update translations 2026-07-30 12:59:19 +02:00
Amruth Pillai 517199471a docs: add changelog of v5.2.5 2026-07-30 12:56:28 +02:00
Santhi Prakash 15f8bce988 docs: align pnpm version with packageManager field (#3278) 2026-07-30 12:47:00 +02:00
Amruth Pillai 164a279306 chore: update dependencies 2026-07-30 12:45:49 +02:00
github-actions[bot]andCrowdin Bot 79e4a3ddc8 Sync Translations from Crowdin (#3279)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-30 12:41:45 +02:00
Amruth PillaiandCursor Agent d2ffbf9618 feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-30 12:39:15 +02:00
4ac19f81b3 fix: clamp page margin values to [0, 100] to prevent crash on paste (#3277)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-29 18:13:42 +02:00
Amruth Pillai b303b89758 fix(web): restore Tiptap Enter and list editing 2026-07-28 15:10:24 +02:00
Amruth Pillai c6ac3fd1a9 docs: remove redirects 2026-07-28 09:39:23 +02:00
Amruth Pillai fe6f84e06d docs: resolve final comparison review findings 2026-07-28 09:19:16 +02:00
Amruth Pillai 9d6426b2e0 docs: publish resume builder comparison cluster 2026-07-28 09:04:43 +02:00
Amruth Pillai d34a429dea docs: remove unsupported deployment comparisons 2026-07-28 08:59:56 +02:00
Amruth Pillai b69583c181 docs: compare career and template resume tools 2026-07-28 08:57:31 +02:00
Amruth Pillai 50f50b2672 fix(server): use public URL for homepage metadata 2026-07-28 08:56:43 +02:00
Amruth Pillai fb8c73be76 docs: narrow unsupported AI comparison claims 2026-07-28 08:53:18 +02:00
Amruth Pillai 18468a5658 docs: tighten AI comparison sourcing 2026-07-28 08:50:03 +02:00
Amruth Pillai 048eab3b49 fix(web): preserve bootstrap attribute order 2026-07-28 08:48:14 +02:00
Amruth Pillai ca774c77c8 docs: compare AI and ATS resume builders 2026-07-28 08:46:25 +02:00
Amruth Pillai a4897c20d7 docs: canonicalize getting started routes 2026-07-28 08:41:53 +02:00
Amruth Pillai bed14a72af docs: compare mainstream resume builders 2026-07-28 08:39:18 +02:00
Amruth Pillai 93c06934bd fix(web): preserve Rocket Loader exclusion in build 2026-07-28 08:38:05 +02:00
Amruth Pillai 1e665fbe7e perf(web): remove homepage video from the LCP path 2026-07-28 08:35:07 +02:00
Amruth Pillai 30812f8a8e docs: compare Reactive Resume with design editors 2026-07-28 08:33:08 +02:00
Amruth Pillai dd0531091b fix(server): limit immutable media cache headers 2026-07-28 08:31:26 +02:00
Amruth Pillai a2901bfb2e docs: plan SEO comparison content cluster 2026-07-28 08:08:29 +02:00
Amruth Pillai 418c7887ee fix(server): emit initial homepage SEO metadata 2026-07-28 08:07:53 +02:00
Amruth Pillai 12407d473d docs: plan SEO and AEO performance improvements 2026-07-28 07:36:27 +02:00
Amruth Pillai 36a46cfd66 docs: design SEO comparison content cluster 2026-07-28 07:34:36 +02:00
Amruth Pillai 0868a92e62 docs: design SEO and AEO performance improvements 2026-07-28 07:31:35 +02:00
Amruth Pillai 822d6f9431 chore: bump version to 5.2.4 2026-07-28 07:00:16 +02:00
Amruth Pillai 994093b981 chore: update translations 2026-07-27 20:57:54 +02:00
Amruth Pillai 9110e86997 refactor: ponytail audit 2026-07-27 20:26:16 +02:00
ServaTilisandClaude Opus 4.8 bb1fb3a7d6 perf(api): lazy-load PDF renderer to cut server cold-start (#3244)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 13:42:07 +02:00
Diego Vega Centeno e34e7be6e0 fix(pdf): add flex to skill name to participate in layout sizing (#3253) 2026-07-27 13:39:14 +02:00
Rakshit Kaintura 34c03b1f73 fix(auth): map oauth login to correct user id instead of account id (#3256) 2026-07-27 13:38:30 +02:00
EMRANandAmruth Pillai 0eb9ce012e fix: profile picture delete icon should reset image correctly (#3176) (#3258)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-27 13:31:57 +02:00
autofix-ci[bot] 966bc3ed58 [autofix.ci] apply automated fixes 2026-07-27 11:23:26 +00:00
EMRAN 08d859010c fix: allow award title unbold via custom styles (#3250) (#3257) 2026-07-27 13:22:37 +02:00
Emanuele TonelloandAmruth Pillai 47349e7ab3 feat: support editing AI provider models in the UI and auto-fill LinkedIn job postings (#3259)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-27 13:21:57 +02:00
Amruth Pillai 3266066826 chore: update dependencies 2026-07-27 13:19:54 +02:00
autofix-ci[bot] 6503da7e49 [autofix.ci] apply automated fixes 2026-07-27 11:14:46 +00:00
落尘 2a0782517c fix: clamp custom style numeric inputs (#3262) 2026-07-27 13:13:48 +02:00
cielhaidir d4cf260aed fix(api): support HTTPS job posting fetches (#3267) 2026-07-27 13:13:25 +02:00
Santhi Prakash e6b4733c5f docs(contributing): fix troubleshooting accordion code block formatting (#3269) 2026-07-27 13:12:44 +02:00
Diego Vega CentenoandAmruth Pillai 689e7e24d4 Filter invalid style intents to preserve valid custom styles (#3241)
* Filter invalid style intents to preserve valid custom styles

* fix: preserve valid custom style rules

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-09 15:50:34 +02:00
github-actions[bot]andCrowdin Bot 9085a199cf Sync Translations from Crowdin (#3243)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-09 15:35:39 +02:00
Andrea Accardo d536b1921f fix: bullet list indentation on page break (#3242)
Signed-off-by: aaccardo <hackardo@gmail.com>
2026-07-09 15:33:40 +02:00
Amruth Pillai 2b0aac820c chore(i18n): sync translations from crowdin 2026-07-09 01:24:39 +02:00
Amruth Pillai ac98139096 docs: pin v4 migration script checkout 2026-07-09 01:17:56 +02:00
Amruth Pillai d50948ddee chore(i18n): update application timeline translations 2026-07-09 01:17:56 +02:00
Amruth Pillai 42bac75ae2 Update README.md 2026-07-09 00:58:11 +02:00
Amruth Pillai c77745f34e Update README.md 2026-07-09 00:56:40 +02:00
Andrea AccardoandAmruth Pillai ed5d10c491 fix: solve list marker page break (#3177) (#3236)
Signed-off-by: aaccardo <hackardo@gmail.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-09 00:47:50 +02:00
Amruth Pillai 18d0c14aa1 feat: add application timeline history (#3237)
* feat: add application timeline history

* fix: address application timeline review

* fix: keep application tracker e2e stable

* fix: use stable timeline e2e selector

* fix: target timeline note input in e2e
2026-07-09 00:36:45 +02:00
Amruth Pillai 1124d3dfda Fix OAuth metadata authorization server list 2026-07-08 22:15:11 +02:00
Amruth Pillai 90105cb148 chore: integrate improve-integration 2026-07-08 19:08:31 +02:00
Amruth Pillai 73daf22b2f docs: publish MCP registry metadata 2026-07-07 18:50:07 +02:00
github-actions[bot]andCrowdin Bot 25021507a0 [skip ci] chore(i18n): sync translations from crowdin (#3233)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-07 18:21:35 +02:00
Amruth Pillai 8570c1c70a fix: render cover letter exports without resume chrome 2026-07-07 17:47:52 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 5270a2a9a0 docs: tighten SEO titles and descriptions across new docs (#3232)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-07 15:08:12 +00:00
github-actions[bot]andCrowdin Bot b87a9d8282 Sync Translations from Crowdin (#3231)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-07 17:06:14 +02:00
Amruth Pillai 46afc65cc6 Add application tracker REST and MCP parity
Add comprehensive Application Tracker REST and MCP coverage, document the MCP workflow, add Markdown/ActionLint checks, bump the release version, and fill all extracted translations.
2026-07-07 17:02:39 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> dfc5559625 docs: document expanded command palette entity search (#3225)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-06 00:32:00 +02:00
github-actions[bot]andCrowdin Bot d37ac57cc5 [skip ci] chore(i18n): sync translations from crowdin (#3224)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-06 00:30:34 +02:00
Amruth Pillai fb9c217af2 feat: add command palette entity search 2026-07-06 00:29:06 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 0a64312bf8 docs: lengthen short SEO descriptions on two guides (#3222)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-05 21:45:25 +00:00
Amruth Pillai b404dbd42a Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot

Add an Applications module at /dashboard/applications: pipeline board
(dnd-kit), table view with bulk actions, Insights (fit tiles, funnel,
sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and
Add/Edit/Detail slide-overs. Each application links a live Reactive
Resume.

AI "Application Copilot" (applications.ai.*): job-posting autofill,
resume↔job match score (fit ring), resume tailoring, and cover-letter /
follow-up drafting — via the user's configured provider.

Board cards + table rows get context menus (edit / move / archive /
delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox.

Also includes local TanStack devtools setup and toolchain bumps.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* feat(applications): close follow-up gaps + squash migrations

Finish the deferred/open items on the applications tracker:

- Cover-letter upload re-enabled. Fix the storage blocker by deriving the
  key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE)
  instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image
  avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add
  coverLetterUrl/coverLetterName columns + Documents-section upload/remove.
- Contacts editor in the detail sheet (add/edit/remove, keyed per app).
- Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more").
- Extract new Lingui messages across locales.
- Guard coverLetterUrl to http(s)/relative at the API boundary.

Squash the five branch-only application-table migrations (create -> +tags
-> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via
drizzle-kit generate.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* chore: update dependencies

* fix(web): address React Doctor findings — compiler, purity, query, component structure

prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces,
handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to
module scope so they aren't rebuilt on every render.

react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the
non-assignment form to unblock auto-memoization.

set-state-in-effect: derive updatedAtLabel at render time instead of syncing
it through useState + useEffect.

query-destructure-result: destructure useQuery results at call site in
resume-analysis and resume-thumbnail to follow TanStack Query v5 convention.

only-export-components: extract non-component exports to sibling .ts files so
Fast Refresh can preserve component state:
  - getNextWeights → typography/get-next-weights.ts
  - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts
  - getLocaleOptions → features/locale/locale-options.tsx
  - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts
  - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts
  - computeDelta + getSparklinePoints → statistics.utils.ts

no-multi-comp: split multi-component files into focused companions:
  - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx
  - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx
  - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx
  - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts

fix(tests): add Resume type import to section-builder mocks and cast partial
mock data as unknown as Resume to satisfy stricter type checking; fix
noExplicitAny Biome errors in the same mocks.

* feat(applications): improve performance

* chore: fix knip issues

* perf(builder): halve per-keystroke render cost

Section-form fields called `form.handleSubmit()` on every keystroke, which
re-validated the whole form and toggled submit state — firing the render
cascade twice per character (~6809 renders/keystroke, FPS dropping to 9).

Persist via a form-level `listeners.onChange` instead and drop the per-field
`handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume
subscriptions to metadata slices so they no longer re-render on content edits.

Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview,
and design controls verified working; 449/449 web tests pass.

* perf(home): eliminate hero CLS from unreserved video box

The hero <section> is `flex items-center` (shrink-to-fit), so the video
wrapper's width depended on the video's intrinsic size, which only resolves
after the media loads. aspect-ratio couldn't reserve height without a definite
width, so the video grew from ~190px to ~563px after first paint and shoved the
centered hero text down ~373px (CLS ~0.095).

Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an
explicit aspect ratio + width/height on the video so its box is reserved before
load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl.

* docs: add application tracker guides

* chore(db): squash application migrations

* fix(email): import React in auth template for server-side rendering compatibility

* chore(release): v5.2.1

* Refactor resume rendering and builder workflows

* fix: address application tracker review findings
2026-07-05 23:44:04 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> be43b4556b Update from code changes: refreshed download and cover letter export docs (#3219)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-05 14:53:48 +02:00
github-actions[bot]andCrowdin Bot 0d1bfd4e6b Sync Translations from Crowdin (#3218)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-05 14:42:41 +02:00
Amruth Pillai 20c803e934 feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown export (#3217)
* feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown

Let people export the resume and cover letter as distinct documents, and add a
Markdown format alongside PDF / DOCX / JSON (handy for AI agents).

- Server/API: scope PDF generation and download URLs to a resume/cover-letter target.
- Export domain: getResumeExportData + resumeHasCoverLetter in @reactive-resume/resume.
- Redesign the download dialog: one global "What to export" scope toggle (Tabs) plus
  flattened per-format rows, reusing existing UI components and design language.
- Add Markdown export (@reactive-resume/resume/markdown) with a small tiptap-HTML converter.
- Fix blank section headings in DOCX and Markdown by injecting the locale-aware
  section-title resolver (titles are stored empty and resolved at render time).
- Locale catalogs updated for the new strings.

* test(e2e): open the download dialog before exporting JSON

The JSON export moved into the redesigned download dialog, so the spec now opens
the dialog from the Export sidebar section before clicking "Download JSON".
2026-07-05 14:40:49 +02:00
Amruth Pillai 6e7fc68068 fix(design-sync): address CodeRabbit review on preview files
- Add `import type * as React from "react"` to the 26 previews that reference
  `React.CSSProperties` under the automatic JSX runtime (React isn't a global
  type namespace there, so the annotation was unresolved standalone).
- Accordion preview: use `multiple` instead of `openMultiple` — @base-ui/react
  1.6 renamed the prop, so the multi-open cell wasn't actually multi-open.

Skipped CodeRabbit's BrandIcon dark-mode note: the preview renders in the
default light card (the dark <img> is hidden there); per-theme sources would
need component support it doesn't expose.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE
2026-07-05 06:30:01 +02:00
Amruth Pillai a28e3baa61 chore(design-sync): add Reactive Resume UI sync inputs (#3216)
* chore(design-sync): add Reactive Resume UI sync inputs

Sync inputs for the claude.ai/design "Reactive Resume" project — the
@reactive-resume/ui design system (39 primary components).

- .design-sync/config.json — converter config (synth-entry, 202->39 card
  prune, overlay/grid cardMode overrides, cssEntry, tsconfig, buildCmd)
- .design-sync/build-css.mjs + tw-entry.css — compile Tailwind v4 globals.css
  to a self-contained stylesheet (inlined IBM Plex font), emit real .d.ts,
  and create the workspace self-symlink the converter needs
- .design-sync/previews/*.tsx — 39 authored preview compositions
- .design-sync/conventions.md — design-agent usage header (readmeHeader)
- .design-sync/NOTES.md — re-sync notes, gotchas, and risks
- packages/ui/tsconfig.emit.json — declaration emit for real prop contracts

Build artifacts (dist/types, .ds-compiled.css, ds-bundle, .cache) are gitignored.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE

* chore(knip): ignore .design-sync inputs and drop stale es-toolkit ignore

- Ignore .design-sync/** (design-sync tooling: build-css.mjs + authored
  previews are standalone, not part of the app import graph — knip --fix was
  deleting them and failing the autofix job).
- Remove es-toolkit from apps/server ignoreDependencies: it's now really used
  (apps/server/src/http/health.ts imports withTimeout), so the ignore is stale.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE
2026-07-05 06:25:51 +02:00
Amruth Pillai 9f9268f380 fix: migrate better auth 2fa schema 2026-07-05 00:05:53 +02:00
Amruth Pillai 8416a92153 fix: improve mobile responsive layouts 2026-07-04 23:54:02 +02:00
Amruth Pillai 3f6e22addb chore(locales): update PO revision dates and add attachment translations 2026-07-04 22:52:09 +02:00
Amruth Pillai 25b70c24f1 chore(deps): update pnpm to version 11.10.0 2026-07-04 22:49:43 +02:00
Amruth Pillai da40422dfa docs(changelog): add ai agent + command palette polish to v5.2.0
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:40:36 +02:00
Amruth Pillai e15edafbff fix(server): restore @uiw/color-convert runtime dependency
The utils color fallback restore re-added the @uiw/color-convert import
to packages/utils but not to apps/server, whose bundle keeps the package
external. Production server startup failed with ERR_MODULE_NOT_FOUND,
breaking the E2E workflow on main. Re-add the dependency.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:40:36 +02:00
autofix-ci[bot] d5b177aa89 [autofix.ci] apply automated fixes 2026-07-04 20:29:16 +00:00
Amruth Pillai d32227ff43 test(web): assert donation-toast cookie security attributes
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:26:25 +02:00
Amruth Pillai 7a0d1e93f3 fix(review): restore cookie attrs + color fallback, drop stale knip entry
Code-review findings:
- donation-toast: restore path/secure/sameSite cookie attributes the
  inlined useCookie dropped (security/scope regression).
- utils/color: restore @uiw fallback so percentage-notation rgb() still
  converts (custom style-rule colors are arbitrary strings); add tests
  pinning the one real difference vs the black fallback.
- knip: drop stale npm-check-updates ignoreDependencies entry.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:25:06 +02:00
Amruth Pillai 560956bbe6 test(api): add normalizeAgentResumePatchOperations to ./resume mock
service.ts calls it (added in 82d961241, merged from origin/main) but the
mock omitted it, breaking the patch-apply test. Identity mock matches the
test's pass-through expectation.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:04:28 +02:00
Amruth Pillai 7f458dc58d docs(utils): correct color fallback comment to state real ceiling
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:00:58 +02:00
Amruth Pillai 361480445f chore(config): finding 5 — drop vitest scripts and config from packages/config
packages/config has no source or test files; the 4 vitest scripts and
vitest.config.ts exist purely for pipeline symmetry. Remove them.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:49 +02:00
Amruth Pillai 57fb23145c chore: finding 4 — remove npm-check-updates from root devDependencies
No script invokes it; pnpm dlx npm-check-updates still works on-demand.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:39 +02:00
Amruth Pillai 6207cbc026 chore(env): finding 3 — remove dead CROWDIN_PROJECT_ID/CROWDIN_API_TOKEN/GOOGLE_CLOUD_API_KEY
These three vars are only read by GitHub Actions workflows and tooling/fonts
scripts that access process.env directly — never by app/server runtime code.
Removes them from the env schema (server.ts) and turbo.json globalEnv.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:30 +02:00
Amruth Pillai a149e614a7 refactor(ui): finding 2 — delete packages/ui use-controlled-state (zero importers)
apps/web has its own copy with 3 importers; the packages/ui copy had none.
Deletes the hook and its 78-line test.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:20 +02:00
Amruth Pillai eab7534ea4 refactor(ui,web): finding 1 — inline use-cookie into donation-toast, drop js-cookie from ui
The 107-line useCookie hook had exactly one consumer (donation-toast) that
only read + set-with-expiry. Inline the two Cookies.* calls directly and
delete the hook + its 128-line test. Drop js-cookie and @types/js-cookie
from packages/ui/package.json (apps/web retains its own js-cookie dep).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:10 +02:00
Amruth Pillai 79a69c5507 refactor(web): finding 1 — extract SectionItemDialog shell from 14 section dialogs
Each of the 14 section-item dialog files (award→volunteer) had identical
~50-line Create/Update shells (DialogContent + header + form + footer).
Added section-item-dialog.tsx with a SectionItemDialog wrapper that takes
title, icon, onSubmit, onCancel, isSubmitting, submitLabel, singleColumn?.
cover-letter and summary-item use singleColumn=true for their one-column
layout. custom.tsx is untouched (it creates section definitions, not items).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:50:09 +02:00
Amruth Pillai 70df113ee6 refactor(web): finding 9 — public-resume reuses useResumeExport hook
Loosen useResumeExport param to ExportableResume { name, slug, data } so the
public resume page (where name may be '' for non-owner viewers) can reuse it.
getExportName() falls back to data.basics.name then slug, matching the
original inline logic.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:48:04 +02:00
Amruth Pillai 44e9a8a29f refactor(web): finding 6 — delete normalizeResumePreviewProps, inline defaults
normalizeResumePreviewProps had exactly one production caller (preview.tsx).
Defaults now live in the ResumePreview destructuring; the normalizer and its
test cases are removed.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:53 +02:00
Amruth Pillai e47cb37ab9 refactor(web): finding 2 — replace dialog renderer indirection with plain typed arrays
defineDialogRenderer/defineDialogRendererRegistry were identity functions.
Each registry now exports a readonly AnyDialogRendererEntry[] directly;
renderer-registry.ts retains only the types. The unused 'domain' field and
its wrapping object are gone; renderers.tsx spreads the arrays directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:37 +02:00
Amruth Pillai 02538836a9 refactor(web): finding 5 — replace match(boolean) with ternary + ActionButton wrapper
Three auth-settings components (password, two-factor, social-provider) each
duplicated an identical m.div hover/tap wrapper for both branches of
match(boolean). Extracted one ActionButton wrapper and replaced match with
a plain ternary; removed ts-pattern imports.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:22 +02:00
Amruth Pillai 22398a502b refactor(web): finding 4 — extract runSignIn helper in SocialAuthButtons
Three near-identical toast→auth→error→invalidate handlers collapsed into
one generic runSignIn(fn) function.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:11 +02:00
Amruth Pillai e00348ef84 refactor(web): finding 3 — trim CountUp to {to, duration?, separator?}
No production caller passes from/direction/delay/startWhen/onStart/onEnd.
Removed those props and their setTimeout bookkeeping; updated tests.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:01 +02:00
Amruth Pillai 8d17ec6583 refactor(web): finding 8 — drop localStorage migration sentinel, use null-check
Number(null) === 0 caused the old code to use a separate :initialized key as
a migration sentinel. A plain null check on localStorage.getItem() is
sufficient and removes the sentinel key entirely.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:46:50 +02:00
Amruth Pillai e93a56d753 refactor(web): finding 1 - extract TypographyGroupFields component
Extract useTypographyForm helper (captures form creation + sync) and
TypographyForm type. Extract TypographyGroupFields component with
prefix "body" | "heading" to replace 2x4 duplicated form.Field blocks.
Font Weight label ("Font Weights" vs "Font Weight") is preserved per
prefix.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:57 +02:00
Amruth Pillai 975cea84e3 refactor(web): finding 5 - replace ts-pattern matchers with data maps
Replace getItemTitle/getItemSubtitle exhaustive ts-pattern matchers with
TITLE_FIELD/SUBTITLE_FIELD lookup maps + special-case branches for
summary and cover-letter. Adds a shared truncateHtml helper. Removes
ts-pattern import.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:44 +02:00
Amruth Pillai 34398a578b refactor(web): finding 8 - inline 7 one-caller alias hooks from draft.ts
useInitializeResumeStore, useMergeResumeMetadata, useSaveStatus,
useCanUndo, useCanRedo, useUndoResume, useRedoResume each had exactly
one caller. Inline useResumeStore selectors at call sites and remove
the wrappers. Skipped usePatchResume (4 callers).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:32 +02:00
Amruth Pillai 27efeab796 refactor(web): finding 7 - extract makeCustomSection, derive isStandard
Extract makeCustomSection factory to eliminate duplicate CustomSection
object literal. Replace hand-maintained SectionType array in
isStandardSectionId with membership check via `id in sections`.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:20 +02:00
Amruth Pillai f5ec471318 refactor(web): finding 6 - remove useQuery picture preview fetch
The uploads endpoint is public (Cache-Control: public, no auth headers),
so a plain img src suffices. Remove createPicturePreviewUrl, the useQuery
call, and the object-URL cleanup useEffect; simplify PicturePreviewControls
to use normalizedPictureUrl directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:10 +02:00
Amruth Pillai 376977a9f7 refactor(web): finding 9 - simplify useBuilderSidebar selector
Remove generic selector overload from useBuilderSidebar; callers
destructure the full return object instead of using a selector that
provides no meaningful benefit (state is rebuilt on every render).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:28:59 +02:00
Amruth Pillai 9b9d5c833c refactor(web): findings 2/3/4 in page/design/custom-styles
Finding 2: Replace 4 number + 3 switch form.Field blocks with
pageNumberFields/pageSwitchFields array maps.
Finding 3: Extract ColorFormField helper for primary/text/background
color fields in ColorSectionForm.
Finding 4: Remove labelPrefix (was always component-specific), replace
local slugify with @reactive-resume/utils/string import; fix ariaLabel
template literals to keep test labels accurate.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:28:47 +02:00
Amruth Pillai 15448cad6a fix(web): raise lib to ES2023 for consumed api source
agent/service.ts uses findLastIndex/Array.prototype.with (ES2023); web
type-checks api source and its ES2022 lib lacked them. lib only affects
type defs, not Vite runtime output.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:04:52 +02:00
Amruth Pillai afd734dd61 refactor(import): add v4section/v4url aliases, inline clamp wrappers, replace classes with fns
Finding 5: Introduce V4Url + V4Section<T> type aliases in reactive-resume-v4-json.tsx; collapse
310-line V4ResumeData type (13x 5-field section header, 11x {label,href}) into typed aliases.
Inferred shape is structurally identical.

Finding 6: Remove 9 single-caller clamp/pxToPt wrappers; replace compile-time constants
(rotation=0, sidebarWidth=35, shadowWidth=0, gapX=4, gapY=6) with literals; inline dynamic
calls (clamp(x, 32, 512) etc.) at the two body/heading typography call sites.

Finding 7: Convert three stateless single-method importer classes to plain functions.
Extract shared rethrowAsImportError() to error.ts, replacing triplicated ZodError catch blocks.
Update web import dialog + all in-package test files to use function API.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:02:17 +02:00
Amruth Pillai 493ef12a9a refactor(utils→import): move single-consumer date/html/level helpers, inline field into fonts
Finding 4 — consumer-count verification:
  @reactive-resume/utils/date  → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/html  → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/level → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/url   → 4 consumers (auth, api/ai, import, server) — SKIPPED, stays in utils
  @reactive-resume/utils/field → 1 consumer (packages/fonts/src/index.ts)

Move date/html/level source + test files into packages/import and update json-resume.tsx imports.
Inline the two-line unique() helper into fonts/src/index.ts and drop the ./field subpath.
Drop the three moved subpaths from packages/utils exports.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:55:22 +02:00
Amruth Pillai a5935dee0f refactor(utils): replace MONTH_NAMES array, drop @uiw/color-convert, use z.enum for localeSchema
Finding 1: Replace hand-rolled MONTH_NAMES[12] array with Intl.DateTimeFormat("en-US", {month:"long"}).
Finding 2: Drop @uiw/color-convert fallback — parseColorString already rejects the same inputs, return "#000000" instead. Remove dep from utils and server package.json.
Finding 3: Replace 56 z.literal calls in z.union with z.enum([...]); identical parse behavior and inferred type.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:53:51 +02:00
Amruth Pillai 5226f04e86 fix(email): use automatic JSX runtime so templates render under tsx
Templates import no React (react-email convention); tsx resolves each
file's nearest tsconfig, so jsx:preserve made esbuild emit classic
React.createElement and background email rendering threw
'React is not defined'.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:45:05 +02:00
Amruth Pillai a1fb0597a3 Merge remote-tracking branch 'origin/main' into chore/ponytail-cleanup 2026-07-04 20:42:34 +02:00
Amruth Pillai a2a2c0a768 Merge branch 'main' into chore/ponytail-cleanup 2026-07-04 20:40:21 +02:00
Amruth Pillai f2ec6a499f refactor(server): replace hand-rolled withTimeout, merge web handlers, clean checks
- health.ts: swap hand-rolled withTimeout for es-toolkit's (fn-taking API); remove
  redundant inner try/catches from checkDatabase/checkStorage since runCheck catches
  all errors (findings 13, health cleanup)
- web.ts: merge handleWebApp/handleWebAppHead into one function; method is the only
  difference — isHead determines body presence (finding 14)
- app.ts: collapse two separate GET/HEAD wildcard routes into app.on(["GET","HEAD"])
- Update web.test.ts and app.test.ts to drop handleWebAppHead references

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:39:14 +02:00
Amruth Pillai 0fb81ad772 refactor(api): replace file-based statistics cache with in-memory Map
Removes fs, path, env, getLocalDataDirectory imports from statistics service.
A module-level Map<string, {value, cachedAt}> gives the same TTL semantics
without touching disk. Adds clearStatisticsCache() for test isolation and
updates the test to call it in afterEach instead of relying on unique
LOCAL_STORAGE_PATH temp dirs (finding 5).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:36:15 +02:00
Amruth Pillai 19470c8cd2 refactor(api,server): storage — sync S3 client, dead upload types, shared inferContentType
- S3StorageService: replace async createClient/getClient/clientPromise chain with a
  synchronous constructor field; new S3Client() is synchronous (finding 7)
- uploadFile: delete speculative screenshot/pdf upload types (never called); hardcode
  picture key and simplify to 3 lines (finding 6)
- Export inferContentType from @reactive-resume/api/features/storage (finding 8)
- uploads.ts: import inferContentType from storage instead of local duplicate; inline
  buildResponseHeaders into handleUpload (findings 11, 12)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:33:34 +02:00
Amruth Pillai 3f050e5213 refactor(api): introduce mapAgentEnvironmentError oRPC middleware (finding 1)
Replace 12 near-identical try/catch blocks in threads/actions/attachments/messages
handlers with a single AnyMiddleware that maps the AGENT_ENVIRONMENT_UNAVAILABLE
sentinel to PRECONDITION_FAILED ORPCError.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:31:25 +02:00
Amruth Pillai 91c4a2421c refactor(api): simplify agent service — findLastIndex, shared select, combined aggregate
- attachModelPartsToLatestUserMessage: findLastIndex + Array.with, drop wrapper (finding 2)
- getOrCreateForResume: extract findActiveThreadForResume, remove 16-line dup select (finding 3)
- attachments.create: combine sum+count into one query (finding 4)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:29:19 +02:00
Amruth Pillai 82d961241e fix: polish ai agent and command palette 2026-07-04 20:24:39 +02:00
Amruth Pillai f3a60432df refactor(pdf): introduce createBaseTemplateStyles factory (~1,150 lines removed)
Move 14 identical style slots (text/heading/div/inline/link/small/bold/
richParagraph/richListItemRow/richListItemMarker/richListItemContent/
splitRow/alignEnd/picture) from all 15 template Page.tsx files into a
single createBaseTemplateStyles factory in templates/shared. Each template
now spreads …base and keeps only its real overrides. Resolved StyleSheet
values are identical to before. Update rtl-fixture and rich-text-template-
styles tests to guard the factory file rather than each template directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:13:42 +02:00
Amruth Pillai 0701f3b62a refactor(pdf): extract EmailContactItem/PhoneContactItem/LocationContactItem
Add three shared contact-item components to packages/pdf/src/templates/shared/
contact-item.tsx alongside the existing WebsiteContactItem and CustomFieldContactItem.
Replace ~18 lines of inline email/phone/location JSX in all 15 template headers
with the shared components (EmailContactItem accepts an optional iconName prop
for ditgar's "at" variant; rhyhorn's array-push pattern also updated).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:04:37 +02:00
Amruth Pillai cf738b9306 refactor(pdf): replace ts-pattern match chains with satisfies Record maps
Two match chains in sections.tsx are replaced with plain lookup maps typed
via `satisfies Record<CustomSectionType, ...>` which preserves compile-time
exhaustiveness without the ts-pattern dependency. Remove ts-pattern from
packages/pdf/package.json.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:52:13 +02:00
Amruth Pillai fcc10c6b31 refactor(pdf): deduplicate parseFiniteNumber/parsePxValue/parseFontSize
Export parseFiniteNumber and parsePxValue from icon-size.ts (already the
canonical home of these helpers). Remove the three private copies in
rich-text-spacing.ts and import the shared ones instead.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:49:39 +02:00
Amruth Pillai 3e96605d4c fix(web): avoid retesting new AI providers 2026-07-04 19:42:41 +02:00
Amruth Pillai 7e35e8b657 chore(schema): delete tautology test, add helpers, collapse styleRuleSlots
- Delete templates.test.ts: re-tests z.enum semantics with a hardcoded
  fixture that duplicates the template list in templates.ts.
- default.ts: add 2-line section(icon) helper; 12 repeated 7-field blocks
  collapse to one-liners. Output is byte-identical.
- data.ts: add 2-line itemSection<T> factory; 12 baseSectionSchema.extend()
  blocks collapse to one-liners. Inferred types unchanged.
- data.ts: replace 15-field hand-listed styleRuleSlotsSchema with
  z.partialRecord(styleSlotSchema, styleIntentSchema); parse behaviour and
  TypeScript type are equivalent (unknown keys still rejected).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:38:01 +02:00
Amruth Pillai 8de15822fb chore(db): delete tautological schema-mirror tests, collapse db singleton
- Delete 4 test files (auth/resume/agent schema mirrors + relations type check)
  that re-assert Drizzle table/column names copied verbatim from the schema files.
- Collapse makeDrizzleClient() + createDatabase() into a two-line module-level
  singleton; preserves globalThis.__drizzle caching behaviour.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:36:22 +02:00
Amruth Pillai e2099b9002 refactor(auth): replace z.enum with plain TS union; drop zod dependency
AuthProvider was the only zod usage in the package — a z.enum solely to
infer a type. Replace with a plain union type and remove zod from
packages/auth/package.json dependencies.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:24 +02:00
Amruth Pillai 5762eb6a3e refactor(ai): collapse parser prompts to template; replace makeEmptyItem with structuredClone
- Replace pdf-parser-system.md + docx-parser-system.md with a single
  parser-system.md template; prompts.ts substitutes 6 placeholders per
  source type. Produced strings are byte-identical to the former files.
- Remove makeEmptyItem recursive walker (all SECTION_ITEM_SHAPES leaves are
  already zero-valued) and call structuredClone(shape) instead.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:13 +02:00
Amruth Pillai dfe75390cd refactor(docx): getBaseRun helper, hoist mainConfig, derive sections, remove dead OL code
- Add getBaseRun() next to getHtmlStyle() and replace 8 inline baseRun spreads (~27 lines).
- Hoist one mainConfig object in buildDocument; sidebar call spreads only the two
  differing color keys (~17 lines).
- Remove BUILT_IN_SECTIONS Set; derive membership via `sectionId in data.sections` (~14 lines).
- Delete unreachable ordered-list numbering machinery in html-to-docx.ts: numberingRef is
  always undefined through all call paths, so isOrdered && numberingRef is always false (~15 lines).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:01 +02:00
Amruth Pillai 439ae114f9 refactor(mcp): derive tool metadata from single TOOL_META record
- Extract one TOOL_META record (title/description/inputSchema/annotations per
  tool) consumed by both registerTools and buildMcpServerCard, eliminating the
  ~200-line duplication in the server card.
- Collapse TOOL_ANNOTATIONS from 14 × 4-line inline objects to 5 named
  annotation-preset consts (READ_IDEMPOTENT, WRITE_NON_IDEMPOTENT, etc.),
  saving ~55 lines.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:26:44 +02:00
github-actions[bot]andCrowdin Bot 9b41edb43d Sync Translations from Crowdin (#3213)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 19:09:57 +02:00
Amruth Pillai d87c6758ab chore: update dependencies 2026-07-04 19:06:31 +02:00
Amruth Pillai 44fa2badb4 fix(ai): pass system prompt via system option instead of system-role message
Some providers (OpenAI Responses API, others) reject system-role messages in
the messages array with AI_InvalidPromptError. Move the analyze/parse system
prompts to the generateText system option, matching the chat handler.
2026-07-04 18:31:40 +02:00
Amruth Pillai 0abb5a07e6 fix(i18n): strip merge conflict markers reintroduced by crowdin sync 2026-07-04 18:18:58 +02:00
Amruth Pillai a9a38ff5dc fix(e2e): restore template name as img alt in gallery so template-switch test passes 2026-07-04 18:17:04 +02:00
github-actions[bot]andCrowdin Bot bf70705f1f [skip ci] chore(i18n): sync translations from crowdin (#3211)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 18:09:01 +02:00
github-actions[bot]andCrowdin Bot 332aa210c4 [skip ci] chore(i18n): sync translations from crowdin (#3209)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 18:08:01 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> da6a9f2c78 fix: repair MDX parse error in community spotlight to unblock link check (#3210)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 16:07:50 +00:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 4541cf1cdc Update from code changes: refresh builder guides for v5.2.0 (#3208)
* docs: refresh builder guides for v5.2.0 (undo/redo, version history, embedded AI, header downloads)

* docs: tighten SEO descriptions on v5.2.0 builder guides

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 18:06:45 +02:00
Amruth Pillai 27df724d2a feat(builder): show live PDF previews in template gallery, remove hover card 2026-07-04 18:05:45 +02:00
Amruth Pillai bc09430fdf chore(i18n): translate missing strings for am-ET, el-GR, km-KH, th-TH
97 strings translated for Amharic (am-ET), 97 for Greek (el-GR),
97 for Khmer (km-KH), and 98 for Thai (th-TH). All placeholders
preserved verbatim ({0}, {label}, <0>, __APP_VERSION__, etc.).

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 17:25:18 +02:00
Amruth Pillai e936f93e3a chore: translate missing strings for kn-IN, ml-IN, or-IN, ta-IN, te-IN
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 17:07:34 +02:00
Amruth Pillai 3ba566506a chore: translate missing strings for bn-BD, hi-IN, mr-IN, ne-NP
Fill 96 empty msgstr entries per locale in the Indic/Devanagari batch:
Bengali (bn-BD), Hindi (hi-IN), Marathi (mr-IN), and Nepali (ne-NP).
2026-07-04 16:41:13 +02:00
Amruth Pillai a7c599b724 chore: translate missing strings for ar-SA, fa-IR, he-IL
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:30:27 +02:00
Amruth Pillai dbb0b179c3 chore: translate missing strings for ja-JP, ko-KR, zh-CN, zh-TW
Fill in 96 empty msgstr entries per locale covering new UI strings
(AI assistant, version history, dashboard, account menu, connection
status, editor controls, and more).

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:24:57 +02:00
Amruth Pillai fc634a202d chore: translate missing strings for id-ID, ms-MY, tr-TR, vi-VN 2026-07-04 16:20:42 +02:00
Amruth Pillai 7fab23870f chore: translate missing strings for az-AZ, bg-BG, ru-RU, sr-SP, uk-UA, uz-UZ
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:08:17 +02:00
Amruth Pillai 20a8a3df9d chore: translate missing strings for pl-PL, pt-BR, pt-PT, ro-RO, sk-SK, sl-SI, sq-AL, sv-SE
Fill in all empty msgstr entries (96 per file, 92 for ro-RO) covering
new UI strings: AI assistant, version history, undo/redo, dashboard,
connection status, export data, and related builder strings.

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 15:54:15 +02:00
Amruth Pillai e38e37383d chore: translate missing strings for cs-CZ, da-DK, fi-FI, hu-HU, lt-LT, lv-LV, nl-NL, no-NO 2026-07-04 15:38:38 +02:00
Amruth Pillai d45116b2ba chore: translate missing strings for af-ZA, ca-ES, de-DE, en-GB, es-ES, fr-FR, it-IT 2026-07-04 15:24:14 +02:00
Amruth Pillai 6ad4f13914 docs: update changelog to not use images 2026-07-04 14:59:47 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 2f5d321051 docs: trim changelog description to meet SEO length target (#3207)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 12:58:39 +00:00
Amruth Pillai 57e9c8c487 v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205) 2026-07-04 14:57:25 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 09bc6ec521 docs: add pg pool error handler fix to weekly changelog (#3204)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 07:30:15 +00:00
helder-mattosandClaude Opus 4.8 50885176e0 fix(db): attach an error handler to the pg pool (#3172)
A Postgres connection can drop at any time — e.g. a serverless Postgres such as
Neon terminating the connection (error code 57P01). node-postgres surfaces this as
an 'error' event; without a listener node re-throws it as an unhandled 'error' and
crashes the process. Idle clients emit on the pool, but a client that is connecting
or checked out emits on the client itself, so we listen on both the pool and each
client. The pool then discards the dead client and opens a fresh one on the next
query, so the server survives transient/idle disconnects.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:28:18 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> cbeecf6596 Draft changelog: weekly update for post-v5.1.9 changes (#3203)
* docs: add weekly changelog entry for post-v5.1.9 changes

* docs: improve changelog title and description for SEO

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 07:22:49 +00:00
Shanu S ee970f2961 fix: wrap list item content in flex View so bullets respect margin (#3202)
* fix(pdf): wrap list item content in flex View so bullets respect margin

* fix(pdf): add minWidth 0 to bullet content so long text wraps
2026-07-04 09:20:01 +02:00
Amruth Pillai 578a983209 feat: polish micro-interactions with consistent motion system across the app
- add strong easing tokens (--ease-out-strong, --ease-in-out-strong, --ease-drawer)
- restore menu open/close animations (dropdown, context menu, combobox) using
  interruptible transitions via Base UI starting/ending styles
- dialogs: 200ms enter / 150ms exit; command palette opts out (keyboard-initiated)
- tooltips: 400ms initial delay with instant adjacent hovers via provider grouping
- buttons: scale press feedback, specific transition properties instead of transition-all
- tabs: sliding active-tab indicator via Base UI Tabs.Indicator
- sheet: iOS drawer curve with asymmetric enter/exit timing
- animate form validation messages and auth page entrance
- remove dead radix-idiom accordion classes in builder sidebars
2026-07-03 21:48:32 +02:00
autofix-ci[bot] 617135466d [autofix.ci] apply automated fixes 2026-07-03 19:22:57 +00:00
Diego Vega Centeno fa4c8adf78 fix: add conditional flex:1 for nested list content to fix layout (#3198) 2026-07-03 21:22:03 +02:00
Amruth Pillai 5b8ab33888 fix: rethrow non-ENOENT errors when loading .env 2026-07-03 20:55:41 +02:00
Amruth Pillai 0ba44865c7 test: add e2e specs for dashboard, sections, templates, sharing and settings workflows
- dashboard-lifecycle: rename, duplicate, delete via card context menu
- section-editing: add experience item, verify persistence across reloads
- template-switch: switch template in gallery, verify persisted selection
- sharing-password: password-protect public link, unlock as anonymous visitor
- lock-resume: lock blocks update/delete, unlock restores them
- settings-profile: profile name change persists
2026-07-03 20:04:36 +02:00
Amruth Pillai a4999c04af refactor: remove dead code, unused exports and redundant dependencies
- drop dotenv (Node 24 process.loadEnvFile) and dompurify (only used by dead code)
- delete unused ui components/hooks (card, progress, checkbox, use-confirm, use-prompt)
- delete dead sanitizeHtml/sanitizeCss, url-security helpers, patch-resume tool,
  schema/page, createResumePatches, patch-proposal preview builder, fonts fallback helpers
- inline single-caller wrappers (flags service, auth getSession, pdf renderer passthrough)
- deduplicate template color helpers into shared/color-helpers
- unexport 50+ internal-only symbols, remove dead export-map entries
- replace hand-rolled unique()/useIsMobile with Set spread and usehooks-ts
2026-07-03 20:04:36 +02:00
Amruth Pillai 2a80e6a1df chore: update dependencies 2026-07-03 20:04:36 +02:00
SimoandClaude Sonnet 4.6 4c8cc5c016 fix: remove overflow hidden from safeTextStyle (#3186)
overflow: hidden on Text elements in @react-pdf/renderer v4.x clips
content at its initial computed height, hiding any text after a line
break. minWidth/maxWidth/flexShrink already handle horizontal
containment so nothing else breaks.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 18:59:00 +02:00
Amruth Pillai d3735ebe27 chore: update dependencies 2026-06-29 09:09:00 +02:00
github-actions[bot]andCrowdin Bot 8eab8fdaa0 Sync Translations from Crowdin (#3183)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-29 09:01:42 +02:00
autofix-ci[bot] fbb9938af6 [autofix.ci] apply automated fixes 2026-06-29 01:26:48 +00:00
Andrea Accardo 5080fddf51 bugfix: fix list break with marker (#3177) (#3178)
* bugfix: fix list break with marker (#3177)

Signed-off-by: aaccardo <hackardo@gmail.com>

* refactor: fix code smell

Signed-off-by: aaccardo <hackardo@gmail.com>

---------

Signed-off-by: aaccardo <hackardo@gmail.com>
2026-06-29 03:25:55 +02:00
Amruth Pillaicoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Cursor Agentautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
dfd2c77bc9 Add Playwright E2E test setup (#3169)
* docs: design e2e test setup

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* docs: plan e2e test implementation

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: add playwright e2e scripts

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: configure playwright

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: add core e2e fixtures and specs

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* ci: run e2e tests on pull requests

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* [autofix.ci] apply automated fixes

* test: stabilize e2e suite

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: ignore playwright artifacts

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* Update .github/workflows/e2e.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test: address e2e review feedback

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-20 07:39:06 +02:00
Amruth PillaiandCursor Agent 56c90947e4 fix: ensure Atlas Cloud sponsor logo links to website (#3170)
Prevent the sponsor logo images from intercepting clicks so the
anchor reliably opens atlascloud.ai in a new tab instead of the SVG
asset.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-06-20 06:03:46 +02:00
github-actions[bot]andCrowdin Bot ae2a1dac12 Sync Translations from Crowdin (#3167)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-18 18:59:00 +02:00
Amruth Pillai dcf1b28c22 chore: release v5.1.9 2026-06-18 18:57:09 +02:00
Amruth Pillai f14d8ce693 feat: add Atlas Cloud sponsorship placements 2026-06-18 18:53:01 +02:00
robertoandAmruth Pillai 2317a82106 fix: register language-specific Noto fallback fonts for non-Latin scripts (#3158)
* fix: use language-specific Noto fonts for CJK PDF fallback

* feat: extend fallback to Arabic/Hebrew/Thai

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-06-17 13:37:09 +02:00
Cantale08andsantino cantale a523e13bfd Problem in word wrapping in the templates (#3136)
Co-authored-by: santino cantale <sopor@ARBA-TSM-WS020.tsm.local>
2026-06-17 13:28:19 +02:00
albanofazzitoandAlbano 1be75240dd fix: use non-empty placeholder for redacted resume name (#3138)
* fix: use non-empty placeholder for redacted resume name

* fix: update stale test title to match new placeholder behavior

---------

Co-authored-by: Albano <alumno26.fazzito.albano@ipm.edu.ar>
2026-06-17 13:27:39 +02:00
sdeonvacation 7275da7303 fix(ai): handle markdown-fenced JSON in analyzeResume response (#3142)
Some providers (notably Anthropic via proxies) wrap JSON output in
markdown code fences (```json ... ```), causing Output.object to
throw NoObjectGeneratedError / JSONParseError.

Replace Output.object with manual JSON boundary extraction that works
regardless of fencing. Also propagate the original AISDKError as cause
in throwAiProviderGatewayError for better diagnostics.
2026-06-17 13:27:06 +02:00
Lihan YANG bc498449d3 Fix MCP PDF download test mock (#3144) 2026-06-17 13:26:42 +02:00
Andrea Accardo 3937f7ed2b feat: add flag to disable api rate limit (#3149)
Signed-off-by: aaccardo <hackardo@gmail.com>
2026-06-17 13:26:27 +02:00
github-actions[bot]andCrowdin Bot d6de3f830f Sync Translations from Crowdin (#3162)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-17 13:18:31 +02:00
Amruth Pillai ef5ff30b13 chore: update linter configuration and add rimraf dependency 2026-06-17 10:51:10 +02:00
Amruth Pillai 37faf592b7 chore: update dependencies 2026-06-17 10:40:23 +02:00
github-actions[bot]andCrowdin Bot 76bd1e80f7 [skip ci] chore(i18n): sync translations from crowdin (#3148)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-06 09:33:09 +02:00
Amruth Pillai 042d076efa chore: update dependencies 2026-06-05 23:35:23 +02:00
github-actions[bot]andCrowdin Bot b9e4ab78ef Sync Translations from Crowdin (#3135)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 15:32:53 +02:00
Amruth Pillai 90a9bb9cf1 feat: add "Hide Link Underline" translation for multiple languages 2026-06-01 15:31:39 +02:00
Amruth Pillai 5fb4976ec9 feat: add hide link underline option to resume settings, resolves #3134 2026-06-01 15:30:49 +02:00
github-actions[bot]andCrowdin Bot d6a9bc6c4b Sync Translations from Crowdin (#3132)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 15:09:18 +02:00
Amruth Pillai 0dcdcd2960 chore(release): v5.1.8 2026-06-01 15:08:22 +02:00
JamesGoslingsandAmruth Pillai e96a51f31c feat(editor): add multicolor highlight with auto-contrast text (#3110)
* feat(editor): add multicolor highlight with auto-contrast text

Enable the Tiptap Highlight extension in multicolor mode, replacing the
single-color yellow toggle with a full color picker (16 presets + custom).
When the chosen highlight color is perceptually dark, text inside the mark
automatically renders white for readability.

Changes span the full pipeline:
- Editor: ColorPicker UI, extended renderHTML for contrast detection
- PDF: normalizeMarkElements preserves data-color as inline style
- DOCX: mergeStyle reads actual background-color from <mark>
- Utils: new isDarkColor() luminance helper

Backward-compatible: legacy <mark> without data-color still renders yellow.

Resolves #3109

* fix: handle multicolor highlight edge cases

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-06-01 14:58:15 +02:00
github-actions[bot]andCrowdin Bot 1507d869c7 Sync Translations from Crowdin (#3131)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 14:05:01 +02:00
JamesGoslingscoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Amruth Pillai
b932711f08 feat: add section heading icons to PDF templates (#3127)
* feat: add section heading icons to PDF templates

Add customizable Phosphor icons before section titles in PDF output.
Users can toggle visibility globally via a new "Hide section heading icons"
switch (independent of item-level icons) and customize individual section
icons through the builder sidebar icon picker.

- Add `icon` field to `baseSectionSchema` and `summarySchema`
- Add `hideSectionIcons` to `pageSchema` (defaults to true for backward compat)
- Implement `SectionHeadingIcon` component with heading font-size scaling
- Support "none" sentinel for per-section icon hiding
- Fallback to sensible defaults (briefcase, graduation-cap, etc.) for legacy data
- Add icon picker to builder sidebar sections and custom section dialogs

Closes #2632

* test: add unit tests for section heading icons

- Add tests for getResumeSectionIcon() covering built-in sections,
  summary, custom sections, "none" sentinel, and default fallbacks
- Add schema tests for baseSectionSchema icon field, summarySchema icon,
  and pageSchema hideSectionIcons default behavior

* refactor: minor updates to icon display

* Update apps/web/locales/es-ES.po

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-01 14:02:42 +02:00
Amruth Pillai 1522794733 fix: typecheck 2026-06-01 10:41:37 +02:00
Lihan YANG e00ff8ceca fix(pdf): avoid toReversed in icon size resolution (#3129) 2026-06-01 10:33:04 +02:00
Amruth Pillai 8e72311bc6 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-06-01 10:31:53 +02:00
Amruth Pillai a8c70d784c fix: typecheck 2026-06-01 10:31:25 +02:00
Amruth Pillai 0df7f21130 feat: implement download_resume_pdf mcp tool 2026-06-01 10:26:28 +02:00
Amruth PillaiandCursor Agent 6852f586ea ci: purge Cloudflare cache after release Docker image deploy (#3122)
* ci: purge Cloudflare cache after release Docker image deploy

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* ci: add timeout and retries to Cloudflare cache purge

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 01:44:30 +02:00
Amruth PillaiandCursor Agent 1414fecade fix(pdf): apply custom style fontSize to icons and level indicators (#3120) and
* fix(pdf): apply custom style fontSize to icon and level indicator sizes

Map fontSize from Icon and Level Indicator custom style slots to Phosphor
icon size and level indicator dimensions, since react-pdf icons ignore
fontSize in favor of the size prop.

* fix: separate global icon and scoped level indicator font sizes

Icon slot fontSize now drives all resume icons plus level display
decorations. Level indicator fontSize overrides only within level display.
Shared sizing logic lives in schema; design sidebar preview uses global rules.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 00:41:21 +02:00
Amruth PillaiandCursor Agent c1d11236ae fix(pdf): keep Glalie contact list border box square (#3121)
The decorative border around contact items must not inherit
picture border radius. Set contactList borderRadius to 0.

Fixes amruthpillai/reactive-resume#3119

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 00:22:51 +02:00
Lihan YANG d09ad2cdc0 Urgent fix server app version dev (#3117)
* fix(server): avoid app version global in MCP dev

* fix(server): use runtime-safe app version metadata
2026-05-29 00:13:45 +02:00
Amruth Pillai 9ce5bacd22 Show experience position with role progression (#3116) 2026-05-28 13:51:02 +02:00
Amruth Pillai 1d761be05b chore(release): v5.1.7 2026-05-27 23:59:14 +02:00
github-actions[bot]andCrowdin Bot c875541001 [skip ci] chore(i18n): sync translations from crowdin (#3113)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-27 23:56:10 +02:00
Amruth Pillai 16f4d2c072 docs: using custom styles 2026-05-27 23:52:19 +02:00
Amruth Pillai b491582637 chore: add missing translations 2026-05-27 23:31:58 +02:00
Amruth Pillai c6a654191c feat: improvements to custom styles 2026-05-27 22:16:14 +02:00
Amruth Pillai 8461aa65d5 chore: remove react-doctor from package scripts and update task dependencies in turbo.json 2026-05-27 11:09:33 +02:00
Amruth Pillai b04eef1479 feat: implement style rules 2026-05-27 10:57:33 +02:00
Amruth Pillai 7bff6644d8 docs: add custom styles header target design 2026-05-26 15:12:48 +02:00
Amruth Pillai 8da780c868 feat: update links for improved accessibility 2026-05-26 13:09:30 +02:00
Amruth Pillai dd1e37e579 refactor: better resume two-way sync in case of MCP/API updates 2026-05-26 12:05:38 +02:00
Amruth Pillai 19b412d84d chore(release): v5.1.6 2026-05-26 10:12:56 +02:00
Amruth Pillai 7eea6675c0 chore: update dependencies 2026-05-26 10:09:58 +02:00
Amruth Pillai 273e17c0d3 fix: issue with color format handling, resolves #3104 2026-05-26 09:59:23 +02:00
Amruth Pillai 17cddbad65 fix: reduce default list item row gap 2026-05-26 00:13:38 +02:00
Amruth PillaiandCursor 7557ab13ab fix(api): delete agent threads with sequential cleanup
Remove attachments and soft-delete the thread before storage cleanup so
partial failures do not leave inconsistent DB state. Log storage errors
without failing the request after the thread is marked deleted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:33:03 +02:00
Amruth PillaiandCursor c66560ee12 refactor(web): dedupe isRTL via utils locale module
Re-export isRTL from @reactive-resume/utils/locale in the web locale
helper and consolidate RTL detection tests in the utils package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:32:58 +02:00
Amruth PillaiandCursor 24c882fa9f feat(pdf): roll out shared RTL layout to all templates
Introduce createRtlStyleHelpers and a single rtl flag on RenderProvider,
migrate every template page to mirrored layout styles, and rename
alignRight to alignEnd. Fix plain rich text rendering via PdfText
paragraph renderers and map legacy Times New Roman to Times-Roman.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:29:50 +02:00
Yu Sun 86fff7237f fix(auth): reconcile migrated social login accounts (#3095) 2026-05-25 15:46:57 +02:00
Eyal Meschmanandautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> 266bc291eb Add RTL rendering for Rhyhorn template (#3099)
* Add RTL rendering for Rhyhorn template

* Add timeout to wait-healthy just command

* Revert prettier formatting

* Revert and ignore personal relevant files

* Revert prettier formatting from all modified files

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-25 15:46:51 +02:00
Amruth Pillai 6ec4da7914 chore: update dependencies 2026-05-25 15:44:40 +02:00
Umair Khurshid 75e9446134 docs(docker): use shallow clone in quick start (#3096) 2026-05-25 15:39:01 +02:00
Amruth Pillai 39e88dd365 chore: lint using react-doctor, update translations, dynamic imports 2026-05-21 09:56:26 +02:00
Amruth Pillai 3596102c63 chore: update dependencies 2026-05-20 23:12:39 +02:00
github-actions[bot]andCrowdin Bot c77684d317 [skip ci] chore(i18n): sync translations from crowdin (#3087)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-19 13:15:46 +02:00
Amruth Pillai 62f8270b3e Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:13:38 2026 +0200

    chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies

commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:08:04 2026 +0200

    fix: remove timestamp conflict guard

commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 12:11:51 2026 +0200

    chore(release): v5.1.5

commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:35 2026 +0200

    revert: compose.yml

commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:08 2026 +0200

    refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086

commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:10:41 2026 +0200

    refactor(pdf): simplify sidebar section filtering and update summary feature logic

commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:53:37 2026 +0200

    chore: update pnpm-lock.yaml and turbo.json

commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:38:30 2026 +0200

    fix(polyfill): add tested polyfill for Map Upsert methods

commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:23:29 2026 +0200

    Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration

    # Conflicts:
    #	packages/api/src/services/agent-url.ts
    #	packages/runtime-externals/package.json

commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:22:12 2026 +0200

    chore: preserve branch changes before main sync

commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Mon May 18 07:50:28 2026 +0200

    chore: lot of fixes for monorepo migration

commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 13:57:17 2026 +0200

    chore: update knip version and refine web app routing with new SEO endpoints

commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 12:10:06 2026 +0200

    refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint

commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:54:29 2026 +0200

    chore: update dependencies and enhance PWA metadata in web app

commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:12:35 2026 +0200

    docs: revise manifest-only pwa testing scope

commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:11:33 2026 +0200

    docs: add manifest-only pwa design

commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:05:04 2026 +0200

    chore(dev): simplify server proxy config

commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:50 2026 +0200

    docs: add unsafe oauth redirect plan

commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:34 2026 +0200

    feat(auth): add unsafe oauth redirect flag

commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:55:02 2026 +0200

    docs: design unsafe oauth redirect flag

commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:22:04 2026 +0200

    chore: update translation source paths

commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:09:25 2026 +0200

    refactor(arch): react spa + hono migration

commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 01:10:47 2026 +0200

    docs: add docker nightly tagging design

commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Thu May 14 20:05:44 2026 +0200

    feat: migrate to hono spa server
2026-05-19 13:14:21 +02:00
JamesGoslingsandAmruth Pillai 5b1297fa2b fix(pdf): register CJK fallback at primary font weights so bold rende… (#3080)
The CJK fallback (Noto Sans SC / Noto Serif SC) was only registered at
weight 400. When react-pdf rendered CJK characters with font-weight 700
(e.g. <strong> from a rich-text section, or templates' bold style), it
walked the font-family stack [primary, cjkFallback], failed on the
primary (no CJK glyphs), then fell back to the only registered fallback
variant (400) — and react-pdf does not synthesize bold. The bold style
was silently dropped for CJK runs in both the live preview and the
exported PDF, while still working for Latin runs.

Register the CJK fallback at the same weight range as the primary font
(lowest + highest, both styles). When body and heading share the same
fallback (the common case where both are sans or both are serif), merge
their weight ranges so each weight is registered exactly once.

webfontlist.json already ships all weights for the default CJK
fallbacks, so no font-list changes are required.

Closes #3079

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-19 09:09:28 +02:00
JamesGoslingsandAmruth Pillai dd7623f11e fix(pdf): align textkit line-box and font metrics to browser behaviour (#3070)
* fix(pdf): align textkit line-box and font metrics to browser behaviour

CJK characters in resumes with a tightened typography line-height
(< ~1.4) had their descenders clipped by the next line. Latin glyphs
in the same resume rendered fine. Fixes the visual regression vs the
v5.0.x Puppeteer-based renderer reported in issue #2986 and follow-ups.

The clipping is caused by two independent gaps in @react-pdf/textkit
relative to standard CSS line-box rules:

1. `height(run)` short-circuits to the user-supplied lineHeight and
   ignores the run's intrinsic ascent + descent. CSS line-boxes are
   spec'd as `max(line-height, content-area)` — when CJK glyphs are
   present the content-area is taller than a tightened lineHeight, so
   the box must grow. textkit didn't, so the baseline (computed from
   the real, larger CJK ascent) sat below the box and the descender
   bled into the next line.

2. `ascent / descent / lineGap` are read directly from fontkit's hhea
   defaults. For Source Han Sans/Serif (the CJK fallbacks registered
   in #3013) hhea is intentionally inflated for legacy Windows GDI
   compatibility (1.45 em vs 1.0 em), so even a fixed line-box would
   have been excessively tall. Browsers (and the v5.0.x Puppeteer
   renderer) read OS/2 sTypoAscender/Descender/LineGap instead, which
   are the values the type designers intend for modern shaping.

Both are upstream behaviours of `@react-pdf/textkit`, but waiting for
an upstream release would leave existing users with broken CJK output.
The fix is shipped as a pnpm patch (~30 LOC):

- `resolveTypoMetrics(font)`: prefer OS/2 typo metrics, fall back to
  hhea when an OS/2 table is absent (e.g. the StandardFont stand-ins
  for Helvetica/Courier/Times). Used by ascent/descent/lineGap so all
  height-related calculations stay consistent.
- `height(run)`: `Math.max(lineHeight || 0, intrinsic)` instead of
  the original short-circuit, matching CSS line-box rules.

The patch is self-contained: existing Latin-only resumes are
unaffected (IBM Plex Serif's typo metrics equal hhea; Roboto's typo
is slightly smaller, but only changes the rendered line-box for users
who set lineHeight below ~1.17, which already used to clip ascenders
under v5.1.x and now lays out as it would in a browser).

Tooling notes:
- `Dockerfile.dev` copies `patches/` before `pnpm install` so the
  dev image build no longer fails on `--frozen-lockfile`. The
  production `Dockerfile` already gets it for free via
  `turbo prune --docker` (the patch reference in package.json marks
  the directory as part of the pruned slice).
- The patch will become a no-op once an equivalent fix lands upstream
  in @react-pdf/textkit; the entry can then be removed from
  `pnpm.patchedDependencies` and the file deleted.

* fix(deps): regenerate lockfile and move patchedDependencies for pnpm 11

The previous commit's lockfile was authored by pnpm 8 (lockfileVersion 6.0)
and kept patchedDependencies under package.json#pnpm. The repository now
declares packageManager: pnpm@11.1.2, which:

- writes lockfileVersion 9.0 and rejects v6 with ERR_PNPM_LOCKFILE_BREAKING_CHANGE
  on --frozen-lockfile (CI failure observed in autofix.ci);
- reads pnpm settings from pnpm-workspace.yaml, silently ignoring the
  package.json#pnpm field — so the textkit patch was no longer applied.

Regenerate pnpm-lock.yaml with pnpm 11.1.2 and move patchedDependencies
to pnpm-workspace.yaml so the patch is applied and CI passes.

* chore: update dependencies

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-18 08:08:47 +02:00
Adian Kozlica 63e8c3ca33 fix: polyfill Map.getOrInsertComputed for Waterfox (#3067) 2026-05-15 01:50:13 +02:00
Amruth Pillai e62090cce0 fix: monkey patch a nitro build error (resolves #3065) 2026-05-14 17:21:41 +02:00
Amruth Pillai 0510c7103b chore: update dependencies 2026-05-14 16:41:24 +02:00
github-actions[bot]andCrowdin Bot 1a5c5252d1 [skip ci] chore(i18n): sync translations from crowdin (#3064)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-14 16:00:23 +02:00
Amruth Pillai 9df2a5287d chore(release): v5.1.4 2026-05-14 15:57:40 +02:00
Amruth Pillai 6d8d8f6e55 feat: add AI agent workspace (#3062)
* chore(ai): remove local AI store now that providers live server-side

The Zustand-based useAIStore has been replaced by the server-side
aiProviders oRPC router (encrypted credentials persisted in DB).
Delete the dead store + tests, drop the ./store export, and remove
zustand/immer deps which are no longer referenced anywhere in
packages/ai/src/.

* feat(agent): archive/delete actions and read-only state for agent threads

- Backend: mark archived threads as read-only in threads.get and reject
  messages.send with CONFLICT when the thread is archived.
- Frontend: render archived threads in the sidebar with muted styling and
  an Archived badge; add a per-thread dropdown menu in the chat header
  with Archive (non-destructive) and Delete (with confirmation); show a
  read-only banner above the message list that disambiguates archived
  vs. missing-resource causes; suppress the Retry and Stop buttons in
  read-only mode.
- Tests: new packages/api/src/services/agent.test.ts covering the
  archived-thread isReadOnly flag and the archived-thread send refusal.

* fix(agent): abort run on archive and verify ownership before deleting thread

- threads.archive: before flipping status, abort any in-flight run controller
  and clear the active-run state on the thread; cleanup failures are logged
  but do not block the status update.
- threads.delete: assert thread ownership via getThread before destructive
  work so an authenticated user cannot wipe another user's attachment rows
  by passing a foreign threadId.

Adds focused tests for both behaviors.

* feat(agent): display patch diffs and surface revert conflicts

Render apply_resume_patch tool messages with a status-aware card (applied/
reverted/conflicted), expandable operation list, and a Revert button that
correctly handles RESUME_VERSION_CONFLICT responses. Adds unit tests for
the inverse-patch builder and the agentService.actions.revert flow.

* chore(agent): remove out-of-scope attachment tests accidentally added in Task 6

The Task 6 commit (73ef1acca) accidentally re-introduced three attachment-
related tests that belong to a separate task:

- `buildAttachmentModelParts > converts text, image, supported binary, and
  unsupported attachments into model parts`
- `agentService.messages.send > persists the user message with file UI parts
  and links selected attachments to it` (was failing — the `ToolLoopAgent`
  mock is not callable as a constructor)
- `agentService.messages.send > rejects attachments that are missing, foreign,
  or already linked before persisting a message`

These were likely re-added during a stash recovery and were not requested
for Task 6, whose scope was limited to the `agentService.actions.revert`
flow. Remove them along with the helpers/fixtures (`buildAttachment`,
`buildActiveThread`, `selectWhereResult`, `selectOrderByResult`) that they
were the only consumers of. `selectLimitResult` is preserved because it is
used by the revert tests.

* chore(agent): configure runtime dependencies

* feat(db): add agent workspace schema

* feat(api): add agent backend services

* feat(web): add agent workspace UI

* chore(agent): remove legacy builder assistant

* test(agent): make agent stream mocks constructible

* chore(web): remove unused resume replacement hook

* feat(api): add unsafe AI base URL flag

* chore(dev): expose local services in compose

* fix(web): normalize resume preview gaps

* feat(api): improve agent tool handling

* feat(web): polish agent workspace UI

* chore: update dependencies

* fix(api,web): address PR review feedback for agent workspace

Security/correctness:
- Restrict AI provider URLs to http/https even in unsafe mode
- Stop exposing Redis on host network by default
- Make .env.local optional and drop app profile in compose.dev.yml
- Store agent attachments with private ACL on S3
- Reset provider test status when provider/model/baseURL changes
- Decouple non-agent AI endpoints from REDIS_URL requirement
- Fix JSON Patch add inverse for existing object members
- Wrap resume patch + agent action insert in db transaction
- Validate partialMessage at runtime and rate-limit attachment uploads
- Add unique index on agent_messages (thread_id, sequence)

UX/bugs:
- Mark agent thread route as ssr: false and guard SSE chunk parsing
- Show config-specific banner only on known configuration error
- Gate AI provider checks behind loading state in resume import
- Fix relative-time formatter blank gap between 45-59 seconds
- Clarify thread delete confirmation message

Polish:
- Raise ENCRYPTION_SECRET minimum to 32 characters
- Bucket AI rate limits by resumeId/threadId/messageId
- Trim form values before submitting AI provider config
- Use single key identifier and nullish-coalesce baseURL display

* fix: address ai agent review feedback

* fix: preserve mobile agent chat state

* docs: add ai agent workspace guides

* feat: introduce design system for Reactive Resume
2026-05-14 15:00:04 +02:00
JamesGoslings 22c60c64b6 fix(fonts): restore legacy local font names via metric-compatible ali… (#3057)
* fix(fonts): restore legacy local font names via metric-compatible aliases

Closes #2989.

In v5.0.x the Puppeteer renderer resolved fonts like 'Times New Roman'
or 'Arial' through the browser's font stack. The v5.1 migration to
@react-pdf/renderer requires every font to be Font.register()-ed; the
legacy local-font names were not carried over, so resumes upgraded
from v5.0.x had their typography silently replaced with IBM Plex Serif,
changing line breaks, page counts and overall layout.

This adds a render-time alias layer mapping the old names to
metric-compatible web fonts already shipped in the webfont list:

  Times New Roman → Tinos
  Cambria         → Tinos
  Arial           → Arimo
  Garamond        → EB Garamond
  Calibri         → Source Sans 3

- packages/fonts:
  - new `legacyFontAliases` map and `resolveLegacyFontAlias` helper.
  - `getFont` falls back to the alias map when the direct lookup misses,
    so any caller that asked 'is this a known family?' now answers
    truthfully for the legacy names.
  - `getFontDisplayName` is intentionally unchanged: the typography
    sidebar keeps showing the user's original choice ('Times New Roman'),
    while the renderer transparently swaps in the alias target.

- packages/pdf/use-register-fonts:
  - `resolvePdfFontFamily` returns the alias target when one applies,
    so `Font.register` runs against the right web font and templates
    receive a family name they can actually render.

Backwards compatible: families that were never aliased (Roboto, IBM
Plex Serif, the standard PDF fonts, ...) take exactly the same code
path as before. The CJK glyph fallback added in #2986 / PR #3013
continues to apply on top of the resolved primary family.

* fix(fonts): use Carlito (not Source Sans 3) as Calibri alias

Per maintainer review feedback: Carlito is metric-compatible with
Calibri, while Source Sans 3 only matches visually. Switching gives
upgraded resumes the same line widths, line breaks and page counts
they had under v5.0.x.

- packages/fonts/webfontlist.json: add Carlito (Google Fonts, weights
  400/700 + italics) so it's a registerable target.
- packages/scripts/fonts/generate.ts: add a getMetricCompatibleFonts
  helper and merge it into the output, mirroring how Computer Modern
  fonts are appended. This way regenerating the list (`pnpm generate`)
  re-emits Carlito automatically and dedupes if it ever enters the
  Google Fonts popularity slice.
- packages/fonts/src/index.ts: alias `Calibri → Carlito`.
- packages/fonts/src/index.test.ts: update alias test cases.
2026-05-14 11:36:15 +02:00
Amruth Pillai affa1d6646 docs: enhance documentation and guides with new features and updates 2026-05-14 03:38:39 +02:00
SirSKillzandAmruth Pillai c71f3b0b92 Feat: Add configurable AI provider base URL flag and update documentation (#3059)
* feat: add FLAG_ALLOW_UNSAFE_AI_BASE_URL for configurable AI provider base URLs

* feat: add FLAG_ALLOW_UNSAFE_AI_BASE_URL documentation

* fix: remove AI_ALLOWED_BASE_URLS from documentation and environment variable reference

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-14 03:01:37 +02:00
Amruth Pillai 6c4a4b2aa5 Render public resumes with PDF.js (#3061)
* fix(web): use native pdf viewer for public resumes

* fix(web): render public resumes with pdf.js

* chore: revert vite hook paths

* chore(web): address pdf viewer review
2026-05-14 02:49:43 +02:00
Amruth Pillai 1294d3354a feat(docker): enhance development setup with reactive_resume service and health checks 2026-05-13 15:35:08 +02:00
Claudeamruthpillaianthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
42fc78dca1 [WIP] Fix dead link to Using Custom CSS in docs (#3056)
* Initial plan

* docs: remove reference to removed Custom CSS guide

Agent-Logs-Url: https://github.com/amruthpillai/reactive-resume/sessions/82961e42-251b-41da-80ee-7697968566f7

Co-authored-by: amruthpillai <1134738+amruthpillai@users.noreply.github.com>

---------

Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
Co-authored-by: amruthpillai <1134738+amruthpillai@users.noreply.github.com>
2026-05-13 11:22:25 +02:00
Amruth Pillai aa7af040fb chore(release): v5.1.3 2026-05-13 09:29:59 +02:00
Amruth Pillai 5f63dc876b feat: new template (scizor) 2026-05-13 09:26:38 +02:00
Kaloian Kozlev 014ceee31f Add DOCX to export formats in README (#3054) 2026-05-13 09:10:30 +02:00
github-actions[bot]andCrowdin Bot 70dff5bf49 [skip ci] chore(i18n): sync translations from crowdin (#3053)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-13 09:10:00 +02:00
Amruth Pillai c5787fe155 chore: translations for a new template 2026-05-13 08:06:31 +02:00
Amruth Pillai 286e165a60 [codex] Hide empty sections from page settings (#3052)
* feat(web): hide empty layout sections

* test: add "scizor" to templates metadata test cases
2026-05-13 01:11:19 +02:00
Amruth Pillai 00dafd0c68 feat: add new resume template "Scizor" 2026-05-13 01:07:35 +02:00
brone1323 d251d602fb fix(pdf): increase headerNameLineHeight to 1.3 to prevent descender clipping (#3050)
All Heading elements apply overflow:hidden via safeTextStyle in primitives.tsx.
At 1.5× heading font size with lineHeight 1.2, the line box is too tight to
fully render descenders (g, p, y, etc.) in the resume header name field,
causing them to appear visually cut off.

Raising headerNameLineHeight from 1.2 to 1.3 adds enough vertical room for
descenders across all 13 templates that share this constant.

Fixes #3042
2026-05-13 01:02:16 +02:00
Amruth Pillai e35ff83911 chore: update dependencies 2026-05-13 00:44:51 +02:00
Amruth Pillai 62b0a1d533 fix(cjk): resolve hyphenation callback with cjk content in resume 2026-05-11 22:04:45 +02:00
Amruth PillaiandCursor Agent 0a8fe05653 Fix split row right content promotion (#3039)
* Fix split row right content promotion

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* refactor: remove MetaLine component and update sections to use Text instead

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-11 15:59:26 +02:00
Amruth Pillai de7baa5faf test: add ~500 tests across web, utils, api, import, ai, db, email, auth (#3038)
* test(web): add tests for zustand stores and pure helpers

Cover stores and pure helpers across the builder/dashboard/command-palette
surfaces that previously had 0% coverage:

- command-palette store (open/close, page stack, search clearing, goBack)
- builder assistant-store
- builder sidebar store + parseBuilderLayoutCookie / mapPanelLayoutToBuilderLayout
- builder section store (collapse, toggle, toggleAll)
- builder preview page-layout toggle
- dashboard resume-thumbnail render-size math + cache key
- MCP tool name + annotations invariants

* test(web): cover MCP helpers and template metadata

Add tests for previously 0%-coverage MCP and dialog helpers:

- buildMcpServerCard: server-info, tool catalog vs MCP_TOOL_NAME, prompts,
  resource templates, configuration schema, auth schemes
- registerPrompts (build/improve/review): registration, args schema, resource
  context with interpolated resume id, read-only / no-fabrication directives
- registerResources (resume://{id}, resume://_meta/schema): handler reads via
  oRPC client, error on missing id, schema returns valid JSON
- templates metadata: ids match display names, valid sidebar positions,
  unique image URLs, every entry has tags + description

* test(web): cover sidebar section helpers and layout screens

Add tests for previously near-0%-coverage modules:

- libs/resume/section: getSectionTitle / getSectionIcon return distinct,
  exhaustive results for every sidebar section + cover-letter; icon props
  forwarding; left/right sidebar collections do not overlap.
- layout/loading-screen: spinner + text render.
- layout/error-screen: error message surfaces, Refresh button triggers reset.
- layout/breakpoint-indicator: default + each corner positioning, all
  breakpoint labels rendered, print-hidden class applied.

* test(web): cover preview canvas math and font-weight defaults

Add tests for pure helpers that previously had no direct coverage:

- typography/getNextWeights: prefers 400 + 600 when both are available,
  returns null for unknown families, never produces duplicates, bounded
  to two weights from the 100..900 set.
- preview.shared/normalizeResumePreviewProps: documented defaults +
  pass-through.
- preview.shared/getScaledPreviewPageSize: scaling identity at 1, and
  fractional scaling.
- preview.shared/getPreviewCanvasScale: respects 4x desired scale for
  small pages, honors high devicePixelRatio, clamps to the 16M-pixel
  canvas budget for large pages.

* test(utils): cover DOCX section renderers and html-to-paragraphs

@reactive-resume/utils/resume/docx was previously at ~2.84% statement
coverage despite being load-bearing for the resume DOCX export.

- section-renderers: empty-string / hidden-section / hidden-item branches
  for renderSummary, renderBuiltInSection, and renderCustomSection;
  cover-letter and summary custom-section dispatch; unknown-type fallback;
  setRenderConfig idempotency.
- html-to-docx: whitespace-only short-circuit, multiple top-level blocks,
  h1..h6 paragraph mapping, inline style and link rendering, custom
  font/size/color/linkColor config, ignored script/comment nodes.

* test: cover DOCX builder smoke paths and reactive-resume JSON importer

- utils/resume/docx/builder: buildDocument runs end-to-end against the
  default and sample resume data, both page formats, full-width and
  sidebar layouts, and gracefully degrades with unparseable color or
  empty font family inputs.
- import/reactive-resume-json: ReactiveResumeJSONImporter validates
  malformed JSON, recovers missing built-in sections by appending them
  to page 1 without reordering, and preserves layouts that already
  contain every built-in section.

* test(import): cover JSONResumeImporter parse/convert

JSONResumeImporter (450 lines) was previously at 0% coverage. Add tests
for the public surface:

- Invalid JSON / invalid-shape errors are surfaced.
- basics, summary, picture, education, projects, skills, profiles all
  map to the corresponding ResumeData sections.
- Empty work/education entries (missing key field) are filtered out.
- Highlights become HTML list items in the description field.
- Skill level parsing flows through utils/level.parseLevel.
- formatLocation joins city, region, countryCode with commas.

* test(web): cover query client serializer and home-page animations

- libs/query/client: getQueryClient returns a fresh QueryClient,
  queryKeyHashFn produces stable JSON envelopes for matching keys
  (and distinct strings for different keys), dehydrate/hydrate
  round-trip Date values via the oRPC serializer.
- components/animation/spotlight: overlay container is
  pointer-events-none, both beam groups render, custom
  width/height/translateY/gradient props flow into inline styles.
- components/animation/comet-card: children mount inside the
  perspective wrapper, custom className is merged with the 3D
  baseline classes, glare overlay renders, mouse move/leave
  handlers do not throw.

* test(web): cover Copyright footer

Verify the footer's MIT license link, Amruth Pillai attribution,
external-tab targets, embedded app version (via __APP_VERSION__ stub),
and custom className merging — previously at 0% coverage.

* test(api): cover flags, auth providers, and resume-access cookies

Unlock @reactive-resume/api by mocking @reactive-resume/env/server and
@tanstack/react-start/server. Previously the only services tested were
the standalone AI test and resume-access-policy.

- services/flags: flagsService.getFlags reads disableSignups/disableEmailAuth
  from env (no stale cache).
- services/auth: providers.list always exposes credential + passkey, and
  only adds Google/GitHub/LinkedIn/custom when both id and secret are set;
  custom provider uses OAUTH_PROVIDER_NAME with a 'Custom OAuth' fallback.
- helpers/resume-access: hasResumeAccess validates against signed cookies
  with constant-time comparison; grantResumeAccess writes a 10-minute
  httpOnly cookie with the secure flag matching APP_URL's https-ness.

* test: cover statistics service and email transport via env mocks

- api/services/statistics: github star count succeeds, retries on
  non-OK, falls back to last-known on fetch error / non-positive /
  non-numeric responses; user and resume counts roll up DB count.
- email/src/transport: returns silently with no text/html, logs when
  SMTP is not configured, dispatches via nodemailer with the env
  config when fully wired, renders react elements to html + text
  bodies, swallows transport errors instead of crashing.

* test(api): cover resume-events publish + subscribe

- publishResumeUpdated issues pg_notify with channel and serialized
  event payload.
- subscribeResumeUpdated yields events whose resumeId+userId match
  the subscription, filters out other resumes/users, ignores
  malformed JSON and notifications on other channels, calls
  LISTEN/UNLISTEN and releases the client, and terminates
  immediately if the abort signal fires before iteration starts.

* test(api): cover oRPC auth resolution

resolveUserFromRequestHeaders is the single point where every oRPC
procedure picks up the authenticated user. Test the priority chain:

- x-api-key wins when present and valid
- on invalid api key, falls back to session via auth.api.getSession
- Bearer JWT in Authorization header is verified via verifyOAuthToken
- invalid Bearer falls back to session
- Authorization scheme other than Bearer is ignored entirely
- thrown errors from token verification are logged and swallowed
  (caller still tries session)
- returns null when no auth method succeeds

* test(api): cover storage helpers

inferContentType, isImageFile, processImageForUpload were 0%
coverage despite being on the picture upload path.

- inferContentType maps known image and pdf extensions, is
  case-insensitive, ignores path depth, and falls back to
  application/octet-stream for unknown.
- isImageFile allows only the upload allowlist (gif/png/jpeg/webp)
  and rejects image/svg+xml, application/pdf, and empty strings.
- processImageForUpload short-circuits to the original bytes when
  FLAG_DISABLE_IMAGE_PROCESSING is true, otherwise pipes through
  sharp and returns image/jpeg.

* test(import): broaden v4 importer section-mapping coverage

The existing v4 importer test focused on a single bug (description-only
custom items) and the skill/language level scaling. This new test
exercises the bulk of the v4 → v5 transformation path:

- basics, picture (with border), summary, customFields
- every section's filter-by-required-field invariant (awards needs
  title, certifications needs name, education needs institution,
  experience needs company, volunteer needs organization, etc.)
- experience / education / awards / certifications / references field
  renames between schemas
- language and skill level scaling (v4 0..10 → v5 0..5)

Brings reactive-resume-v4-json from ~66% statement coverage to a
materially higher figure (the bulk of the 410-line transformer body).

* test: cover buildDocx entry and AI configuration store

- utils/resume/docx/index: buildDocx returns a non-empty Blob for both
  default and populated resumes (previously 0% coverage despite being
  the public DOCX entry point).
- ai/store: useAIStore preserves verification status across no-op
  updates, but resets testStatus + enabled whenever provider, model,
  apiKey, or baseURL changes; canEnable is gated to testStatus=success;
  setEnabled(true) is refused unless verified; reset clears every
  field. Brings @reactive-resume/ai from ~72% to materially higher
  coverage.

* test(db): cover resume schema definitions

packages/db was previously at 0% coverage. Smoke-test the public
resume / resume_statistics / resume_analysis tables:

- getTableName matches the SQL identifier used by migrations
- expected columns are present on each table
- defaultResumeData wiring on the data column resolves to a valid
  shape

These are structural assertions that catch accidental renames /
removals without needing a live database connection.

* test(db): cover auth schema tables and relations export

- src/schema/auth: table-driven test for each of the 12 auth tables
  asserting SQL name and presence of the key columns (user/session/
  account/verification/two_factor/passkey/apikey/jwks/oauth_*).
- src/relations: smoke test confirming the relations export is defined.

Brings @reactive-resume/db from 0% to materially higher coverage.

* test(auth): cover getSession isomorphic helper

@reactive-resume/auth was previously at 0% coverage. functions.ts
is the server entry point that other packages call. Mock the auth
config + tanstack/react-start to verify:

- getSession forwards getRequestHeaders() to auth.api.getSession
- returns null when better-auth returns null

* test(web): cover BuilderSidebarEdge

Small presentational component on the builder layout — assert children
mount, left/right positioning class branches, and the sm:flex
mobile-hide behavior.

* test(web): cover section-title-locale resolver cache and hook

The section-title-locale module wraps createSectionTitleResolver with
a per-locale async cache and a React hook for consumers in the
builder. Cover:

- createSectionTitleResolverForLocale returns a usable resolver
- repeated calls for the same locale share a cached promise
- unknown locales fall back through resolveLocale
- useSectionTitleResolver returns null while loading and when no
  locale is passed
- the hook resolves to a function once the async loader settles

* test(web): cover BaseCommandGroup page-stack gating

BaseCommandGroup conditionally renders based on the top of the
command-palette page stack. Tests cover:

- root group renders when no sub-page is active
- root group hides when a sub-page is on top
- sub-page group renders only when its page matches
- mismatched sub-page leaves the group hidden

* test(web): cover ThemeProvider context

- useTheme outside ThemeProvider throws the documented error
- useTheme inside ThemeProvider returns the theme + setTheme +
  toggleTheme helpers

* test(web): cover ConfirmDialogProvider + useConfirm hook

- useConfirm outside provider throws the documented error
- confirm returns a pending promise
- promise resolves false when the Cancel button is clicked
- promise resolves true when the Confirm button is clicked
- works with custom confirmText label

apps/web has its own copy of this hook distinct from
packages/ui (mirrors the existing UI-package tests).

* test(web): cover PromptDialogProvider + usePrompt hook

- usePrompt outside provider throws the documented error
- returns a function when wrapped
- Cancel click resolves the promise to null
- Confirm click resolves to the current input value
- defaultValue option seeds the initial input value

* test(web): cover DashboardHeader

Small presentational header used across dashboard routes — title h1,
icon rendering, className merge, mobile sidebar trigger present and
hidden on md+.

* test(web): cover Create/Import resume cards

Both cards on the resumes dashboard wire a click handler to open
the appropriate dialog via the dialog store:

- CreateResumeCard opens resume.create
- ImportResumeCard opens resume.import

Also asserts the i18n copy strings (icons aside, the cards are
otherwise structural).

* test(web): cover command-palette language sub-page

LanguageCommandPage is a BaseCommandGroup gated on page='language'.
Tests assert:

- it is hidden when 'language' is not the top of the page stack
- when active, it renders a CommandItem per localeMap entry
- documented locale codes (en-US, de-DE, ja-JP) appear

* test(web): cover command-palette theme + preferences sub-pages

- ThemeCommandPage: hidden when 'theme' is not on top, renders Light
  and Dark options when active
- PreferencesCommandGroup: root group renders both Change theme to...
  and Change language to... items; clicking each pushes the
  corresponding page onto the command-palette stack

* test(ai): cover executePatchResume tool

- patchResumeInputSchema rejects empty operations and unknown op
  values; accepts valid replace/add/remove
- executePatchResume returns the applied operations on success
- executePatchResume throws when an operation targets an invalid path
  (passes through the underlying applyResumePatches validation)
- multi-op patches against top-level fields succeed end-to-end

* test(ai): cover sanitize edge branches

Hit the previously-uncovered branches in sanitize.ts:

- numeric 1 coerces to true
- '1' / '0' string shorthand coerces to true/false
- missing item.hidden gets salvaged to false
- empty input causes a non-Zod throw (caught + rethrown with generic message)

* test(ai): cover patch-proposal preview + normalize edge cases

- remove operations surface before-value with after=undefined
- buildResumePatchProposalPreview labels metadata/page paths sanely
- normalizeResumePatchProposals stamps every proposal with baseUpdatedAt
- normalizeResumePatchProposals preserves input order

* test(web): cover getLocaleOptions helper

Locale combobox surface — verify the option list mirrors localeMap
shape, uses locale codes as values, populates label + keywords with
the translated display name, and produces unique values.

* test(web): cover LevelTypeCombobox option mapping

LevelTypeCombobox maps levelDesignSchema.shape.type.options through
the internal getLevelTypeName labeler. Assert all 7 level types are
exposed and that each produces a non-empty label.

* test(web): cover ThemeToggleButton fallback paths

- aria-label flips between 'Switch to light theme' and 'Switch to
  dark theme' based on current theme
- clicking when document.startViewTransition is unavailable
  short-circuits to toggleTheme directly
- prefers-reduced-motion forces the direct toggle path even when
  the view-transition API is available

* test(web): cover NotFoundScreen

Mock the TanStack Router Link so the screen renders standalone, then
assert: documented error heading, routeId is surfaced verbatim, and
the Go Back link points to '..' (parent route).

* test(web): cover InformationSectionBuilder

Stub SectionBase so the donation/info section renders standalone.
Assert: donation prompt copy, OpenCollective CTA link, all 5
external resource links present, and external links target _blank
with rel=noopener.

* test(web): cover NotesSectionBuilder

Mock SectionBase, RichInput, and the resume-draft hooks so the
notes section renders in isolation. Assert: privacy hint copy
renders, RichInput is seeded with metadata.notes, and onChange
proxies through updateResumeData with a draft recipe that mutates
metadata.notes.

* test(web): cover TemplateSectionBuilder

Stub SectionBase and useCurrentResume so the right-sidebar template
section renders standalone. Asserts: current template name in the
heading, template tags rendered as badges, preview image points to
the catalog asset, and clicking the preview opens the
resume.template.gallery dialog.

* test(web): cover ColorPicker preset selection and trigger override

Mock the heavy @uiw/react-color-colorful dependency. Test:

- the trigger swatch reflects the controlled value
- clicking a preset color invokes onChange with an rgba() string
- a custom trigger replaces the default swatch when provided

* test(web): cover ExportSectionBuilder

Mock the heavy export pipelines (buildDocx, createResumePdfBlob,
downloadWithAnchor) and the resume-draft hook to test:

- JSON button packages resume.data as application/json and triggers
  download with the {name}.json filename
- DOCX button awaits buildDocx and downloads .docx
- PDF button awaits createResumePdfBlob and downloads .pdf

* test(web): cover ProfilesSectionBuilder

Stub the resume-draft hooks, SectionBase, SectionItem, and
SectionAddItemButton so the profiles section renders standalone:

- one SectionItem per profile with network as title and username as
  subtitle
- 'Add a new profile' affordance present
- when items.length > 0, the wrapper uses a solid border (not dashed)

* test(web): cover SkillsSectionBuilder

Mirror the profiles test for the skills section — verifies one
SectionItem per skill (name → title, proficiency → subtitle) and
the Add a new skill affordance.

* test(web): bulk-cover 7 left-sidebar section builders

Single test file covers awards, certifications, interests, languages,
publications, references, and volunteer builders. For each:

- one SectionItem rendered with the documented field → title/subtitle
  mapping
  - awards: title → awarder
  - certifications: title → 'issuer • date'
  - interests: name → (no subtitle)
  - languages: language → fluency
  - publications: title → publisher
  - references: name → (no subtitle)
  - volunteer: organization → location
- the 'Add a new {kind}' affordance with the matching copy

Mocks SectionBase, SectionItem, SectionAddItemButton, and the
resume-draft hooks so each builder renders standalone.

* test(web): cover ProjectsSectionBuilder buildSubtitle

The projects section is the only left-sidebar builder with a
composite subtitle. Tests three branches of its inline
buildSubtitle helper:

- period + website.label → joined with ' • '
- period only → just the period
- empty period + whitespace-only website.label → returns undefined

* test(web): cover Education + Experience section builders

- Education: school → title, degree → subtitle, add-new affordance
- Experience: position → subtitle when set; falls back to '1 role' /
  'N roles' (lingui plural) when position empty and roles[] present;
  add-new affordance

* test(web): cover CountUp animated number renderer

- default aria attributes (aria-live=polite, aria-atomic=true)
- initial textContent seeds to 'from' (up) or 'to' (down) value
- separator option formats with grouping
- decimal places are preserved when from/to are fractional
- aria-hidden=true strips aria-live + aria-atomic
- custom className is applied to the rendered span

* test(web): cover TextMaskEffect SVG renderer

- supplied text renders in every visible <text> layer
- aria-hidden + aria-label forwarded onto the root svg
- mouse enter/move/leave handlers don't throw
- custom className merges into the svg's class attribute

* test(web): cover URLInput prefix handling

- displayed input strips the https:// prefix so users only edit the
  meaningful portion
- editing re-adds the prefix on the way back through onChange
- pre-prefixed input is preserved
- cleared input emits an empty url (no prefix forced)
- hideLabelButton=true removes the popover trigger; default keeps it

* test(web): cover GithubStarsButton

Mocks useQuery + the CountUp animation so the button renders
standalone. Asserts:

- anchor points at the project repo with rel=noopener + target=_blank
- aria-label is the no-count copy when star count is undefined
- CountUp renders only once the count loads
- aria-label includes the localized count once data arrives

* test(web): cover ui/Combobox trigger label rendering

The shared Combobox wraps base-ui's combobox primitives. Smoke-test
the trigger label resolution:

- placeholder shows when nothing is selected
- selected option's label renders in the trigger
- multi-select default values render all labels
- empty options array renders the placeholder without crashing

* test(web): cover IconPicker trigger rendering

Stub react-window's Grid so happy-dom can render the picker without
layout-measurement deps. Verify:

- trigger renders an <i class='ph-{value}'> for the current value
- changing value updates the trigger icon class
- the picker emits a trigger button

* test(web): cover ChipInput add/dedupe/description behaviors

- existing chips render as Badges
- Enter adds the typed value to the chip list
- comma also commits the typed value
- duplicate input is dropped (onChange not called with a longer list)
- empty / whitespace-only input is dropped
- hideDescription removes the keyboard hint <kbd>; default keeps it

* test(web): cover StatisticsSectionBuilder

Mock useQuery + useParams + section-base so the right-sidebar
statistics section renders standalone:

- returns null content while the query is loading
- shows the private-resume hint when isPublic=false
- shows views/downloads counters and labels when isPublic=true
- includes 'Last viewed' timestamp copy when lastViewedAt is set

* test(web): cover TemplateGalleryDialog selection flow

- title + intro copy render
- one tile per template (>= 14)
- currently-selected template tile carries the ring-highlight
- clicking a different tile triggers updateResumeData with a recipe
  that sets metadata.template to the chosen template id

* test(web): cover Prefooter home-page section

- community tagline heading renders
- community-thanks paragraph renders
- the decorative TextMaskEffect renders an svg

* test(web): cover home-page Footer

- Resources and Community headings render
- documented resource links (Documentation, Sponsorships, Source
  Code, Changelog) all appear in the rendered output
- documented community links (Report an issue, Translations,
  Subreddit, Discord) all appear
- social anchors point at GitHub, LinkedIn, and X (Twitter)
- Copyright sub-component surfaces the app version via __APP_VERSION__

* test(web): cover home-page Header navigation

Mock TanStack Router Link + child components so the header renders
standalone. Verify:

- homepage anchor (/ link) carries the documented aria-label
- dashboard anchor points to /dashboard
- ThemeToggleButton and GithubStarsButton both mount
- the <nav> landmark is labeled 'Main navigation'

* chore: fix linter warnings
2026-05-11 14:25:10 +02:00
Amruth Pillai 48555f58e5 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-05-11 13:56:01 +02:00
Amruth Pillai 0daf868cd4 fix(knip): remove duplicate export 2026-05-11 13:55:56 +02:00
github-actions[bot]andCrowdin Bot e574d4005f [skip ci] chore(i18n): sync translations from crowdin (#3037)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-11 13:54:49 +02:00
Amruth Pillai fda4e500b3 feat(toast): add non-invasive, dismissible donation banner 2026-05-11 13:50:32 +02:00
Amruth Pillai adfc9b527b docs: map existing codebase
Adds .planning/codebase/ with parallel-mapper outputs covering stack,
integrations, architecture, structure, conventions, testing, and concerns.
2026-05-11 11:16:33 +02:00
Amruth Pillai 71aadbd73d docs: update AGENTS.md with detailed codebase structure, environment setup, and common commands 2026-05-11 09:42:58 +02:00
Amruth Pillai 0713cf20d4 fix: filter invalid/empty section items and experience roles 2026-05-11 09:27:14 +02:00
Amruth Pillai 334ea48bc7 fix: enhance rich text handling in PDF generation
- Bump @tanstack/react-form version to 1.32.0 in package.json.
- Refactor rich-input component to simplify highlight configuration.
- Improve rich text HTML normalization to handle <mark> elements and apply styles correctly in PDF output.
- Update global CSS for WYSIWYG to adjust paragraph and list margins.
2026-05-11 09:04:42 +02:00
Amruth PillaiandCursor Agent 69c23211a0 Fix award title and date layout (#3029)
* Fix award title date layout

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* Keep award date inline across templates

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-11 05:54:51 +02:00
Amruth Pillai 143aaa741b fix(lapras): adjust lapras border color to fixed gray 2026-05-11 01:11:17 +02:00
Amruth Pillai e4cc6a8e57 docs: add react-pdf-html prose spacing design 2026-05-11 01:00:46 +02:00
Amruth Pillai 92a0e3ddb8 feat: implement resume preview loading enhancements and add tests for preview components 2026-05-11 00:59:16 +02:00
Amruth Pillai 4ebe9e5a67 fix: enhance resume schema validation and improve slugify function for CJK input 2026-05-11 00:27:47 +02:00
Amruth Pillai 0abee1048c fix(docs): update healthcheck test script on docs, resolves #3027 2026-05-11 00:09:07 +02:00
Amruth Pillai 83a407bc10 fix: add special hyphenation callback for Chinese script in PDF font registration 2026-05-10 23:54:37 +02:00
Amruth Pillai 02973a1eb1 fix: correct path for app source files in globals.css 2026-05-10 23:35:46 +02:00
Amruth Pillai 2e04e71f4a fix: update build process to ensure clean output for web app 2026-05-10 23:27:06 +02:00
Amruth Pillai 978cbaf1f3 fix: run knip --fix to add unlisted dependencies 2026-05-10 22:12:35 +02:00
Amruth Pillai 3cd228bd84 chore: update postcss to version 8.5.14 and sort font weights in typography components 2026-05-10 22:09:37 +02:00
Amruth Pillai 64ac3ff328 fix: revert to default hyphenation method 2026-05-10 21:50:27 +02:00
Amruth Pillai 846b7856a7 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-05-10 20:46:31 +02:00
Amruth Pillai 7a60a42a04 chore: migrate from jsdom to happy-dom for testing environment 2026-05-10 20:46:28 +02:00
github-actions[bot]andCrowdin Bot b0de64ad13 [skip ci] chore(i18n): sync translations from crowdin (#3026)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-10 20:20:08 +02:00
Amruth Pillai b321e01658 Merge branch 'main' into feat/increase-test-coverage
# Conflicts:
#	packages/ui/src/components/input-group.test.tsx
2026-05-10 20:05:00 +02:00
Amruth Pillai 6a01207b6b test: add unit and component tests across the monorepo
Adds ~1000 tests to bring the previously-untested packages and apps
under coverage:

- packages/utils — string, color, date, file, level, locale, sanitize,
  field, html, network-icons, rate-limit, url, url-security, monorepo,
  resume/patch, resume/docx/link-utils, style helpers (~97% on the
  testable utility files)
- packages/ui — 28 component test files plus the use-controlled-state,
  use-mobile, use-confirm, use-prompt hooks (95% statements)
- packages/pdf — shared template helpers (filtering, columns, picture,
  metrics, section-links, page-size, rich-text-html, section-title)
- packages/schema — resumeDataSchema, page, templates, default
- packages/fonts — expanded coverage on font helpers
- packages/ai — resume sanitize and extraction template
- packages/api — resume-access-policy
- apps/web — error-message, locale, theme, pwa, dialogs/store, and
  the resume/move-item / section-actions / make-section-item helpers

Adds jsdom polyfills (ResizeObserver, IntersectionObserver,
scrollIntoView, matchMedia) and an explicit React Testing Library
cleanup hook to vitest.setup.ts so portal- and overlay-based components
work without per-test setup.
2026-05-10 20:00:07 +02:00
Amruth Pillai 2f6a8904e4 fix: enforce hasPassword to be a boolean 2026-05-10 19:56:50 +02:00
Amruth Pillai 56c9eb2ff4 fix(contact-list): resolves an issue where website label was uneditable 2026-05-10 19:30:09 +02:00
Amruth Pillai 33103536ae fix: fallback for cjk fonts when italic font style not available 2026-05-10 17:35:32 +02:00
Amruth PillaiandCursor Agent a93e7bd190 docs: add AGENTS.md with Cursor Cloud development instructions (#3024)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-10 13:34:19 +02:00
github-actions[bot]andCrowdin Bot 4cd4b8c193 [skip ci] chore(i18n): sync translations from crowdin (#3023)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-10 13:27:59 +02:00
Amruth Pillai be9285aa33 chore(release): v5.1.2 2026-05-10 13:27:06 +02:00
Amruth Pillai 6787175a8a feat(ai): implement an AI chat window for agentic resume building (#3022) 2026-05-10 13:23:32 +02:00
Amruth Pillai 42e83cc676 fix: improper rendering of text blocks in PDFs 2026-05-10 13:22:21 +02:00
JamesGoslings 62f4532157 fix(pdf): register CJK fallback font so Chinese/Japanese/Korean text renders correctly (#3016)
Closes #2986.

Since v5.1.0 the renderer was migrated from Puppeteer to
@react-pdf/renderer. The new pipeline only registers the user-selected
typography family (e.g. Roboto, IBM Plex Serif), which contains no CJK
glyphs, so any Chinese / Japanese / Korean characters in the resume
fall back to .notdef and render as garbled boxes in both the in-app
preview and the exported PDF.

@react-pdf/renderer's textkit layer already supports per-codepoint
font substitution when a Text node is styled with `fontFamily` as a
string array — but only if every family in the stack has been
registered via Font.register. This change wires that up:

- packages/fonts: new `getPdfCjkFallbackFontFamily(family)` returns
  Noto Sans SC / Noto Serif SC depending on whether the primary font
  is sans-serif or serif, and `null` when no fallback is needed
  (standard PDF font, or primary already is the fallback). Source Han
  Sans/Serif SC covers all CJK-Unified ideographs, so a single font
  transparently handles Simplified/Traditional Chinese, Japanese
  kanji and Korean hanja.

- packages/pdf/hooks/use-register-fonts: after registering the
  primary body/heading fonts as before, additionally register the
  resolved CJK fallback (regular weight only — substitution is
  per-codepoint, not per-weight, so one face is enough). The
  function's return type is widened to a new `PdfTypography` whose
  `body.fontFamily` and `heading.fontFamily` become
  `[primary, cjkFallback]` two-element stacks.

- packages/pdf/document: cast the widened typography back through the
  schema-typed `ResumeData` so the wider runtime value reaches
  templates without changing the public `Typography` schema. All 15
  templates already consume `metadata.typography.body.fontFamily`
  directly, and `StyleSheet.fontFamily` accepts both string and
  string[], so no template edits are required.

Latin-only resumes are unaffected:
- `getPdfCjkFallbackFontFamily` returns `null` for standard PDF fonts
  and existing CJK selections, so the extra Font.register call is
  skipped.
- When no fallback applies, `registerFonts` returns the original
  typography reference unchanged (zero allocation).
- Even when the fallback is registered, textkit only consults it for
  codepoints the primary font cannot render, so Latin glyphs still
  come from the user-selected font with identical metrics.
2026-05-09 18:49:59 +02:00
Donovan WattsandAmruth Pillai fabe22089d fix(api): allow empty name in public resume output schema (#3012)
The redactResumeForViewer helper intentionally blanks the dashboard
title for non-owner viewers (the title can leak owner-only context like
"Senior Eng @ Foo - final draft"), but the getBySlug output schema
inherits name.min(1) from resumeSchema. Zod rejects the redacted
payload, oRPC throws Output validation failed, and every public resume
URL returns HTTP 500.

Relax the constraint to z.string() on the getBySlug output only -
ownership-gated procedures (getById, update, patch, list, ...) keep
min(1).

Fixes #3011

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-09 18:49:43 +02:00
Nicolas Oddo fa38f3e84a fix: correct v4 to v5 import for custom sections, skill levels, and hidden items (#3013) 2026-05-09 18:48:21 +02:00
github-actions[bot]andCrowdin Bot 0606e0072b [skip ci] chore(i18n): sync translations from crowdin (#3005)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-08 16:19:27 +02:00
Amruth PillaiandClaude bdfb854602 fix: resolve storage healthcheck path via LOCAL_STORAGE_PATH env var (#3004)
* fix: resolve local data directory to /app/data in production Docker

In the official Docker image, cwd is /app/apps/web (set via WORKDIR), but
the data volume is mounted at /app/data. Without pnpm-workspace.yaml present
in the runtime image, findWorkspaceRoot() returns null, so getLocalDataDirectory()
fell back to <cwd>/data = /app/apps/web/data, which the node user has no
permission to create. This caused the storage healthcheck to fail with
EACCES.

Add a production fallback: when cwd ends in apps/web, resolve the data
directory to two levels up (matching /app/data in the official image).

Re-resolves #2990.

https://claude.ai/code/session_015pSTtukxf7mFTty2Y6PHZf

* fix: replace apps/web heuristic with LOCAL_STORAGE_PATH env var

The previous fix special-cased a cwd ending in apps/web to land on /app/data,
but the heuristic could false-positive on any path with that suffix and was
fragile to Dockerfile changes. pnpm-workspace.yaml is never copied into the
runtime image, so the workspace-root walk was also dead code in production.

Replace the heuristic with an explicit LOCAL_STORAGE_PATH env var:
- Set LOCAL_STORAGE_PATH=/app/data in the Dockerfile (single source of truth).
- Add LOCAL_STORAGE_PATH to the env schema; storage and statistics services
  pass it through to getLocalDataDirectory.
- getLocalDataDirectory now uses the override when set, else workspace root
  (dev), else cwd/data.
- New Nitro plugin validates the resolved local data directory at startup
  and refuses to boot with a clear error if it isn't writable, surfacing
  permission issues immediately instead of at first upload/healthcheck.
- Document the new variable in .env.example and the Docker self-hosting docs.

https://claude.ai/code/session_015pSTtukxf7mFTty2Y6PHZf

* fix: address review feedback on storage path handling

- apps/web/plugins/2.storage.ts: use the default-import style for
  node:fs/promises (matches the rest of the repo, sidesteps any
  named-export concerns for fs.constants).
- packages/env/src/server.ts: reject relative LOCAL_STORAGE_PATH values
  via a zod refinement. Relative paths would be resolved against cwd,
  which differs between dev and Docker — exactly the same surprise the
  original bug had. Failing fast at config validation time gives a
  clear error before the server boots.

https://claude.ai/code/session_015pSTtukxf7mFTty2Y6PHZf

* fix: update data volume configuration in Docker Compose and enhance Nitro plugin

* fix: remove "Can I customize the templates?" FAQ entry from multiple language files

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-08 16:05:47 +02:00
github-actions[bot]andCrowdin Bot 05f094bd13 [skip ci] chore(i18n): sync translations from crowdin (#3001)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-08 13:07:45 +02:00
github-actions[bot]andCrowdin Bot d1c301de83 [skip ci] chore(i18n): sync translations from crowdin (#2999)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-08 13:06:37 +02:00
Amruth PillaiandClaude e42af3cd04 Remove "Can I customize the templates?" FAQ from homepage (#3000)
https://claude.ai/code/session_013T4jd8fPiWR5jMw2yxwgez

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-08 13:06:27 +02:00
Amruth Pillai f3375adecb chore(release): v5.1.1 2026-05-08 13:05:55 +02:00
Amruth Pillai d9e3289f69 chore: remove unused dependencies and update locale references in resume components 2026-05-08 13:01:18 +02:00
github-actions[bot]andCrowdin Bot 7c08007a9d [skip ci] chore(i18n): sync translations from crowdin (#2997)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-08 12:39:10 +02:00
Amruth Pillai 296f7951ec fix: make nested lists work in PDF renderer, resolves #2993 2026-05-08 12:29:16 +02:00
github-actions[bot]andCrowdin Bot bf1a540fd1 [skip ci] chore(i18n): sync translations from crowdin (#2995)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-08 11:58:47 +02:00
Amruth Pillai e4a574ccd0 chore: remove tests 2026-05-08 11:58:25 +02:00
Amruth Pillai 26ca5c29c3 fix: auto-load .env, resolves #2987 2026-05-08 11:57:59 +02:00
Amruth Pillai 2cd774dab7 feat: implement free-form resume page formats, resolves #2991 2026-05-08 11:28:18 +02:00
Amruth Pillai 9cbb30d3ba fix: resolves #2990, revert the local storage path to /app/data 2026-05-08 11:10:24 +02:00
Amruth Pillai ed42f181ca fix: update dialog component styles for improved responsiveness and overflow handling 2026-05-08 10:39:34 +02:00
Amruth Pillai 21cadd76fe fix: update sample resume picture URL to point to a valid image 2026-05-08 02:37:01 +02:00
Amruth Pillai 4c771307e0 fix: fallback to "IBM Plex Serif" when unknown font encountered 2026-05-08 00:51:07 +02:00
Amruth Pillai 6d54ffa88b fix: font registration to support italic styles in PDF generation 2026-05-08 00:29:10 +02:00
Amruth Pillai 5042ad9d1f feat: add PDF download endpoint and export createLocalizedResumeDocument function 2026-05-08 00:05:55 +02:00
Amruth Pillai 3b82aa90f3 fix: resolves #2793, accept looseObject from resumeDataSchema 2026-05-07 22:50:45 +02:00
Amruth Pillai 524a7a6f4e fix: resolves #2949, remove route preloading 2026-05-07 22:46:32 +02:00
Amruth Pillai 36a16f6483 fix(#2983): fixes #2983, experience role progression causes re-renders 2026-05-07 22:30:47 +02:00
Amruth Pillai e320f3a920 chore: remove @tanstack/zod-adapter 2026-05-07 22:22:58 +02:00
Amruth Pillai d5891ff035 fix(#2978): implement inlineLink functionality 2026-05-07 21:47:00 +02:00
Amruth Pillai 891ce60270 fix(#2984): add prose styles to rich-input editor 2026-05-07 21:43:07 +02:00
Amruth Pillai 1b0bb067b6 fix(#2976): update header name line height across multiple templates 2026-05-07 21:34:01 +02:00
autofix-ci[bot] 2b3af1f7b7 [autofix.ci] apply automated fixes 2026-05-07 19:29:59 +00:00
Amruth Pillai ced765e229 fix(#2978): show underline on all links 2026-05-07 21:29:10 +02:00
Amruth Pillai 023cb4a594 fix(#2981): runtime error when enabling 2fa 2026-05-07 21:23:53 +02:00
github-actions[bot]andCrowdin Bot a43538c919 [skip ci] chore(i18n): sync translations from crowdin (#2975)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-07 16:52:18 +02:00
Amruth Pillai faba604813 ci: do not run lingui:extract on autofix 2026-05-07 16:51:59 +02:00
github-actions[bot]andCrowdin Bot f8b3437e33 [skip ci] chore(i18n): sync translations from crowdin (#2974)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-07 16:50:29 +02:00
Amruth Pillai 4ac16df3d6 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-05-07 16:49:48 +02:00
Amruth Pillai 4ae3e1a230 fix: update resume ID type from uuid to text and use generateId for ID generation 2026-05-07 16:49:44 +02:00
autofix-ci[bot] dcccbcfa9e [autofix.ci] apply automated fixes 2026-05-07 14:49:24 +00:00
Amruth Pillai 92a3916e76 fix: convert postgres uuid to text columns, to capture old cuid resume IDs 2026-05-07 16:48:25 +02:00
github-actions[bot]andCrowdin Bot 6321e99b9e [skip ci] chore(i18n): sync translations from crowdin (#2973)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-07 16:38:34 +02:00
github-actions[bot]andCrowdin Bot 94c953005e [skip ci] chore(i18n): sync translations from crowdin (#2972)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-07 16:31:49 +02:00
Amruth Pillai cf957692fc chore: update phosphor-icons-react-pdf to version 0.1.3 in package.json and pnpm-lock.yaml 2026-05-07 16:31:26 +02:00
Amruth Pillai 64492ce2f1 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-05-07 15:19:19 +02:00
Amruth Pillai 4165c10a71 fix: readme banner image url 2026-05-07 15:19:13 +02:00
autofix-ci[bot] ef6961c9eb [autofix.ci] apply automated fixes 2026-05-07 13:18:55 +00:00
Amruth Pillai 13787db34a Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-05-07 15:16:34 +02:00
github-actions[bot]andCrowdin Bot f64daeb89e [skip ci] chore(i18n): sync translations from crowdin (#2971)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-07 15:15:23 +02:00
Amruth Pillai 6e447d1bcc Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-05-07 15:14:21 +02:00
Amruth Pillai 148d564fe8 fix: readme banner image url 2026-05-07 15:14:20 +02:00
Amruth Pillai 50ba37a27f v5.1.0 (#2970)
* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
2026-05-07 15:12:33 +02:00
Joshua Fife 51c366310e Fixed docker compose for linux self hosted deployments (#2952) 2026-05-02 23:57:27 +02:00
Amruth Pillai 4e58ea6c8a chore: remove .devcontainer 2026-04-30 02:07:58 +02:00
Amruth Pillai fd892b0e14 fix: make the dash plugin optional
- fixes #2947
2026-04-29 23:30:07 +02:00
Amruth Pillai dc4830d41b chore: remove getSubsequentPageTopMarginStyle 2026-04-29 21:42:20 +02:00
Amruth Pillai 480e9a2612 fix: switch back to csp-report-only 2026-04-29 21:40:57 +02:00
Amruth Pillai 4cf5cdd181 fix: remove timeout to check data-wf-loaded 2026-04-29 21:38:40 +02:00
Amruth Pillai b9894c687c fix: handle /assets/ requests before it reaches orpc 2026-04-29 20:40:28 +02:00
autofix-ci[bot] 9048702cb8 [autofix.ci] apply automated fixes 2026-04-29 18:17:46 +00:00
Kuchizu dd94d070af fix: make AI resume analysis schema provider-compatible (#2939) 2026-04-29 20:16:29 +02:00
Amruth Pillai e035088269 fix: add-to-cache-list-conflicting-entries 2026-04-29 20:15:15 +02:00
Amruth Pillai 1fbe89bc01 update ipAddressHeaders 2026-04-29 19:55:31 +02:00
Amruth Pillai 4b071f2db7 update ipAddressHeaders 2026-04-29 19:54:21 +02:00
Amruth Pillai 80ca3a8875 update Dockerfile 2026-04-29 19:34:41 +02:00
Amruth Pillai e95be536fa remove CSP reporting 2026-04-29 19:19:36 +02:00
Amruth Pillai d6287dbd65 better rate limiting, verbose logging for username/slug path 2026-04-29 18:44:07 +02:00
Amruth Pillai 47abce351b sync translations with crowdin 2026-04-27 15:22:13 +02:00
Amruth Pillai 8f6c65b7fd chore(release): 🚀 v5.0.20 2026-04-27 15:21:07 +02:00
github-actions[bot]andCrowdin Bot 5a88ac12bf Sync Translations from Crowdin (#2935)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-27 15:13:05 +02:00
RuzenieAmruth Pillaiautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
118004b3d3 feat:icon colors (#2928)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-27 15:12:55 +02:00
github-actions[bot]andCrowdin Bot 6a8dd480cb Sync Translations from Crowdin (#2934)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-27 13:39:40 +02:00
Amruth Pillai 5d8126d4b0 feat: combine settings pages into a single integrations page 2026-04-27 12:56:15 +02:00
JamesGoslings 8566a9fd0f fix(i18n): translate Skills section title to Chinese (zh-CN) (#2931) 2026-04-27 10:47:46 +02:00
github-actions[bot]andCrowdin Bot 0d0bc61582 Sync Translations from Crowdin (#2932)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-27 10:47:22 +02:00
JamesGoslings e3af637100 docs: list Meowth alongside existing templates (#2929) 2026-04-27 10:47:14 +02:00
github-actions[bot]andCrowdin Bot f752145c57 Sync Translations from Crowdin (#2926)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-27 10:45:59 +02:00
Amruth Pillai b87f200767 feat: Add better email templates for password reset and email verification. 2026-04-27 10:45:44 +02:00
Amruth Pillai 73ec8b2ffb chore: release version 5.0.20 with new Meowth resume template and updated translations 2026-04-27 08:52:18 +02:00
github-actions[bot]andCrowdin Bot 107537999f Sync Translations from Crowdin (#2925)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-27 08:50:43 +02:00
Amruth Pillai e1bccbcc93 chore: update dependencies, cleanup exports (knip) 2026-04-27 08:48:35 +02:00
JamesGoslingsAmruth Pillaiautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
54e256be09 feat: add Meowth template with inline three-column entry header (Asian-style compact ATS) (#2923)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-27 08:45:08 +02:00
github-actions[bot]andCrowdin Bot b26d510294 Sync Translations from Crowdin (#2922)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-26 11:46:56 +02:00
Amruth Pillai 623ca5c675 allow loopback to localhost on mcp, only for local MCP clients 2026-04-26 10:59:10 +02:00
Amruth PillaiandClaude 76e00ae019 Revert "fix(resume): prevent orphaned section headings at page break (#2851)" (#2920)
This reverts commit 775f625f6e.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-26 01:23:22 +02:00
Amruth Pillai ff0ef12ecc Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-04-26 01:08:00 +02:00
Amruth Pillai 1bcb2e2af6 update changelog 2026-04-26 01:07:58 +02:00
1954 changed files with 681840 additions and 333150 deletions
-10
View File
@@ -1,10 +0,0 @@
FROM mcr.microsoft.com/devcontainers/typescript-node:24
RUN corepack enable
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
&& rm -rf /var/lib/apt/lists/*
EXPOSE 3000
-33
View File
@@ -1,33 +0,0 @@
{
"name": "Reactive Resume",
"service": "reactive_resume",
"dockerComposeFile": "docker-compose.yml",
"workspaceFolder": "/workspace",
"forwardPorts": [3000, 4000, 5432, 8333],
"portsAttributes": {
"3000": { "label": "Reactive Resume", "onAutoForward": "openBrowser" },
"4000": { "label": "Browserless (Printer)" },
"5432": { "label": "PostgreSQL" },
"8333": { "label": "SeaweedFS (S3)" }
},
"customizations": {
"vscode": {
"extensions": [
"oxc.oxc-vscode",
"bradlc.vscode-tailwindcss",
"lokalise.i18n-ally",
"typescriptteam.native-preview"
],
"settings": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "always"
}
}
}
},
"postCreateCommand": "corepack enable && pnpm install"
}
-95
View File
@@ -1,95 +0,0 @@
services:
reactive_resume:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ..:/workspace:cached
command: sleep infinity
depends_on:
postgres:
condition: service_healthy
browserless:
condition: service_started
seaweedfs:
condition: service_healthy
seaweedfs_create_bucket:
condition: service_completed_successfully
environment:
TZ: Etc/UTC
APP_URL: http://localhost:3000
PRINTER_APP_URL: http://reactive_resume:3000
PRINTER_ENDPOINT: ws://browserless:3000?token=1234567890
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
AUTH_SECRET: change-me-to-a-secure-secret-key-in-production
S3_ACCESS_KEY_ID: seaweedfs
S3_SECRET_ACCESS_KEY: seaweedfs
S3_REGION: us-east-1
S3_ENDPOINT: http://seaweedfs:8333
S3_BUCKET: reactive-resume
S3_FORCE_PATH_STYLE: "true"
postgres:
image: postgres:latest
restart: unless-stopped
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "postgres"]
start_period: 10s
interval: 30s
timeout: 10s
retries: 3
browserless:
image: ghcr.io/browserless/chromium:latest
restart: unless-stopped
environment:
QUEUED: "10"
HEALTH: "true"
CONCURRENT: "5"
TOKEN: "1234567890"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"]
interval: 10s
timeout: 5s
retries: 10
seaweedfs:
image: chrislusf/seaweedfs:latest
restart: unless-stopped
command: server -s3 -filer -dir=/data -ip=0.0.0.0
environment:
AWS_ACCESS_KEY_ID: seaweedfs
AWS_SECRET_ACCESS_KEY: seaweedfs
volumes:
- seaweedfs_data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8888"]
start_period: 10s
interval: 30s
timeout: 10s
retries: 3
seaweedfs_create_bucket:
image: quay.io/minio/mc:latest
restart: on-failure
entrypoint: >
/bin/sh -c "
until mc alias set seaweedfs http://seaweedfs:8333 seaweedfs seaweedfs; do
echo 'Waiting for SeaweedFS...';
sleep 2;
done;
mc mb seaweedfs/reactive-resume --ignore-existing;
"
depends_on:
seaweedfs:
condition: service_healthy
volumes:
postgres_data:
seaweedfs_data:
+24 -7
View File
@@ -1,15 +1,32 @@
dist
docs
# VCS and editor state
.git
.gitignore
.cursor
.DS_Store
.vite-hooks
# Local configuration and runtime state
.env*
/data
# Dependency and package-manager caches
node_modules
**/node_modules
.pnpm-store
# Build, test, and framework output
dist
**/dist
.nitro
.cursor
.output
.vercel
.netlify
coverage
.DS_Store
.tanstack
node_modules
.turbo
**/.turbo
coverage
reports
# Generated service-worker assets are rebuilt during the web build.
public/sw.js
public/workbox-*.js
public/workbox-*.js
+62 -31
View File
@@ -1,93 +1,124 @@
# --- Server ---
TZ="Etc/UTC"
# --- Application ---
# Public port used by the production server and the Vite web server in local development.
PORT="3000"
# Port used by the Hono server in local development. Vite proxies API requests to this port.
SERVER_PORT="3001"
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
# OpenGraph metadata, and absolute upload URLs.
APP_URL="http://localhost:3000"
# Optional, uses APP_URL by default
# PLEASE READ: This should be set to an internal URL (like http://host.docker.internal:3000 or http://{docker_service}:3000)
# to let the browser navigate to a non-public instance of Reactive Resume.
# This is required when the printer service is running inside Docker, and cannot reach the app via the APP URL,
# which is usually when the APP_URL is localhost or a local network IP/hostname.
PRINTER_APP_URL="http://host.docker.internal:3000"
# --- Printer ---
PRINTER_ENDPOINT="ws://localhost:4000?token=change-me"
# --- Database (PostgreSQL) ---
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
# when running directly on your machine, `localhost` is typical.
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
# --- Authentication ---
# Generated using `openssl rand -hex 32`
AUTH_SECRET="change-me-to-a-secure-secret-key-in-production"
# Better Auth Dashboard (optional)
# Enables the Better Auth Dashboard plugin when set, you probably don't need this.
BETTER_AUTH_API_KEY=""
# Social Auth (Google, optional)
# Set both values to enable Google sign-in.
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Social Auth (GitHub, optional)
# Set both values to enable GitHub sign-in.
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Social Auth (LinkedIn, optional)
# Set both values to enable LinkedIn sign-in.
LINKEDIN_CLIENT_ID=""
LINKEDIN_CLIENT_SECRET=""
# Custom OAuth Provider (optional)
# Set OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET plus either OAUTH_DISCOVERY_URL or
# the three manual endpoint URLs below.
OAUTH_PROVIDER_NAME=""
OAUTH_CLIENT_ID=""
OAUTH_CLIENT_SECRET=""
OAUTH_DISCOVERY_URL=""
OAUTH_AUTHORIZATION_URL=""
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=""
OAUTH_TOKEN_URL=""
OAUTH_USER_INFO_URL=""
# AI provider base URL allowlist (optional, comma-separated hosts/origins)
# Example: api.openai.com,https://gateway.ai.vercel.com
AI_ALLOWED_BASE_URLS=""
# Space-separated scopes requested from the custom OAuth provider.
OAUTH_SCOPES="openid profile email"
# --- Email (optional) ---
# If all keys are disabled, the app logs the email to be sent to the console instead.
SMTP_HOST="localhost"
SMTP_PORT="1025"
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
# email to the console instead.
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
SMTP_SECURE="false"
# --- Storage (optional) ---
# If all keys are disabled, the app uses local filesystem (/data) to store uploads instead.
# If all S3 keys are disabled, the app uses local filesystem storage instead.
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
# LOCAL_STORAGE_PATH overrides where local uploads/cache are written.
# Defaults to /app/data in the official Docker image; in dev, defaults to <workspace>/data.
# LOCAL_STORAGE_PATH="/app/data"
# Seaweedfs
S3_ACCESS_KEY_ID="seaweedfs"
S3_SECRET_ACCESS_KEY="seaweedfs"
S3_REGION="us-east-1"
S3_ENDPOINT="http://localhost:8333"
S3_ENDPOINT="http://seaweedfs:8333"
S3_BUCKET="reactive-resume"
S3_FORCE_PATH_STYLE="true"
# --- Feature Flags ---
# This flag bypasses the check that the endpoint to fetch data for the printing of PDFs `getByIdForPrinter`, is only accessible from the server.
# Useful for when you want to debug the /printer/{resumeId} route to quickly take a peek at the page that is sent to the printer.
FLAG_DEBUG_PRINTER="false"
# --- AI Agent Workspace (optional) ---
# Required only for the authenticated /agent workspace and saved AI providers.
REDIS_URL="redis://redis:6379"
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
# --- Feature Flags ---
# This flag disables new signups, both on the web app and the server.
FLAG_DISABLE_SIGNUPS="false"
# This flag disables email/password login. Disables email verification, forgot password, and reset password flows. Users can still sign up via social auth (Google/GitHub/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true.
# This flag disables email/password login. Disables email verification, forgot password, and reset password flows.
# Users can still sign up via social auth (Google/GitHub/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true.
FLAG_DISABLE_EMAIL_AUTH="false"
# This flag disables the image processing.
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
FLAG_DISABLE_IMAGE_PROCESSING="false"
# This flag disables API rate limiting for authentication endpoints.
# Rate limiting is enabled by default in production to prevent abuse.
FLAG_DISABLE_API_RATE_LIMIT="false"
# This flag shows sponsor placements on the public landing page.
FLAG_SHOW_SPONSORS="false"
# Allows dynamic OAuth client registration to use any parseable redirect URI,
# including custom schemes, private hosts, and non-loopback http:// URLs.
# WARNING: Enabling this on a public or multi-tenant deployment can enable phishing
# or token exfiltration. Only enable this on a trusted, self-hosted instance.
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI="false"
# Allows AI providers to be configured with any base URL, including http:// and
# private/loopback addresses (e.g. http://localhost:11434 for a local Ollama instance).
# WARNING: Enabling this on a multi-tenant deployment is a Server-Side Request Forgery (SSRF)
# risk. Only enable this on a trusted, single-tenant self-hosted instance.
FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
# --- Others ---
# Google Cloud API Key (optional)
# This is not used within Reactive Resume, but in src/scripts/fonts/generate.ts to generate a list of fonts served by Google Fonts.
# Note: Make sure "Google Fonts Developer API" is unrestricted.
# For font-list generation tooling.
# Requires "Google Fonts Developer API" to be enabled.
GOOGLE_CLOUD_API_KEY=""
# --- Crowdin ---
# Crowdin (optional)
# For translation tooling.
CROWDIN_PROJECT_ID=""
CROWDIN_API_TOKEN=""
CROWDIN_API_TOKEN=""
-1
View File
@@ -1 +0,0 @@
locales/*.po linguist-generated=true
+66 -66
View File
@@ -1,68 +1,68 @@
{
"$schema": "https://fundingjson.org/schema/v1.1.0.json",
"version": "v1.1.0",
"entity": {
"type": "individual",
"role": "maintainer",
"name": "Amruth Pillai",
"email": "im.amruth@gmail.com",
"description": "Software Engineer",
"webpageUrl": {
"url": "https://rxresu.me/funding.json"
}
},
"projects": [
{
"guid": "reactive-resume",
"name": "Reactive Resume",
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
"webpageUrl": {
"url": "https://rxresu.me"
},
"repositoryUrl": {
"url": "https://github.com/amruthpillai/reactive-resume",
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
},
"licenses": ["spdx:MIT"],
"tags": ["data", "design", "productivity", "resume-builder"]
}
],
"funding": {
"plans": [
{
"guid": "sponsor",
"status": "active",
"name": "Sponsor",
"description": "Support the project on a recurring basis by becoming a sponsor.",
"amount": 10,
"currency": "EUR",
"frequency": "monthly",
"channels": ["github", "open-collective"]
},
{
"guid": "donation",
"status": "active",
"name": "Donation",
"description": "Show your support for the project by making a one-time donation.",
"amount": 0,
"currency": "EUR",
"frequency": "one-time",
"channels": ["github", "open-collective"]
}
],
"channels": [
{
"guid": "github",
"type": "payment-provider",
"description": "GitHub Sponsors",
"address": "https://github.com/sponsors/AmruthPillai"
},
{
"guid": "open-collective",
"type": "payment-provider",
"description": "Open Collective",
"address": "https://opencollective.com/reactive-resume"
}
]
}
"$schema": "https://fundingjson.org/schema/v1.1.0.json",
"version": "v1.1.0",
"entity": {
"type": "individual",
"role": "maintainer",
"name": "Amruth Pillai",
"email": "hello@amruthpillai.com",
"description": "Software Engineer",
"webpageUrl": {
"url": "https://rxresu.me/funding.json"
}
},
"projects": [
{
"guid": "reactive-resume",
"name": "Reactive Resume",
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
"webpageUrl": {
"url": "https://rxresu.me"
},
"repositoryUrl": {
"url": "https://github.com/amruthpillai/reactive-resume",
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
},
"licenses": ["spdx:MIT"],
"tags": ["data", "design", "productivity", "resume-builder"]
}
],
"funding": {
"plans": [
{
"guid": "sponsor",
"status": "active",
"name": "Sponsor",
"description": "Support the project on a recurring basis by becoming a sponsor.",
"amount": 10,
"currency": "EUR",
"frequency": "monthly",
"channels": ["github", "open-collective"]
},
{
"guid": "donation",
"status": "active",
"name": "Donation",
"description": "Show your support for the project by making a one-time donation.",
"amount": 0,
"currency": "EUR",
"frequency": "one-time",
"channels": ["github", "open-collective"]
}
],
"channels": [
{
"guid": "github",
"type": "payment-provider",
"description": "GitHub Sponsors",
"address": "https://github.com/sponsors/AmruthPillai"
},
{
"guid": "open-collective",
"type": "payment-provider",
"description": "Open Collective",
"address": "https://opencollective.com/reactive-resume/donate"
}
]
}
}
+83 -26
View File
@@ -1,67 +1,124 @@
name: 🐞 Bug Report
description: Create a bug report to help improve Reactive Resume
description: Report a reproducible problem with Reactive Resume
title: "[Bug] <title>"
labels: [bug, v5, needs triage]
assignees: "AmruthPillai"
labels: ["bug", "status: needs triage"]
assignees: []
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
label: Existing issue
description: Search open and closed issues before submitting a new report.
options:
- label: Yes, I have searched the existing issues and none of them match my problem.
- label: I searched the existing issues and could not find a matching report.
required: true
- type: dropdown
id: variant
attributes:
label: Product Variant
description: What variant of Reactive Resume are you using?
label: Product variant
description: Where does the problem occur?
options:
- Cloud (https://rxresu.me)
- Self-Hosted
- Cloud
- Self-hosted
validations:
required: true
- type: input
id: version
attributes:
label: Reactive Resume version
description: Find this in Settings or provide the container image tag or commit SHA.
placeholder: 5.2.6
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Choose the part of Reactive Resume most closely related to the problem.
options:
- Resume builder & data
- Templates, preview & export
- Accounts & sharing
- AI & Agent
- Language & localization
- Self-hosting
- API & integrations
- Applications & cover letters
- Other / unsure
validations:
required: true
- type: input
id: environment
attributes:
label: Environment
description: Include your operating system and browser. For self-hosted installations, also include the deployment method.
placeholder: Firefox 143 on Ubuntu 26.04, deployed with Docker Compose
validations:
required: true
- type: textarea
id: summary
attributes:
label: Describe the bug you're experiencing
description: A detailed description of what you're experiencing. Please provide as much detail as possible as it will help me diagnose and fix the issue faster.
label: Summary
description: Briefly describe the problem and its impact.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Provide the smallest reliable sequence that demonstrates the problem.
placeholder: |
1. Open ...
2. Select ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: dropdown
id: template
attributes:
label: What template are you using?
description: Leave blank if the issue applies to all templates, or is not template-specific.
multiple: false
label: Template
description: Leave blank when the problem is not template-specific.
options:
- Azurill
- Bronzor
- Chikorita
- Ditto
- Ditgar
- Ditto
- Gengar
- Glalie
- Kakuna
- Lapras
- Leafish
- Meowth
- Onyx
- Pikachu
- Rhyhorn
validations:
required: false
- Scizor
- type: textarea
id: logs
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
label: Logs and screenshots
description: Add relevant logs, screenshots, or a minimal reproduction. Remove secrets and personal resume data first.
+69 -10
View File
@@ -1,23 +1,82 @@
name: ✨ Feature Request
description: Suggest an feature or idea that you would like to see in Reactive Resume
description: Propose an actionable improvement to Reactive Resume
title: "[Feature] <title>"
labels: [enhancement, v5, needs triage]
assignees: "AmruthPillai"
labels: ["enhancement", "status: needs triage"]
assignees: []
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this feature?
description: Please search to see if an issue already exists for the feature you requested.
label: Existing issue
description: Search open and closed issues before submitting a new proposal.
options:
- label: Yes, I have searched the existing issues and it doesn't exist.
- label: I searched the existing issues and could not find a matching proposal.
required: true
- type: textarea
- type: dropdown
id: variant
attributes:
label: Feature Description
description: A detailed description of the feature you would like to see in Reactive Resume. Please provide as much detail as possible as it will help me implement the feature faster.
label: Product variant
description: Choose the primary environment for this proposal.
options:
- Cloud
- Self-hosted
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Choose the part of Reactive Resume most closely related to the proposal.
options:
- Resume builder & data
- Templates, preview & export
- Accounts & sharing
- AI & Agent
- Language & localization
- Self-hosting
- API & integrations
- Applications & cover letters
- Other / unsure
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What user problem or limitation should Reactive Resume solve?
validations:
required: true
- type: textarea
id: outcome
attributes:
label: Desired outcome
description: Describe the behavior you want without prescribing an implementation.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Describe current workarounds or alternatives. Write "None" if there are none.
validations:
required: true
- type: textarea
id: scope
attributes:
label: Proposed scope
description: Explain what should be included and what can remain out of scope.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add examples, mockups, or related issues when useful. Remove personal resume data first.
+7
View File
@@ -1 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and support
url: https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a
about: Get help with setup, configuration, and using Reactive Resume.
- name: Security vulnerability
url: https://github.com/amruthpillai/reactive-resume/security/advisories/new
about: Report security vulnerabilities privately.
+17
View File
@@ -0,0 +1,17 @@
<!-- caveman-begin -->
Respond terse like smart caveman. All technical substance stay. Only fluff die.
Rules:
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
- Pattern: [thing] [action] [reason]. [next step].
- Not: "Sure! I'd be happy to help you with that."
- Yes: "Bug in auth middleware. Fix:"
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
Stop: "stop caveman" or "normal mode"
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
Boundaries: code/commits/PRs written normal.
<!-- caveman-end -->
+19 -9
View File
@@ -19,20 +19,30 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
- name: Check for merge conflict markers
run: |
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
echo "::error::Merge conflict markers found in tracked files"
exit 1
fi
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
cache: true
node-version: "lts/*"
cache: "pnpm"
- name: Install Dependencies
run: vp install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Run Tests
run: vp test
- name: Remove Unused Dependencies
run: pnpm knip --fix
- name: Run Lint and Format
run: vp check --fix
- name: Lint and Format
run: pnpm check
- name: Autofix
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8
+73 -17
View File
@@ -2,6 +2,11 @@ name: Build Docker Image
on:
workflow_dispatch:
push:
branches:
- main
tags:
- "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,17 +17,34 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
mode:
runs-on: ubuntu-latest
outputs:
nightly: ${{ steps.mode.outputs.nightly }}
release: ${{ steps.mode.outputs.release }}
matrix: ${{ steps.mode.outputs.matrix }}
steps:
- name: Determine publishing mode
id: mode
run: |
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "nightly=true" >> "$GITHUB_OUTPUT"
echo "release=false" >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"}]}' >> "$GITHUB_OUTPUT"
else
echo "nightly=false" >> "$GITHUB_OUTPUT"
echo "release=true" >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"},{"platform":"linux/arm64","runner":"ubuntu-24.04-arm","arch":"arm64"}]}' >> "$GITHUB_OUTPUT"
fi
build:
needs: mode
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
matrix: ${{ fromJSON(needs.mode.outputs.matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
@@ -97,7 +119,9 @@ jobs:
retention-days: 1
merge:
needs: build
needs:
- mode
- build
timeout-minutes: 30
runs-on: ubuntu-latest
@@ -160,16 +184,25 @@ jobs:
docker.io/${{ env.IMAGE }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest
type=raw,value=v${{ steps.version.outputs.version }}
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }}
type=raw,value=v${{ steps.semver.outputs.major }}
type=raw,value=nightly,enable=${{ needs.mode.outputs.nightly == 'true' }}
type=raw,value=nightly-{{date 'YYYYMMDDHHmmss' tz='UTC'}},enable=${{ needs.mode.outputs.nightly == 'true' }}
type=raw,value=latest,enable=${{ needs.mode.outputs.release == 'true' }}
type=raw,value=v${{ steps.version.outputs.version }},enable=${{ needs.mode.outputs.release == 'true' }}
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }},enable=${{ needs.mode.outputs.release == 'true' }}
type=raw,value=v${{ steps.semver.outputs.major }},enable=${{ needs.mode.outputs.release == 'true' }}
- name: Create manifest list and push
id: manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
if [[ "${{ needs.mode.outputs.nightly }}" == "true" ]]; then
FINAL_TAG="nightly"
else
FINAL_TAG="v${{ steps.version.outputs.version }}"
fi
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
--annotation "index:org.opencontainers.image.licenses=MIT" \
@@ -184,8 +217,9 @@ jobs:
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
# Get the digest of the multi-arch manifest
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "final_tag=$FINAL_TAG" >> "$GITHUB_OUTPUT"
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
@@ -202,10 +236,11 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
- name: Redeploy Stack
if: ${{ needs.mode.outputs.release == 'true' }}
uses: appleboy/ssh-action@v1
with:
key: ${{ secrets.SSH_KEY }}
@@ -214,3 +249,24 @@ jobs:
script: |
cd docker
./manage_stack.sh up reactive_resume
- name: Purge Cloudflare cache
if: ${{ needs.mode.outputs.release == 'true' }}
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -euo pipefail
response=$(curl -fsS --max-time 10 --retry 3 --retry-delay 5 --retry-connrefused -X POST \
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}')
if [ "$(jq -r '.success' <<< "$response")" != "true" ]; then
echo "$response" | jq .
exit 1
fi
echo "Cloudflare cache purged successfully."
+91
View File
@@ -0,0 +1,91 @@
name: E2E Tests
on:
pull_request:
push:
branches: ["main"]
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
APP_URL: http://localhost:3000
PORT: "3000"
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
FLAG_DISABLE_SIGNUPS: "false"
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_API_RATE_LIMIT: "true"
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run Server and Tooling Tests
run: pnpm exec turbo run test:ci --filter=server --filter=@reactive-resume/tooling
- name: Install Playwright Browser
run: pnpm exec playwright install --with-deps chromium
- name: Generate Test Secrets
run: |
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
- name: Prepare Storage
run: mkdir -p "$LOCAL_STORAGE_PATH"
- name: Run Database Migrations
run: pnpm db:migrate
- name: Build
run: pnpm build
- name: Run Baseline E2E Tests
run: pnpm exec playwright test --grep-invert "@semantic-css"
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
playwright-report
test-results
if-no-files-found: ignore
retention-days: 7
+34
View File
@@ -0,0 +1,34 @@
name: Label New Issues
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Apply Form Labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { getIssueLabels } = await import(`${process.env.GITHUB_WORKSPACE}/tooling/issue-labels.mjs`);
const labels = getIssueLabels(context.payload.issue.body ?? "");
if (labels.length > 0) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels,
});
}
+30
View File
@@ -0,0 +1,30 @@
name: Close Issues Awaiting Information
on:
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
permissions:
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- name: Close Inactive Issues Awaiting Information
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11
with:
only-issue-labels: "status: needs info"
days-before-issue-stale: 14
days-before-issue-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
stale-issue-message: >-
This issue is waiting for information requested by a maintainer. It will close in 7 days if no new information is provided.
close-issue-message: >-
Closing because the requested information was not provided. Add the missing details in a comment and a maintainer can reopen the issue.
close-issue-reason: not_planned
remove-issue-stale-when-updated: true
+62 -19
View File
@@ -1,21 +1,64 @@
dist
.env*
/data
.nitro
.agents
.output
.vercel
.cursor
.claude
TODO.md
coverage
.netlify
.DS_Store
.tanstack
# Dependencies
node_modules
.pnpm-store
# Build Outputs
dist
.vercel
.wrangler
# Environment Variables
.env*
!.env.example
public/sw.js
public/sw.js.map
scripts/**/*.json
public/workbox-*.js
public/workbox-*.js.map
# IDEs and Editors
*~
*.swp
*.swo
.DS_Store
.idea
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Log Files
logs
*.log*
# Turborepo
.turbo
# TanStack Start
.tanstack
# Testing
coverage
reports
playwright-report
test-results
# Cache
tmp
temp
.cache
# AI
.codex
.agents
.claude
.cursor
.opencode
.superpowers
.worktrees
.migration
graphify-out
# Local Storage Data
/data
/apps/web/data
# Git Hooks
.vite-hooks
+31
View File
@@ -0,0 +1,31 @@
config:
default: true
MD007: false
MD009: false
MD010: false
MD012: false
MD013: false
MD001: false
MD022: false
MD024: false
MD025: false
MD028: false
MD031: false
MD032: false
MD033: false
MD034: false
MD036: false
MD040: false
MD041: false
MD046: false
MD060: false
frontMatter: "^---[\\s\\S]*?---"
gitignore: true
globs:
- "**/*.{md,mdx}"
ignores:
- ".design-sync/**"
- "node_modules/**"
- ".turbo/**"
- "dist/**"
+11 -11
View File
@@ -1,17 +1,17 @@
// @ts-check
const nextPackages = ["@monaco-editor/react"];
const betaPackages = ["drizzle-orm", "drizzle-kit", "drizzle-zod"];
const betaPackages = ["drizzle-zod"];
const rcPackages = ["drizzle-orm", "drizzle-kit"];
/** @type {import('npm-check-updates').RunOptions} */
module.exports = {
upgrade: true,
install: "always",
packageManager: "pnpm",
target: (packageName) => {
if (nextPackages.includes(packageName)) return "@next";
if (betaPackages.includes(packageName)) return "@beta";
return "latest";
},
upgrade: true,
workspaces: true,
install: "always",
packageManager: "pnpm",
target: (packageName) => {
if (betaPackages.includes(packageName)) return "@beta";
if (rcPackages.includes(packageName)) return "@rc";
return "latest";
},
};
-1
View File
@@ -1 +0,0 @@
vp staged
+1 -7
View File
@@ -1,9 +1,3 @@
{
"recommendations": [
"oxc.oxc-vscode",
"bradlc.vscode-tailwindcss",
"lokalise.i18n-ally",
"typescriptteam.native-preview",
"VoidZero.vite-plus-extension-pack"
]
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss", "typescriptteam.native-preview"]
}
+32 -39
View File
@@ -1,41 +1,34 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll.oxc": "explicit"
},
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"files.associations": {
"*.css": "tailwindcss"
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true,
"pnpm-lock.yaml": true
},
"files.watcherExclude": {
"**/routeTree.gen.ts": true,
"locales/**.po": true,
"pnpm-lock.yaml": true
},
"i18n-ally.enabledParsers": ["po"],
"i18n-ally.localesPaths": ["locales"],
"i18n-ally.sourceLanguage": "en-US",
"oxc.enable.oxfmt": true,
"oxc.enable.oxlint": true,
"oxc.configPath": "./vite.config.ts",
"oxc.fmt.configPath": "./vite.config.ts",
"search.exclude": {
"**/routeTree.gen.ts": true,
"locales/**.po": true,
"pnpm-lock.yaml": true
},
"tailwindCSS.classFunctions": ["cn", "cva"],
"tailwindCSS.experimental.classRegex": [
["class:\\s*?[\"'`]([^\"'`]*).*?,"],
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
"typescript.experimental.useTsgo": true
"biome.enabled": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"files.readonlyInclude": {
"**/locales/**.po": true,
"**/routeTree.gen.ts": true,
"pnpm-lock.yaml": true
},
"files.watcherExclude": {
"**/locales/**.po": true,
"**/routeTree.gen.ts": true,
"pnpm-lock.yaml": true
},
"search.exclude": {
"**/locales/**.po": true,
"**/routeTree.gen.ts": true,
"pnpm-lock.yaml": true
},
"tailwindCSS.classFunctions": ["cn", "cva"],
"tailwindCSS.experimental.classRegex": [
["class:\\s*?[\"'`]([^\"'`]*).*?,"],
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
"typescript.experimental.useTsgo": true,
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+186 -103
View File
@@ -1,131 +1,214 @@
## Overview
<!-- intent-skills:start -->
## Skill Loading
Reactive Resume is a single-package full-stack TypeScript app (not a monorepo) built with [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/overview) (React, Vite, Nitro). It serves both frontend and API on port 3000.
Before editing files for a substantial task:
- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills.
- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` before changing files.
- Use the loaded `SKILL.md` guidance while making the change.
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
<!-- intent-skills:end -->
This project uses [Vite+](https://vite.dev/blog/announcing-viteplus), a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. All modules should be imported from the `vite-plus` dependency (e.g., `import { defineConfig } from 'vite-plus'` or `import { expect, test, vi } from 'vite-plus/test'`).
<!-- caveman-begin -->
Respond terse like smart caveman. All technical substance stay. Only fluff die.
## Key Libraries
Rules:
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
- Pattern: [thing] [action] [reason]. [next step].
- Not: "Sure! I'd be happy to help you with that."
- Yes: "Bug in auth middleware. Fix:"
| Area | Library | Docs |
| -------------------- | ------------------------------------------------------------------------ | ---------------------------------- |
| Frontend framework | React | https://react.dev |
| Full-stack framework | TanStack Start | https://tanstack.com/start/latest |
| Router | TanStack React Router | https://tanstack.com/router/latest |
| Server state | TanStack React Query | https://tanstack.com/query/latest |
| Client state | Zustand (+ Zundo for undo/redo, Immer for immutable updates) | https://zustand.docs.pmnd.rs |
| Type-safe API | oRPC | https://orpc.unnoq.com |
| Database ORM | Drizzle ORM (PostgreSQL) | https://orm.drizzle.team |
| Authentication | Better Auth (+ Drizzle adapter, OAuth provider, API keys, 2FA, Passkeys) | https://www.better-auth.com |
| Styling | Tailwind CSS | https://tailwindcss.com |
| UI Components | shadcn/ui (built on Base UI) | https://ui.shadcn.com |
| Icons | Phosphor Icons | https://phosphoricons.com |
| Forms | React Hook Form (+ Zod resolvers) | https://react-hook-form.com |
| Rich text editor | Tiptap | https://tiptap.dev |
| Validation | Zod | https://zod.dev |
| AI | Vercel AI SDK (OpenAI, Anthropic, Google, Ollama providers) | https://ai-sdk.dev |
| MCP | Model Context Protocol SDK | https://modelcontextprotocol.io |
| i18n | Lingui | https://lingui.dev |
| Animations | Motion (Framer Motion) | https://motion.dev |
| PDF export | Puppeteer Core (via Browserless) | https://pptr.dev |
| Drag and drop | dnd-kit | https://dndkit.com |
| Server engine | Nitro | https://nitro.build |
| PWA | Vite PWA Plugin | https://vite-pwa-org.netlify.app |
| Unused deps | Knip | https://knip.dev |
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
Stop: "stop caveman" or "normal mode"
## Project Structure
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
```
src/
components/ UI, resume, layout, animation, theme, locale components
routes/ File-based routing (TanStack React Router)
integrations/ Feature modules (auth, drizzle, orpc, ai, email, jobs, mcp, storage)
schema/ Zod schemas for resume data validation
utils/ Utility functions (locale, theme, env, resume processing)
dialogs/ Modal/dialog components
hooks/ Custom React hooks
styles/ CSS and Tailwind configuration
stores/ Zustand stores (resume, AI, dialog, command palette)
migrations/ Drizzle database migrations
locales/ Lingui i18n message catalogs (47+ locales)
Boundaries: code/commits/PRs written normal.
<!-- caveman-end -->
<!-- graphify-begin -->
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
<!-- graphify-end -->
# AGENTS.md
## Agent skills
### Issue tracker
Issues and specs are tracked in GitHub Issues for `amruthpillai/reactive-resume`. See `docs/agents/issue-tracker.md`.
### Domain docs
This repository uses a multi-context domain-doc layout. See `docs/agents/domain.md`.
## Cursor Cloud specific instructions
### Overview
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (TanStack Start / React 19 / Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000, with `apps/server` mounting the API/auth/MCP/static routes and serving the built web app.
Internal packages are source-consumed through `package.json` export maps that point at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
### Prerequisites
- **Node.js 24** (matches Dockerfile `ARG NODE_VERSION=24`). Use `nvm install 24 && nvm use 24` if needed.
- **Docker** is required to run PostgreSQL. Start it with `sudo dockerd &` if the daemon isn't running.
- **pnpm 11.21.0**. Install pnpm directly using the [official installation guide](https://pnpm.io/installation).
### Codebase map
- `apps/web` owns TanStack Start routes, Vite config, PWA setup, oRPC browser client wiring, web features, and the resume builder UI.
- `apps/server` owns the production Hono app, route composition, auth/RPC/MCP/OpenAPI handlers, static uploads, schema JSON, web-dist fallback serving, and startup checks.
- `packages/api` contains oRPC routers, DTOs, rate limiting, and feature-owned API modules under `packages/api/src/features/*`. The router export at `@reactive-resume/api/routers` aggregates those feature routers for `/api/rpc`.
- `packages/auth` contains Better Auth config, auth helper functions, and exported auth types. The server auth adapter in `apps/server/src/http/auth.ts` delegates to `auth.handler`.
- `packages/db` contains the Drizzle client and schema. Migration files live at the repo root in `migrations/`.
- `packages/env` defines server environment validation and auto-loads the root `.env` for app/server code.
- `packages/schema` contains Zod schemas and typed resume/page/template models.
- `packages/pdf` contains the React PDF document, font registration, shared template primitives, template implementations, and browser/server PDF generation adapters. PDF.js viewer UI stays in `apps/web`.
- `packages/resume` contains pure resume-domain behavior such as JSON Patch helpers and social-network icon mapping.
- `packages/docx` contains DOCX export generation.
- `packages/mcp` contains MCP tools, prompts, resources, server-card generation, and tool metadata.
- `packages/ui` contains shared Base UI/shadcn-style components and hooks.
- `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, and `packages/config` provide focused support surfaces. Prefer their existing exports over adding cross-package shortcuts.
- Development-only scripts live in `tooling/`, not under `packages/`, so packages only contain code bundled by the app/runtime.
### Web app conventions
- Routes are file-based under `apps/web/src/routes`. Do not hand-edit `apps/web/src/routeTree.gen.ts`; it is generated by TanStack Router tooling.
- Server-owned HTTP behavior lives in `apps/server/src/{http,rpc,mcp,openapi,static,startup}`. Keep API/RPC/auth/MCP/static route wiring in `apps/server`, not in web routes.
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context where possible instead of refetching these concerns ad hoc.
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
- Browser-only resume preview code lives under `apps/web/src/features/resume/preview`, and public resume PDF viewer code lives under `apps/web/src/features/resume/public`. Keep PDF.js/canvas/browser APIs out of SSR paths and out of `packages/pdf`.
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
- For React components with explicit props, prefer a named TypeScript props type over inline object annotations in the function signature, especially once the props include more than one field or generics. For example:
```ts
type IntentSelectFieldProps<TValue extends string> = {
label: string;
id: string;
value: TValue | undefined;
options: readonly ComboboxOption<TValue>[];
onChange: (value: TValue | undefined) => void;
};
function IntentSelectField<TValue extends string>(props: IntentSelectFieldProps<TValue>) {
// ...
}
```
### Key Config Files
### Package and feature boundaries
- `vite.config.ts` — Vite + Nitro + TanStack Start + PWA + Tailwind + Lingui
- `drizzle.config.ts` — PostgreSQL dialect, schema at `./src/integrations/drizzle/schema.ts`
- `tsconfig.json` — ES2022, strict mode, path alias `@/*` → `./src/*`
- `lingui.config.ts` — i18n extraction and locale configuration
- `components.json` — shadcn CLI configuration
- Workspace dependencies must go through package names and package export maps. Do not import another workspace's `src` tree through repository paths, `@reactive-resume/*/src/*`, or TypeScript path aliases.
- `turbo boundaries` is the executable package-boundary check. Workspace-level `turbo.json` files declare coarse tags:
- `app:web` for the TanStack Start app.
- `app:server` and `runtime:server` for the Node/Hono process.
- `runtime:server` for server-only packages such as API/auth/db/env/email/MCP.
- `runtime:browser` for browser-only shared UI.
- `runtime:universal` for environment-neutral domain packages.
- `role:domain`, `role:infra`, `role:adapter`, `role:api`, `role:rendering`, and `role:tooling` for package intent.
- Browser/server runtime-specific code should live behind explicit export subpaths such as `@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, or `@reactive-resume/env/server`. Keep root exports environment-neutral unless the package is intentionally server-only.
- Wildcard exports are allowed only for leaf libraries whose public surface is intentionally file-like, currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume model files. Prefer explicit exports for packages that own runtime behavior.
- Add new API procedures and business logic inside the owning `packages/api/src/features/*` module. Keep route wiring, DTO usage, helpers, and services colocated by feature/capability, then expose only intentional public surfaces through `packages/api/package.json`. Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures.
- Add database columns/tables in `packages/db/src/schema/*`, then generate root-level migrations with `dotenvx run -f .env.local -- pnpm db:generate`.
- Add or change resume data shape in `packages/schema/src/resume/*` first, then update API DTOs, importers, PDF rendering, and web forms that consume that shape.
- Add or rename templates in all relevant places: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, template source under `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}`.
- Resume JSON Patch behavior belongs in `@reactive-resume/resume/patch`; do not put resume-domain helpers in `@reactive-resume/utils`.
- DOCX export behavior belongs in `@reactive-resume/docx`; do not put DOCX builders in `@reactive-resume/utils`.
- Shared PDF section filtering lives in `packages/pdf/src/templates/shared/filtering.ts`. Keep template-specific visual exceptions in the owning template directory unless multiple templates need the same behavior.
- `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation behavior.
- PDF generation helpers live behind `@reactive-resume/pdf/browser` and `@reactive-resume/pdf/server`; locale-specific section-title resolution stays in the caller.
- MCP implementation belongs in `@reactive-resume/mcp`; app packages must not import MCP implementation from another app's source tree.
- `packages/utils` has narrowly exported helpers. If another package needs a utility, add an explicit export path instead of importing private files.
### API Architecture
Placement decision tree:
- **oRPC API** (`/api/rpc/*`) — Type-safe RPC with routers for: `ai`, `auth`, `resume`, `storage`, `printer`, `jobs`, `statistics`, `flags`. Three procedure types: `publicProcedure`, `protectedProcedure`, `serverOnlyProcedure`.
- **Better Auth API** (`/api/auth/*`) — OAuth, session management, social provider callbacks.
- **MCP Server** (`/mcp/`) — Model Context Protocol with OAuth Bearer tokens and API key auth. Exposes resumes as resources and tools for resume CRUD.
1. If the change is a web route, route loader, or user-facing web workflow, start in `apps/web/src/routes` or `apps/web/src/features`.
2. If the change is a server HTTP route/adapter, startup check, static handler, MCP transport, or OpenAPI/well-known handler, start in `apps/server/src`.
3. If it is authenticated API behavior, put the contract and implementation in the owning `packages/api/src/features/*` module.
4. If it is pure resume data behavior with no DB, HTTP, DOM, or PDF renderer dependency, put it in `packages/resume`.
5. If it renders resume PDFs, put shared React PDF/template code in `packages/pdf`; put PDF.js viewer/canvas UI in `apps/web/src/features/resume`.
6. If it creates DOCX exports, put it in `packages/docx`.
7. If it exposes MCP tools/prompts/resources, put it in `packages/mcp`.
8. If it is a generic UI primitive or hook, put it in `packages/ui`; if it is workflow-specific UI, keep it in the owning web feature.
9. If it is a narrow cross-cutting helper, add an explicit `packages/utils` export only after checking that no domain package is a better owner.
## Infrastructure Services
### Database
Before running the dev server, Docker must be running with at least PostgreSQL. Start services via `compose.dev.yml`:
PostgreSQL runs via Docker Compose:
```bash
sudo dockerd &>/var/log/dockerd.log &
sudo docker compose -f compose.dev.yml up -d postgres browserless
```
sudo docker compose -f compose.dev.yml up -d postgres
```
- **PostgreSQL** (port 5432) — required. The app auto-runs Drizzle migrations on startup via a Nitro plugin.
- **Browserless** (port 4000) — required for PDF export. Maps container port 3000 to host port 4000.
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
## Environment Variables
**Important**: `drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly — it does **not** auto-load the `.env` file. Run migration commands through `dotenvx`, for example `dotenvx run -f .env.local -- pnpm db:migrate`, so `DATABASE_URL` is present in the process environment.
Copy `.env.example` to `.env` if not present. Key notes for local dev:
The production server runs migrations during startup before serving traffic. Manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
- `APP_URL` — local dev server origin on port 3000.
- `PRINTER_APP_URL` — must use the Docker bridge gateway IP (not localhost) so the Browserless container can reach the app on the host. Get the IP with: `sudo docker network inspect reactive_resume_default --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'`
- `PRINTER_ENDPOINT` — websocket URL to Browserless on host port 4000 with token `1234567890`.
- `DATABASE_URL` — PostgreSQL connection using `postgres:postgres` credentials on localhost:5432.
- S3/Storage and SMTP vars can be left empty — the app falls back to local filesystem and console-logged emails.
### Environment
## Common Commands
Copy `.env.example` to `.env.local`. The three required variables are:
`vp` is the global CLI for Vite+. Do not use pnpm/npm/yarn directly — Vite+ wraps the underlying package manager.
- `APP_URL` (default `http://localhost:3000`)
- `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`)
- `AUTH_SECRET` (any non-empty string)
| Task | Command |
| ----------------------------- | --------------------------------------------------------------- |
| Install dependencies | `vp install` |
| Dev server (port 3000) | `vp dev` |
| Lint (Oxlint, type-aware) | `vp lint --type-aware` |
| Format (Oxfmt) | `vp fmt` |
| Check (lint + format + types) | `vp check` |
| Typecheck | `pnpm typecheck` (uses tsgo) |
| Run tests | `vp test` |
| DB migrations | `pnpm db:generate` / `pnpm db:migrate` (auto-runs on dev start) |
| DB studio | `pnpm db:studio` |
| i18n extraction | `pnpm lingui:extract` |
| Add a dependency | `vp add <package>` |
| Remove a dependency | `vp remove <package>` |
| One-off binary | `vp dlx <package>` |
| Build for production | `vp build` |
| Preview production build | `vp preview` |
| Start production server | `pnpm start` |
S3/SeaweedFS is optional. If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. The checked-in `.env.example` sets SeaweedFS defaults, so either start the `seaweedfs` compose service too or comment out those S3 vars to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
## Vite+ Pitfalls
`REDIS_URL` and `ENCRYPTION_SECRET` are optional for core resume flows, but both are required for saved AI providers and the authenticated `/agent` workspace. Start the `redis` compose service and set both vars in `.env.local` when working on those features. For host-run development, use `REDIS_URL=redis://localhost:6379`; the container-run app uses `REDIS_URL=redis://redis:6379`.
- **Do not use pnpm/npm/yarn directly** for package operations — use `vp add`, `vp remove`, `vp install`, etc.
- **Do not run `vp vitest` or `vp oxlint`** — they don't exist. Use `vp test` and `vp lint`.
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly** — Vite+ bundles them.
- **Import from `vite-plus`**, not from `vite` or `vitest` directly (e.g., `import { defineConfig } from 'vite-plus'`).
- **Vite+ commands take precedence** over `package.json` scripts. If there's a naming conflict, use `vp run <script>`.
- **Use `vp dlx`** instead of `npx` or `pnpm dlx`.
- **Type-aware linting** works out of the box with `vp lint --type-aware` — no need to install `oxlint-tsgolint`.
When running dev servers or migration commands, prefix the command with `dotenvx run -f .env.local --`. For example: `dotenvx run -f .env.local -- pnpm dev`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need this prefix by default. If one of those commands fails because a specific environment variable is required, rerun it with the `dotenvx run -f .env.local --` prefix.
## Gotchas
### Common commands
- The Docker daemon needs `fuse-overlayfs` storage driver and `iptables-legacy` in the cloud VM (nested container environment).
- `pnpm.onlyBuiltDependencies` in `package.json` controls which packages are allowed to run install scripts — no interactive `pnpm approve-builds` needed.
- Email verification is optional in dev — after signup, click "Continue" to skip.
- Vite and Nitro use beta/nightly builds. Occasional upstream issues may occur.
| Task | Command |
|------|---------|
| Install deps | `pnpm install` |
| Start Postgres only | `sudo docker compose -f compose.dev.yml up -d postgres` |
| Start Postgres + SeaweedFS | `sudo docker compose -f compose.dev.yml up -d postgres seaweedfs seaweedfs_create_bucket` |
| Start full dev infrastructure | `sudo docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket` |
| Generate migrations | `dotenvx run -f .env.local -- pnpm db:generate` |
| Run migrations | `dotenvx run -f .env.local -- pnpm db:migrate` |
| Dev server | `dotenvx run -f .env.local -- pnpm dev` (starts on port 3000) |
| Web dev server only | `dotenvx run -f .env.local -- pnpm dev:web` |
| Lint/format | `pnpm check` (Biome) |
| Boundary check | `pnpm exec turbo boundaries` |
| Tests | `pnpm test` (Vitest) |
| Build | `pnpm build` |
| Typecheck | `pnpm typecheck` |
## Review Checklist for Agents
For focused validation, prefer package filters before repo-wide commands, for example:
- [ ] Run `vp install` after pulling remote changes and before getting started.
- [ ] Run `pnpm lint:fix`, `pnpm format:fix`, `pnpm typecheck` and `vp test` to validate changes.
```
pnpm --filter web typecheck
pnpm --filter @reactive-resume/pdf test
pnpm --filter @reactive-resume/api test
pnpm exec turbo boundaries
```
Vitest test paths are package-relative when running through `pnpm --filter <package> test -- <path>`.
### Gotchas
- The server startup path auto-runs migrations before serving traffic, so `pnpm db:migrate` is mainly needed for first-time setup, migration debugging, or applying migrations without starting the app.
- Email sending requires SMTP config; without it, emails are logged to console. This is fine for dev — the app still functions, but email verification links appear in server logs.
- The `lefthook.yml` pre-commit hook runs `biome check` on staged files. Run `pnpm check` before committing to avoid hook failures.
- `pnpm check` is write-capable (`biome check --write --unsafe .`). Call that out when using it, and use narrower Biome commands if you need a non-mutating inspection.
- Biome uses tabs, double quotes, line width 120, organized import groups, and sorted Tailwind classes for `clsx`, `cva`, and `cn`.
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
- **New env vars require a `turbo.json` entry.** Turborepo 2.x runs in strict env mode by default — it filters out env vars that are not listed in `globalEnv` (or task-level `env`/`passThroughEnv`). Any new environment variable added to `packages/env/src/server.ts` must also be added to the `globalEnv` array in `turbo.json`, or the variable will be `undefined` inside child processes at runtime even if it is correctly set in the OS/container environment.
+347
View File
@@ -0,0 +1,347 @@
---
version: alpha
name: Reactive Resume
description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support.
colors:
primary: "#343434"
primary-foreground: "#FBFBFB"
secondary: "#F7F7F7"
secondary-foreground: "#343434"
background: "#FFFFFF"
foreground: "#252525"
muted: "#F7F7F7"
muted-foreground: "#8E8E8E"
card: "#FFFFFF"
card-foreground: "#252525"
border: "#EBEBEB"
input: "#EBEBEB"
ring: "#B5B5B5"
destructive: "#DC2626"
on-destructive: "#FFFFFF"
typography:
heading:
fontFamily: IBM Plex Sans Variable
fontSize: 1rem
fontWeight: 500
body:
fontFamily: IBM Plex Sans Variable
fontSize: 0.875rem
fontWeight: 400
body-sm:
fontFamily: IBM Plex Sans Variable
fontSize: 0.75rem
fontWeight: 400
label:
fontFamily: IBM Plex Sans Variable
fontSize: 0.8rem
fontWeight: 500
hero-heading:
fontFamily: IBM Plex Sans Variable
fontSize: 3.75rem
fontWeight: 700
letterSpacing: -0.025em
rounded:
sm: 0.18rem
md: 0.24rem
lg: 0.3rem
xl: 0.42rem
2xl: 0.54rem
3xl: 0.66rem
4xl: 0.78rem
spacing:
xs: 4px
sm: 8px
md: 16px
lg: 24px
xl: 32px
2xl: 48px
components:
button-default:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-outline:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-secondary:
backgroundColor: "{colors.secondary}"
textColor: "{colors.secondary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-ghost:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-destructive:
backgroundColor: "{colors.destructive}"
textColor: "{colors.on-destructive}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
card:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.lg}"
padding: 16px
input:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
input-focus:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
badge:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 4px
popover:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 4px
sidebar:
backgroundColor: "{colors.muted}"
textColor: "{colors.foreground}"
padding: 8px
sidebar-item:
backgroundColor: "{colors.muted}"
textColor: "{colors.muted-foreground}"
rounded: "{rounded.lg}"
padding: 8px
sidebar-item-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 8px
tooltip:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 6px
separator:
backgroundColor: "{colors.border}"
height: 1px
dialog:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 24px
input-invalid:
backgroundColor: "{colors.background}"
textColor: "{colors.destructive}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
---
## Overview
Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it.
The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page.
The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site.
One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic").
## Colors
The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable.
- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent.
- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes.
- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode.
- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth.
- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs.
- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually.
- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color.
- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable.
- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning.
- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements.
- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization.
Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette.
## Typography
The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100–900) and excellent readability at small sizes, both on screen and in PDFs.
- **Hero heading (responsive: 2.25rem mobile / 3rem tablet / 3.75rem desktop, weight 700, tracking-tight):** Landing page headline only. Large, bold, and commanding. Scales across three breakpoints.
- **Section heading (1rem / 16px, weight 500):** Used for section titles in the builder sidebar, settings panels, and dashboard cards. Medium weight provides hierarchy without shouting.
- **Body (0.875rem / 14px, weight 400):** The workhorse. All form labels, descriptions, card content, and general UI text.
- **Small body (0.75rem / 12px, weight 400):** Captions, helper text, timestamps, and metadata.
- **Label (0.8rem / ~13px, weight 500):** Button text, badge labels, and form field labels. Slightly heavier than body to denote interactivity.
The resume content itself uses a separate font system — users choose from 1,000+ Google Fonts for their resume headings and body text, with category-aware fallback stacks including CJK support (Noto Sans SC, PingFang SC, Hiragino Sans GB for sans-serif; Noto Serif SC, Songti SC for serif). Standard PDF fonts (Helvetica, Courier, Times-Roman) are available as offline fallbacks.
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
## Layout
### Builder (Three-Panel Workspace)
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
- **Left sidebar (default 22%):** Resume section forms — personal info, experience, education, skills, and custom sections. Scrollable with collapsible section groups.
- **Center artboard (default 56%):** Live resume preview rendered via PDF.js canvas. Supports zoom, pan, and pinch gestures via `react-zoom-pan-pinch`. The preview maintains A4 aspect ratio (210:297) with a subtle shadow to simulate a physical page.
- **Right sidebar (default 22%):** Design controls — template picker, font selection, color picker, layout manager (page assignments, section ordering via drag-and-drop).
Panel sizes persist in cookies. On mobile (< 768px), sidebars collapse to 0% width and become toggleable overlays (max 95% width when open). The desktop minimum collapsed width is 48px (icon rail).
### Dashboard
Standard sidebar navigation layout using the `Sidebar` component system. The sidebar contains: logo, resume list link, agent link, settings subnavigation (profile, preferences, authentication, API keys, integrations, danger zone), and a footer with user avatar. Content area shows a responsive grid of resume cards.
### Landing Page
Full-width single-column marketing layout:
1. **Floating builder preview** — A non-interactive screenshot of the builder as a hero visual, creating an immediate "this is what you get" impression.
2. **Hero** — Centered headline, subheadline, and two CTAs (primary "Get Started" with arrow, ghost "Learn More" with icon).
3. **Features grid** — 4-column responsive grid with icon + title + description cards, separated by thin border lines.
4. **Template carousel** — Horizontally scrolling row of template preview thumbnails with Pokemon-themed names.
5. **Testimonials** — Tiled user quotes in a masonry-style grid.
6. **Support / FAQ / Footer** — Accordion FAQ, community section, and a 4-column footer with logo, resource links, community links, and license info.
### Responsive Breakpoints
Mobile detection uses a 768px threshold via `MediaQueryList`. The layout is optimized for workspace productivity on larger screens, with responsive mobile support that adapts the multi-panel builder into a streamlined single-panel experience. Both desktop and mobile are supported experiences — the builder's three-panel layout leverages desktop space, while mobile surfaces the same editing capabilities through collapsible overlays.
### Page Aspect Ratio
A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions wherever resume pages are rendered (builder preview, public view, PDF export).
## Animation
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
**Timing principles:**
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
## Elevation & Depth
Elevation is handled through background color layering rather than drop shadows:
- **Level 0 — Background:** The base canvas (`--background`).
- **Level 1 — Card:** One step lighter in dark mode (`--card`), used for sidebars, panels, and cards.
- **Level 2 — Popover:** Same as card, but appears above the content layer in popovers, dropdowns, and command palette.
- **Level 3 — Overlay:** Backdrop blur (`backdrop-blur-xs` at 0.5px or `backdrop-blur-2xl` at 40px) with `backdrop-saturate-150` for modal overlays, creating a frosted-glass effect over the workspace.
The resume preview page uses a subtle drop shadow to simulate a physical sheet of paper floating above the dark artboard — one of the few places actual shadows appear.
## Shapes
Border radius follows a multiplicative scale from a single `--radius` base of `0.3rem`:
| Token | Value | Usage |
|:------|:------|:------|
| `sm` | 0.18rem (≈3px) | Small badges, inline chips |
| `md` | 0.24rem (≈4px) | XS/SM buttons, compact elements |
| `lg` | 0.3rem (≈5px) | Default buttons, cards, inputs |
| `xl` | 0.42rem (≈7px) | Larger cards, modal corners |
| `2xl` | 0.54rem (≈9px) | Dialog containers |
| `3xl` | 0.66rem (≈11px) | Large panels |
| `4xl` | 0.78rem (≈12px) | Full-page modals |
The radius scale is deliberately tight — the largest value (0.78rem) is still quite subtle. This avoids the "rounded everything" aesthetic and keeps the UI feeling precise and tool-like. Interactive elements consistently use `rounded-lg` as the default.
## Components
### Buttons
Six variants, all sharing `rounded-lg` corners, `font-medium`, `text-sm`, and a 1px `translate-y` on active press (except when the button opens a popup):
- **Default:** Solid primary background. The highest-emphasis action on any screen.
- **Outline:** Transparent with a border. For secondary actions that need clear boundaries.
- **Secondary:** Muted background. For paired actions alongside a primary button.
- **Ghost:** No background or border. For toolbar actions and inline controls where chrome would be noise.
- **Destructive:** Red at 10% opacity background with red text. Visually alarming without being garish.
- **Link:** Underline-on-hover text. For inline navigation within prose.
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
### Cards
White/dark surface with foreground text. Composed of `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, and `CardAction` slots. Default vertical padding is `py-4` (compact: `py-3`).
### Forms
Built on TanStack Form with Zod validation. Composed of `FormItem`, `FormLabel`, `FormControl`, `FormMessage`, and `FormDescription`. Validation errors only appear after field touch. Invalid fields get a red destructive border with a ring.
### Dialogs
Centralized dialog manager with 40+ dialog types, all rendered via pattern matching (`ts-pattern`). Dialogs support before-close validation, form blocking for unsaved changes, and confirmation prompts. Used for all CRUD operations on resume sections, settings changes, and import/export flows.
### Command Palette
Triggered by `Cmd+K` / `Ctrl+K`. Built on `cmdk` with fuzzy search via `Fuse.js`. Multi-page navigation (resumes, settings, preferences) with back navigation via Backspace. Screen-reader accessible with `sr-only` headings.
### Toast Notifications
Powered by Sonner, positioned bottom-right with rich colors. Used for auto-save feedback, form submission status, error reporting, and donation prompts. Loading toasts are used during async operations (PDF generation, resume creation) with dismiss-on-complete.
### Drag and Drop
Powered by `@dnd-kit` with `PointerSensor` and `KeyboardSensor`. Used in chip inputs (skill tags, URL lists) and page layout management (section ordering across resume pages). Smooth animations via Motion library.
## Internationalization
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
**Direction:** The `<html>` element receives `dir="rtl"` or `dir="ltr"` based on the active locale, detected via `isRTL()` which checks the language prefix against a known RTL set. All layout mirroring flows from this single attribute.
**Logical properties:** Use CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `inline-start`, `inline-end`, `inset-s-`, `inset-e-`) instead of physical (`pl-`, `pr-`, `ml-`, `mr-`, `left`, `right`). Button components already use `has-data-[icon=inline-start]:ps-2` and `has-data-[icon=inline-end]:pe-2` patterns. This ensures correct spacing in both LTR and RTL layouts without separate stylesheets.
**Variable-length text:** Translations can be 30–50% longer than English (German, Finnish) or significantly shorter (CJK). UI elements should accommodate variable text length — avoid fixed widths on buttons and labels. Use `whitespace-nowrap` only where truncation is acceptable, and prefer `min-w-0` with `truncate` over fixed-width containers.
**Icons:** Directional icons (arrows, chevrons, progress indicators) should mirror in RTL contexts. Phosphor Icons provides mirrored variants for directional icons. Non-directional icons (settings gear, checkmark, delete) do not mirror.
**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, `<Trans>`) — never hardcode English text in components. Translation files are `.po` format under `/locale/`.
## Do's and Don'ts
### Do
- **Use the grayscale palette for all app chrome.** The absence of color is the brand. The resume content is the only thing that should be colorful.
- **Default to dark mode.** The dark workspace makes resume previews pop and reduces eye strain during extended editing sessions.
- **Use `text-sm` (14px) as the base text size.** The UI is information-dense — form fields, section labels, metadata — and needs to be scannable without feeling cramped.
- **Keep border radius tight.** Use `rounded-lg` (0.3rem) as the default. The tool should feel precise, not playful.
- **Respect reduced motion preferences.** All animations collapse to 0.01ms when `prefers-reduced-motion: reduce` is active.
- **Use Phosphor Icons consistently.** Regular weight, `size-4` (16px) default. Icons should be functional labels, not decorative.
- **Maintain the three-panel builder proportions.** The center artboard should always dominate. Sidebars are support panels, not equal peers.
- **Use transparent-white borders in dark mode.** `oklch(1 0 0 / 10%)` blends naturally with any surface rather than introducing a distinct gray band.
### Don't
- **Don't introduce accent colors into the app shell.** No blues, greens, or purples for primary actions. The only chromatic color is destructive red. The inherited indigo sidebar-primary token exists in CSS custom properties but is not actively used.
- **Don't use drop shadows for elevation.** Rely on background color layering and border separation. The one exception is the resume page preview shadow.
- **Don't make the UI compete with the resume content.** If a new feature draws more visual attention than the resume preview, it needs to be toned down.
- **Don't use large border radii.** Nothing above `rounded-xl` on standard components. Large pills and full-round shapes conflict with the precision-tool aesthetic.
- **Don't hardcode colors outside the token system.** All colors flow through CSS custom properties so that dark/light mode switching works automatically.
- **Don't use multiple typefaces in the app shell.** IBM Plex Sans Variable is the only UI font. Resume templates have their own font system, but the chrome stays single-family.
- **Don't skip the `data-slot` attribute on components.** It's used for styling hooks and accessibility selectors throughout the component library.
- **Don't forget RTL.** The app supports 40+ locales including Arabic, Hebrew, Persian, and Urdu. Use logical properties (`ps`, `pe`, `ms`, `me`) instead of physical (`pl`, `pr`, `ml`, `mr`).
+48 -35
View File
@@ -1,39 +1,44 @@
# syntax=docker/dockerfile:1
# syntax=docker/dockerfile:1.7
# ---------- Dependencies Layer ----------
FROM node:24-slim AS dependencies
ARG PNPM_VERSION=11.21.0
ARG NODE_VERSION=24
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS base
RUN mkdir -p /tmp/dev /tmp/prod
ARG NODE_VERSION
COPY package.json pnpm-lock.yaml /tmp/dev/
COPY package.json pnpm-lock.yaml /tmp/prod/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
cd /tmp/dev && pnpm install --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
cd /tmp/prod && pnpm install --frozen-lockfile --prod
# ---------- Builder Layer ----------
FROM node:24-slim AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
WORKDIR /app
COPY --from=dependencies /tmp/dev/node_modules ./node_modules
ENV TURBO_TELEMETRY_DISABLED=1
FROM base AS pruner
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm dlx turbo@2.9.12 prune web server --docker
RUN pnpm run build
FROM base AS builder
COPY --from=pruner /app/out/json/ ./
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
# ---------- Runtime Layer ----------
FROM node:24-slim AS runtime
COPY --from=pruner /app/out/full/ ./
RUN rm -rf apps/web/dist apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
FROM base AS runtime-pruner
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm dlx turbo@2.9.12 prune server --docker
FROM base AS runtime-deps
COPY --from=runtime-pruner /app/out/json/ ./
COPY --from=runtime-pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --prod --frozen-lockfile
FROM node:${NODE_VERSION}-slim AS runtime
LABEL maintainer="amruthpillai"
LABEL org.opencontainers.image.licenses="MIT"
@@ -44,20 +49,28 @@ LABEL org.opencontainers.image.url="https://rxresu.me"
LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV="production" \
PORT=3000 \
LOCAL_STORAGE_PATH=/app/data
WORKDIR /app
ENV NODE_ENV=production
RUN mkdir -p /app/apps/server /app/apps/web /app/data && chown node:node /app/data
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/migrations ./migrations
COPY --from=dependencies /tmp/prod/node_modules ./node_modules
COPY --from=runtime-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=pruner --chown=node:node /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
COPY --from=runtime-deps --chown=node:node /app/apps/server/package.json ./apps/server/package.json
COPY --from=runtime-deps --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
COPY --from=builder --chown=node:node /app/apps/web/dist ./apps/web/dist
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
COPY --from=pruner --chown=node:node /app/migrations ./migrations
WORKDIR /app
USER node
EXPOSE 3000/tcp
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT ?? 3000}/api/health`).then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
ENTRYPOINT ["node", ".output/server/index.mjs"]
CMD ["node", "apps/server/dist/index.mjs"]
+33 -11
View File
@@ -1,21 +1,43 @@
# syntax=docker/dockerfile:1
# syntax=docker/dockerfile:1.7
# ---------- Development Layer ----------
FROM node:24-slim AS development
ARG PNPM_VERSION=11.21.0
ARG NODE_VERSION=24
ENV NODE_ENV="development"
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS dev
RUN corepack enable
ARG NODE_VERSION
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
ENV NODE_ENV=development \
TURBO_TELEMETRY_DISABLED=1
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY patches ./patches
COPY apps/server/package.json ./apps/server/package.json
COPY apps/web/package.json ./apps/web/package.json
COPY packages/ai/package.json ./packages/ai/package.json
COPY packages/api/package.json ./packages/api/package.json
COPY packages/auth/package.json ./packages/auth/package.json
COPY packages/config/package.json ./packages/config/package.json
COPY packages/db/package.json ./packages/db/package.json
COPY packages/email/package.json ./packages/email/package.json
COPY packages/env/package.json ./packages/env/package.json
COPY packages/fonts/package.json ./packages/fonts/package.json
COPY packages/import/package.json ./packages/import/package.json
COPY packages/pdf/package.json ./packages/pdf/package.json
COPY packages/schema/package.json ./packages/schema/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY tooling/package.json ./tooling/package.json
EXPOSE 3000
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY . .
EXPOSE 3000/tcp 3001/tcp
CMD ["pnpm", "run", "dev"]
+55 -27
View File
@@ -1,6 +1,6 @@
<div align="center">
<a href="https://rxresu.me">
<img src="public/opengraph/banner.jpg" alt="Reactive Resume" />
<img src="apps/web/public/opengraph/banner.jpg" alt="Reactive Resume" />
</a>
<h1>Reactive Resume</h1>
@@ -14,14 +14,14 @@
</p>
<p>
<img src="https://img.shields.io/github/package-json/v/amruthpillai/reactive-resume?style=flat-square" alt="Reactive Resume version">
<img src="https://img.shields.io/github/package-json/v/amruthpillai/reactive-resume?style=flat-square" alt="Reactive Resume Version">
<img src="https://img.shields.io/github/stars/amruthpillai/Reactive-Resume?style=flat-square" alt="GitHub Stars">
<img src="https://img.shields.io/github/license/amruthpillai/Reactive-Resume?style=flat-square" alt="License" />
<img src="https://img.shields.io/docker/pulls/amruthpillai/reactive-resume?style=flat-square" alt="Docker Pulls" />
<a href="https://discord.gg/aSyA5ZSxpb"><img src="https://img.shields.io/discord/1173518977851473940?style=flat-square&label=discord" alt="Discord" /></a>
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
<a href="https://github.com/sponsors/AmruthPillai"><img src="https://img.shields.io/github/sponsors/AmruthPillai?style=flat-square&label=sponsors" alt="Sponsors" /></a>
<a href="https://opencollective.com/reactive-resume"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
<a href="https://opencollective.com/reactive-resume/donate"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
</p>
</div>
@@ -31,12 +31,26 @@ Reactive Resume makes building resumes straightforward. Pick a template, fill in
Built with privacy as a core principle, Reactive Resume gives you complete ownership of your data. The codebase is fully open-source under the MIT license, with no tracking, no ads, and no hidden costs.
## Sponsors
Reactive Resume stays free, open-source, and independent because companies choose to support the work behind it. Thank you to every sponsor who helps fund hosting, maintenance, and continued development for the community.
<p>
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume">
<img src="apps/web/public/sponsors/atlas-cloud-logo-white.svg" alt="Atlas Cloud" width="320" />
</a>
</p>
[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume) supports Reactive Resume as a project sponsor. Atlas Cloud provides a unified AI platform for developers, with access to hundreds of models for chat, image generation, video generation, media processing, and GPU cloud workloads through one API key, one endpoint, and one billing account.
If your company would like to sponsor Reactive Resume, email [hello@amruthpillai.com](mailto:hello@amruthpillai.com).
## Features
**Resume Building**
- Real-time preview as you type
- Multiple export formats (PDF, JSON)
- Multiple export formats (PDF, JSON, DOCX)
- Drag-and-drop section ordering
- Custom sections for any content type
- Rich text editor with formatting support
@@ -46,7 +60,7 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
- Professionally designed templates
- A4 and Letter size support
- Customizable colors, fonts, and spacing
- Custom CSS for advanced styling
- Structured Style Rules for section and text styling
**Privacy & Control**
@@ -69,63 +83,71 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
<table>
<tr>
<td align="center">
<img src="public/templates/jpg/azurill.jpg" alt="Azurill" width="150" />
<img src="apps/web/public/templates/jpg/azurill.jpg" alt="Azurill" width="150" />
<br /><sub><b>Azurill</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/bronzor.jpg" alt="Bronzor" width="150" />
<img src="apps/web/public/templates/jpg/bronzor.jpg" alt="Bronzor" width="150" />
<br /><sub><b>Bronzor</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/chikorita.jpg" alt="Chikorita" width="150" />
<img src="apps/web/public/templates/jpg/chikorita.jpg" alt="Chikorita" width="150" />
<br /><sub><b>Chikorita</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/ditto.jpg" alt="Ditto" width="150" />
<img src="apps/web/public/templates/jpg/ditto.jpg" alt="Ditto" width="150" />
<br /><sub><b>Ditto</b></sub>
</td>
</tr>
<tr>
<td align="center">
<img src="public/templates/jpg/gengar.jpg" alt="Gengar" width="150" />
<img src="apps/web/public/templates/jpg/gengar.jpg" alt="Gengar" width="150" />
<br /><sub><b>Gengar</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/glalie.jpg" alt="Glalie" width="150" />
<img src="apps/web/public/templates/jpg/glalie.jpg" alt="Glalie" width="150" />
<br /><sub><b>Glalie</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/kakuna.jpg" alt="Kakuna" width="150" />
<img src="apps/web/public/templates/jpg/kakuna.jpg" alt="Kakuna" width="150" />
<br /><sub><b>Kakuna</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/lapras.jpg" alt="Lapras" width="150" />
<img src="apps/web/public/templates/jpg/lapras.jpg" alt="Lapras" width="150" />
<br /><sub><b>Lapras</b></sub>
</td>
</tr>
<tr>
<td align="center">
<img src="public/templates/jpg/leafish.jpg" alt="Leafish" width="150" />
<img src="apps/web/public/templates/jpg/leafish.jpg" alt="Leafish" width="150" />
<br /><sub><b>Leafish</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/onyx.jpg" alt="Onyx" width="150" />
<img src="apps/web/public/templates/jpg/onyx.jpg" alt="Onyx" width="150" />
<br /><sub><b>Onyx</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/pikachu.jpg" alt="Pikachu" width="150" />
<img src="apps/web/public/templates/jpg/pikachu.jpg" alt="Pikachu" width="150" />
<br /><sub><b>Pikachu</b></sub>
</td>
<td align="center">
<img src="public/templates/jpg/rhyhorn.jpg" alt="Rhyhorn" width="150" />
<img src="apps/web/public/templates/jpg/rhyhorn.jpg" alt="Rhyhorn" width="150" />
<br /><sub><b>Rhyhorn</b></sub>
</td>
</tr>
<tr>
<td align="center">
<img src="public/templates/jpg/ditgar.jpg" alt="Ditgar" width="150" />
<img src="apps/web/public/templates/jpg/ditgar.jpg" alt="Ditgar" width="150" />
<br /><sub><b>Ditgar</b></sub>
</td>
<td align="center">
<img src="apps/web/public/templates/jpg/meowth.jpg" alt="Meowth" width="150" />
<br /><sub><b>Meowth</b></sub>
</td>
<td align="center">
<img src="apps/web/public/templates/jpg/scizor.jpg" alt="Scizor" width="150" />
<br /><sub><b>Scizor</b></sub>
</td>
</tr>
</table>
@@ -135,7 +157,7 @@ The quickest way to run Reactive Resume locally:
```bash
# Clone the repository
git clone https://github.com/amruthpillai/reactive-resume.git
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
cd reactive-resume
# Start all services
@@ -160,7 +182,7 @@ For detailed setup instructions, environment configuration, and self-hosting gui
| API | ORPC (Type-safe RPC) |
| Auth | Better Auth |
| Styling | Tailwind CSS |
| UI Components | Radix UI |
| UI Components | Base UI + shadcn-style package |
| State Management | Zustand + TanStack Query |
## Documentation
@@ -180,9 +202,10 @@ Comprehensive guides are available at [docs.rxresu.me](https://docs.rxresu.me):
Reactive Resume can be self-hosted using Docker. The stack includes:
- **PostgreSQL** — Database for storing user data and resumes
- **Printer** — Headless Chromium service for PDF and screenshot generation
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
> **From v5.1.0 onwards** — PDF generation now runs entirely client-side via `@react-pdf/renderer`. New deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
Pull the latest image from Docker Hub or GitHub Container Registry:
```bash
@@ -203,7 +226,7 @@ Reactive Resume is and always will be free and open-source. If it has helped you
<a href="https://github.com/sponsors/AmruthPillai">
<img src="https://img.shields.io/badge/GitHub%20Sponsors-Support-ea4aaa?style=flat-square&logo=github-sponsors" alt="GitHub Sponsors" />
</a>
<a href="https://opencollective.com/reactive-resume">
<a href="https://opencollective.com/reactive-resume/donate">
<img src="https://img.shields.io/badge/Open%20Collective-Contribute-7FADF2?style=flat-square&logo=open-collective" alt="Open Collective" />
</a>
</p>
@@ -211,17 +234,18 @@ Reactive Resume is and always will be free and open-source. If it has helped you
Other ways to support:
- Star this repository
- Report bugs and suggest features
- Report reproducible bugs and suggest actionable features
- Help other users in [GitHub Discussions](https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a)
- Improve documentation
- Help with translations
## Star History
<a href="https://www.star-history.com/#amruthpillai/reactive-resume&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=amruthpillai%2Freactive-resume&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
</picture>
</a>
@@ -237,6 +261,10 @@ Contributions make open-source thrive. Whether fixing a typo or adding a feature
See the [development setup guide](https://docs.rxresu.me/contributing/development) for detailed instructions on how to set up the project locally.
Maintainers review the [`status: needs triage` queue](https://github.com/amruthpillai/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
weekly. Triaged bugs become `status: confirmed`; feature proposals become `status: accepted`; reports that need details become
`status: needs info`.
## License
[MIT](./LICENSE) — do whatever you want with it.
+99
View File
@@ -0,0 +1,99 @@
{
"name": "server",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsdown",
"start": "node dist/index.mjs",
"docs:gen": "tsx src/openapi/generate-spec.ts",
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"imports": {
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.44",
"@ai-sdk/cerebras": "^3.0.39",
"@ai-sdk/cohere": "^4.0.33",
"@ai-sdk/deepseek": "^3.0.35",
"@ai-sdk/fireworks": "^3.0.42",
"@ai-sdk/google": "^4.0.54",
"@ai-sdk/groq": "^4.0.33",
"@ai-sdk/mistral": "^4.0.35",
"@ai-sdk/openai": "^4.0.50",
"@ai-sdk/openai-compatible": "^3.0.39",
"@ai-sdk/perplexity": "^4.0.34",
"@ai-sdk/togetherai": "^3.0.40",
"@ai-sdk/xai": "^4.0.47",
"@aws-sdk/client-s3": "^3.1119.0",
"@better-auth/api-key": "^1.7.2",
"@better-auth/drizzle-adapter": "^1.7.2",
"@better-auth/infra": "^0.4.3",
"@better-auth/oauth-provider": "^1.7.2",
"@better-auth/passkey": "^1.7.2",
"@bramus/specificity": "^2.4.2",
"@hono/node-server": "^2.1.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@orpc/client": "^1.15.0",
"@orpc/experimental-ratelimit": "^1.15.0",
"@orpc/json-schema": "^1.15.0",
"@orpc/openapi": "^1.15.0",
"@orpc/server": "^1.15.0",
"@orpc/zod": "^1.15.0",
"@react-pdf/renderer": "^4.8.1",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/mcp": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^7.0.83",
"bcrypt": "^6.0.0",
"better-auth": "1.7.2",
"cjk-regex": "^3.4.0",
"css-tree": "^3.2.1",
"deepmerge-ts": "^8.0.2",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.51.0",
"fast-json-patch": "^3.1.1",
"hono": "^4.13.5",
"jsonrepair": "^3.15.0",
"node-html-parser": "^9.0.1",
"nodemailer": "^9.0.5",
"ollama-ai-provider-v2": "^4.0.1",
"pg": "^8.23.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.8",
"react-email": "^6.9.3",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.35.4",
"tokenx": "^2.1.0",
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.4.0",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"tsdown": "^0.22.14",
"tsx": "^4.23.12",
"typescript": "^7.0.2",
"vitest": "^4.1.11"
}
}
+1
View File
@@ -0,0 +1 @@
export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__;
+207
View File
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
handleAuth: vi.fn(),
handleOAuth: vi.fn(),
handleRpc: vi.fn(),
handleOpenApi: vi.fn(),
handleHealth: vi.fn(),
handleUpload: vi.fn(),
handleMcp: vi.fn(),
handleResumePdfDownload: vi.fn(),
handlePublicResumePdf: vi.fn(),
handleMcpServerCard: vi.fn(),
handleOAuthAuthorizationServer: vi.fn(),
handleOAuthProtectedResource: vi.fn(),
handleOpenIdConfiguration: vi.fn(),
handleWellKnownFallback: vi.fn(),
handleRobots: vi.fn(),
handleSitemap: vi.fn(),
handleLlms: vi.fn(),
serveWebDistStatic: vi.fn(),
handleWebApp: vi.fn(),
}));
vi.mock("./auth", () => ({
handleAuth: mocks.handleAuth,
handleOAuth: mocks.handleOAuth,
}));
vi.mock("./health", () => ({
handleHealth: mocks.handleHealth,
}));
vi.mock("../rpc/handler", () => ({
handleRpc: mocks.handleRpc,
}));
vi.mock("../openapi/handler", () => ({
handleOpenApi: mocks.handleOpenApi,
}));
vi.mock("../openapi/metadata", () => ({
handleMcpServerCard: mocks.handleMcpServerCard,
handleOAuthAuthorizationServer: mocks.handleOAuthAuthorizationServer,
handleOAuthProtectedResource: mocks.handleOAuthProtectedResource,
handleOpenIdConfiguration: mocks.handleOpenIdConfiguration,
handleWellKnownFallback: mocks.handleWellKnownFallback,
}));
vi.mock("../static/uploads", () => ({
handleUpload: mocks.handleUpload,
}));
vi.mock("../static/seo", () => ({
handleRobots: mocks.handleRobots,
handleSitemap: mocks.handleSitemap,
handleLlms: mocks.handleLlms,
}));
vi.mock("../static/web", () => ({
serveWebDistStatic: mocks.serveWebDistStatic,
handleWebApp: mocks.handleWebApp,
}));
vi.mock("../mcp/handler", () => ({
handleMcp: mocks.handleMcp,
}));
vi.mock("./resume-pdf", () => ({
handleResumePdfDownload: mocks.handleResumePdfDownload,
}));
vi.mock("./public-resume-pdf", () => ({
handlePublicResumePdf: mocks.handlePublicResumePdf,
}));
const transportEnv = (remoteAddress: string) =>
({
incoming: { socket: { remoteAddress } },
}) as never;
beforeEach(() => {
vi.clearAllMocks();
mocks.handleAuth.mockResolvedValue(new Response("auth"));
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
mocks.handleOpenApi.mockResolvedValue(new Response("openapi"));
mocks.handleHealth.mockReturnValue(new Response("health"));
mocks.handleUpload.mockResolvedValue(new Response("upload"));
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
mocks.handlePublicResumePdf.mockResolvedValue(new Response("public-pdf"));
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
mocks.handleOpenIdConfiguration.mockReturnValue(new Response("openid-configuration"));
mocks.handleWellKnownFallback.mockReturnValue(new Response("well-known"));
mocks.handleRobots.mockReturnValue(new Response("robots"));
mocks.handleSitemap.mockReturnValue(new Response("sitemap"));
mocks.handleLlms.mockReturnValue(new Response("llms"));
mocks.serveWebDistStatic.mockResolvedValue(undefined);
mocks.handleWebApp.mockResolvedValue(new Response("web"));
});
describe("createApp", () => {
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/api/auth/oauth?client_id=test-client");
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe("oauth");
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
expect(mocks.handleAuth).not.toHaveBeenCalled();
});
it("routes signed resume PDF downloads before the web fallback", async () => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe("pdf");
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
const { createApp } = await import("./app");
const app = createApp();
const first = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
headers: { "x-forwarded-for": "198.51.100.1" },
});
const rotated = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
headers: { "x-forwarded-for": "198.51.100.2" },
});
const env = transportEnv("203.0.113.9");
const response = await app.fetch(first, env);
await app.fetch(rotated, env);
await expect(response.text()).resolves.toBe("public-pdf");
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(1, first, "jane", "resume", "203.0.113.9");
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(2, rotated, "jane", "resume", "203.0.113.9");
expect(mocks.handleResumePdfDownload).not.toHaveBeenCalled();
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it("passes the transport address to RPC and OpenAPI and fails closed when it is unavailable", async () => {
const { createApp } = await import("./app");
const app = createApp();
const trustedRpcRequest = new Request("http://localhost:3001/api/rpc", {
headers: { "cf-connecting-ip": "198.51.100.1" },
});
const unknownRpcRequest = new Request("http://localhost:3001/api/rpc", {
headers: { "cf-connecting-ip": "198.51.100.2" },
});
const trustedOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
const unknownOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
await app.fetch(trustedRpcRequest, transportEnv("203.0.113.9"));
await app.fetch(unknownRpcRequest);
await app.fetch(trustedOpenApiRequest, transportEnv("203.0.113.9"));
await app.fetch(unknownOpenApiRequest);
expect(mocks.handleRpc).toHaveBeenNthCalledWith(1, trustedRpcRequest, "203.0.113.9");
expect(mocks.handleRpc).toHaveBeenNthCalledWith(2, unknownRpcRequest, "unknown");
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(1, trustedOpenApiRequest, "203.0.113.9");
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
});
it.each([
["GET", "/robots.txt", "robots", mocks.handleRobots],
["HEAD", "/robots.txt", "", mocks.handleRobots],
["GET", "/sitemap.xml", "sitemap", mocks.handleSitemap],
["HEAD", "/sitemap.xml", "", mocks.handleSitemap],
["GET", "/llms.txt", "llms", mocks.handleLlms],
["HEAD", "/llms.txt", "", mocks.handleLlms],
])("routes %s %s before the static fallback", async (method, pathname, expectedBody, handler) => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request(`http://localhost:3001${pathname}`, { method });
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe(expectedBody);
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it.each(["GET", "HEAD"])("routes %s / to the web app handler so SEO markup is injected", async (method) => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/", { method });
const response = await app.fetch(request);
expect(response.status).toBe(200);
expect(mocks.handleWebApp).toHaveBeenCalledWith(request);
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
import type { Context } from "hono";
import { isIP } from "node:net";
import { getConnInfo } from "@hono/node-server/conninfo";
import { Hono } from "hono";
import { handleMcp } from "../mcp/handler";
import { handleOpenApi } from "../openapi/handler";
import {
handleMcpServerCard,
handleOAuthAuthorizationServer,
handleOAuthProtectedResource,
handleOpenIdConfiguration,
handleWellKnownFallback,
} from "../openapi/metadata";
import { handleRpc } from "../rpc/handler";
import { handleSchemaJson } from "../static/schema";
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
import { handleUpload } from "../static/uploads";
import { handleWebApp, serveWebDistStatic } from "../static/web";
import { handleAuth, handleOAuth } from "./auth";
import { handleHealth } from "./health";
import { handlePublicResumePdf } from "./public-resume-pdf";
import { handleResumePdfDownload } from "./resume-pdf";
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
try {
const address = getConnInfo(context).remote.address?.trim();
return address && isIP(address) ? address : "unknown";
} catch {
return "unknown";
}
};
export function createApp() {
const app = new Hono<ServerEnvironment>();
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
app.get("/api/health", () => handleHealth());
app.get("/api/resumes/:username/:slug/pdf", (c) =>
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), getTrustedClient(c)),
);
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/schema.json", () => handleSchemaJson());
app.all("/mcp", (c) => handleMcp(c.req.raw));
app.all("/mcp/*", (c) => handleMcp(c.req.raw));
app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard());
app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw));
app.get("/.well-known/oauth-authorization-server/*", (c) => handleOAuthAuthorizationServer(c.req.raw));
app.get("/.well-known/openid-configuration", (c) => handleOpenIdConfiguration(c.req.raw));
app.get("/.well-known/oauth-protected-resource", () => handleOAuthProtectedResource());
app.get("/.well-known/oauth-protected-resource/*", () => handleOAuthProtectedResource());
app.all("/.well-known/*", () => handleWellKnownFallback());
app.on(["GET", "HEAD"], "/robots.txt", (c) => handleRobots({ head: c.req.method === "HEAD" }));
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
// Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would
// return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp.
app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw));
app.use("/*", serveWebDistStatic);
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
return app;
}
+97
View File
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
handler: vi.fn(),
env: {
SERVER_PORT: 3001,
APP_URL: "http://localhost:3000",
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: false,
},
}));
vi.mock("@reactive-resume/auth/config", () => ({
auth: {
api: {
getSession: mocks.getSession,
},
handler: mocks.handler,
},
}));
vi.mock("@reactive-resume/db/client", () => ({ db: {} }));
vi.mock("@reactive-resume/db/schema", () => ({ oauthClient: {}, verification: {} }));
vi.mock("@reactive-resume/env/server", () => ({
env: mocks.env,
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = false;
mocks.handler.mockResolvedValue(new Response("ok"));
});
describe("handleAuth", () => {
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
const { handleAuth } = await import("./auth");
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/callback"] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "invalid_redirect_uri",
error_description: "redirect_uri is not allowed",
});
expect(mocks.handler).not.toHaveBeenCalled();
});
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
const { handleAuth } = await import("./auth");
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(200);
expect(mocks.handler).toHaveBeenCalledOnce();
});
});
describe("handleOAuth", () => {
it("redirects unauthenticated users to the same-origin login route", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce(null);
const response = await handleOAuth(
new Request(
"http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456",
),
);
expect(response.status).toBe(302);
const location = response.headers.get("Location");
expect(location).toMatch(/^\/auth\/login\?/);
const loginUrl = new URL(location ?? "", "http://localhost:3000");
const callbackUrl = new URL(loginUrl.searchParams.get("callbackURL") ?? "", "http://localhost:3000");
expect(loginUrl.origin).toBe("http://localhost:3000");
expect(callbackUrl.pathname).toBe("/api/auth/oauth");
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
expect(callbackUrl.searchParams.get("state")).toBe("abc");
expect(callbackUrl.searchParams.has("exp")).toBe(false);
expect(callbackUrl.searchParams.has("sig")).toBe(false);
});
});
+214
View File
@@ -0,0 +1,214 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { auth } from "@reactive-resume/auth/config";
import { db } from "@reactive-resume/db/client";
import { oauthClient, verification } from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { generateId } from "@reactive-resume/utils/string";
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
const oauthAuthorizeSanitizedParams = [
"prompt",
"redirect_uri",
"client_id",
"code_challenge",
"code_challenge_method",
"response_type",
"scope",
"state",
"resource",
] as const;
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
if (request.method !== "GET") return request;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
const sanitizeValue = (value: string) =>
value
.replace(/[\r\n\t]+/g, " ")
.replace(/\s+/g, " ")
.trim();
const sanitizeParam = (key: string) => {
const value = url.searchParams.get(key);
if (!value) return;
url.searchParams.set(key, sanitizeValue(value));
};
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
const redirectUri = url.searchParams.get("redirect_uri");
if (redirectUri && !URL.canParse(redirectUri)) {
try {
const decodedRedirectUri = decodeURIComponent(redirectUri);
if (URL.canParse(decodedRedirectUri)) {
url.searchParams.set("redirect_uri", decodedRedirectUri);
}
} catch {
// Ignore malformed encoded values and let Better Auth validation handle them.
}
}
if (url.toString() === request.url) return request;
return new Request(url.toString(), request);
}
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
if (request.method !== "POST") return request;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/register")) return request;
const cloned = request.clone();
let body: Record<string, unknown>;
try {
body = await cloned.json();
} catch {
return request;
}
if (!request.headers.get("authorization")) {
body.token_endpoint_auth_method = "none";
}
return new Request(url.toString(), {
method: request.method,
headers: request.headers,
body: JSON.stringify(body),
});
}
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
if (request.method !== "POST") return;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/register")) return;
const cloned = request.clone();
let body: Record<string, unknown>;
try {
body = await cloned.json();
} catch {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
for (const redirectUri of redirectUris) {
if (
typeof redirectUri !== "string" ||
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, {
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
})
) {
return Response.json(
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
{ status: 400 },
);
}
}
}
export async function handleAuth(request: Request) {
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
if (registrationValidationError) return registrationValidationError;
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
return auth.handler(finalRequest);
}
function generateCode() {
return crypto.randomBytes(32).toString("base64url");
}
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("base64url");
}
export async function handleOAuth(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
const url = new URL(request.url);
if (session?.user) {
const clientId = url.searchParams.get("client_id");
const redirectUri = url.searchParams.get("redirect_uri");
const state = url.searchParams.get("state");
const scope = url.searchParams.get("scope");
const codeChallenge = url.searchParams.get("code_challenge");
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
if (!clientId || !redirectUri) {
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
}
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
if (!client) {
return Response.json({ error: "invalid client" }, { status: 400 });
}
if (!client.redirectUris.includes(redirectUri)) {
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
}
const code = generateCode();
const hashedCode = hashCode(code);
const now = new Date();
const expiresAt = new Date(now.getTime() + 600_000);
await db.insert(verification).values({
id: generateId(),
identifier: hashedCode,
value: JSON.stringify({
type: "authorization_code",
query: {
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
},
userId: session.user.id,
sessionId: session.session.id,
authTime: new Date(session.session.createdAt).getTime(),
}),
expiresAt,
createdAt: now,
updatedAt: now,
});
const callbackUrl = new URL(redirectUri);
callbackUrl.searchParams.set("code", code);
if (state) callbackUrl.searchParams.set("state", state);
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
return new Response(null, {
status: 302,
headers: { Location: callbackUrl.toString() },
});
}
const loginUrl = new URL("/auth/login", env.APP_URL);
const oauthParams = new URLSearchParams();
for (const [key, value] of url.searchParams) {
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
oauthParams.set(key, value);
}
}
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
return new Response(null, {
status: 302,
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
});
}
+22
View File
@@ -0,0 +1,22 @@
export function getCookie(request: Request, name: string): string | undefined {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return;
for (const part of cookieHeader.split(";")) {
const [rawName, ...rawValue] = part.trim().split("=");
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
}
}
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
if ([...headers].length === 0) return response;
const nextHeaders = new Headers(response.headers);
for (const [key, value] of headers) nextHeaders.append(key, value);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: nextHeaders,
});
}
+69
View File
@@ -0,0 +1,69 @@
import { sql } from "drizzle-orm";
import { withTimeout } from "es-toolkit";
import { getStorageService } from "@reactive-resume/api/features/storage";
import { db } from "@reactive-resume/db/client";
const HEALTHCHECK_TIMEOUT_MS = 1_500;
type CheckResult = {
status: "healthy" | "unhealthy";
latencyMs: number;
error?: string;
[key: string]: unknown;
};
// ponytail: es-toolkit withTimeout takes a fn, not a promise — call site passes check (not check())
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
const startedAt = performance.now();
try {
const data = await withTimeout(check, HEALTHCHECK_TIMEOUT_MS);
const latencyMs = Math.round(performance.now() - startedAt);
const result = data as { status?: string };
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
return { ...(data as object), status: "healthy", latencyMs };
} catch (error) {
return {
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
latencyMs: Math.round(performance.now() - startedAt),
};
}
}
// ponytail: inner try/catches removed; runCheck's outer catch handles all errors
async function checkDatabase() {
await db.execute(sql`SELECT 1`);
return { status: "healthy" };
}
const checkStorage = () => getStorageService().healthcheck();
export async function handleHealth() {
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
const checks = {
service: "reactive-resume",
version: process.env.npm_package_version,
status,
timestamp: new Date().toISOString(),
uptime: `${process.uptime().toFixed(2)}s`,
database,
storage,
};
if (status === "unhealthy") {
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
}
const headers = new Headers();
const body = JSON.stringify(checks);
headers.set("Content-Type", "application/json; charset=UTF-8");
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
return new Response(body, {
headers,
status: checks.status === "unhealthy" ? 503 : 200,
});
}
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createPublicResumePdf: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/public-pdf", () => ({
createPublicResumePdf: mocks.createPublicResumePdf,
}));
const { handlePublicResumePdf } = await import("./public-resume-pdf");
const trustedClient = "203.0.113.9";
describe("handlePublicResumePdf", () => {
beforeEach(() => vi.clearAllMocks());
it("returns the authorized on-demand PDF without forwarding compatibility metadata", async () => {
const body = new File(["%PDF"], "Ada_Lovelace.pdf", { type: "text/plain" });
mocks.createPublicResumePdf.mockResolvedValueOnce({
body,
filename: "Ada_Lovelace.pdf",
});
const request = new Request("https://example.com/api/resumes/jane/resume/pdf?ignored=true", {
headers: { "x-forwarded-for": "203.0.113.7" },
});
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/pdf");
expect(response.headers.get("Content-Disposition")).toBe('inline; filename="Ada_Lovelace.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(await response.text()).toBe("%PDF");
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
username: "jane",
slug: "resume",
requestHeaders: request.headers,
trustedClient,
});
});
it("keeps password and private responses uncacheable", async () => {
mocks.createPublicResumePdf.mockResolvedValueOnce({
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
filename: "resume.pdf",
});
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
username: "jane",
slug: "resume",
requestHeaders: request.headers,
trustedClient,
});
});
it.each([
[{ code: "NEED_PASSWORD" }, 401],
[{ code: "NOT_FOUND" }, 404],
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
])("maps controlled API errors without caching the response", async (error, status) => {
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
const response = await handlePublicResumePdf(
new Request("https://example.com/api/resumes/jane/resume/pdf"),
"jane",
"resume",
trustedClient,
);
expect(response.status).toBe(status);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
});
+43
View File
@@ -0,0 +1,43 @@
import { createPublicResumePdf } from "@reactive-resume/api/features/resume/public-pdf";
const noStoreResponse = (body: string, status: number) =>
new Response(body, { status, headers: { "Cache-Control": "private, no-store" } });
const errorStatus = (error: unknown): number => {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
if (code === "NEED_PASSWORD") return 401;
if (code === "NOT_FOUND") return 404;
if (code === "RATE_LIMIT_EXCEEDED") return 429;
return 500;
};
export async function handlePublicResumePdf(
request: Request,
username: string,
slug: string,
trustedClient = "unknown",
): Promise<Response> {
try {
const result = await createPublicResumePdf({
username,
slug,
requestHeaders: request.headers,
trustedClient,
});
return new Response(result.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${result.filename.replaceAll('"', "")}"`,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
const status = errorStatus(error);
return noStoreResponse(
status === 500 ? "Failed to generate public resume PDF" : "Public resume PDF unavailable",
status,
);
}
}
+153
View File
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createResumePdfDownload: vi.fn(),
verifyResumePdfDownloadToken: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/export", () => ({
createResumePdfDownload: mocks.createResumePdfDownload,
verifyResumePdfDownloadToken: mocks.verifyResumePdfDownloadToken,
}));
const { handleResumePdfDownload } = await import("./resume-pdf");
describe("handleResumePdfDownload", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the PDF when the signed URL token is valid", async () => {
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Scizor.pdf", { type: "application/pdf" });
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "resume",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Scizor.pdf"' },
body: pdf,
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
"resume-1",
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/pdf");
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(await response.text()).toBe("%PDF");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1", target: "resume" });
});
it("passes the cover letter target through to PDF rendering", async () => {
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Cover Letter.pdf", { type: "application/pdf" });
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "cover-letter",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
body: pdf,
});
await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
"resume-1",
);
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
id: "resume-1",
userId: "user-1",
target: "cover-letter",
});
});
it("defaults a legacy token without a target to resume", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
body: new File([], "Cover Letter.pdf", { type: "application/pdf" }),
});
await handleResumePdfDownload(new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy"), "resume-1");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
id: "resume-1",
userId: "user-1",
target: "resume",
});
});
it("rejects a cover-letter target for a legacy token without one", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy&target=cover-letter"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
it("rejects a target that differs from the signed token", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "resume",
expiresAt: "2026-06-01T10:10:00.000Z",
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
it("rejects missing, invalid, and expired tokens before rendering", async () => {
let response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "invalid_signature" });
response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=bad"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "expired" });
response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=expired"),
"resume-1",
);
expect(response.status).toBe(410);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
function unauthorizedResponse() {
return new Response("Unauthorized", {
status: 401,
headers: {
"Cache-Control": "private, no-store",
},
});
}
function expiredResponse() {
return new Response("Download link expired", {
status: 410,
headers: {
"Cache-Control": "private, no-store",
},
});
}
function errorStatus(error: unknown) {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
return code === "NOT_FOUND" ? 404 : 500;
}
export async function handleResumePdfDownload(request: Request, id: string) {
const searchParams = new URL(request.url).searchParams;
const token = searchParams.get("token");
if (!token) return unauthorizedResponse();
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
const queryTarget = searchParams.get("target");
if (
verification.target
? queryTarget !== null && queryTarget !== verification.target
: queryTarget && queryTarget !== "resume"
)
return unauthorizedResponse();
try {
const target = verification.target ?? "resume";
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
return new Response(download.body, {
headers: {
"Content-Type": download.body.type || "application/pdf",
"Content-Disposition": download.headers["content-disposition"],
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[PDF Download]", error);
return new Response("Failed to generate resume PDF", {
status: errorStatus(error),
headers: {
"Cache-Control": "private, no-store",
},
});
}
}
+41
View File
@@ -0,0 +1,41 @@
import { pathToFileURL } from "node:url";
import { serve } from "@hono/node-server";
import { env } from "@reactive-resume/env/server";
import { createApp } from "./http/app";
import { runStartupChecks } from "./startup/checks";
export { createApp } from "./http/app";
async function main() {
await runStartupChecks();
// Safety net: Node 24 crashes the whole process on an unhandled rejection. One request's
// stray promise must not take the server down for everyone, so log and keep serving.
// Registered after startup checks so a broken startup still fails loudly. (Left uncaught
// exceptions on Node's default crash-and-restart, since process state is unsafe after one.)
process.on("unhandledRejection", (reason) => {
console.error("[unhandledRejection]", reason);
});
const port =
process.env.NODE_ENV === "production" ? Number.parseInt(process.env.PORT ?? "3000", 10) : env.SERVER_PORT;
const app = createApp();
serve(
{
fetch: app.fetch,
port,
},
(info) => {
console.info(`🚀 Up and running on http://localhost:${info.port}`);
},
);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
+50
View File
@@ -0,0 +1,50 @@
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
const OAUTH_WARN_THROTTLE_MS = 60_000;
let lastOAuthWarnAt = 0;
function warnOAuthThrottled(message: string, detail?: unknown): void {
const now = Date.now();
if (now - lastOAuthWarnAt < OAUTH_WARN_THROTTLE_MS) return;
lastOAuthWarnAt = now;
if (detail !== undefined) {
console.warn(message, detail);
return;
}
console.warn(message);
}
export class AuthError extends Error {
constructor() {
super("Unauthorized");
}
}
export async function authenticateRequest(request: Request): Promise<void> {
const authHeader = request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
try {
const payload = await verifyOAuthToken(authHeader.slice(7));
if (payload?.sub) return;
warnOAuthThrottled("[MCP] OAuth token verified but missing `sub` claim");
} catch (error) {
warnOAuthThrottled("[MCP] OAuth token verification failed:", error);
}
}
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
try {
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
if (result.valid) return;
} catch {
// Invalid or malformed key; fall through to AuthError.
}
}
throw new AuthError();
}
+42
View File
@@ -0,0 +1,42 @@
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { env } from "@reactive-resume/env/server";
import { AuthError, authenticateRequest } from "./auth";
import { createMcpServer } from "./server";
export async function handleMcp(request: Request) {
try {
await authenticateRequest(request);
const server = createMcpServer(request);
const transport = new WebStandardStreamableHTTPServerTransport({
enableJsonResponse: true,
});
await server.connect(transport);
return await transport.handleRequest(request);
} catch (error) {
if (error instanceof AuthError) {
return Response.json(
{ id: null, jsonrpc: "2.0", error: { code: -32603, message: "Unauthorized" } },
{
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${env.APP_URL}/.well-known/oauth-protected-resource"`,
},
},
);
}
console.error("[MCP]", error);
return Response.json({
id: null,
jsonrpc: "2.0",
error: {
code: -32603,
message: `Error handling request: ${error instanceof Error ? error.message : String(error)}`,
},
});
}
}
+68
View File
@@ -0,0 +1,68 @@
import type { RouterClient } from "@orpc/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { onError } from "@orpc/client";
import { createRouterClient } from "@orpc/server";
import router from "@reactive-resume/api/routers";
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
import { appVersion } from "../app-version";
import { getRequestLocale } from "../rpc/locale";
function createRequestClient(request: Request): RouterClient<typeof router> {
return createRouterClient(router, {
interceptors: [
onError((error) => {
console.error("[MCP oRPC]", error);
}),
],
context: () => ({
locale: getRequestLocale(request),
reqHeaders: request.headers,
resHeaders: new Headers(),
}),
});
}
export function createMcpServer(request: Request) {
const server = new McpServer(
{
name: "reactive-resume",
version: appVersion,
title: "Reactive Resume",
websiteUrl: "https://rxresu.me",
description:
"Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.",
icons: [
{
src: "https://rxresu.me/icon/light.svg",
mimeType: "image/svg+xml",
theme: "light",
},
{
src: "https://rxresu.me/icon/dark.svg",
mimeType: "image/svg+xml",
theme: "dark",
},
],
},
{
instructions: [
"You are connected to Reactive Resume over MCP.",
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
].join(" "),
},
);
const client = createRequestClient(request);
registerResources(server, client);
registerTools(server, client, request.headers);
registerPrompts(server);
return server;
}
+20
View File
@@ -0,0 +1,20 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
export async function generateOpenApiDocumentation(
target = fileURLToPath(new URL("../../../../docs/spec.json", import.meta.url)),
) {
const packageJson = JSON.parse(await readFile(new URL("../../../../package.json", import.meta.url), "utf8")) as {
version: string;
};
process.env.APP_URL ??= "https://rxresu.me";
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_docs";
process.env.AUTH_SECRET ??= "documentation-generation-isolated-process-only";
const { generateOpenApiSpec } = await import("./generator");
const spec = await generateOpenApiSpec({ appUrl: "https://rxresu.me", version: packageJson.version });
await writeFile(target, `${JSON.stringify(spec, null, "\t")}\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await generateOpenApiDocumentation(process.argv[2]);
}
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
type GeneratedSpecView = {
components?: { schemas?: Record<string, unknown> };
paths?: Record<
string,
Record<
string,
{
requestBody?: {
content?: Record<string, { schema?: unknown }>;
};
}
>
>;
};
// Building the spec walks every router and resume JSON schema, which costs seconds. It is
// deterministic and every test here only reads it, so generate it once for the whole file —
// regenerating per test made the first case time out under a loaded machine.
let specPromise: ReturnType<typeof generateOnce> | undefined;
async function generateOnce() {
const { generateOpenApiSpec } = await import("./generator");
return generateOpenApiSpec({
appUrl: "https://rxresu.me",
version: "9.8.7",
});
}
function generateSpec() {
specPromise ??= generateOnce();
return specPromise;
}
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
}
function containsImpossibleSchema(value: unknown): boolean {
if (Array.isArray(value)) return value.some(containsImpossibleSchema);
if (typeof value !== "object" || value === null) return false;
const object = value as Record<string, unknown>;
const negated = object.not;
if (typeof negated === "object" && negated !== null && Object.keys(negated).length === 0) {
return true;
}
return Object.values(object).some(containsImpossibleSchema);
}
function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
const impossibleRequests: string[] = [];
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(operations)) {
for (const [mediaType, content] of Object.entries(operation.requestBody?.content ?? {})) {
if (containsImpossibleSchema(content.schema)) {
impossibleRequests.push(`${method.toUpperCase()} ${path} (${mediaType})`);
}
}
}
}
return impossibleRequests;
}
describe("generateOpenApiSpec", () => {
it("uses caller-provided application URL and version", async () => {
const spec = await generateSpec();
expect(spec.info).toMatchObject({
title: "Reactive Resume",
version: "9.8.7",
});
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
expect(spec.externalDocs).toEqual({
url: "https://docs.rxresu.me",
description: "Reactive Resume Documentation",
});
}, 15_000);
it("uses the canonical input-side ResumeData schema in update requests", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
expect(spec.components?.schemas?.ResumeData).toEqual(canonicalInputSchema);
expect(getRequestSchema(spec, "/resumes/{id}", "put")).toMatchObject({
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
});
});
it("publishes the custom-section type and item correlation", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
it("does not publish impossible request schemas", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
});
it("checks every request body media type for impossible schemas", () => {
const spec: GeneratedSpecView = {
paths: {
"/documents": {
post: {
requestBody: {
content: {
"application/json": { schema: { type: "object" } },
"multipart/form-data": { schema: { not: {} } },
},
},
},
},
},
};
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
});
it("documents imported data as an accepted ResumeData input", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
type: "object",
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
required: ["data"],
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import { OpenAPIGenerator } from "@orpc/openapi";
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
import router from "@reactive-resume/api/routers";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
export const openAPIRouter = {
...router,
resume: {
...router.resume,
downloadPdf: downloadResumePdfProcedure,
},
};
const { $schema: _dialect, ...resumeDataInputSchema } = createResumeDataJsonSchema();
type ResumeDataInputJsonSchema = Parameters<typeof JSON_SCHEMA_INPUT_REGISTRY.add<typeof resumeDataSchema>>[1];
JSON_SCHEMA_INPUT_REGISTRY.add(resumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
const importResumeInputSchema = openAPIRouter.resume.import["~orpc"].inputSchema;
if (importResumeInputSchema) {
JSON_SCHEMA_INPUT_REGISTRY.add(importResumeInputSchema, {
type: "object",
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
required: ["data"],
});
}
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [
new ZodToJsonSchemaConverter({
interceptors: [
({ options, next }) => {
const [required, schema] = next();
const impossible =
Object.keys(schema).length === 1 &&
typeof schema.not === "object" &&
schema.not !== null &&
Object.keys(schema.not).length === 0;
return options.strategy === "input" && impossible ? [required, {}] : [required, schema];
},
],
}),
],
});
type GenerateOpenApiSpecOptions = {
appUrl: string;
version: string;
};
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
return await openAPIGenerator.generate(openAPIRouter, {
info: {
title: "Reactive Resume",
version,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
},
servers: [{ url: `${appUrl}/api/openapi` }],
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
commonSchemas: {
ResumeData: { schema: resumeDataSchema, strategy: "input" },
},
components: {
securitySchemes: {
apiKey: {
type: "apiKey",
name: "x-api-key",
in: "header",
description: "The API key to authenticate requests.",
},
},
},
security: [{ apiKey: [] }],
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
});
}
+41
View File
@@ -0,0 +1,41 @@
import { SmartCoercionPlugin } from "@orpc/json-schema";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { onError } from "@orpc/server";
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { env } from "@reactive-resume/env/server";
import { appVersion } from "../app-version";
import { mergeResponseHeaders } from "../http/headers";
import { getRequestLocale } from "../rpc/locale";
import { generateOpenApiSpec, openAPIRouter } from "./generator";
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
plugins: [
new BatchHandlerPlugin(),
new RequestHeadersPlugin(),
new StrictGetMethodPlugin(),
new SmartCoercionPlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
}),
],
interceptors: [
onError((error) => {
console.error("[OpenAPI]", error);
}),
],
});
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
}
const resHeaders = new Headers();
const { response } = await openAPIHandler.handle(request, {
prefix: "/api/openapi",
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
});
if (!response) return new Response("NOT_FOUND", { status: 404 });
return mergeResponseHeaders(response, resHeaders);
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
auth: {},
env: {
APP_URL: "https://rxresu.me",
},
}));
vi.mock("@better-auth/oauth-provider", () => ({
oauthProviderAuthServerMetadata: vi.fn(() => vi.fn(() => Response.json({}))),
oauthProviderOpenIdConfigMetadata: vi.fn(() => vi.fn(() => Response.json({}))),
}));
vi.mock("@reactive-resume/auth/config", () => ({
auth: mocks.auth,
}));
vi.mock("@reactive-resume/env/server", () => ({
env: mocks.env,
}));
vi.mock("@reactive-resume/mcp/server-card", () => ({
buildMcpServerCard: vi.fn(() => ({})),
}));
vi.mock("../app-version", () => ({
appVersion: "test",
}));
describe("handleOAuthProtectedResource", () => {
it("advertises the mounted auth issuer as the authorization server", async () => {
const { handleOAuthProtectedResource } = await import("./metadata");
const response = await handleOAuthProtectedResource();
await expect(response.json()).resolves.toMatchObject({
resource: "https://rxresu.me",
authorization_servers: ["https://rxresu.me/api/auth"],
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
import { auth } from "@reactive-resume/auth/config";
import { env } from "@reactive-resume/env/server";
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
import { appVersion } from "../app-version";
export const handleOAuthAuthorizationServer = oauthProviderAuthServerMetadata(auth);
export const handleOpenIdConfiguration = oauthProviderOpenIdConfigMetadata(auth);
export function handleWellKnownFallback() {
return new Response("OK", { status: 200 });
}
export function handleMcpServerCard() {
return Response.json(buildMcpServerCard(appVersion), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
},
});
}
export function handleOAuthProtectedResource() {
const metadata = {
resource: env.APP_URL,
bearer_methods_supported: ["header"],
authorization_servers: [`${env.APP_URL}/api/auth`],
};
return Response.json(metadata, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
},
});
}
+31
View File
@@ -0,0 +1,31 @@
import { onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
import router from "@reactive-resume/api/routers";
import { mergeResponseHeaders } from "../http/headers";
import { getRequestLocale } from "./locale";
const rpcHandler = new RPCHandler(router, {
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin(), new StrictGetMethodPlugin()],
interceptors: [
onError((error) => {
console.error("[oRPC Server]", error);
}),
],
});
export async function handleRpc(request: Request, trustedClient = "unknown") {
const resHeaders = new Headers();
const { response } = await rpcHandler.handle(request, {
prefix: "/api/rpc",
context: {
locale: getRequestLocale(request),
reqHeaders: request.headers,
resHeaders,
trustedClient,
},
});
if (!response) return new Response("NOT_FOUND", { status: 404 });
return mergeResponseHeaders(response, resHeaders);
}
+8
View File
@@ -0,0 +1,8 @@
import type { Locale } from "@reactive-resume/utils/locale";
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
import { getCookie } from "../http/headers";
export function getRequestLocale(request: Request): Locale {
const locale = getCookie(request, "locale");
return isLocale(locale) ? locale : defaultLocale;
}
+78
View File
@@ -0,0 +1,78 @@
import { constants, existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { env } from "@reactive-resume/env/server";
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
function resolveFromCurrentModule(relativePath: string) {
return fileURLToPath(new URL(relativePath, import.meta.url));
}
function resolveWorkspaceFolder(folderName: string): string {
let dir = resolveFromCurrentModule(".");
while (dir !== path.dirname(dir)) {
const candidate = path.join(dir, folderName);
if (existsSync(candidate)) return candidate;
dir = path.dirname(dir);
}
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
}
async function runDatabaseMigrations() {
console.info("Running database migrations...");
const pool = new Pool({ connectionString: env.DATABASE_URL });
const db = drizzle({ client: pool });
try {
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
console.info("Database migrations completed");
} catch (error) {
console.error("Database migrations failed", { error });
throw error;
} finally {
await pool.end();
}
}
async function validateLocalStoragePath() {
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) return;
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
console.info(`Validating local storage path: ${dataDirectory}`);
try {
await fs.mkdir(dataDirectory, { recursive: true });
await fs.access(dataDirectory, constants.R_OK | constants.W_OK);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(
`Local storage path is not writable: ${dataDirectory}\n` +
` ${message}\n` +
"Set LOCAL_STORAGE_PATH to a writable directory or fix permissions on the existing path.",
);
throw error;
}
}
async function reapStaleAgentRuns() {
try {
const { reapStaleAgentRunsAtBoot } = await import("@reactive-resume/api/features/agent/runs");
await reapStaleAgentRunsAtBoot();
} catch (error) {
// A reap failure must not block serving traffic; stuck runs also heal lazily on access.
console.error("Failed to reap stale agent runs at boot", { error });
}
}
export async function runStartupChecks() {
await runDatabaseMigrations();
await validateLocalStoragePath();
await reapStaleAgentRuns();
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { handleSchemaJson } from "./schema";
describe("handleSchemaJson", () => {
it("publishes the custom-section type and item correlation", async () => {
const response = handleSchemaJson();
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { appVersion } from "../app-version";
export function handleSchemaJson() {
return Response.json(createResumeDataJsonSchema(), {
status: 200,
headers: {
"Content-Type": "application/schema+json; charset=utf-8",
"Cache-Control": "public, max-age=86400, immutable",
"Surrogate-Control": "max-age=86400",
"X-Content-Type-Options": "nosniff",
"X-Robots-Tag": "index, follow",
ETag: appVersion,
Vary: "Accept",
},
});
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@reactive-resume/env/server", () => ({
env: {
APP_URL: "https://app.example.com/",
},
}));
const { handleLlms, handleRobots, handleSitemap } = await import("./seo");
describe("SEO static endpoints", () => {
it("generates robots.txt from the normalized app URL", async () => {
const response = handleRobots();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(text).toContain("User-agent: *");
expect(text).toContain("Allow: /");
expect(text).toContain("Disallow: /api/rpc");
expect(text).toContain("Disallow: /api/auth");
expect(text).toContain("Disallow: /mcp");
expect(text).toContain("Disallow: /.well-known");
expect(text).toContain("Sitemap: https://app.example.com/sitemap.xml");
expect(text).toContain("Sitemap: https://docs.rxresu.me/sitemap.xml");
expect(text).not.toMatch(/GPTBot|ClaudeBot|PerplexityBot|CCBot|ChatGPT-User/);
});
it("generates an app-domain-only sitemap", async () => {
const response = handleSitemap();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
expect(text).toContain("<loc>https://app.example.com/</loc>");
expect(text).toContain("<loc>https://app.example.com/ats-checker</loc>");
expect(text).not.toContain("docs.rxresu.me");
expect(text).not.toContain("/auth");
expect(text).not.toContain("/dashboard");
expect(text).not.toContain("/builder");
expect(text).not.toContain("/templates");
expect(text).not.toContain("/schema.json");
});
it("generates a lightweight llms.txt product index", async () => {
const response = handleLlms();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(text).toContain("# Reactive Resume");
expect(text).toContain("- Product: https://app.example.com");
expect(text).toContain("- Documentation: https://docs.rxresu.me");
expect(text).toContain("- Documentation sitemap: https://docs.rxresu.me/sitemap.xml");
expect(text).toContain("- Documentation llms.txt: https://docs.rxresu.me/llms.txt");
expect(text).toContain("- API documentation: https://docs.rxresu.me/api-reference");
expect(text).toContain("- Resume schema: https://app.example.com/schema.json");
expect(text).toContain("- MCP documentation: https://docs.rxresu.me/guides/using-the-mcp-server");
expect(text).toContain("- OpenAPI specification: https://app.example.com/api/openapi/spec.json");
});
it("returns headers without a body for HEAD responses", async () => {
const response = handleLlms({ head: true });
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(await response.text()).toBe("");
});
});
+78
View File
@@ -0,0 +1,78 @@
import { env } from "@reactive-resume/env/server";
const DOCS_URL = "https://docs.rxresu.me";
type StaticSeoOptions = {
head?: boolean;
};
function appUrl() {
return env.APP_URL.replace(/\/+$/, "");
}
function textResponse(body: string, options: StaticSeoOptions = {}) {
return new Response(options.head ? null : body, {
headers: { "Content-Type": "text/plain; charset=UTF-8" },
});
}
export function handleRobots(options?: StaticSeoOptions) {
const baseUrl = appUrl();
const body = [
"User-agent: *",
"Allow: /",
"Disallow: /api/rpc",
"Disallow: /api/auth",
"Disallow: /mcp",
"Disallow: /.well-known",
"",
`Sitemap: ${baseUrl}/sitemap.xml`,
`Sitemap: ${DOCS_URL}/sitemap.xml`,
"",
].join("\n");
return textResponse(body, options);
}
export function handleSitemap(options?: StaticSeoOptions) {
const baseUrl = appUrl();
const body = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
" <url>",
` <loc>${baseUrl}/</loc>`,
" </url>",
" <url>",
` <loc>${baseUrl}/ats-checker</loc>`,
" </url>",
"</urlset>",
"",
].join("\n");
return new Response(options?.head ? null : body, {
headers: { "Content-Type": "application/xml; charset=UTF-8" },
});
}
export function handleLlms(options?: StaticSeoOptions) {
const baseUrl = appUrl();
const body = [
"# Reactive Resume",
"",
"Reactive Resume is an open-source resume builder for creating, managing, and exporting resumes.",
"",
"## Links",
"",
`- Product: ${baseUrl}`,
`- Documentation: ${DOCS_URL}`,
`- Documentation sitemap: ${DOCS_URL}/sitemap.xml`,
`- Documentation llms.txt: ${DOCS_URL}/llms.txt`,
`- API documentation: ${DOCS_URL}/api-reference`,
`- Resume schema: ${baseUrl}/schema.json`,
`- MCP documentation: ${DOCS_URL}/guides/using-the-mcp-server`,
`- OpenAPI specification: ${baseUrl}/api/openapi/spec.json`,
"",
].join("\n");
return textResponse(body, options);
}
+54
View File
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const readMock = vi.fn();
vi.mock("@reactive-resume/api/features/storage", () => ({
getStorageService: () => ({
read: readMock,
}),
}));
vi.mock("@reactive-resume/env/server", () => ({
env: {
APP_URL: "https://example.com",
},
}));
const { handleUpload } = await import("./uploads");
describe("handleUpload", () => {
beforeEach(() => {
readMock.mockReset();
});
it("serves public upload keys", async () => {
readMock.mockResolvedValueOnce({
data: new TextEncoder().encode("image"),
size: 5,
contentType: "image/jpeg",
});
const response = await handleUpload(new Request("https://example.com/api/uploads/user-1/pictures/photo.jpeg"));
expect(response.status).toBe(200);
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
expect(response.headers.get("Cross-Origin-Resource-Policy")).toBe("same-site");
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("does not serve private agent attachment keys through the public uploads route", async () => {
readMock.mockResolvedValueOnce({
data: new TextEncoder().encode("secret"),
size: 6,
contentType: "text/plain",
});
const response = await handleUpload(
new Request("https://example.com/api/uploads/user-1/agent/thread-1/attachment.txt"),
);
expect(response.status).toBe(404);
expect(readMock).not.toHaveBeenCalled();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { createHash } from "node:crypto";
import { basename, extname, normalize } from "node:path";
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
export async function handleUpload(request: Request) {
const { userId, filePath } = parseRouteParams(request.url);
if (!userId || !filePath) return new Response("Bad Request", { status: 400 });
if (!isValidPath(userId) || !isValidPathSegments(filePath)) return new Response("Forbidden", { status: 403 });
if (isPrivateUploadPath(filePath)) return new Response("Not Found", { status: 404 });
const storageService = getStorageService();
const key = `uploads/${userId}/${filePath}`;
const storedFile = await storageService.read(key);
if (!storedFile) return new Response("Not Found", { status: 404 });
const filename = filePath.split("/").pop() ?? filePath;
const ext = extname(filename).toLowerCase();
const contentType = storedFile.contentType ?? inferContentType(filename);
const etag = createEtag(storedFile);
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
const shouldForceDownload = [".pdf"].includes(ext);
const headers = new Headers();
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
headers.set("Content-Length", storedFile.size.toString());
if (shouldForceDownload) {
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
}
headers.set("Cache-Control", "public, max-age=31536000, immutable");
headers.set("ETag", etag);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Robots-Tag", "noindex, nofollow");
headers.set("Cross-Origin-Resource-Policy", "same-site");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("X-Frame-Options", "DENY");
headers.set("X-Download-Options", "noopen");
return new Response(toArrayBuffer(storedFile.data), { headers });
}
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
const pathname = new URL(url).pathname;
const [, pathAfterUploads] = pathname.split("/uploads/");
if (!pathAfterUploads) return { userId: undefined, filePath: undefined };
const firstSlashIndex = pathAfterUploads.indexOf("/");
if (firstSlashIndex === -1) {
return { userId: pathAfterUploads, filePath: undefined };
}
const userId = pathAfterUploads.slice(0, firstSlashIndex);
const filePath = pathAfterUploads.slice(firstSlashIndex + 1);
return { userId, filePath: filePath || undefined };
}
function isValidPath(segment: string): boolean {
const normalized = normalize(segment).replace(/^(\.\.(\/|\\|$))+/, "");
return normalized === segment;
}
function isValidPathSegments(path: string): boolean {
const segments = path.split("/");
return segments.every((segment) => isValidPath(segment));
}
function isPrivateUploadPath(path: string): boolean {
return path.split("/")[0] === "agent";
}
function isNotModified(headers: Headers, etag: string): boolean {
const ifNoneMatch = headers.get("If-None-Match");
const candidates = ifNoneMatch?.split(",").map((s) => s.trim()) ?? [];
return candidates.includes(etag);
}
function makeNotModifiedResponse(etag: string): Response {
return new Response(null, {
status: 304,
headers: { ETag: etag, "Cache-Control": "public, max-age=31536000, immutable" },
});
}
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
? (data.buffer as ArrayBuffer)
: (data.slice().buffer as ArrayBuffer);
}
function createEtag(storedFile: { data: Uint8Array; size: number; etag?: string }): string {
if (storedFile.etag) {
const tag = storedFile.etag.trim();
if (tag.startsWith("W/") || tag.startsWith('"')) {
return tag;
}
return `"${tag}"`;
}
const hash = createHash("sha256").update(storedFile.data).digest("hex");
return `"${storedFile.size}-${hash}"`;
}
+291
View File
@@ -0,0 +1,291 @@
import fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
env: { APP_URL: "https://rxresu.me" },
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
getPublicResumeSocialMeta: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/social-meta", () => ({
getPublicResumeSocialMeta: mocks.getPublicResumeSocialMeta,
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => true),
}));
vi.mock("node:fs/promises", () => ({
default: {
readFile: vi.fn(),
},
}));
vi.mock("@hono/node-server/serve-static", () => ({
serveStatic: mocks.serveStatic,
}));
vi.mock("@reactive-resume/env/server", () => ({
env: mocks.env,
}));
type StaticOptions = {
onFound?: (
path: string,
context: {
req: { path: string };
header: (name: string, value: string) => void;
},
) => void | Promise<void>;
};
const { handleWebApp } = await import("./web");
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
describe("web app fallback classification", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
});
it("serves the shell for the root app route without noindex", async () => {
const response = await handleWebApp(new Request("https://example.com/"));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
expect(await response.text()).toBe("<html>app</html>");
});
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
vi.mocked(fs.readFile).mockResolvedValue(`
<!doctype html>
<html>
<head>
<title>Reactive Resume — A free and open-source resume builder</title>
<meta
name="description"
content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
>
</head>
<body><div id="app"></div></body>
</html>
`);
const response = await handleWebApp(new Request("http://server.internal/?utm_source=search"));
const html = await response.text();
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
expect(html).toContain('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
expect(html).toContain('id="reactive-resume-structured-data"');
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
expect(html).toContain('"url":"https://rxresu.me/"');
expect(html).not.toContain("utm_source");
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
});
describe("the ATS checker page", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("serves an indexable shell rather than a 404", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const response = await handleWebApp(new Request("https://example.com/ats-checker"));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
});
it("replaces the shell metadata with the checker's own", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/ats-checker"))).text();
expect(html).toContain("<title>ATS Checker - Reactive Resume</title>");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
expect(html).toContain('id="ats-checker-structured-data"');
expect(html).not.toContain("Marketing copy.");
});
it("answers HEAD without a body", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker", { method: "HEAD" }));
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
});
it("does not treat the checker path as a public resume owner", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker/anything"));
expect(response.status).toBe(404);
expect(mocks.getPublicResumeSocialMeta).not.toHaveBeenCalled();
});
});
describe("public resume social cards", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("injects resume-specific social metadata and replaces the shell title", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: "Jane Doe",
title: "Jane Doe — Staff Engineer",
description: "Builds resilient distributed systems.",
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" });
expect(html).toContain("<title>Jane Doe - Reactive Resume</title>");
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
expect(html).not.toContain("Marketing copy.");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
expect(html).toContain('<meta property="og:type" content="profile">');
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
});
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: 'Jane" onload="alert(1)',
title: "<script>alert(1)</script>",
description: 'Ends with " and & ampersand',
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).not.toContain('onload="alert(1)');
expect(html).toContain('<meta property="og:title" content="&lt;script&gt;alert(1)&lt;/script&gt;">');
expect(html).toContain('content="Ends with &quot; and &amp; ampersand"');
});
it("serves the plain shell when the resume is not publicly shareable", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text();
expect(html).toBe(shell);
});
it("serves the plain shell when the lookup fails", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable"));
const response = await handleWebApp(new Request("https://example.com/jane/resume"));
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(shell);
});
});
it("caches versioned homepage media immutably", async () => {
const headers = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse-v1.mp4" },
header: (name, value) => headers.set(name, value),
});
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
const unversionedHeaders = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse.mp4" },
header: (name, value) => unversionedHeaders.set(name, value),
});
expect(unversionedHeaders.get("Cache-Control")).toBeNull();
});
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(200);
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("frame-ancestors 'none'");
});
it.each(["/auth/login", "/dashboard", "/builder/resume-1", "/agent", "/templates", "/templates/azurill.pdf"])(
"serves noindex shell for known app prefix %s",
async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await response.text()).toBe("<html>app</html>");
},
);
it("serves noindex shell for public resume shaped routes", async () => {
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
expect(response.status).toBe(200);
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await response.text()).toBe("<html>app</html>");
});
it("returns noindex 404 for unknown non-asset routes", async () => {
const response = await handleWebApp(new Request("https://example.com/unknown/extra/path"));
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
});
it.each(["/api/foo", "/mcp/foo", "/uploads/foo"])(
"does not treat reserved two-segment path %s as a public resume",
async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
},
);
it("returns plain 404 for missing asset-looking paths", async () => {
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
expect(response.status).toBe(404);
expect(response.headers.get("X-Robots-Tag")).toBeNull();
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
});
it("mirrors fallback status and headers for HEAD without a body", async () => {
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
const unknownResponse = await handleWebApp(
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
);
expect(knownResponse.status).toBe(200);
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(knownResponse.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await knownResponse.text()).toBe("");
expect(unknownResponse.status).toBe(404);
expect(unknownResponse.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(unknownResponse.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await unknownResponse.text()).toBe("");
});
});
+331
View File
@@ -0,0 +1,331 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { serveStatic } from "@hono/node-server/serve-static";
import { env } from "@reactive-resume/env/server";
function resolveWebDistPath() {
const candidates = [
// Source layout: apps/server/src/static/web.ts -> apps/web/dist
fileURLToPath(new URL("../../../web/dist", import.meta.url)),
// Bundled layout: apps/server/dist/index.mjs -> apps/web/dist
fileURLToPath(new URL("../../web/dist", import.meta.url)),
];
const [fallback] = candidates;
if (!fallback) throw new Error("Could not resolve web dist path");
return candidates.find((candidate) => existsSync(candidate)) ?? fallback;
}
const staticRoot = resolveWebDistPath();
const indexHtmlPath = `${staticRoot}/index.html`;
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
/**
* Marketing pages the SPA owns that search engines should index.
*
* Without an entry here the fallback below returns 404 for the path in production — the dev Vite
* server serves the shell for anything, so this failure only ever shows up once deployed.
*/
const indexableAppPaths = new Set(["/ats-checker"]);
const reservedPublicResumeSegments = new Set([
"api",
"mcp",
".well-known",
"uploads",
"auth",
"dashboard",
"builder",
"agent",
"templates",
"ats-checker",
]);
function isAssetPath(pathname: string): boolean {
return pathname.split("/").pop()?.includes(".") ?? false;
}
function getPathSegments(pathname: string) {
return pathname.split("/").filter(Boolean);
}
function isNoindexShellPath(pathname: string): boolean {
return noindexShellPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
function isPublicResumePath(pathname: string): boolean {
const segments = getPathSegments(pathname);
const [firstSegment] = segments;
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
}
const BASE_SECURITY_HEADERS = {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy-Report-Only":
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
};
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ROOT_DESCRIPTION =
"Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.";
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
const ROOT_FAQ_ITEMS = [
{
question: "Is Reactive Resume really free?",
answer:
"Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.",
},
{
question: "How is my data protected?",
answer:
"Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.",
},
{
question: "Can I export my resume to PDF?",
answer:
"Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.",
},
{
question: "Is Reactive Resume available in multiple languages?",
answer:
"Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings page, or using the language switcher in the top right corner. If you don't see your language, or you would like to improve the existing translations, you can contribute to the translations on Crowdin.",
},
{
question: "What makes Reactive Resume different from other resume builders?",
answer:
"Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.",
},
{
question: "How do I share my resume?",
answer:
"You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!",
},
] as const;
function createRootSeoMarkup(canonicalUrl: string) {
const origin = new URL(canonicalUrl).origin;
const imageUrl = `${origin}/opengraph/banner.jpg`;
const structuredData = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
name: "Reactive Resume",
url: canonicalUrl,
},
{
"@type": ["SoftwareApplication", "WebApplication"],
name: "Reactive Resume",
url: canonicalUrl,
description: ROOT_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
codeRepository: "https://github.com/amruthpillai/reactive-resume",
},
{
"@type": "Project",
name: "Reactive Resume",
url: canonicalUrl,
sameAs: ["https://github.com/amruthpillai/reactive-resume"],
},
{
"@type": "FAQPage",
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
},
],
};
return `
<link rel="canonical" href="${canonicalUrl}">
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ROOT_TITLE}">
<meta property="og:description" content="${ROOT_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ROOT_TITLE}">
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
`;
}
const ATS_CHECKER_TITLE = "ATS Checker - Reactive Resume";
// Kept under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ATS_CHECKER_DESCRIPTION =
"Check whether software can read your resume PDF. Runs in your browser, so your file is never uploaded.";
function createAtsCheckerSeoMarkup(origin: string) {
const canonicalUrl = `${origin}/ats-checker`;
const imageUrl = `${origin}/opengraph/ats-checker.png`;
const structuredData = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "ATS Checker",
url: canonicalUrl,
description: ATS_CHECKER_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
isPartOf: { "@type": "WebSite", name: "Reactive Resume", url: `${origin}/` },
};
return `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ATS_CHECKER_TITLE}">
<meta property="og:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ATS_CHECKER_TITLE}">
<meta name="twitter:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="ats-checker-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
`;
}
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
// HTML unescaped.
const escapeAttribute = (value: string) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
async function createPublicResumeSeoMarkup(pathname: string, origin: string) {
const [username, slug] = getPathSegments(pathname);
if (!username || !slug) return null;
// A card render must never take down the page: any lookup failure falls back to the plain shell.
const meta = await import("@reactive-resume/api/features/resume/social-meta")
.then((module) => module.getPublicResumeSocialMeta({ username, slug }))
.catch(() => null);
if (!meta) return null;
const canonicalUrl = `${origin}/${username}/${slug}`;
const imageUrl = `${origin}/templates/jpg/${meta.template}.jpg`;
const pageTitle = escapeAttribute(`${meta.name} - Reactive Resume`);
const title = escapeAttribute(meta.title);
const description = escapeAttribute(meta.description);
return {
pageTitle,
description,
markup: `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="profile">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
`,
};
}
export const serveWebDistStatic = serveStatic({
root: staticRoot,
onFound: (_path, context) => {
if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) {
context.header("Cache-Control", "public, max-age=31536000, immutable");
}
},
});
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/" || indexableAppPaths.has(pathname)) {
return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
}
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
return {
"Content-Type": "text/html; charset=UTF-8",
"X-Robots-Tag": "noindex, follow",
...BASE_SECURITY_HEADERS,
};
}
return null;
}
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
return new Response(options.head ? null : "Not Found", {
status: 404,
headers,
});
}
// ponytail: GET and HEAD share the same routing logic; method determines body presence
export async function handleWebApp(request: Request) {
const isHead = request.method === "HEAD";
const pathname = new URL(request.url).pathname;
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
return new Response(isHead ? null : "Not Found", { status: 404 });
}
const headers = getFallbackResponseHeaders(pathname);
if (!headers) return notFoundResponse({ head: isHead, noindex: true });
if (isHead) return new Response(null, { status: 200, headers });
const html = await fs.readFile(indexHtmlPath, "utf-8");
const canonicalUrl = new URL("/", env.APP_URL).toString();
if (pathname === "/") {
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
}
if (pathname === "/ats-checker") {
const origin = new URL(env.APP_URL).origin;
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${ATS_CHECKER_TITLE}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${ATS_CHECKER_DESCRIPTION}">`);
return new Response(withTitle.replace("</head>", `${createAtsCheckerSeoMarkup(origin)}</head>`), { headers });
}
if (isPublicResumePath(pathname)) {
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
if (resumeSeo) {
// The shell's generic title/description are replaced so shares and previews show the resume,
// not the marketing copy baked into index.html.
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${resumeSeo.pageTitle}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${resumeSeo.description}">`);
return new Response(withTitle.replace("</head>", `${resumeSeo.markup}</head>`), { headers });
}
}
return new Response(html, { headers });
}
+1
View File
@@ -0,0 +1 @@
declare const __APP_VERSION__: string;
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tsdown.config.ts", "vitest.config.ts"],
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ESNext", "DOM"],
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { TsdownPlugin } from "tsdown";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "tsdown";
const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as {
version?: string;
};
const shouldExternalizeThirdParty = (id: string) => {
if (id.startsWith("@reactive-resume/")) return false;
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
return true;
};
const aiPromptsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../packages/ai/src/prompts");
const promptAssetsPlugin: TsdownPlugin = {
name: "prompt-assets",
buildStart() {
for (const filename of readdirSync(aiPromptsDir)) {
if (!filename.endsWith(".md")) continue;
this.emitFile({
type: "asset",
fileName: `prompts/${filename}`,
source: readFileSync(resolve(aiPromptsDir, filename), "utf-8"),
});
}
},
};
export default defineConfig({
entry: { index: "src/index.ts" },
format: "esm",
platform: "node",
target: "node24",
outDir: "dist",
clean: true,
shims: true,
dts: false,
define: { __APP_VERSION__: JSON.stringify(rootPackageJson.version ?? "0.0.0") },
// The flagged dynamic imports are deliberate: they defer evaluation of env-dependent
// modules so tests can run without env vars, not to split chunks.
suppressWarnings: [/dynamic import will not move module into another chunk/],
outExtensions: () => ({ js: ".mjs" }),
deps: {
alwaysBundle: [/^@reactive-resume\//],
neverBundle: shouldExternalizeThirdParty,
},
plugins: [promptAssetsPlugin],
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": ["//"],
"tags": ["app:server", "runtime:server", "role:adapter"],
"tasks": {
"test:ci": {
"cache": false
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import { fileURLToPath } from "node:url";
// @boundaries-ignore root shared Vitest config
import { createVitestProjectConfig } from "../../vitest.shared.mts";
export default createVitestProjectConfig({
name: "server",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "phosphor",
"aliases": {
"components": "@reactive-resume/ui/components",
"utils": "@reactive-resume/utils/style",
"hooks": "@reactive-resume/ui/hooks",
"lib": "@reactive-resume/utils",
"ui": "@reactive-resume/ui/components"
},
"rtl": true,
"menuColor": "default-translucent",
"menuAccent": "subtle",
"registries": {}
}
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#09090B" />
<meta name="application-name" content="Reactive Resume" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines. -->
<meta name="description" content="Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.">
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" type="image/png" sizes="180x180 any" />
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
<title>Reactive Resume — A free and open-source resume builder</title>
</head>
<body>
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
<div id="app"></div>
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
<div id="initial-loader">
<img src="/icon/dark.svg" width="48" height="48" alt="Reactive Resume" />
<div class="initial-loader__spinner"></div>
<span class="initial-loader__sr-only">Loading</span>
</div>
<style>
@keyframes app-spin { to { transform: rotate(360deg) } }
#initial-loader {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
background: #09090b;
}
#app:not(:empty) ~ #initial-loader { display: none; }
.initial-loader__spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(250, 250, 250, 0.2);
border-top-color: #fafafa;
border-radius: 9999px;
animation: app-spin 0.7s linear infinite;
}
.initial-loader__sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
<script type="module" data-cfasync="false" src="/src/main.tsx"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
import { defineConfig } from "@lingui/cli";
import { formatter } from "@lingui/format-po";
export default defineConfig({
sourceLocale: "en-US",
pseudoLocale: "zu-ZA",
locales: [
"af-ZA",
"am-ET",
"ar-SA",
"az-AZ",
"bg-BG",
"bn-BD",
"ca-ES",
"cs-CZ",
"da-DK",
"de-DE",
"el-GR",
"en-US",
"en-GB",
"es-ES",
"fa-IR",
"fi-FI",
"fr-FR",
"he-IL",
"hi-IN",
"hu-HU",
"id-ID",
"it-IT",
"ja-JP",
"km-KH",
"kn-IN",
"ko-KR",
"lt-LT",
"lv-LV",
"ml-IN",
"mr-IN",
"ms-MY",
"ne-NP",
"nl-NL",
"no-NO",
"or-IN",
"pl-PL",
"pt-BR",
"pt-PT",
"ro-RO",
"ru-RU",
"sk-SK",
"sl-SI",
"sq-AL",
"sr-SP",
"sv-SE",
"ta-IN",
"te-IN",
"th-TH",
"tr-TR",
"uk-UA",
"uz-UZ",
"vi-VN",
"zh-CN",
"zh-TW",
"zu-ZA",
],
fallbackLocales: {
"zu-ZA": "en-US",
default: "en-US",
},
format: formatter({
lineNumbers: false,
}),
catalogs: [
{
path: "<rootDir>/locales/{locale}",
include: ["src"],
},
],
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More