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 | /minimal, /tailwind, /jsonresume | Show basics.image as avatar; absent = no photo |
?photo_pos=x:y | /minimal, /tailwind | Crop anchor: both values 0–100, default 50:20 (center-top heuristic for portraits). Either part optional: ?photo_pos=:30 sets only Y. |
?theme=<name> | /jsonresume | jsonresume theme package name (default: even). Any npm:jsonresume-theme-<name> is accepted. |
?preprocess=html | /jsonresume | Pre-render Markdown text fields to HTML before passing to the theme. Use for Mustache/Handlebars themes (kendall, flat, etc.) that output unescaped HTML via {{{triple}}}. Do not use with even — it runs its own Markdown renderer. |
?force=1 | all | Bypass cache read; always fetch fresh data from upstream (result still saved to cache) |
| Theme | Markdown handling | Notes |
|---|---|---|
even (default) | ✅ native (micromark) | Processes every text field internally; do not preprocess |
kendall | ✅ built-in per-field map | Mustache: {{{triple}}} on summary/highlights, {{double}} on projects.description — map applies markdown only where safe |
flat | ❌ none | Handlebars with {{double}} everywhere — HTML would be escaped and shown as raw text; nl2br helper unused in template |
stackoverflow | ✅ native (nl2br/paragraphs) | Applies its own paragraph transformation; do not preprocess |
paper | unknown | Use ?preprocess=html if text fields appear as raw Markdown |
elegant | unknown | Use ?preprocess=html if text fields appear as raw Markdown |
Why
flatshows raw HTML in About/Work with?preprocess=html:flatuses{{double}}braces throughout — Handlebars escapes the output, so pre-rendered HTML appears as literal<p>tags. Flat does not support rich text formatting at all.Why
kendallshows raw HTML in Projects:projects[].descriptionuses{{double}}in the Mustache template (unlikesummarywhich uses{{{triple}}}). The built-in map deliberately skipsprojects.descriptionfor this reason.
Themes with exotic runtime dependencies (native addons, missing peer packages like @resume/core) may fail — the error page shows the exact message.
The unofficial jsonresume-theme-* npm ecosystem contains hundreds of community themes. The input field in the planned theme selector will accept arbitrary names.
| Header | Description |
|---|---|
X-Cache-Age | Seconds since the last successful cache write. -1 = no cache entry exists yet. 0 = data just fetched fresh. |
| 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 |
DEFAULTwarning in logs ("DEFAULT" is not set) is harmless — the val falls back tominimal.
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 entry exists (any age), stale data is served silently |
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:
- Markdown-compatible paragraph/bullet formatting
- photo extraction from
RICH_MEDIAlog - JSON Resume schema v1.0.0 compliance
linkedin-resume-ui applies presentation-only transformations:
md()— renders Markdown to HTML vianpm:marked(GFM); used in/minimaland/tailwindpreprocessMarkdown()— same, applied to all text fields before passing to/jsonresumetheme when?preprocess=htmlflatSkills()— expands{ name: "Skills", keywords: [...] }into chipsdedupDegree()— removes"X, X"duplicated degree stringsdateRange()— formats ISO dates asYYYY-MM – YYYY-MMorYYYY-MM – Present- label normalization —
\n-separated role titles →·separator
Photos are rendered as circular avatars (96×96 px) using a container div + inner <img>:
- Proportional scaling: the
<img>fills its container viaobject-fit: cover. Source aspect ratio is always preserved — no distortion. - Crop anchor:
object-positionis controlled by?photo_pos=x:y. Default is50% 20%(center horizontally, slightly above center vertically — a heuristic that works well for LinkedIn-style portrait photos where the face tends to be in the upper third). /jsonresume:basics.imageis stripped from the payload when?photois absent, because themes render the photo unconditionally if the field is set.
| Situation | Behaviour |
|---|---|
| LinkedIn API 429/5xx + stale cache | Serve stale cache silently |
| LinkedIn API 429/5xx + no cache | Pass through response as-is |
| Network error + stale cache | Serve stale cache silently |
| Network error + no cache | 502 + error page with DATA_API_URL hint |
| API returns non-JSON | 502 + error page |
basics.name missing | 502 + error page |
| Unknown route | 404 + error page listing valid routes |
Theme has no render() export | 500 + error page with list of working alternatives |
| Theme import/render throws | 500 + error page with exact error message and Deno 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 |
| v6 | API error passthrough, basics.name validation, object-fit: cover |
| v7–v12 | npm: import fix (Deno module cache, __dirname support), theme compatibility |
| v13–v14 | Blob cache (24h TTL) |
| v15–v18 | Stale-while-revalidate, X-Cache-Age header, ?force=1 |
| v19–v20 | ?photo (presence-only), ?photo_pos=x:y, strip basics.image from /jsonresume payload |
| v11 | prose() → marked (full GFM Markdown), proportional photo with container div + object-fit, ?preprocess=html for Handlebars themes, contextual error hints |
-
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: npmjs.com search: jsonresume-theme -
Banner support —
linkedin-mdp-apiexposes a banner/background image URL in the raw data. JSON Resume schema does not have a standard banner field. Investigate: (a) whether any jsonresume themes support it via a non-standard field; (b) adding a banner strip to/minimal//tailwind. -
basics.imageproxy — LinkedIn photo URLs may expire or require auth; consider proxying through the val to avoid broken images.
Approaches evaluated (priority: minimum code + fast):
| Approach | Effort | Speed | Quality | Notes |
|---|---|---|---|---|
print.css polish for /minimal | Minimal | Instant (browser Ctrl+P) | Good | /minimal already has @media print skeleton; needs page-break rules and margin tuning. Recommended first step. |
Puppeteer /pdf route | ~10 lines | ~1s | Excellent | Requires npm:puppeteer or Val Town @browserless; cold-start latency |
npm:marked + Pandoc WASM client-side DOCX | ~20 lines JS | 5–20s (WASM load) | Good | Client-side, no server cost; Markdown is the source format |
Rejected approaches:
- RenderCV — Python/Pydantic/LaTeX CLI only; no WASM, no serverless path.
- Pandoc server-side — Haskell binary; cannot run in Deno/Val Town.
- OpenResume — React SPA with proprietary JSON schema; no public render API.
- HackMyResume/FluentCV — abandoned (last commit 2018).
-
<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).
| Tool | Format | Render | Status |
|---|---|---|---|
| JSON Resume | JSON | 30+ npm themes | ✅ In use |
| RenderCV | YAML | LaTeX/Typst → PDF CLI | CLI only, no serverless |
| OpenResume | Proprietary JSON | React SPA → PDF | No public API |
| Manfred MAC | MAC JSON | getmanfred.com SaaS | No open-source renderer |
| HackMyResume | JSON Resume / FRESH | HTML/PDF/DOCX CLI | Abandoned since 2018 |
| Pandoc | Markdown | DOCX/HTML/PDF | Useful as Markdown→DOCX (WASM, client-side) |
LinkedIn DMA API has a per-day rate limit. Once exhausted, all endpoints return
429 until midnight UTC. The stale cache fallback means the UI continues serving
the last known data silently.
Themes imported via npm:jsonresume-theme-<name> work if they only depend on
pure-JS packages. Themes with native addons or missing peer packages (e.g.
@resume/core) will fail — the error page shows the exact message.
Val Town emits WARNING: "DEFAULT" is not set on every request. Cosmetic only
— set the env var to silence it.
Profile edits may take hours or up to a day to appear. LinkedIn DMA API limitation.
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. If the file grows beyond ~1000 lines, consider splitting into:
themes/minimal.ts,themes/tailwind.ts,themes/jsonresume.tsutils.ts(md, esc, dateRange, flatSkills, dedupDegree, photoCSS)main.ts(router + cache + 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 by temporarily hardcoding a
fixture 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: [] };