linkedin-resume-ui

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.


Live Endpoints

URLDescription
/302 redirect to the route configured in DEFAULT env var
/minimalSerif CSS-only theme (print-friendly)
/tailwindCard-based theme via Tailwind CDN
/jsonresumeServer-side render using a jsonresume.org theme

Query Parameters

ParamDescription
?photoShow basics.image as avatar; absent = no photo. Applies to all routes.
?photo_pos=x:yCrop 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.
?forceBypass cache read; always fetch fresh data from upstream (result still saved to cache). Presence-only — value is ignored. Applies to all routes.

/jsonresume Parameters

ParamDescription
?theme=<name>jsonresume theme package name (default: even). Any npm:jsonresume-theme-<name> is accepted.

Known-working themes for /jsonresume

ThemeMarkdown handlingNotes
even (default)✅ native (micromark)Processes every text field internally; do not preprocess
kendall✅ sentinel post-processingWorks for all fields: {{{triple}}} and {{double}} both handled
flat✅ sentinel post-processingSchema compat (url→website, name→company) applied first
stackoverflow✅ native (nl2br/paragraphs)Built-in paragraph transform; sentinel skipped
paper✅ sentinel post-processingApplied automatically as unknown theme
elegant✅ sentinel post-processingApplied automatically as unknown theme

Sentinel post-processing is applied to all themes except even and stackoverflow (which have native Markdown renderers). Each rich-text field is replaced with a unique \x02MDPL_{n}\x03 sentinel before rendering. After render(), sentinels are located in the output HTML (in either raw or Mustache-escaped &#x2;...&#x3; form) and replaced with the markdown-rendered HTML. Fields not rendered by a theme are simply not found and skipped silently.

Themes with exotic runtime dependencies (native addons, missing peer packages like @resume/core) may fail — the error page shows the exact message.


Response Headers

HeaderDescription
X-Cache-AgeSeconds since the last successful cache write. -1 = no cache entry exists yet. 0 = data just fetched fresh.

Environment Variables

KeyRequiredDefaultDescription
DATA_API_URLhardcoded linkedin-mdp-api endpointBase URL of the data API (no trailing slash)
DEFAULTminimalDefault route for / redirect. Can include query string, e.g. tailwind?photo

DEFAULT warning in logs ("DEFAULT" is not set) is harmless — the val falls back to minimal.


Caching

Resume data is cached in Val Town Blob Storage for 24 hours.

DetailValue
Blob keylinkedin_resume_cache
Format{ fetchedAt: number (ms), data: JsonResume }
TTL24 hours
?forceSkips cache read only; result is still written to cache
Stale fallbackIf upstream returns 429/5xx/network error and a stale entry exists (any age), stale data is served silently

Data Flow

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_MEDIA log
  • JSON Resume schema v1.0.0 compliance

linkedin-resume-ui applies presentation-only transformations:

  • md() — renders Markdown to HTML via npm:marked (GFM); used in /minimal and /tailwind
  • mdStr() — same, for pre-processing fields before passing to /jsonresume themes
  • preprocessAll() — applies mdStr() to all text fields; used when ?preprocess=html
  • patchedKendallRender() — kendall-specific: sentinel post-processing for projects.description
  • flatSkills() — expands { name: "Skills", keywords: [...] } into chips
  • dedupDegree() — removes "X, X" duplicated degree strings
  • dateRange() — formats ISO dates as YYYY-MM – YYYY-MM or YYYY-MM – Present
  • label normalization — \n-separated role titles → · separator

Photo Rendering

Photos are rendered as circular avatars (96×96 px) using a container div + inner <img>:

  • Proportional scaling: the <img> fills its container via object-fit: cover. Source aspect ratio is always preserved — no distortion.
  • Crop anchor: object-position is controlled by ?photo_pos=x:y. Default is 50% 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.image is stripped from the payload when ?photo is absent, because themes render the photo unconditionally if the field is set.

Error Handling

SituationBehaviour
LinkedIn API 429/5xx + stale cacheServe stale cache silently
LinkedIn API 429/5xx + no cachePass through response as-is
Network error + stale cacheServe stale cache silently
Network error + no cache502 + error page with DATA_API_URL hint
API returns non-JSON502 + error page
basics.name missing502 + error page
Unknown route404 + error page listing valid routes
Theme has no render() export500 + error page with list of working alternatives
Theme import/render throws500 + error page with exact error message and Deno hint

Current Version: v16

Changelog

VersionKey changes
v1–v3Initial themes, token passing, basic structure
v4Skills flatting, degree dedup, prose(), label normalization
v5DEFAULT redirect, ?photo, jsonresume server-side render
v6API error passthrough, basics.name validation, object-fit: cover
v7–v10npm: import fix, theme compatibility, blob cache (24h TTL)
v11–v12Stale-while-revalidate, X-Cache-Age, ?force, ?photo_pos=x:y
v13prose()marked (full GFM), proportional photo, per-theme markdown map
v14–v15kendall CSS override, sentinel post-processing, ?force presence-only
v16Universal sentinelRender for all non-native themes; ?preprocess=html removed; flat now gets markdown rendering via sentinel

TODO

Active / next

  • 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 supportlinkedin-mdp-api exposes 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.image proxy — LinkedIn photo URLs may expire or require auth; consider proxying through the val to avoid broken images.

PDF / DOCX export

Approaches evaluated (priority: minimum code + fast):

ApproachEffortSpeedQualityNotes
print.css polish for /minimalMinimalInstant (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~1sExcellentRequires npm:puppeteer or Val Town @browserless; cold-start latency
npm:marked + Pandoc WASM client-side DOCX~20 lines JS5–20s (WASM load)GoodClient-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).

Lower priority

  • <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 that flat's template renders a projects section, then apply the same per-field sentinel approach used for kendall.

Alternative Tools & Ecosystem

ToolFormatRenderStatus
JSON ResumeJSON30+ npm themes✅ In use
RenderCVYAMLLaTeX/Typst → PDF CLICLI only, no serverless
OpenResumeProprietary JSONReact SPA → PDFNo public API
Manfred MACMAC JSONgetmanfred.com SaaSNo open-source renderer
HackMyResumeJSON Resume / FRESHHTML/PDF/DOCX CLIAbandoned since 2018
PandocMarkdownDOCX/HTML/PDFUseful as Markdown→DOCX (WASM, client-side)

Known Issues

429 Too Many Requests from LinkedIn

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.

projects section missing in some themes

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.

jsonresume theme compatibility

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.

DEFAULT env var warning in logs

Val Town emits WARNING: "DEFAULT" is not set on every request. Cosmetic only — set the env var to silence it.

LinkedIn API data is a snapshot

Profile edits may take hours or up to a day to appear. LinkedIn DMA API limitation.



Development Notes

File structure

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.ts
  • utils.ts (md, esc, dateRange, flatSkills, dedupDegree, photoCSS)
  • main.ts (router + cache + error handler)

Deno import conventions (Val Town)

  • npm packages: import X from "npm:package-name"
  • Val Town std lib: import { blob } from "https://esm.town/v/std/blob"

Testing without LinkedIn token

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: [] };