Presentation layer (HTTP val) that fetches JSON Resume data from
linkedin-mdp-api and
renders it as a styled HTML page in the browser.
Architecture: This val is a pure UI layer. It contains zero LinkedIn/OAuth logic — all data fetching and normalization lives in
linkedin-mdp-api.
| URL | Description |
|---|---|
/ | 302 redirect to the route configured in DEFAULT env var |
/minimal | Serif CSS-only theme (print-friendly) |
/tailwind | Card-based theme via Tailwind CDN |
/jsonresume | Server-side render using a jsonresume.org theme |
| Param | Applies to | Description |
|---|---|---|
?photo=1 | /minimal, /tailwind | Show basics.image as a circular avatar (96×96 px) |
?theme=<name> | /jsonresume | jsonresume theme package name (default: even) |
?force=1 | all | Bypass cache read; always fetch fresh data from upstream API (result is still saved to cache) |
Known-working themes for /jsonresume: even, kendall, flat,
stackoverflow, paper, elegant and most others from
jsonresume.org/themes.
Themes are loaded via npm: so Deno unpacks the real package and
fs.readFileSync works correctly with __dirname.
Unofficial themes are published to npm under the jsonresume-theme-* prefix
beyond the official list. You can use any such package by name:
?theme=macchiato, ?theme=onepage, etc. The input field in the theme selector
(planned) will allow arbitrary names for this reason.
| Header | Description |
|---|---|
X-Cache-Age | Seconds since the last successful cache write. -1 means no cache entry exists yet. 0 means data was just fetched fresh. |
Set these in the val's Environment Variables sidebar.
| Key | Required | Default | Description |
|---|---|---|---|
DATA_API_URL | — | hardcoded linkedin-mdp-api endpoint | Base URL of the data API (no trailing slash) |
DEFAULT | — | minimal | Default route for / redirect. Can include query string, e.g. tailwind?photo=1 |
Note:
DEFAULTwarning in logs ("DEFAULT" is not set) is harmless — the val falls back tominimalautomatically.
Resume data is cached in Val Town Blob Storage for 24 hours.
| Detail | Value |
|---|---|
| Blob key | linkedin_resume_cache |
| Format | { fetchedAt: number (ms), data: JsonResume } |
| TTL | 24 hours |
?force=1 | Skips cache read only; result is still written to cache |
| Stale fallback | If upstream returns 429 / 5xx / network error and a stale cache entry exists (any age), the stale data is served instead of an error page |
Browser → /minimal (or /tailwind, /jsonresume)
└─ Blob cache (24h TTL)
└─ fetch DATA_API_URL/jsonresume [on cache miss or ?force]
└─ linkedin-mdp-api
└─ LinkedIn DMA API (OAuth)
linkedin-mdp-api applies all data normalization:
- double-space →
\nparagraph trick - bullet character detection via
BULLETSenv var - photo extraction from
RICH_MEDIAlog - JSON Resume schema v1.0.0 compliance
linkedin-resume-ui applies presentation-only fixes on top:
flatSkills()— expands{ name: "Skills", keywords: [...] }into chipsdedupDegree()— removes"X, X"duplicated degree strings from APIprose()— converts\n\n→<p>,\n→<br>, strips /\u00a0dateRange()— formats ISO dates asYYYY-MM – YYYY-MMorYYYY-MM – Present- label normalization —
\n-separated role titles →·separator
| Situation | Behaviour |
|---|---|
| LinkedIn API returns 429 / 401 / 5xx + stale cache exists | Serve stale cache silently |
| LinkedIn API returns 429 / 401 / 5xx + no cache | Response body passed through as-is with original status code |
Network error reaching DATA_API_URL + stale cache exists | Serve stale cache silently |
Network error reaching DATA_API_URL + no cache | 502 + styled error page with URL shown |
| API returns non-JSON | 502 + styled error page |
basics.name missing or empty | 502 + styled error page (guards against blank render) |
Unknown route (e.g. /foo) | 404 + styled error page listing valid routes |
| jsonresume theme import fails | 500 + styled error page with hint |
| Version | Key changes |
|---|---|
| v1–v3 | Initial themes, token passing, basic structure |
| v4 | Skills flatting, degree dedup, prose(), label normalization |
| v5 | DEFAULT redirect, ?photo=1, jsonresume server-side render via esm.sh, require → ESM fix |
| v6 | API error passthrough, basics.name validation, photo object-fit: cover, photo removed from /jsonresume |
| v7–v12 | npm: import fix (Deno module cache, __dirname support), theme compatibility |
| v13–v14 | Blob cache (24h TTL), stale-while-revalidate skeleton |
| v15–v18 | Stale-while-revalidate (serve stale on 429/network error), X-Cache-Age response header, ?force=1 |
-
/jsonresumephoto control — themes renderbasics.imageunconditionally if set. Stripbasics.imagefrom payload when?photo=0(or when?photo=1is absent), mirroring/minimal//tailwindbehaviour. Currently?photois ignored for/jsonresume. -
Theme selector UI — a nav bar / dropdown listing available routes and themes. Input field should be editable (free text) to support unofficial
jsonresume-theme-*npm packages beyond the official list. Official theme list source: registry.npmjs.org/-/v1/search?text=jsonresume-theme -
Photo crop position —
object-fit: coveralways crops to geographic center, which is wrong for portraits. Options:?photo_x=<0–100>&?photo_y=<0–100>query params →object-position: X% Y%- Read EXIF/XMP
SubjectArea/RegionAreaface metadata from the image (requires a small fetch + parser, e.g.npm:exifr) - Default heuristic:
object-position: center 20%(faces tend to be in top third of portrait photos)
-
Banner support —
linkedin-mdp-apiexposes a banner/background image URL in the raw data. Investigate whether any jsonresume themes support a banner field, and whether it's worth adding a banner strip to/minimal//tailwind. -
Paragraph formatting in jsonresume themes — the JSON Resume spec defines
summaryas plain text; most npm themes output it as a single<p>block (wall of text). Options:- Pre-process
summaryinto HTML before passing to theme render (risky: some themes HTML-escape the value again) - Post-process theme HTML output with a regex replacing
\n\ninside<p> - Contribute a patch upstream or pick themes that handle it well
- Pre-process
Comparison of approaches (priority: minimum code + fast):
| Approach | Code effort | Speed | Quality | Notes |
|---|---|---|---|---|
print.css in /minimal | Minimal — already has @media print skeleton | Instant (browser native) | Good | Zero server cost; user triggers Ctrl+P. No new route needed. Recommended first step. |
Puppeteer /pdf route | ~10 lines | Fast (~1s) | Excellent (Chrome PDF) | Requires npm:puppeteer or Val Town @browserless; cold-start latency |
npm:marked /markdown route | ~5 lines | Instant | Good (GitHub-style) | Fetches /markdown from API, renders to HTML; no new data type needed |
| Pandoc WASM client-side DOCX | ~20 lines JS | Slow (5–20s WASM load) | Good (Pandoc) | Client-side only, no server cost; markdown is the source format |
Rejected approaches:
- RenderCV — Python/Pydantic/LaTeX CLI only; no WASM, no serverless path.
- Pandoc server-side — Pandoc is a Haskell binary; cannot run in Deno/Val Town.
- OpenResume — React SPA with its own proprietary JSON schema; no public render API. Embedding would require mapping our JSON Resume → their schema (~50 lines) plus iframe/postMessage plumbing, for the same result Puppeteer gives in 10 lines.
- HackMyResume/FluentCV — abandoned (last commit 2018), broken deps.
-
basics.imageproxy — LinkedIn photo URLs may expire or require auth; consider proxying through the val to avoid broken images. -
<meta og:…>tags for social sharing preview. - Dark mode via
@media (prefers-color-scheme: dark). -
?lang=param to filter multi-language data (dependent on API support).
Tools we evaluated that solve similar problems. Kept here for reference.
| Tool | Format | Render | Why not used |
|---|---|---|---|
| JSON Resume | jsonresume JSON | 30+ npm themes, registry renderer | ✅ In use — best open-source theme ecosystem |
| RenderCV | YAML (Pydantic) | LaTeX/Typst → PDF CLI | CLI only, no WASM, no serverless |
| OpenResume | Proprietary JSON | React SPA → PDF | No public API; own schema incompatible with JSON Resume |
| Manfred MAC | MAC JSON | getmanfred.com SaaS | No open-source renderer; format is niche |
| HackMyResume | JSON Resume / FRESH | HTML/PDF/DOCX CLI | Abandoned since 2018 |
| Europass | XML | Official EU renderer | XML-heavy, EU-specific, no npm library |
| Pandoc | Markdown (source) | DOCX/HTML/PDF | No resume semantics; useful only as Markdown→DOCX converter (WASM, client-side) |
LinkedIn DMA API has a per-day rate limit per application. Once exhausted,
all endpoints return 429 until midnight UTC. The stale cache fallback means
the UI continues to serve the last known data silently.
Themes are imported via npm:jsonresume-theme-<name>. Deno unpacks the full
package into its module cache, so __dirname and fs.readFileSync work
correctly. Most themes from jsonresume.org/themes
should work. If a theme has exotic runtime dependencies it may still fail —
the error page shows the exact message.
Val Town's runtime emits WARNING: "DEFAULT" is not set on every request.
This is cosmetic — the val defaults to minimal. Set the env var to silence it.
Profile edits on LinkedIn may take hours or up to a day to appear in API output. This is a LinkedIn DMA API limitation, not a bug in this val.
foxdark410/linkedin-mdp-api— data layer: fetches LinkedIn DMA snapshot, exports JSON Resume, MAC, RenderCV, Markdown, plain text.
linkedin-resume-ui/
└── main.ts # Single HTTP handler — all themes, routing, helpers in one file
All logic intentionally lives in one file to keep the val self-contained and easy to fork. If the file grows beyond ~1000 lines, consider splitting into:
themes/minimal.tsthemes/tailwind.tsthemes/jsonresume.tsutils.ts(esc, prose, dateRange, flatSkills, dedupDegree)main.ts(router + error handler)
- npm packages:
import X from "npm:package-name" - Val Town std lib:
import { blob } from "https://esm.town/v/std/blob"
When the LinkedIn API limit is hit (429), test locally by temporarily hardcoding
a fixture JSON Resume object in main.ts and bypassing the fetch call:
// Temporary fixture for local dev / rate-limit debugging
const json = { basics: { name: "Test User", label: "Engineer" }, work: [] };