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 | Description |
|---|---|
?photo | Show basics.image as avatar; absent = no photo. Applies to all routes. |
?photo_pos=x:y | Crop anchor for avatar: both values 0–100, default 50:20 (center-top heuristic for portraits). Either part optional: ?photo_pos=:30 sets only Y. Applies to /minimal, /tailwind. |
?force | Bypass cache read; always fetch fresh data from upstream (result still saved to cache). Presence-only — value is ignored. Applies to all routes. |
| Param | Description |
|---|---|
?theme=<name> | jsonresume theme package name (default: even). Any npm:jsonresume-theme-<name> is accepted. |
| Theme | Markdown handling | Notes |
|---|---|---|
even (default) | ✅ native (micromark) | Processes every text field internally; do not preprocess |
kendall | ✅ sentinel post-processing | Works for all fields: {{{triple}}} and {{double}} both handled |
flat | ✅ sentinel post-processing | Schema compat (url→website, name→company) applied first |
stackoverflow | ✅ native (nl2br/paragraphs) | Built-in paragraph transform; sentinel skipped |
paper | ✅ sentinel post-processing | Applied automatically as unknown theme |
elegant | ✅ sentinel post-processing | Applied automatically as unknown theme |
Why
flatshows raw Markdown:flatuses{{double}}braces throughout — Mustache/Handlebars escapes the output. Pre-rendered HTML would appear as literal<p>tags.flatdoes not support rich text.Sentinel post-processing (kendall
projects.description): Since{{description}}is double-braced in the kendall template, we substitute a unique sentinel string before rendering, then replace the escaped sentinel in the output HTML with the markdown-rendered HTML. This avoids false replacements that would occur with naive text matching.
Themes with exotic runtime dependencies (native addons, missing peer packages like @resume/core) may fail — the error page shows the exact message.
| 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 | 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/tailwindmdStr()— same, for pre-processing fields before passing to/jsonresumethemespreprocessAll()— appliesmdStr()to all text fields; used when?preprocess=htmlpatchedKendallRender()— kendall-specific: sentinel post-processing forprojects.descriptionflatSkills()— 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, jsonresume server-side render |
| v6 | API error passthrough, basics.name validation, object-fit: cover |
| v7–v10 | npm: import fix, theme compatibility, blob cache (24h TTL) |
| v11–v12 | Stale-while-revalidate, X-Cache-Age, ?force, ?photo_pos=x:y |
| v13 | prose() → marked (full GFM), proportional photo, ?preprocess=html, per-theme markdown map |
| v14 | kendall CSS override (#photo img), sentinel post-processing for projects.description |
| v15 | ?force changed to presence-only (was =1); README cleanup and version sync |
-
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). - Extend sentinel post-processing to
flat— verify thatflat's template renders aprojectssection, then apply the same per-field sentinel approach used forkendall.
| 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 control which sections they render. Some (e.g. flat) do not include a
projects section in their template — this cannot be fixed without forking the theme.
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: [] };