<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: yunjie</title>
    <description>The latest articles on DEV Community by yunjie (@1436941541).</description>
    <link>https://dev.to/1436941541</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3897268%2F013a6466-faef-4c95-be55-7f6ae3be77c4.jpg</url>
      <title>DEV Community: yunjie</title>
      <link>https://dev.to/1436941541</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/1436941541"/>
    <language>en</language>
    <item>
      <title>The Share Button Is the Product: Engineering a Viral Loop in Vanilla JS</title>
      <dc:creator>yunjie</dc:creator>
      <pubDate>Sun, 05 Jul 2026 14:22:34 +0000</pubDate>
      <link>https://dev.to/1436941541/the-share-button-is-the-product-engineering-a-viral-loop-in-vanilla-js-48n0</link>
      <guid>https://dev.to/1436941541/the-share-button-is-the-product-engineering-a-viral-loop-in-vanilla-js-48n0</guid>
      <description>&lt;p&gt;Most of us treat the share button as the last checkbox before shipping: drop in a &lt;code&gt;navigator.share&lt;/code&gt; call, maybe a Twitter intent URL, done. I did that too, until I built a personality quiz — and realized the share path &lt;em&gt;is&lt;/em&gt; the product. Nobody needs a quiz result for themselves. The entire reason these things spread (remember the Soldier-Poet-King wave?) is that the result exists to be shown to other people.&lt;/p&gt;

&lt;p&gt;So when I built &lt;a href="https://princebanditmagequiz.org/" rel="noopener noreferrer"&gt;a Prince / Bandit / Mage quiz&lt;/a&gt; riding this year's animated-MV meme, I flipped my usual priorities. The quiz engine took an afternoon. The share funnel took a week. This post is about that week: encoding results into URLs with no backend, drawing Instagram-ratio share cards on a canvas, and a handful of small decisions that only matter once you assume every visitor arrived from someone else's screenshot.&lt;/p&gt;

&lt;p&gt;One constraint up front, because it shaped everything: the whole site is static. No server, no database, no build step, no framework. The quiz engine is one IIFE in a single vanilla JS file. Everything below happens in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  A result page that doesn't exist
&lt;/h2&gt;

&lt;p&gt;The first problem with a serverless quiz: what URL do you share? There's no &lt;code&gt;/results/abc123&lt;/code&gt; because there's no database to put &lt;code&gt;abc123&lt;/code&gt; in.&lt;/p&gt;

&lt;p&gt;The answer is old and unglamorous: the URL hash. When the quiz finishes, the full result gets encoded into the fragment and stamped onto the current URL without a navigation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;result=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;p=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prince&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
             &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;b=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bandit&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;m=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line buys a lot. Reload the page and an IIFE at the bottom of the file parses the hash and re-renders the exact result — same archetype, same percentage bars — without replaying fifteen questions. Copy the URL to a friend and they see &lt;em&gt;your&lt;/em&gt; result, not a landing page.&lt;/p&gt;

&lt;p&gt;The restore path has to be paranoid, though, because hashes are user-editable. Anyone can type &lt;code&gt;#result=mage&amp;amp;p=99&amp;amp;b=0&amp;amp;m=0&lt;/code&gt;. So the restore function validates that the three scores sum to exactly the question count; if they don't, it keeps the claimed archetype but swaps in a neutral fallback distribution. Tampered links still render something sane — and a hand-typed &lt;code&gt;#result=mage&amp;amp;p=0&amp;amp;b=0&amp;amp;m=0&lt;/code&gt; doesn't divide by zero into a page of NaN bars.&lt;/p&gt;

&lt;h2&gt;
  
  
  The share links don't point at the quiz
&lt;/h2&gt;

&lt;p&gt;Here's the decision I'd defend hardest. The social share buttons — X, Reddit, Tumblr, WhatsApp, Facebook — do &lt;strong&gt;not&lt;/strong&gt; share the quiz URL with the hash. They share one of three static landing pages, one per archetype:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shareUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/result/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Three reasons that only became obvious after thinking of shares as inbound doors rather than outbound exits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Crawlers can't see hashes.&lt;/strong&gt; A shared &lt;code&gt;quiz/#result=bandit&lt;/code&gt; link gives every social platform the identical generic preview. A real &lt;code&gt;/result/bandit/&lt;/code&gt; page gets its own title, description, and OG image — the unfurl card in the feed &lt;em&gt;is&lt;/em&gt; the advertisement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search engines can index them.&lt;/strong&gt; Three static pages targeting "prince bandit mage quiz [archetype]" queries cost nothing and catch people searching after seeing a friend's result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The landing page can pitch the quiz.&lt;/strong&gt; Someone clicking a friend's result doesn't want &lt;em&gt;your&lt;/em&gt; fifteen answers replayed — they want to know what the thing is and take it themselves. Different job, different page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hash-URL still exists for the "copy link" button, where the receiver is a friend who genuinely wants to see your exact mix. Two share paths, two audiences, deliberately not unified.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result has to feel unrepeatable
&lt;/h2&gt;

&lt;p&gt;A share loop dies if two friends compare results and they're identical. With three buckets and fifteen questions, collisions are guaranteed — so the trick is layering enough variation that the &lt;em&gt;rendered&lt;/em&gt; result almost never repeats.&lt;/p&gt;

&lt;p&gt;Scoring itself is a naive tally, but the tie-break is opinionated. Rather than &lt;code&gt;Math.max&lt;/code&gt; order or randomness, ties resolve toward the &lt;em&gt;rarer&lt;/em&gt; archetype, implemented as nothing more than iteration order plus &lt;code&gt;&amp;gt;=&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;winnerOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// tie-break order favors the rarer archetype: mage &amp;gt; bandit &amp;gt; prince&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prince&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bandit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're torn between the crowd-pleaser and the weird one, you get the weird one. Rare results get shared more; the tie-break quietly feeds the loop.&lt;/p&gt;

&lt;p&gt;The bigger lever is what I called the &lt;em&gt;undercurrent&lt;/em&gt;: a second reading based on your runner-up archetype. Six winner+secondary combinations each get distinct copy ("there's a Bandit riding in your verse..."), and when the secondary scores 3 or fewer out of 15 — nearly all answers pointing one way — it's replaced by a rare "pure verse" variant instead. That's nine narrative outcomes multiplied by a unique percentage mix, from a scoring function that's ten lines long. The variety lives in the copy, not the algorithm, and copy is cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing the screenshot for them
&lt;/h2&gt;

&lt;p&gt;People don't share links on Instagram; they share images. So the result screen renders a 1080×1350 share card (Instagram's portrait ratio) on a client-side canvas: radial-gradient parchment background, the tarot-style card art clipped through an &lt;code&gt;arcTo&lt;/code&gt; rounded-rect path, the archetype name drawn onto a blank banner baked into the artwork, and three mini bars showing your percentage mix.&lt;/p&gt;

&lt;p&gt;Canvas text rendering taught me two things the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fonts load late.&lt;/strong&gt; Draw before your webfont arrives and the canvas silently uses the fallback — the PNG looks off and no error tells you why. The fix is gating the entire draw on &lt;code&gt;document.fonts.ready&lt;/code&gt;. It's a promise; await it, then paint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Art and text have to agree on coordinates.&lt;/strong&gt; Each card's artwork has its empty banner at a slightly different height, so each archetype carries its own &lt;code&gt;bannerY&lt;/code&gt; ratio (0.846, 0.868, 0.848) and the name is drawn at &lt;code&gt;cy + ch * bannerY&lt;/code&gt;. Hardcoding one offset looked fine on one card and misaligned on the other two.&lt;/p&gt;

&lt;p&gt;The finished card goes through &lt;code&gt;canvas.toBlob&lt;/code&gt; and gets cached, so when someone taps share, &lt;code&gt;navigator.share&lt;/code&gt; can attach it as an actual image &lt;code&gt;File&lt;/code&gt; — checked via &lt;code&gt;navigator.canShare({ files: [...] })&lt;/code&gt; first. No Web Share support? Fall back to downloading the PNG plus a copy-caption button. The user should never dead-end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pixel-level stuff that isn't optional
&lt;/h2&gt;

&lt;p&gt;Last one, small but load-bearing: when the result appears, the card art must &lt;em&gt;already be there&lt;/em&gt;. A placeholder flashing into a tarot card reads as jank, and jank on the money screen taxes the exact moment you want screenshotted.&lt;/p&gt;

&lt;p&gt;Two-part fix. First, the moment the quiz starts, all three card images get preloaded — fifteen questions is plenty of time to fetch three webp files. Second, the reveal itself is gated on the decoder, not the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emblem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;emblem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reveal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reveal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;img.decode()&lt;/code&gt; resolves only after the image is fully decoded and paintable, so the result section stays hidden until the correct art can render in the same frame it appears. (Both promise branches call &lt;code&gt;reveal&lt;/code&gt; — a broken image should degrade, not brick the page.) The percentage bars then animate via a double &lt;code&gt;requestAnimationFrame&lt;/code&gt;, because setting a width and transitioning it in the same frame gets batched into no transition at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take away
&lt;/h2&gt;

&lt;p&gt;None of this is hard engineering — it's a few hundred lines of vanilla JS with no dependencies. What changed for me was the frame: for anything designed to spread, the share path deserves the same rigor as the core feature. Encode state where crawlers and friends can both use it, ship the unfurl page separately from the app page, and draw the screenshot yourself instead of hoping users take a good one.&lt;/p&gt;

&lt;p&gt;If you want to see the loop end to end, &lt;a href="https://princebanditmagequiz.org/quiz/" rel="noopener noreferrer"&gt;take the quiz&lt;/a&gt; and watch what the share modal hands you. I got The Mage, apparently. The tie-break may have been involved.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>webdev</category>
      <category>canvas</category>
    </item>
    <item>
      <title>I built firsttripchina.com — a China travel guide written by someone who lives here</title>
      <dc:creator>yunjie</dc:creator>
      <pubDate>Wed, 29 Apr 2026 13:55:25 +0000</pubDate>
      <link>https://dev.to/1436941541/i-built-firsttripchinacom-a-china-travel-guide-written-by-someone-who-lives-here-che</link>
      <guid>https://dev.to/1436941541/i-built-firsttripchinacom-a-china-travel-guide-written-by-someone-who-lives-here-che</guid>
      <description>&lt;p&gt;👋 Hey dev.to — I just shipped &lt;a href="https://firsttripchina.com" rel="noopener noreferrer"&gt;firsttripchina.com&lt;/a&gt;, a practical China travel guide written by someone who actually lives in China (me — Yunjie, a software engineer in Chengdu).&lt;/p&gt;

&lt;p&gt;It's a content site for first-time foreign visitors, covering the friction points existing English-language China resources consistently get wrong: the &lt;a href="https://firsttripchina.com/guide/visa" rel="noopener noreferrer"&gt;240-hour visa-free transit policy&lt;/a&gt;, how to &lt;a href="https://firsttripchina.com/guide/payment" rel="noopener noreferrer"&gt;register Alipay with a foreign card&lt;/a&gt;, the &lt;a href="https://firsttripchina.com/blog/booking-china-attraction-tickets-foreigner-guide" rel="noopener noreferrer"&gt;real-name attraction booking&lt;br&gt;
  system&lt;/a&gt;, and structured city itineraries (&lt;a href="https://firsttripchina.com/blog/perfect-4-day-beijing-itinerary" rel="noopener noreferrer"&gt;4-day Beijing&lt;/a&gt;, &lt;a href="https://firsttripchina.com/blog/perfect-3-day-zhangjiajie-itinerary" rel="noopener noreferrer"&gt;3-day Zhangjiajie&lt;/a&gt;, &lt;a href="https://firsttripchina.com/blog/beijing-to-xian-high-speed-rail" rel="noopener noreferrer"&gt;Beijing → Xi'an by&lt;br&gt;
  HSR&lt;/a&gt;). No tour packages. No affiliate scams. Free to read, no signup.&lt;/p&gt;

&lt;p&gt;## Why I built it&lt;/p&gt;

&lt;p&gt;Every Western friend who's ever told me they were planning a China trip sent me the same five questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Will my Visa card actually work?"&lt;/li&gt;
&lt;li&gt;"Do I need a VPN?"&lt;/li&gt;
&lt;li&gt;"Is the high-speed rail bookable in English?"&lt;/li&gt;
&lt;li&gt;"How do I book Forbidden City tickets?"&lt;/li&gt;
&lt;li&gt;"Do I need a tour, or can I do it solo?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The English-language China travel internet is in a strange state. Lonely Planet's last meaningful update to its China chapter was years ago. TripAdvisor's China forum is full of well-intentioned travelers giving each other 2018 advice. The big content sites (Travel China Guide, China Highlights) are mostly OTA fronts pushing tour packages.&lt;/p&gt;

&lt;p&gt;None of them know that the 240-hour visa-free policy expanded in late 2024, that Alipay now accepts foreign cards but with a ¥200 single-transaction limit and 3% fee, or that buying a Forbidden City ticket requires a real-name passport-linked booking 7 days in advance. I write Markdown for fun, I have a Vercel account, so I built the version I wished&lt;br&gt;
  existed.&lt;/p&gt;

&lt;p&gt;## The tech stack (briefly)&lt;/p&gt;

&lt;p&gt;Stack is React 19 + Vite + TypeScript on the frontend, Vercel Functions + Neon Postgres on the backend, deployed on Vercel.&lt;/p&gt;

&lt;p&gt;The "obvious" content-site setup is &lt;code&gt;content/posts/*.md&lt;/code&gt; parsed at build time — Astro or Hugo would have been the textbook answer. I went with Postgres rows for content because I want to edit from a hosted admin UI, not commit-and-deploy. The tradeoff is real (you can't &lt;code&gt;git diff&lt;/code&gt; a content edit), but for a solo project the speed of "open admin, edit, save"&lt;br&gt;
  beats the audit trail.&lt;/p&gt;

&lt;p&gt;For SEO I prerender the SPA to static HTML at build time — Puppeteer pulls every published slug from Postgres, visits each URL on a local &lt;code&gt;vite preview&lt;/code&gt; server, and writes the rendered HTML into &lt;code&gt;dist/&amp;lt;path&amp;gt;/index.html&lt;/code&gt;. Vercel then serves the static HTML before falling back to the SPA. This took longer than expected to get running on Vercel's build&lt;br&gt;
  container (you need &lt;code&gt;puppeteer-core&lt;/code&gt; + &lt;code&gt;@sparticuz/chromium&lt;/code&gt;, not regular Puppeteer, because the container doesn't ship the shared libraries Chromium expects). Each route runs through a &lt;code&gt;usePageSeo&lt;/code&gt; hook that imperatively injects per-route &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, meta tags, canonical, and JSON-LD, so prerender captures complete SEO HTML for every page.&lt;/p&gt;

&lt;p&gt;The result: every page on the site lands in Google's index as static HTML — important, because Googlebot only executes JavaScript ~70% of the time on first crawl.&lt;/p&gt;

&lt;p&gt;## What's currently live&lt;/p&gt;

&lt;p&gt;12 destination pages (Beijing, Shanghai, Suzhou, Hangzhou, Zhangjiajie, Chengdu, plus more) and 8+ deep-dive blog posts.&lt;/p&gt;

&lt;p&gt;Coming soon: Xi'an, Guilin, Yunnan destinations; a China high-speed rail master guide; a DiDi (rideshare) walkthrough for foreign passport holders.&lt;/p&gt;




&lt;p&gt;The site is at &lt;a href="https://firsttripchina.com" rel="noopener noreferrer"&gt;firsttripchina.com&lt;/a&gt; — browse the &lt;a href="https://firsttripchina.com/destinations" rel="noopener noreferrer"&gt;destinations&lt;/a&gt; or &lt;a href="https://firsttripchina.com/blog" rel="noopener noreferrer"&gt;blog&lt;/a&gt; and take a look.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I built a privacy-first PDF dark mode converter that runs entirely in your browser</title>
      <dc:creator>yunjie</dc:creator>
      <pubDate>Sat, 25 Apr 2026 08:58:01 +0000</pubDate>
      <link>https://dev.to/1436941541/i-built-a-privacy-first-pdf-dark-mode-converter-that-runs-entirely-in-your-browser-3ebl</link>
      <guid>https://dev.to/1436941541/i-built-a-privacy-first-pdf-dark-mode-converter-that-runs-entirely-in-your-browser-3ebl</guid>
      <description>&lt;p&gt;TL;DR: I shipped &lt;a href="https://pdfdark.org" rel="noopener noreferrer"&gt;pdfdark.org&lt;/a&gt; — a browser-side PDF dark mode converter. Files don't get&lt;/p&gt;

&lt;p&gt;uploaded; the entire conversion happens in your browser via PDF.js + a Web Worker. Open source (MIT), free, no signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;Reading long PDFs at night was killing my eyes. The two paths I had both sucked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Existing "dark mode PDF" web tools → required uploading the file. For research papers, contracts, medical records&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;— that felt sketchy. No way to verify what they did with my data.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;OS-level "invert colors" → wrecked photos and charts. Faces became X-rays. Graphs became noise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I built one with two non-negotiable defaults:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nothing leaves your browser. PDF.js parses the file, a Web Worker does the dark-mode pass, pdf-lib stitches the&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;result. Verify it yourself in DevTools → Network.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Images keep their original colors. A saturation classifier detects photos and figures and leaves them untouched&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;while it darkens text and UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Drop PDF&lt;/p&gt;

&lt;p&gt;→ PDF.js renders pages to canvas&lt;/p&gt;

&lt;p&gt;→ Web Worker classifies each pixel by saturation&lt;/p&gt;

&lt;p&gt;├─ saturated pixels (images, charts) → preserved&lt;/p&gt;

&lt;p&gt;└─ low-saturation pixels (text, UI) → themed&lt;/p&gt;

&lt;p&gt;→ pdf-lib assembles a new PDF&lt;/p&gt;

&lt;p&gt;→ User downloads&lt;/p&gt;

&lt;p&gt;The classifier runs on OffscreenCanvas inside a Web Worker, so the UI thread never blocks on large PDFs.&lt;/p&gt;

&lt;p&gt;Output is a real PDF (image-based, one JPEG per page), so the dark mode persists when you email it, sync it to iPad, or&lt;/p&gt;

&lt;p&gt;open it on a Kindle. Not a viewer toggle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost to ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Domain: $7.50 (Cloudflare Registrar, .org)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Hosting: $0 (Vercel free tier)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Email forwarding: $0 (Cloudflare Email Routing)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Error monitoring: $0 (Sentry free tier, errors only with full content masking)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Time: a few weekends&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cost: $7.50.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm watching
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Whether the "no-upload" angle resonates beyond privacy nerds&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Edge cases that break the algorithm (weird embedded fonts, scanned PDFs)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Whether to add a vector-preserving mode for text-only PDFs (current output is image-based, so text isn't selectable)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Live: &lt;a href="https://pdfdark.org" rel="noopener noreferrer"&gt;pdfdark.org&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;GitHub: &lt;a href="https://github.com/1436941541/pdf-dark" rel="noopener noreferrer"&gt;github.com/1436941541/pdf-dark&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Privacy: &lt;a href="https://pdfdark.org/privacy" rel="noopener noreferrer"&gt;pdfdark.org/privacy&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
