<?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: Yuriy Stasyuk</title>
    <description>The latest articles on DEV Community by Yuriy Stasyuk (@ystasyuk).</description>
    <link>https://dev.to/ystasyuk</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%2F4207478%2F3ccba585-9160-4090-ab68-de3b7488fec4.png</url>
      <title>DEV Community: Yuriy Stasyuk</title>
      <link>https://dev.to/ystasyuk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ystasyuk"/>
    <language>en</language>
    <item>
      <title>Three CSVs In, 1,272 Food Pages Out: PregNut's Boring Static Build</title>
      <dc:creator>Yuriy Stasyuk</dc:creator>
      <pubDate>Tue, 27 Oct 2026 17:29:45 +0000</pubDate>
      <link>https://dev.to/ystasyuk/three-csvs-in-1272-food-pages-out-pregnuts-boring-static-build-2il4</link>
      <guid>https://dev.to/ystasyuk/three-csvs-in-1272-food-pages-out-pregnuts-boring-static-build-2il4</guid>
      <description>&lt;p&gt;PregNut is a free pregnancy food lookup I've been building since 2018. You search a food and see how 100 grams of it stacks up against the daily amounts pregnancy asks for. It has 1,272 foods and 13 nutrients, and each food gets its own page.&lt;/p&gt;

&lt;p&gt;There's no database server behind it. It's an Eleventy site that builds from three CSV files and deploys to Cloudflare Pages. I like boring stacks for side projects, and this one has stayed easy to work on through three pregnancies, so here's how it fits together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F89j9xxmlx22p141wb8uh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F89j9xxmlx22p141wb8uh.png" alt="The home page. Everything on it is plain HTML from the build." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The inputs
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;database/&lt;/code&gt; folder has three files: &lt;code&gt;FoodList.csv&lt;/code&gt;, &lt;code&gt;NutrientList.csv&lt;/code&gt; and &lt;code&gt;daily requirements.csv&lt;/code&gt;. The food list has the USDA NDB number, name, food group and a warning field. The nutrient list is long and skinny, one row per food per nutrient with a value and a unit. The requirements file has the daily reference amount for each nutrient as a label like "1000 mg" or "0.3 g."&lt;/p&gt;

&lt;p&gt;All of it comes from a fixed snapshot of the USDA's SR Legacy data. USDA's final Standard Reference release was in 2018, so the numbers are frozen. For a reference site I count that as a feature. A value doesn't change under you between builds. The cost is that newer data and current product labels can differ, and the About page says that.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny build script instead of a dependency
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scripts/build-fooddata.js&lt;/code&gt; reads the CSVs and writes one JSON file the site uses everywhere. I wrote a minimal CSV parser for it, small enough to read in one sitting. It handles quoted fields and doubled quotes, and it strips a byte order mark if there is one. That BOM will quietly rename your first column header if you don't strip it.&lt;/p&gt;

&lt;p&gt;The requirements file has a reference list under the main table, so the parser stops when it hits the &lt;code&gt;SourceId&lt;/code&gt; row. Reference amounts get parsed with a small regex into a number and a unit.&lt;/p&gt;

&lt;p&gt;One choice I'd make again. If the CSVs aren't present but the JSON already exists, the script prints a warning and keeps the existing JSON instead of failing. That lets a deploy work from the committed JSON alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  One page per food with pagination
&lt;/h2&gt;

&lt;p&gt;Eleventy's pagination does the heavy lifting. The food page template paginates over a data file with &lt;code&gt;size: 1&lt;/code&gt;, so every item becomes its own page. The permalink is &lt;code&gt;/food/{{ food.slug }}/&lt;/code&gt;, and the slug starts with the USDA number: &lt;code&gt;/food/15151-shrimp-cooked/&lt;/code&gt;, &lt;code&gt;/food/15152-shrimp-canned/&lt;/code&gt;. Food names repeat a lot in USDA data. The number keeps every URL unique without me thinking about it.&lt;/p&gt;

&lt;p&gt;The data file is also where per-page decisions live. Title overrides for pages that get search traffic, related blog posts, and special cases. The human milk record, for example, stays in the dataset but is labeled as a historical data record only and left out of the food listings.&lt;/p&gt;

&lt;p&gt;Units needed a helper. DHA is stored in grams and its benchmark is 0.3 g, while other nutrients are in mg or mcg. There's a small conversion function so comparisons always happen in the same unit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb805fuyo7allbnbsvwus.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb805fuyo7allbnbsvwus.png" alt="A generated food page, phone-sized." width="780" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each page shows two columns: per 100 g, and a serving based on FDA reference amounts. Cooked shrimp shows 3 oz (85 g) next to 100 g. The 100 g column is for comparing foods. The serving column is closer to what's on a plate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data can't do
&lt;/h2&gt;

&lt;p&gt;The dataset has no iodine values. Rather than fake it, the site has an iodine guide and says the data is missing. Folate is shown as DFE, which isn't the same thing as the folic acid number on a prenatal label, so that gets its own guide too.&lt;/p&gt;

&lt;p&gt;PregNut is an educational reference and isn't medical advice. Every page says so in the footer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploys and one contact form
&lt;/h2&gt;

&lt;p&gt;A GitHub Actions workflow runs &lt;code&gt;npm run build&lt;/code&gt; and deploys with wrangler. Pushes to master go to pregnut.com. Pull requests get a preview deploy, and the link gets posted on the PR. Previews are only built for branches in the repo, and the actions are pinned to commit SHAs. The &lt;code&gt;*.pages.dev&lt;/code&gt; hosts send &lt;code&gt;X-Robots-Tag: noindex&lt;/code&gt; from &lt;code&gt;_headers&lt;/code&gt;, so Google only indexes the real domain.&lt;/p&gt;

&lt;p&gt;The one dynamic piece is &lt;code&gt;/api/contact&lt;/code&gt;, a Cloudflare Pages Function. I wanted a free email relay and picked FormSubmit, which turned out to refuse requests coming from Workers. So the function does validation, a honeypot, a timing check and rate limiting, and only then hands the browser the relay endpoint to send to. Bots that fail the checks never see it.&lt;/p&gt;

&lt;p&gt;If you want to see the output, open any food page on &lt;a href="https://pregnut.com" rel="noopener noreferrer"&gt;pregnut.com&lt;/a&gt; and view source. The numbers and the notes are right there in the HTML.&lt;/p&gt;

</description>
      <category>eleventy</category>
      <category>javascript</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Things That Bit Me Wrapping the YouTube IFrame API for a Kids' App</title>
      <dc:creator>Yuriy Stasyuk</dc:creator>
      <pubDate>Tue, 27 Oct 2026 17:29:04 +0000</pubDate>
      <link>https://dev.to/ystasyuk/things-that-bit-me-wrapping-the-youtube-iframe-api-for-a-kids-app-2n91</link>
      <guid>https://dev.to/ystasyuk/things-that-bit-me-wrapping-the-youtube-iframe-api-for-a-kids-app-2n91</guid>
      <description>&lt;p&gt;I built WiseTube because the YouTube sidebar kept leading my kids somewhere I didn't want them. It's a free library of hand-picked learning videos that play inside my own player, with YouTube's suggestions, Shorts and autoplay-to-the-next-thing taken out.&lt;/p&gt;

&lt;p&gt;The covering-up part gets most of the attention. This post is about the smaller stuff that cost me evenings. If you're wrapping the IFrame API for anything, some of these will probably find you too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5uhlmhnfrxxiw2d6ly1w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5uhlmhnfrxxiw2d6ly1w.png" alt="The phone view, where a kid actually taps all this." width="780" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The API script can hang, so give it a deadline
&lt;/h2&gt;

&lt;p&gt;You load &lt;code&gt;https://www.youtube.com/iframe_api&lt;/code&gt; and wait for &lt;code&gt;onYouTubeIframeAPIReady&lt;/code&gt;. Most of the time it's fine. On a flaky connection it can sit there forever, and a kid staring at a spinner will go find another app.&lt;/p&gt;

&lt;p&gt;I wrap the load in a promise, cache it so it only happens once, and clear the cache if the script errors so the next try starts clean. The player also starts a 15 second timer. If &lt;code&gt;onReady&lt;/code&gt; hasn't fired by then, it shows "The film didn't load" with a retry button.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. YouTube resets your playback speed
&lt;/h2&gt;

&lt;p&gt;I save speed and volume to localStorage so a kid who likes 1.25x doesn't have to set it every time. I set the rate in &lt;code&gt;onReady&lt;/code&gt;, and that isn't enough, because YouTube can reset the rate when a film starts playing. The fix is boring. On every PLAYING state change, read &lt;code&gt;getPlaybackRate()&lt;/code&gt; and set it again if it doesn't match the saved value.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Autoplay for the chosen film still gets blocked sometimes
&lt;/h2&gt;

&lt;p&gt;I want the film a kid picked to start right away, so the player is created with &lt;code&gt;autoplay: 1&lt;/code&gt;. Browsers don't always allow it. There's no clean event for "autoplay was blocked," so after &lt;code&gt;onReady&lt;/code&gt; I wait about 1.2 seconds and check the state. If it's still UNSTARTED or CUED, I show my own big play button.&lt;/p&gt;

&lt;p&gt;That's the only autoplay in the app. When a film ends, nothing else starts on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Don't let callbacks restart the player
&lt;/h2&gt;

&lt;p&gt;The Player component takes &lt;code&gt;onTick&lt;/code&gt;, &lt;code&gt;onEnded&lt;/code&gt; and &lt;code&gt;onError&lt;/code&gt; from the page. If those are effect dependencies, any parent re-render can tear down the iframe and build a new one mid-film. I use React's &lt;code&gt;useEffectEvent&lt;/code&gt; for them, so the effect that mounts the player only depends on the video id, the retry count and the title.&lt;/p&gt;

&lt;p&gt;Progress gets read every 250ms for the scrub bar but only reported up every 5 seconds. Saving progress four times a second felt silly.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Validate pasted links before you trust them
&lt;/h2&gt;

&lt;p&gt;Parents can add a film by pasting a YouTube link. People paste everything: &lt;code&gt;youtu.be&lt;/code&gt; links, &lt;code&gt;/embed/&lt;/code&gt; links, &lt;code&gt;watch?v=&lt;/code&gt; links, Shorts. I parse the URL, accept the normal shapes, and reject anything under &lt;code&gt;/shorts/&lt;/code&gt; or &lt;code&gt;/live/&lt;/code&gt; with "Paste a normal YouTube link. Shorts stay out of WiseTube."&lt;/p&gt;

&lt;p&gt;Then I call YouTube's oEmbed endpoint for the link. If it doesn't come back with a title and channel name, the film doesn't get added. That catches typos, private videos and videos that can't be embedded, before a kid ever taps them.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A PIN on a device with no server
&lt;/h2&gt;

&lt;p&gt;There are no accounts. Profiles, progress and settings sit in localStorage. The four-digit parent PIN is stored as a SHA-256 hash with a random 16-byte salt, using &lt;code&gt;crypto.subtle&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I'll be straight about what that buys. A four-digit PIN on a device the kid holds is a speed bump. It keeps a six-year-old out of the settings. A teenager with dev tools could get past it. For a free app with no backend, I'm fine with that, and I'd rather say it than pretend it's a vault.&lt;/p&gt;

&lt;p&gt;Because everything is on one device, the parent settings have an export button that downloads a &lt;code&gt;wisetube-library.json&lt;/code&gt; with profiles, PIN, added and hidden films, and progress. Import shows how many learners are in the file and asks before replacing anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Febc6qncawrkvx6xb9516.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Febc6qncawrkvx6xb9516.png" alt="The little-kids look. The PIN keeps them in this lane." width="780" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Vite preview lies about routing
&lt;/h2&gt;

&lt;p&gt;The site is a static build on Cloudflare Pages. Public pages are prerendered for search. Private app routes like &lt;code&gt;/watch/:youtubeId&lt;/code&gt; and &lt;code&gt;/parent&lt;/code&gt; are rewritten to a noindex &lt;code&gt;app.html&lt;/code&gt; through &lt;code&gt;public/_redirects&lt;/code&gt;, so a refresh doesn't 404.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vite preview&lt;/code&gt; ignores &lt;code&gt;_redirects&lt;/code&gt; and Pages' 404 behavior, so a route can look fine locally and behave differently once deployed. I test routing with &lt;code&gt;npx wrangler pages dev dist&lt;/code&gt;, which behaves like the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Tests for the catalog, too
&lt;/h2&gt;

&lt;p&gt;The video list is data, and data drifts. &lt;code&gt;npm run check&lt;/code&gt; runs TypeScript, oxlint and vitest, and the catalog tests fail if an import sneaks in a Short, a duplicate, an unknown topic, or a channel I already ruled out. That has caught more mistakes than any of my UI tests.&lt;/p&gt;

&lt;p&gt;You can try the result at &lt;a href="https://wisetube.yuriystasyuk.com" rel="noopener noreferrer"&gt;wisetube.yuriystasyuk.com&lt;/a&gt;. If you've wrapped the IFrame API and found a cleaner way around any of these, I'd love to hear it.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>youtube</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
