<?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: Piyak</title>
    <description>The latest articles on DEV Community by Piyak (@piyaklabs).</description>
    <link>https://dev.to/piyaklabs</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%2F3978503%2Ff47fd521-55a0-4412-abb0-827deac144be.png</url>
      <title>DEV Community: Piyak</title>
      <link>https://dev.to/piyaklabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/piyaklabs"/>
    <language>en</language>
    <item>
      <title>Comments on a static blog — no backend, no login, all Cloudflare</title>
      <dc:creator>Piyak</dc:creator>
      <pubDate>Tue, 23 Jun 2026 04:19:07 +0000</pubDate>
      <link>https://dev.to/piyaklabs/comments-on-a-static-blog-no-backend-no-login-all-cloudflare-18b</link>
      <guid>https://dev.to/piyaklabs/comments-on-a-static-blog-no-backend-no-login-all-cloudflare-18b</guid>
      <description>&lt;h1&gt;
  
  
  Comments on a static blog — no backend, no login, all Cloudflare
&lt;/h1&gt;

&lt;p&gt;I run a small Astro blog on Cloudflare Pages. It mixes developer write-ups with personal, everyday posts, so adding comments came with one hard constraint: &lt;strong&gt;no login wall.&lt;/strong&gt; A GitHub-login widget like Giscus or Utterances would shut out every non-developer reader.&lt;/p&gt;

&lt;p&gt;That ruled out the easy paths. Disqus is heavy and tracker-laden. Waline is genuinely good, but it wants a backend + database running &lt;em&gt;outside&lt;/em&gt; Cloudflare — one more thing to operate. The blog already lives on Cloudflare Pages, so the goal became: &lt;strong&gt;keep comments inside the same stack. No login, no spam, no separate server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what I shipped — comments, likes, and moderation — entirely on Pages + D1 + Turnstile, with a Telegram bot as the moderation UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Static Astro (dist) ── Cloudflare Pages
   ├─ /api/comments  (Pages Function) → Turnstile verify → D1 insert (approved=0) → Telegram notify
   ├─ /api/likes     (Pages Function) → D1 counter (POST +1 / DELETE -1)
   └─ /api/telegram/webhook → approve / reject / delete (secret_token auth)
D1: comments, likes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The site is statically built. Anything dynamic is just a Pages Function hitting one D1 database. There is no origin server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comments: no login, pre-moderated
&lt;/h2&gt;

&lt;p&gt;Spam protection without a login is &lt;strong&gt;Cloudflare Turnstile&lt;/strong&gt; — a free, privacy-friendly CAPTCHA. The browser solves the challenge, and the Function verifies the token server-side before it touches the database.&lt;/p&gt;

&lt;p&gt;Every comment is stored with &lt;code&gt;approved = 0&lt;/code&gt; and &lt;strong&gt;is not shown until I approve it.&lt;/strong&gt; For a brand-new blog, that means it can never be papered over with spam — nothing is public until I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moderation UI is a Telegram bot
&lt;/h2&gt;

&lt;p&gt;I didn't build an admin page. When a comment lands, the bot DMs me with inline buttons — &lt;code&gt;[✅ Approve] [❌ Reject]&lt;/code&gt;. Approving flips &lt;code&gt;approved = 1&lt;/code&gt;. The approved message then keeps a &lt;code&gt;🗑 Delete&lt;/code&gt; button, so I can remove an already-published comment from the same chat later.&lt;/p&gt;

&lt;p&gt;The webhook is authenticated. Telegram sends an &lt;code&gt;X-Telegram-Bot-Api-Secret-Token&lt;/code&gt; header (you set it via &lt;code&gt;secret_token&lt;/code&gt; on &lt;code&gt;setWebhook&lt;/code&gt;), and the Function rejects anything that doesn't match:&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="c1"&gt;// setWebhook (placeholders — never commit real values)&lt;/span&gt;
&lt;span class="c1"&gt;// POST https://api.telegram.org/bot&amp;lt;YOUR_BOT_TOKEN&amp;gt;/setWebhook&lt;/span&gt;
&lt;span class="c1"&gt;//   url=https://log.piyaklabs.com/api/telegram/webhook&lt;/span&gt;
&lt;span class="c1"&gt;//   secret_token=&amp;lt;YOUR_WEBHOOK_SECRET&amp;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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELEGRAM_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Telegram-Bot-Api-Secret-Token&lt;/span&gt;&lt;span class="dl"&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;got&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELEGRAM_WEBHOOK_SECRET&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Forbidden&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&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;h2&gt;
  
  
  Likes: a counter on a static site (and the bug I earned)
&lt;/h2&gt;

&lt;p&gt;Likes are a D1 counter plus &lt;code&gt;localStorage&lt;/code&gt; to remember "you liked this." First version: like → &lt;code&gt;POST&lt;/code&gt; (+1), unlike → only clear &lt;code&gt;localStorage&lt;/code&gt;. The &lt;strong&gt;bug&lt;/strong&gt;: refresh, unlike locally, like again, and the server count climbs forever — because the server never saw the unlike.&lt;/p&gt;

&lt;p&gt;The fix is to make it symmetric: like = &lt;code&gt;POST&lt;/code&gt; (+1), unlike = &lt;code&gt;DELETE&lt;/code&gt; (−1, floored at 0). No login means there's no perfect one-person-one-vote, but for a personal blog this is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small stuff
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The comment form tells readers up front that comments appear after approval.&lt;/li&gt;
&lt;li&gt;Private posts get neither comments nor likes (reusing an existing &lt;code&gt;isPrivate&lt;/code&gt; flag).&lt;/li&gt;
&lt;li&gt;I also dropped in Cloudflare Web Analytics — cookieless, no consent banner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I like this shape
&lt;/h2&gt;

&lt;p&gt;Pre-moderation plus Telegram-as-admin means I run zero extra infrastructure and moderate from my phone with one tap. Cost is $0, the stack is one thing, and there's no backend to keep alive.&lt;/p&gt;

&lt;p&gt;If you're on Cloudflare Pages and want comments that feel self-hosted without running a server, this pattern is worth copying.&lt;/p&gt;

&lt;p&gt;See it live at the bottom of any post: &lt;strong&gt;&lt;a href="https://log.piyaklabs.com" rel="noopener noreferrer"&gt;https://log.piyaklabs.com&lt;/a&gt;&lt;/strong&gt; — leave a comment, or borrow the pattern for your own blog.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>astro</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Routing around Google Maps in Korea: Naver &amp; Kakao deep links, weird coordinates, and iOS clipboard</title>
      <dc:creator>Piyak</dc:creator>
      <pubDate>Thu, 18 Jun 2026 04:50:22 +0000</pubDate>
      <link>https://dev.to/piyaklabs/routing-around-google-maps-in-korea-naver-kakao-deep-links-weird-coordinates-and-ios-clipboard-25mf</link>
      <guid>https://dev.to/piyaklabs/routing-around-google-maps-in-korea-naver-kakao-deep-links-weird-coordinates-and-ios-clipboard-25mf</guid>
      <description>&lt;p&gt;If you've traveled to Korea, you've hit this wall: &lt;strong&gt;Google Maps can't give you walking or transit directions here.&lt;/strong&gt;&lt;br&gt;
Map-data export is restricted, so locals use &lt;strong&gt;Naver Map&lt;/strong&gt; or &lt;strong&gt;KakaoMap&lt;/strong&gt; instead. The usual workaround for visitors is&lt;br&gt;
painful — copy a place's Korean name from Google, paste it into Naver, repeat for every stop.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://kmap.piyaklabs.com" rel="noopener noreferrer"&gt;&lt;strong&gt;K-Map Router&lt;/strong&gt;&lt;/a&gt;: paste a Google Maps link → it opens that place (and the route) in&lt;br&gt;
Naver or Kakao. Free, no sign-up, nothing stored. Code is here:&lt;br&gt;
&lt;a href="https://github.com/piyaklabs/k-map-router" rel="noopener noreferrer"&gt;github.com/piyaklabs/k-map-router&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post isn't a "look how hard I worked" story — honestly, I shipped it fast with heavy AI pair-programming (Claude Code).&lt;br&gt;
It's a &lt;strong&gt;field guide to the stuff that's genuinely hard to find documented&lt;/strong&gt;: how Korean map deep links and coordinates&lt;br&gt;
actually work. If you ever build something in this space, I hope this saves you a few days.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture, in one breath
&lt;/h2&gt;

&lt;p&gt;A single &lt;strong&gt;Cloudflare Worker&lt;/strong&gt; serves both the React SPA (static assets) and the &lt;code&gt;POST /api/resolve&lt;/code&gt; endpoint — same origin,&lt;br&gt;
free tier, &lt;strong&gt;zero runtime dependencies&lt;/strong&gt;. Coordinate resolution is server-side (the browser → Google is blocked by CORS) and&lt;br&gt;
is nothing but &lt;code&gt;fetch&lt;/code&gt; + regex + a little decoding. No DB, stateless.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hard part #1: coordinates live in &lt;em&gt;different&lt;/em&gt; formats per URL
&lt;/h2&gt;

&lt;p&gt;You can't just regex one pattern. A Google Maps URL (after following redirects) hides the coordinates in one of several&lt;br&gt;
shapes, and you have to try them in priority order:&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="c1"&gt;// 1) place pin — most authoritative&lt;/span&gt;
  &lt;span class="c1"&gt;//    ...!3d{lat}!4d{lng}&lt;/span&gt;
  &lt;span class="c1"&gt;// 2) directions waypoint — ⚠️ REVERSED: !1d{lng}!2d{lat}&lt;/span&gt;
  &lt;span class="c1"&gt;//    multiple pairs =&amp;gt; last = destination, first = origin&lt;/span&gt;
  &lt;span class="c1"&gt;// 3) viewport center — /@{lat},{lng},17z&lt;/span&gt;
  &lt;span class="c1"&gt;// 4) ?query= / &amp;amp;destination= / &amp;amp;daddr=&lt;/span&gt;
  &lt;span class="c1"&gt;// 5) ?ll= / &amp;amp;sll=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one that bit me hardest: &lt;strong&gt;&lt;code&gt;/dir/&lt;/code&gt; directions URLs store &lt;code&gt;!1d{longitude}!2d{latitude}&lt;/code&gt; — longitude first.&lt;/strong&gt; Read it as&lt;br&gt;
&lt;code&gt;(lat, lng)&lt;/code&gt; and you'll happily return a point that's in the ocean. And when there are multiple pairs, the &lt;em&gt;last&lt;/em&gt; pair is the&lt;br&gt;
destination, the &lt;em&gt;first&lt;/em&gt; is the start point (which is how the tool can preserve A→B routes).&lt;/p&gt;
&lt;h2&gt;
  
  
  Hard part #2: mobile "Copy link" hides coordinates in a protobuf
&lt;/h2&gt;

&lt;p&gt;Links shared from the &lt;strong&gt;Google Maps mobile app&lt;/strong&gt; (the ones with &lt;code&gt;?g_st=...&lt;/code&gt;) are special. They resolve to something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  .../maps?saddr=Seoul+Station&amp;amp;daddr=Gyeongbokgung&amp;amp;geocode=FWoPPQId...;FWFrPQIdEYSRBy...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are &lt;strong&gt;no plaintext coordinates anywhere&lt;/strong&gt; — they're base64url-encoded in the &lt;code&gt;geocode=&lt;/code&gt; param, as a tiny protobuf. Each&lt;br&gt;
&lt;code&gt;;&lt;/code&gt;-separated entry encodes one endpoint:&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="c1"&gt;// 0x15 = field 2, fixed32 (little-endian) = lat * 1e6&lt;/span&gt;
  &lt;span class="c1"&gt;// 0x1D = field 3, fixed32 (little-endian) = lng * 1e6&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeGeocodeEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&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="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;tag&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mh"&gt;0x15&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mh"&gt;0x1d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// LE&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;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mh"&gt;0x7fffffff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mh"&gt;0x100000000&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;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&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;lat&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="p"&gt;}&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified against 경복궁 (Gyeongbokgung): &lt;code&gt;FWFrPQIdEYSRBy...&lt;/code&gt; → &lt;code&gt;37.579617, 126.977041&lt;/code&gt;. ✅&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard part #3: the deep link specs (and their gotchas)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Naver&lt;/strong&gt; (primary — best transit + English):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  nmap://route/public?dlat={lat}&amp;amp;dlng={lng}&amp;amp;dname={enc}&amp;amp;appname={APPNAME}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;appname&lt;/code&gt; is &lt;strong&gt;required&lt;/strong&gt; (silently fails without it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dname&lt;/code&gt; is optional — omit it and Naver shows the real address. Don't send a literal &lt;code&gt;Destination&lt;/code&gt; placeholder.&lt;/li&gt;
&lt;li&gt;Modes are different action paths: &lt;code&gt;route/walk&lt;/code&gt;, &lt;code&gt;route/car&lt;/code&gt;, &lt;code&gt;route/public&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Kakao&lt;/strong&gt; (secondary):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  kakaomap://route?ep={lat},{lng}&amp;amp;by=publictransit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Modes: &lt;code&gt;by=foot | car | publictransit&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Android:&lt;/strong&gt; custom schemes are flaky from Chrome. Use an &lt;code&gt;intent://&lt;/code&gt; URL with a built-in store fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  intent://route/public?...#Intent;scheme=nmap;package=com.nhn.android.nmap;S.browser_fallback_url=...;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Hard part #4: Kakao's web URL uses a coordinate system from another dimension
&lt;/h2&gt;

&lt;p&gt;For desktop fallback, Kakao's legacy &lt;code&gt;link/to&lt;/code&gt; API can't take a start point. But its redirect target can — if you feed it&lt;br&gt;
Kakao's internal &lt;strong&gt;WCongnamul&lt;/strong&gt; coordinates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  https://map.kakao.com/?map_type=TYPE_MAP&amp;amp;target=traffic&amp;amp;rt={sx},{sy},{ex},{ey}&amp;amp;rt1={from}&amp;amp;rt2={to}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WCongnamul turned out to be &lt;strong&gt;EPSG:5181 (a GRS80 Transverse Mercator) scaled ×2.5&lt;/strong&gt;. I implemented the projection by hand and&lt;br&gt;
it matched Kakao's own conversion to the integer for every test point. (Also: never put a comma in the &lt;code&gt;rt1/rt2&lt;/code&gt; label — it&lt;br&gt;
breaks the parser and silently drops the destination.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Hard part #5: the iOS clipboard "paste" button that wouldn't paste
&lt;/h2&gt;

&lt;p&gt;The "Paste from clipboard" button worked everywhere except iOS. Two reasons, both subtle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Google Maps "Copy link" puts the URL on the clipboard as &lt;strong&gt;&lt;code&gt;text/uri-list&lt;/code&gt; only&lt;/strong&gt; — no &lt;code&gt;text/plain&lt;/code&gt;. So
&lt;code&gt;navigator.clipboard.readText()&lt;/code&gt; returns an empty string.&lt;/li&gt;
&lt;li&gt;iOS WebKit expires the user activation after your first &lt;code&gt;await&lt;/code&gt;. So a &lt;code&gt;readText()&lt;/code&gt; → fall back to &lt;code&gt;read()&lt;/code&gt; chain &lt;strong&gt;always
fails on the second call&lt;/strong&gt; with &lt;code&gt;NotAllowedError&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is to make &lt;strong&gt;exactly one&lt;/strong&gt; clipboard call inside the gesture, then read the type off the already-resolved&lt;br&gt;
&lt;code&gt;ClipboardItem&lt;/code&gt; (those &lt;code&gt;getType&lt;/code&gt; calls reuse the granted permission):&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;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// one call, in the gesture&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/uri-list&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;text/plain&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;text/html&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="c1"&gt;// uri-list: first non-comment line is the URL&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;text&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;text&lt;/span&gt;&lt;span class="p"&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;The iOS "Paste" permission bubble itself is unavoidable — it's OS-enforced for any programmatic clipboard read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: respect the mode the user already picked
&lt;/h2&gt;

&lt;p&gt;Google encodes the travel mode in the link (&lt;code&gt;travelmode=driving&lt;/code&gt;, &lt;code&gt;dirflg=d&lt;/code&gt;, or &lt;code&gt;!3e0&lt;/code&gt;). Reading it means a shared &lt;em&gt;driving&lt;/em&gt;&lt;br&gt;
route opens directly in driving directions in Naver/Kakao — "plan in Google Maps, navigate in Korea," unchanged.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it / take it
&lt;/h2&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2067124302403772609-944" src="https://platform.twitter.com/embed/Tweet.html?id=2067124302403772609"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2067124302403772609-944');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2067124302403772609&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;strong&gt;&lt;a href="https://kmap.piyaklabs.com" rel="noopener noreferrer"&gt;kmap.piyaklabs.com&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Code (MIT-ish, stateless, zero deps): &lt;strong&gt;&lt;a href="https://github.com/piyaklabs/k-map-router" rel="noopener noreferrer"&gt;github.com/piyaklabs/k-map-router&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building anything that bridges Google Maps and Korean map apps, steal the deep-link and coordinate logic — that's&lt;br&gt;
exactly why it's public. Questions welcome. 🐣&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
