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) |
Known-working themes for /jsonresume: even, kendall, flat,
stackoverflow. Themes that read HTML templates from disk (e.g. elegant,
paper) will fail with a descriptive error — see Known Issues.
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.
Browser → /minimal (or /tailwind, /jsonresume)
└─ fetch DATA_API_URL/jsonresume
└─ 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 | Response body passed through as-is with original status code |
Network error reaching DATA_API_URL | 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 (no longer wraps in 502), basics.name validation, photo object-fit: cover scaling, photo removed from /jsonresume (theme-managed), fs-error hint for incompatible themes |
- Caching — add a short-lived cache (e.g. blob storage, 5–15 min TTL)
for API responses to survive LinkedIn's
429 Too Many Requestsday limit gracefully. Currently hitting the limit renders nothing. -
/jsonresumephoto control — jsonresume themes render photo unconditionally ifbasics.imageis set. Consider strippingbasics.imagefrom the payload unless?photo=1is passed (mirror/minimal//tailwindbehaviour). - PDF export — add a
/pdfroute using Puppeteer/@browserless/chromeor a print-CSS?format=pdfflag.
- Theme selector UI — a small nav bar listing available routes/themes so the user doesn't have to type URLs manually.
-
/jsonresumetheme list — enumerate known-working themes with links, possibly as a/jsonresume?theme=listdiscovery endpoint. - Error page improvements — show the raw API error body (status + message) when the upstream returns 4xx/5xx, not just the status code.
-
basics.imageproxy — LinkedIn photo URLs may expire or require auth headers; consider proxying through the val to avoid broken images.
- Add
<meta og:…>tags for social sharing preview. - Dark mode via
@media (prefers-color-scheme: dark). - Print-specific CSS polish for
/tailwind(page breaks, margins). -
?lang=param to filter multi-language data (dependent on API support).
LinkedIn DMA API has a per-day rate limit per application. Once exhausted,
all endpoints return 429 verbatim until midnight UTC. No workaround exists
without caching (see TODO above). Plan renders in the morning when the limit resets.
Themes such as elegant, paper, classy internally call fs.readFileSync()
to load their Handlebars/HTML templates. This is incompatible with the Deno
serverless runtime. These themes throw at import time. The error page now shows
a descriptive hint. Only pure-JS themes (even, kendall, flat,
stackoverflow) are known to work.
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" - esm.sh (for CJS packages without Deno support):
import("https://esm.sh/package") - Val Town std lib:
import { sqlite } from "https://esm.town/v/std/sqlite/main.ts"
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: [] };