<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[alondrilich]]></title><description><![CDATA[alondrilich]]></description><link>https://alondrilich.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 15 Nov 2026 19:03:18 GMT</lastBuildDate><atom:link href="https://alondrilich.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Furnishing a room is mostly arithmetic: five interior-design rules as code]]></title><description><![CDATA[Interior design advice sounds like taste, but a lot of it is plain arithmetic: how big a chandelier, how wide the art over a sofa, whether the sofa gets…]]></description><link>https://alondrilich.hashnode.dev/furnishing-a-room-is-mostly-arithmetic-five-interior-design-rules-as-code</link><guid isPermaLink="true">https://alondrilich.hashnode.dev/furnishing-a-room-is-mostly-arithmetic-five-interior-design-rules-as-code</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Design]]></category><dc:creator><![CDATA[Alon Drilich]]></dc:creator><pubDate>Sun, 15 Nov 2026 18:07:15 GMT</pubDate><content:encoded><![CDATA[<p>Interior design advice sounds like taste, but a lot of it is plain arithmetic: how big a chandelier, how wide the art over a sofa, whether the sofa gets through the door at all. Once a rule of thumb is written down precisely, it fits in a few lines of code.</p>
<p>Here are five of the rules published with the <a href="https://loftloom.com/tools">LoftLoom sizing calculators</a>, written as small JavaScript functions so you can read and check them. They're rules of thumb, not laws of physics, and a designer may break them on purpose.</p>
<h2>1. Chandelier diameter: add the room's sides</h2>
<p>Add the room's length and width in feet, and read the result in inches. A 14 × 16 ft room gets a fixture of about 30 inches.</p>
<pre><code class="language-js">const chandelierForRoom = (lengthFt, widthFt) =&gt; lengthFt + widthFt;
</code></pre>
<p>Over a dining table the room doesn't matter; the table does. The fixture should be one-half to two-thirds of the table's width.</p>
<pre><code class="language-js">const chandelierForTable = (tableWidthIn) =&gt;
  [Math.round(tableWidthIn / 2), Math.round((tableWidthIn * 2) / 3)];

chandelierForTable(42); // [21, 28]
</code></pre>
<h2>2. The biggest dining table a room can take</h2>
<p>Everyone at the table needs 36 inches behind them to pull out a chair, on every side. So subtract 72 inches from each room dimension. That's a ceiling, not a target.</p>
<pre><code class="language-js">const largestTable = (roomLengthFt, roomWidthFt) =&gt;
  [roomLengthFt * 12 - 72, roomWidthFt * 12 - 72];

largestTable(12, 16); // [72, 120] → a 72 × 120 in. footprint at most
</code></pre>
<p>Seating is the same idea in the other direction: about 24 inches of table edge per person with standard chairs, and 28 to 30 inches with armchairs.</p>
<pre><code class="language-js">const seatsAlongEdge = (edgeIn, perPerson = 24) =&gt; Math.floor(edgeIn / perPerson);

2 * seatsAlongEdge(96); // 8 along the two long sides of a 96 in. table
</code></pre>
<h2>3. Art over a sofa: two-thirds to three-quarters</h2>
<p>The art, or the whole group of frames, should span about two-thirds to three-quarters of the furniture below it.</p>
<pre><code class="language-js">const artWidth = (furnitureWidthIn) =&gt;
  [Math.floor((furnitureWidthIn * 2) / 3), Math.floor((furnitureWidthIn * 3) / 4)];

artWidth(84); // [56, 63] for an 84 in. sofa
</code></pre>
<p>Rounding down matters here. With <code>Math.round</code>, a 62-inch headboard gives 41 to 47 inches; the published guidance says 41 to 46. Hang it 8 to 10 inches above a sofa back.</p>
<h2>4. Will the sofa go through the door?</h2>
<p>This is the one that ruins delivery days. Measure the doorway's clear width with the door open. If the sofa's depth or its height, whichever is smaller, is at least 2 inches narrower than the opening, it goes through on its back or side. If not, compare its diagonal depth (top of the back frame to the front of the arm), again with 2 inches to spare.</p>
<pre><code class="language-js">function sofaFits({ doorIn, depthIn, heightIn, diagonalIn }) {
  const clearance = 2;
  if (Math.min(depthIn, heightIn) + clearance &lt;= doorIn) return "flat";
  if (diagonalIn &amp;&amp; diagonalIn + clearance &lt;= doorIn) return "angled";
  return "no";
}

sofaFits({ doorIn: 32, depthIn: 34, heightIn: 30 }); // "flat"
</code></pre>
<p>Taking the door off its hinges usually adds about 2 inches, and every doorway on the route counts, not just the front one.</p>
<h2>5. Check against a table, not just a formula</h2>
<p>Each LoftLoom calculator page also publishes a quick-reference table, and the functions in this post were tested against those rows: room 14 × 16 ft → about 30 in., a 54 in. table → 27 to 36 in., an 11 × 14 ft room → a 60 × 96 in. table at most, and every row of the wall-art table. That's how the rounding bug in rule 3 showed up: one row was off by an inch.</p>
<h2>Try them</h2>
<p>All nine calculators are free at <a href="https://loftloom.com/tools">loftloom.com/tools</a>: rug, chandelier, dining table, coffee table, wall art, lampshade, bar stool height, sofa-through-the-door, and Christmas tree size. Each one shows its working, so you can see which rule produced the number.</p>
]]></content:encoded></item><item><title><![CDATA[I'm selling 6 side projects I built: the honest numbers]]></title><description><![CDATA[I built six websites over the last year, mostly React + Supabase and one Shopify store. I don't have the time to market all of them, so I'm selling them…]]></description><link>https://alondrilich.hashnode.dev/i-m-selling-6-side-projects-i-built-the-honest-numbers</link><guid isPermaLink="true">https://alondrilich.hashnode.dev/i-m-selling-6-side-projects-i-built-the-honest-numbers</guid><dc:creator><![CDATA[Alon Drilich]]></dc:creator><pubDate>Sat, 14 Nov 2026 21:55:36 GMT</pubDate><content:encoded><![CDATA[<p>I built six websites over the last year, mostly React + Supabase and one Shopify store. I don't have the time to market all of them, so I'm selling them. Revenue is small or zero, and I'd rather say that up front.</p>
<p>Each sale includes the domain, full source code, the backend and 30 days of handover help.</p>
<h2>1. 72fm.com ($900)</h2>
<p>Browser radio player with 30,000+ live stations, podcasts and radio tools. Premium is sold as one-time PayPal passes; the first one sold in November. About 1,440 human visits a month from search, social and GitHub, and rising.</p>
<h2>2. events.lat ($900)</h2>
<p>Event discovery for Latin America in English, Spanish and Portuguese. 3,671 upcoming events across 7 countries, 500+ articles, organizer promotion slots and PayPal ticket checkout. About 2,000 search and Facebook visits a month. Pre-revenue.</p>
<h2>3. furnifindusa.com ($3,000)</h2>
<p>Live Shopify furniture store with 38,547 products dropshipped from a US supplier, so no inventory. $2,305 in net sales over the last 12 months from 10 orders. To be clear, $1,999.99 of that was a single order.</p>
<h2>4. loftloom.com ($900)</h2>
<p>Furniture brand with a custom React storefront over a 31,728-product catalogue, 197 design guides and 9 sizing calculators. Pre-revenue. Its checkout runs on FurniFindUSA's store, so it's best bought together with #3.</p>
<h2>5. wine.equipment ($900)</h2>
<p>B2B directory where wineries compare production equipment (156 listings) and request quotes. Pre-revenue, no signed manufacturers yet, one real quote request so far.</p>
<h2>6. check.tattoo ($900)</h2>
<p>AI tool that scores a tattoo idea and previews it on the body before booking. The full report costs $19 via PayPal. Comes with an MCP server and a CC BY size chart. Pre-revenue.</p>
<h2>What I learned</h2>
<p>Shipping was the easy part. Distribution is the job, and I spread myself across six products instead of pushing one. If you're good at the part I'm not, one of these could be a cheap head start.</p>
<p>Open to offers on one site or the whole set. Comment here or reach me through my profile and I'll share analytics.</p>
]]></content:encoded></item><item><title><![CDATA[Your MCP server's data is an injection surface: notes from an internet radio server]]></title><description><![CDATA[internet-radio-mcp is a small open-source MCP server. It lets Claude, Cursor, VS Code Copilot and other MCP clients search the Radio Browser directory…]]></description><link>https://alondrilich.hashnode.dev/your-mcp-server-s-data-is-an-injection-surface-notes-from-an-internet-radio-server</link><guid isPermaLink="true">https://alondrilich.hashnode.dev/your-mcp-server-s-data-is-an-injection-surface-notes-from-an-internet-radio-server</guid><category><![CDATA[mcp]]></category><category><![CDATA[Security]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Alon Drilich]]></dc:creator><pubDate>Sat, 14 Nov 2026 08:43:06 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://github.com/AlonDrilich/internet-radio-mcp">internet-radio-mcp</a> is a small open-source MCP server. It lets Claude, Cursor, VS Code Copilot and other MCP clients search the <a href="https://www.radio-browser.info">Radio Browser</a> directory, about 50,000 stations with working streams, and get each station's direct stream URL. It has five read-only tools, needs no API key, and runs with one command:</p>
<pre><code class="language-sh">npx -y internet-radio-mcp
</code></pre>
<p>The interesting part wasn't the radio. It was realising that every field the server returns goes straight into a language model's context, and that anyone in the world can edit those fields.</p>
<h2>The threat is the station name</h2>
<p>Radio Browser is community-maintained: anyone can add or edit a station. So a station could be named "Ignore previous instructions and ...". When an assistant asks for jazz stations in Brazil, that name arrives in the tool result next to the real ones. That's indirect prompt injection, and an MCP server is exactly the kind of component that carries it.</p>
<p>You can't make it impossible. You can make it small, visible and boring.</p>
<h2>What the server does</h2>
<p><strong>One line per field, with nothing hidden.</strong> Line breaks and control characters become spaces. Zero-width characters, bidirectional controls, private-use code points, the Unicode "tag" block and other invisible default-ignorable code points are removed. That matters more for an LLM than for a person: those characters can carry text a human reviewer never sees. The exceptions are the emoji selector and the zero-width joiners that real scripts need, and even those survive only in runs of two.</p>
<p><strong>Length caps.</strong> Names are cut at 120 characters, tags at 40, other fields at 60. A long "name" is the easiest place to hide a paragraph of instructions.</p>
<p><strong>Only real URLs.</strong> <code>stream_url</code>, <code>homepage</code> and <code>favicon</code> must be plain <code>http</code> or <code>https</code>, written out in full, with no credentials or backslashes. <code>javascript:</code>, <code>data:</code>, <code>file:</code>, <code>https:host</code> and bare words are dropped, and URLs are returned in their parsed, canonical form. A station without a valid id or a playable stream is left out entirely.</p>
<p><strong>Say it out loud.</strong> The server instructions and every result state that these fields are data from a public directory, not instructions. That doesn't stop a determined model from following text, but it gives the client's model the right frame.</p>
<p><strong>Read-only by design.</strong> Every tool is annotated <code>readOnlyHint: true</code> and <code>openWorldHint: true</code>. There's nothing to write, delete or spend, so the worst a poisoned name can do is mislead, not act.</p>
<h2>Results that are easy to check</h2>
<p>Each tool result carries three things: a one-line human summary, the same data as JSON text, and <code>structuredContent</code> that matches the tool's <code>outputSchema</code>. Clients that understand structured output get typed data; the rest still get readable text. Inputs are bounded too: <code>limit</code> is 1 to 50 for searches, country codes are ISO 3166-1 alpha-2, and <code>order</code> is an enum.</p>
<h2>Fail loudly, keep running</h2>
<p>The server tries three Radio Browser mirrors in order, each with an 8-second timeout. Replies are capped at 5 MB and must be a JSON list; a mirror that redirects or returns something else counts as failed. If all three fail, the tool returns a clear <code>isError: true</code> result instead of crashing the server or inventing an answer.</p>
<h2>Try it</h2>
<p>In Claude Code:</p>
<pre><code class="language-sh">claude mcp add internet-radio -- npx -y internet-radio-mcp
</code></pre>
<p>Then ask something like "find jazz stations in Brazil" or "what's trending on internet radio right now?". The README has configs for Claude Desktop, Cursor, VS Code, Gemini CLI and Docker, and the server is in the official MCP Registry as <code>io.github.AlonDrilich/internet-radio-mcp</code>.</p>
<p>It's MIT-licensed, plain ES modules with JSDoc types, and depends only on the official MCP TypeScript SDK and <code>zod</code>. If you build an MCP server over any public, editable data set, the same list applies: one line per field, nothing invisible, capped lengths, canonical URLs, read-only tools, and a clear statement that the data is data.</p>
]]></content:encoded></item><item><title><![CDATA[What 101 strength-training programs actually prescribe (an open dataset)]]></title><description><![CDATA[Training advice online is mostly opinion: how many sets, how long to rest, how many days a week. I wanted the boring answer instead: what do written…]]></description><link>https://alondrilich.hashnode.dev/what-101-strength-training-programs-actually-prescribe-an-open-dataset</link><guid isPermaLink="true">https://alondrilich.hashnode.dev/what-101-strength-training-programs-actually-prescribe-an-open-dataset</guid><category><![CDATA[Data Science]]></category><category><![CDATA[fitness]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Alon Drilich]]></dc:creator><pubDate>Sat, 14 Nov 2026 08:41:15 GMT</pubDate><content:encoded><![CDATA[<p>Training advice online is mostly opinion: how many sets, how long to rest, how many days a week. I wanted the boring answer instead: what do written programs actually <em>prescribe</em>? So the 101 programs in the <a href="https://depot.fitness">depot.fitness</a> library are now an open dataset, with one row per program, one per exercise, and the full set-by-set prescriptions for the five programs that are free in full.</p>
<p>It's on GitHub as CSV under CC BY 4.0: <a href="https://github.com/AlonDrilich/depot-strength-programs">AlonDrilich/depot-strength-programs</a> (DOI <a href="https://doi.org/10.5281/zenodo.24082645">10.5281/zenodo.24082645</a>). The programs follow documented templates such as 5/3/1, 5×5, push/pull/legs and upper/lower.</p>
<p>One caveat up front: this is what one library <em>prescribes</em>, not what people did, and not a sample of all training programs.</p>
<h2>A few numbers</h2>
<ul>
<li><strong>Volume grows with level, but less than you'd think.</strong> Median weekly prescribed sets: beginner 47, intermediate 59.5, advanced 67. The whole range is 12 to 130.</li>
<li><strong>Rest follows the rep range.</strong> Median rest is 180 s when the top of the rep range is 5 or fewer, 120 s at 6 to 8, 90 s at 9 to 12, and 60 s at 13 or more.</li>
<li><strong>Sessions are small.</strong> The median session is 5 exercises and 16 sets; 92.1% of sessions have 3 to 6 exercises.</li>
<li><strong>Three or four days a week.</strong> 42 programs train 3 days, 40 train 4, and only 19 ask for 5 or 6.</li>
<li><strong>Moderate reps dominate.</strong> Of 1,565 rep-count prescriptions, 33.4% top out at 9 to 12 reps and 32.9% at 13 to 15. Only 13.0% are in the 1 to 5 range.</li>
<li><strong>Nobody prescribes RPE or RIR.</strong> Progression is free text, and only 10 programs mention a deload or reset.</li>
</ul>
<h2>Load it in a few lines</h2>
<pre><code class="language-python">import pandas as pd

base = "https://raw.githubusercontent.com/AlonDrilich/depot-strength-programs/main/"
programs = pd.read_csv(base + "programs.csv")
exercises = pd.read_csv(base + "exercises.csv")

# Median weekly prescribed sets by level
print(programs.groupby("level")["weekly_prescribed_sets"].median())

# The ten exercises that appear in the most programs
print(exercises.nlargest(10, "programs_using")[["exercise_name", "programs_using"]])
</code></pre>
<p>The second query puts back squat and bench press on top, in 56 programs each, followed by barbell row (44), Romanian deadlift and lateral raise (39 each).</p>
<h2>The messy part: exercise names</h2>
<p>The source is free text, so "Back Squat", "Back Squats", "Barbell Squat" and "Barbell Squats" are the same lift, and so are "Leg Curl" and "Hamstring Curls (Lying)". Names are slugified, run through an alias table (abbreviations expanded, parentheticals dropped, four barbell pairs merged), and a trailing "s" is merged only when the singular also appears. Names that could mean something different, like "Squats" and "Back Squat T1", are left apart rather than guessed. The result is 484 canonical exercises, and <code>exercises.csv</code> lists every spelling that was merged into each one, so you can check or redo it.</p>
<p>Reps are free text too. Each prescription gets a <code>reps_kind</code> (fixed, range, 5/3/1 wave, per-set list, AMRAP, time, interval and so on), and numeric bounds only where they make sense.</p>
<h2>What's not in it</h2>
<ul>
<li>Exercise-by-exercise prescriptions for the other 96 programs. They're counted in the summaries but not released.</li>
<li>Any mapping from exercises to muscles. The source only has program-level muscle groups.</li>
<li>Anything about outcomes. It's a description of prescriptions, nothing more.</li>
</ul>
<p>The charts and the full method are at <a href="https://depot.fitness/data">depot.fitness/data</a>. Corrections are welcome at <a href="mailto:support@depot.fitness">support@depot.fitness</a>, or as an issue on the repo.</p>
]]></content:encoded></item><item><title><![CDATA[A station name is untrusted input: building M3U playlists from 52,000 radio stations]]></title><description><![CDATA[Radio Browser is a community-maintained, public-domain directory of internet radio stations. Anyone can add a station. Every week I turn it into plain M3U…]]></description><link>https://alondrilich.hashnode.dev/a-station-name-is-untrusted-input-building-m3u-playlists-from-52-000-radio-stations</link><guid isPermaLink="true">https://alondrilich.hashnode.dev/a-station-name-is-untrusted-input-building-m3u-playlists-from-52-000-radio-stations</guid><category><![CDATA[Security]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Alon Drilich]]></dc:creator><pubDate>Sat, 14 Nov 2026 08:39:13 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://www.radio-browser.info">Radio Browser</a> is a community-maintained, public-domain directory of internet radio stations. Anyone can add a station. Every week I turn it into plain M3U playlists, one per country and one per genre, that open in VLC, mpv, pyradio or an ESP32 radio: <a href="https://github.com/AlonDrilich/radio-playlists">AlonDrilich/radio-playlists</a>.</p>
<p>The 9 November build has 51,965 working stations after de-duplication, in 172 country playlists, 60 genre playlists and a top-500 list.</p>
<p>Writing an M3U file is trivial. Writing one from data that anyone can edit takes more care, because an M3U is a tiny program that a player executes: a list of <code>#EXTINF</code> lines and URLs to open. Here is what I had to defend against.</p>
<h2>1. A line break in a name is a new entry</h2>
<p>An entry is two lines:</p>
<pre><code>#EXTINF:-1,Station name
https://stream.example/live
</code></pre>
<p>If a station's name contains a newline followed by a URL, the "name" becomes an extra playlist entry pointing wherever the author likes. So names lose every line-breaking character: C0 and C1 controls, plus U+2028 and U+2029, which many tools forget.</p>
<pre><code class="language-js">const LINE_BREAKING = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
const name = raw.replace(LINE_BREAKING, " ");
</code></pre>
<h2>2. Commas and quotes in the <code>#EXTINF</code> line</h2>
<p>Several players split <code>#EXTINF</code> on the first comma to find the title, and attributes such as <code>tvg-logo="..."</code> sit before that comma. A name like <code>X tvg-logo="http://..."</code> would smuggle in an attribute. Titles lose double quotes, and attribute values lose quotes and commas.</p>
<h2>3. Text you can't see</h2>
<p>Bidi overrides (U+202E and friends) can make a name display differently from what it says. Zero-width characters and the Unicode "tag" block can hide text altogether. Those are stripped. The exceptions are ZWJ, ZWNJ and the emoji variation selector, because Persian, Indic scripts and emoji need them, and even those are capped at runs of two.</p>
<h2>4. URLs that point into your network</h2>
<p>A public playlist shouldn't make someone's player, or their Home Assistant box, connect to <code>192.168.1.1</code>. Stream URLs are kept only if they are plain public <code>http(s)</code> addresses:</p>
<ul>
<li>no username or password in the URL</li>
<li>no <code>localhost</code>, <code>.local</code>, <code>.lan</code>, <code>.internal</code>, or bare hostnames that only resolve on a LAN</li>
<li>no private, loopback, link-local or CGNAT IPv4 ranges (<code>10/8</code>, <code>127/8</code>, <code>169.254/16</code>, <code>172.16/12</code>, <code>192.168/16</code>, <code>100.64/10</code>), and the IPv6 equivalents</li>
<li>no backslashes, which <code>new URL()</code> and curl read differently, and no <code>https:host</code> without slashes</li>
</ul>
<p>One detail matters more than it looks. The build writes <code>new URL(value).href</code>, the parsed and canonical form, so the string that was checked is exactly the string that ends up in the file.</p>
<h2>5. Check the output, not just the input</h2>
<p>Before each file is written, the build parses the text it just generated and throws if the structure isn't exactly what it meant to produce: the <code>#EXTM3U</code> header, every <code>#EXTINF</code> followed by one URL, no URL without an <code>#EXTINF</code>, attributes in the expected form, no control characters anywhere, and the expected number of entries. If a hostile entry ever gets past the sanitising, the weekly update stops instead of publishing it.</p>
<p>There's also a guard against the source shrinking. If the directory suddenly returns far fewer stations than last week, for example during an outage, the build refuses to replace the old playlists with near-empty ones.</p>
<h2>Using the playlists</h2>
<p>Open any of them straight from GitHub, for example jazz:</p>
<pre><code class="language-bash">mpv https://raw.githubusercontent.com/AlonDrilich/radio-playlists/main/genres/jazz.m3u
</code></pre>
<p><a href="https://github.com/AlonDrilich/radio-playlists/blob/main/index.json"><code>index.json</code></a> lists every playlist with its URL and station count, if you'd rather pick one in code. The same weekly build also writes per-country station lists for Euro Truck Simulator 2 and American Truck Simulator, Farming Simulator, and Garry's Mod.</p>
<p>The station data is public domain (Radio Browser), and the playlists are CC0. The streams themselves belong to their stations. If you'd rather not deal with files, the same directory plays in the browser at <a href="https://72fm.com">72fm.com</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Why your pdf-lib signature lands in the wrong place on scanned PDFs]]></title><description><![CDATA[You render a PDF with pdf.js, let the user click where the signature goes, and draw it with pdf-lib. It works on every test file you have. Then someone…]]></description><link>https://alondrilich.hashnode.dev/why-your-pdf-lib-signature-lands-in-the-wrong-place-on-scanned-pdfs</link><guid isPermaLink="true">https://alondrilich.hashnode.dev/why-your-pdf-lib-signature-lands-in-the-wrong-place-on-scanned-pdfs</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[pdf]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Alon Drilich]]></dc:creator><pubDate>Sat, 14 Nov 2026 08:37:21 GMT</pubDate><content:encoded><![CDATA[<p>You render a PDF with pdf.js, let the user click where the signature goes, and draw it with pdf-lib. It works on every test file you have. Then someone uploads a scan, and the signature shows up sideways, in the wrong corner, or off the page.</p>
<p>The bug is almost never in your click handling. It comes from two coordinate systems that agree on ordinary pages and disagree on the ones that come from scanners.</p>
<h2>Two coordinate systems</h2>
<p><strong>What pdf-lib draws in.</strong> <code>page.drawImage()</code> and friends work in the page's raw <em>user space</em>. The origin is at the bottom-left of the MediaBox, y points up, and the page's <code>/Rotate</code> entry is ignored.</p>
<p><strong>What the user sees.</strong> A viewer, pdf.js included, shows the <em>CropBox</em> (the visible part of the page), turned by <code>/Rotate</code>, with the origin at the top-left and y pointing down.</p>
<p>On a plain portrait page with no crop, the difference is a y-flip:</p>
<pre><code class="language-ts">const userY = page.getHeight() - viewY;
</code></pre>
<p>That's the line most tutorials give you. It's correct until one of these shows up:</p>
<ul>
<li><strong><code>/Rotate 90</code> (or 180, 270).</strong> Scanners often store a landscape page as portrait pixels plus a rotate flag. The viewer turns it. pdf-lib doesn't, so your x and y axes are swapped, and the image is drawn rotated relative to what the user saw.</li>
<li><strong>A CropBox smaller than the MediaBox.</strong> The viewer shows only the crop. Your click coordinates are relative to the crop's corner, but pdf-lib measures from the MediaBox corner, so everything is shifted.</li>
<li><strong>Both at once.</strong> That's common in real scanned contracts.</li>
</ul>
<h2>The mapping</h2>
<p>For a point in view space (points, top-left origin, y down) on a page whose visible box starts at <code>(bx, by)</code> and is <code>bw × bh</code> points:</p>
<table>
<thead>
<tr>
<th><code>/Rotate</code></th>
<th>user-space x</th>
<th>user-space y</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td><code>bx + vx</code></td>
<td><code>by + bh - vy</code></td>
</tr>
<tr>
<td>90</td>
<td><code>bx + vy</code></td>
<td><code>by + vx</code></td>
</tr>
<tr>
<td>180</td>
<td><code>bx + bw - vx</code></td>
<td><code>by + vy</code></td>
</tr>
<tr>
<td>270</td>
<td><code>bx + bw - vy</code></td>
<td><code>by + bh - vx</code></td>
</tr>
</tbody></table>
<p>The displayed width and height also swap for 90 and 270. Drawing an image <em>upright</em> means passing the matching rotation to pdf-lib, and accounting for the fact that pdf-lib rotates around the image's own origin, not its centre. That last part is where most hand-rolled fixes break.</p>
<p>One more edge case: pdf.js treats a <code>/Rotate</code> value that isn't a multiple of 90 as 0. Do the same, or your output won't match what the user saw.</p>
<h2>A small library for it</h2>
<p>I pulled this logic into <a href="https://github.com/AlonDrilich/pdf-view-coords">pdf-view-coords</a>, a small MIT TypeScript helper for pdf-lib. You stay in view space, and it does the translation:</p>
<pre><code class="language-ts">import { PDFDocument } from "pdf-lib";
import { viewport, drawImageInView, rectFromFractions } from "pdf-view-coords";

const pdf = await PDFDocument.load(bytes);
const page = pdf.getPage(0);
const png = await pdf.embedPng(signaturePng);

// The box the user drew, as fractions of the rendered page size.
const vp = viewport(page);
const rect = rectFromFractions(vp, { x: 0.1, y: 0.8, width: 0.3, height: 0.08 });

drawImageInView(page, png, rect); // right place, upright, aspect ratio kept
</code></pre>
<p><code>viewport(page)</code> also gives you <code>toUser(x, y)</code> and <code>toView(x, y)</code> if you'd rather draw yourself. The tests check every rotation, with and without a crop, and confirm that the area pdf-lib actually paints matches the view-space rectangle you asked for.</p>
<p>The <a href="https://alondrilich.github.io/pdf-view-coords/">live demo</a> runs in the browser. Load your own PDF, click on a rotated page, and compare where plain pdf-lib puts the box with where the library puts it.</p>
<p>Install it from GitHub for now:</p>
<pre><code class="language-bash">npm install github:AlonDrilich/pdf-view-coords pdf-lib
</code></pre>
<p>If you've hit a PDF where it still lands wrong, open an issue with the file. Odd real-world PDFs are exactly what it needs to be tested against.</p>
]]></content:encoded></item><item><title><![CDATA[That $2.57 domain renews at $43.77: check the renewal price before you register]]></title><description><![CDATA[You find a good name for a side project. The registrar shows .store for $2.57, you register it, and a year later the renewal email says $43.77. Nothing…]]></description><link>https://alondrilich.hashnode.dev/that-2-57-domain-renews-at-43-77-check-the-renewal-price-before-you-register</link><guid isPermaLink="true">https://alondrilich.hashnode.dev/that-2-57-domain-renews-at-43-77-check-the-renewal-price-before-you-register</guid><category><![CDATA[#domains]]></category><category><![CDATA[webdev]]></category><category><![CDATA[side project]]></category><dc:creator><![CDATA[Alon Drilich]]></dc:creator><pubDate>Sat, 14 Nov 2026 08:35:09 GMT</pubDate><content:encoded><![CDATA[<p>You find a good name for a side project. The registrar shows <code>.store</code> for $2.57, you register it, and a year later the renewal email says $43.77.</p>
<p>Nothing went wrong. Many extensions are priced this way on purpose: a low first year to get the registration, and the real price from year two. The renewal price is published, but checkout pages put the first-year price front and centre.</p>
<h2>How big the gap is</h2>
<p>I keep a weekly snapshot of one registrar's public list prices (Porkbun) for 532 extensions. In the snapshot from 9 November 2026:</p>
<ul>
<li>309 of the 532 extensions renew at 2× their first-year price or more.</li>
<li>188 renew at 5× or more.</li>
</ul>
<p>The first year tells you little about what a name costs to keep. Here are five years of ownership (first year plus four renewals) for some extensions developers often buy:</p>
<table>
<thead>
<tr>
<th>Extension</th>
<th align="right">First year</th>
<th align="right">Renewal</th>
<th align="right">5 years</th>
</tr>
</thead>
<tbody><tr>
<td>.xyz</td>
<td align="right">$2.04</td>
<td align="right">$14.21</td>
<td align="right">$58.88</td>
</tr>
<tr>
<td>.com</td>
<td align="right">$11.81</td>
<td align="right">$11.81</td>
<td align="right">$59.05</td>
</tr>
<tr>
<td>.dev</td>
<td align="right">$8.75</td>
<td align="right">$12.87</td>
<td align="right">$60.23</td>
</tr>
<tr>
<td>.app</td>
<td align="right">$8.75</td>
<td align="right">$14.93</td>
<td align="right">$68.47</td>
</tr>
<tr>
<td>.online</td>
<td align="right">$1.96</td>
<td align="right">$28.84</td>
<td align="right">$117.32</td>
</tr>
<tr>
<td>.shop</td>
<td align="right">$2.06</td>
<td align="right">$31.41</td>
<td align="right">$127.70</td>
</tr>
<tr>
<td>.co</td>
<td align="right">$15.76</td>
<td align="right">$31.20</td>
<td align="right">$140.56</td>
</tr>
<tr>
<td>.store</td>
<td align="right">$2.57</td>
<td align="right">$43.77</td>
<td align="right">$177.65</td>
</tr>
<tr>
<td>.tech</td>
<td align="right">$6.99</td>
<td align="right">$50.98</td>
<td align="right">$210.91</td>
</tr>
<tr>
<td>.io</td>
<td align="right">$28.12</td>
<td align="right">$51.80</td>
<td align="right">$235.32</td>
</tr>
<tr>
<td>.ai</td>
<td align="right">$82.70</td>
<td align="right">$82.70</td>
<td align="right">$413.50</td>
</tr>
</tbody></table>
<p><code>.online</code> costs less than <code>.com</code> in year one and about twice as much over five years. <code>.ai</code> is expensive from day one, but at least it doesn't change.</p>
<p>These are one registrar's list prices on one date, in USD. Other registrars price differently, and promotions change often, so treat the table as a reason to check rather than as a quote.</p>
<h2>Check it yourself in a few lines</h2>
<p>Porkbun publishes its prices at an endpoint that needs no API key. This prints the extensions whose renewal is at least five times the first year:</p>
<pre><code class="language-js">const res = await fetch("https://api.porkbun.com/api/json/v3/pricing/get", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: "{}",
});
const { pricing } = await res.json();

const rows = Object.entries(pricing)
  .map(([tld, p]) =&gt; ({
    tld,
    first: Number(p.registration),
    renewal: Number(p.renewal),
  }))
  .filter((r) =&gt; r.first &gt; 0 &amp;&amp; r.renewal / r.first &gt;= 5)
  .sort((a, b) =&gt; b.renewal / b.first - a.renewal / a.first);

console.table(rows.slice(0, 20));
</code></pre>
<p>It runs in Node 18+ (save it as an <code>.mjs</code> file) or in the browser console.</p>
<h2>Before you register</h2>
<ol>
<li>Look for the renewal price, not just the first-year price. Most registrars list both on their pricing page.</li>
<li>Multiply it out over the years you expect to keep the name.</li>
<li>If you're choosing between two extensions, compare the renewals. The cheaper first year often turns out to be the dearer domain.</li>
</ol>
<p>The full table for all 532 extensions, updated weekly, is on GitHub as CSV under CC BY 4.0: <a href="https://github.com/AlonDrilich/tld-renewal-prices">AlonDrilich/tld-renewal-prices</a> (DOI <a href="https://doi.org/10.5281/zenodo.24082643">10.5281/zenodo.24082643</a>). There's also a sortable version at <a href="https://namesale.store/renewal-prices">namesale.store/renewal-prices</a>.</p>
<p>Not affiliated with Porkbun. Its endpoint is simply the easiest public source to snapshot.</p>
]]></content:encoded></item></channel></rss>