<?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: Andrew Gnasevych</title>
    <description>The latest articles on DEV Community by Andrew Gnasevych (@andrew_gnasevych_eceaa0ef).</description>
    <link>https://dev.to/andrew_gnasevych_eceaa0ef</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%2F4235393%2Fd8607b65-6a23-43c1-96ce-12492eb004fe.jpg</url>
      <title>DEV Community: Andrew Gnasevych</title>
      <link>https://dev.to/andrew_gnasevych_eceaa0ef</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andrew_gnasevych_eceaa0ef"/>
    <language>en</language>
    <item>
      <title>Serve Markdown to AI crawlers from static HTML pages with a few .htaccess lines</title>
      <dc:creator>Andrew Gnasevych</dc:creator>
      <pubDate>Thu, 12 Nov 2026 09:49:07 +0000</pubDate>
      <link>https://dev.to/andrew_gnasevych_eceaa0ef/serve-markdown-to-ai-crawlers-from-static-html-pages-with-a-few-htaccess-lines-5e95</link>
      <guid>https://dev.to/andrew_gnasevych_eceaa0ef/serve-markdown-to-ai-crawlers-from-static-html-pages-with-a-few-htaccess-lines-5e95</guid>
      <description>&lt;p&gt;AI assistants read web pages, and HTML is a noisy format for them: navigation, scripts, styles and cookie banners around a few paragraphs of content. A Markdown version of the same page is cheaper to read and easier to quote. I added one to the static pages of my site and it took a handful of Apache rules. Here is the setup, with the mistakes I made.&lt;/p&gt;

&lt;p&gt;Disclosure: the pages I used this on belong to my app, Teleprompter Offline &amp;amp; Private, and I also build a WordPress plugin that does the same job for WordPress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The idea&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For every page &lt;code&gt;/some-page/index.html&lt;/code&gt;, put a file &lt;code&gt;/some-page/index.md&lt;/code&gt; next to it with the same content. Then serve the Markdown file when the request comes from a known AI crawler or when the client asks for it with an &lt;code&gt;Accept: text/markdown&lt;/code&gt; header. Everyone else, browsers and Googlebot included, keeps getting the HTML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rules&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;AddType&lt;/span&gt; &lt;span class="s2"&gt;"text/markdown; charset=utf-8"&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;md&lt;/span&gt;

&lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="nc"&gt;On&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;REQUEST_URI&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;$
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;DOCUMENT_ROOT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;REQUEST_URI&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;md&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;HTTP_ACCEPT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;markdown&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;NC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="no"&gt;OR&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;HTTP_USER_AGENT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GPTBot&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;ChatGPT&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="no"&gt;OAI&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;SearchBot&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;ClaudeBot&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;Claude&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;PerplexityBot&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;CCBot&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;Bytespider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;NC&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;RewriteRule ^(.*)$ $1index.md [E&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="no"&gt;MDPAGE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="no"&gt;L&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt; &lt;span class="nc"&gt;Vary&lt;/span&gt; &lt;span class="s2"&gt;"Accept, User-Agent"&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="no"&gt;MDPAGE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The file-exists condition.&lt;/strong&gt; Only pages that have an &lt;code&gt;index.md&lt;/code&gt; are touched. Pages without one are served as usual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;Vary&lt;/code&gt; header.&lt;/strong&gt; The same URL now returns two different bodies, so caches must know it depends on &lt;code&gt;Accept&lt;/code&gt; and &lt;code&gt;User-Agent&lt;/code&gt;. Without &lt;code&gt;Vary&lt;/code&gt;, a shared cache can hand the Markdown to a browser or the HTML to a bot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The OR flag.&lt;/strong&gt; The Accept check and the User-Agent check are alternatives, so the first has &lt;code&gt;[NC,OR]&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I also added a discovery link in the page head, so crawlers that read HTML can find the Markdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text/markdown"&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/some-page/index.md"&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Markdown version"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test it with curl&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# browser: HTML&lt;/span&gt;
curl -s -A &lt;span class="s2"&gt;"Mozilla/5.0"&lt;/span&gt; https://example.com/some-page/ &lt;span class="o"&gt;|&lt;/span&gt; head -3
&lt;span class="c"&gt;# bot user agent: Markdown&lt;/span&gt;
curl -s -A &lt;span class="s2"&gt;"GPTBot"&lt;/span&gt; https://example.com/some-page/ &lt;span class="o"&gt;|&lt;/span&gt; head -3
&lt;span class="c"&gt;# content negotiation: Markdown&lt;/span&gt;
curl -s -H &lt;span class="s2"&gt;"Accept: text/markdown"&lt;/span&gt; https://example.com/some-page/ &lt;span class="o"&gt;|&lt;/span&gt; head -3
&lt;span class="c"&gt;# check the headers&lt;/span&gt;
curl -sI -A &lt;span class="s2"&gt;"GPTBot"&lt;/span&gt; https://example.com/some-page/ &lt;span class="o"&gt;|&lt;/span&gt; grep -i &lt;span class="s2"&gt;"content-type\|vary"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mistakes I made&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cache in front of the server.&lt;/strong&gt; After deploying, my bot test still returned HTML. A reverse-proxy cache had the old page for that exact URL and served it before Apache saw the request. The same URL with &lt;code&gt;?x=1&lt;/code&gt; returned Markdown. After clearing the cache everything worked. Test with a cache-busting query string, and purge after changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating user agents as identity.&lt;/strong&gt; A User-Agent is a claim anyone can make. That is fine for serving public content, but it means this is not an access control. A WordPress plugin can go further and verify the bot's IP; plain Apache rules cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different content.&lt;/strong&gt; The Markdown must say what the page says. Serving different content to bots than to people is cloaking, and search engines treat it as spam. I generate both files from the same source so they cannot drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Does it work?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I cannot tell yet how much AI assistants use it. What I can verify is the delivery: browsers get HTML, bots get Markdown, caches vary correctly. I also list the pages in the sitemap and keep the usual structured data in the HTML, because normal search still matters.&lt;/p&gt;

&lt;p&gt;Where I use it: the pages of my teleprompter app. The landing page is &lt;a href="https://www.a-devgroup.com/teleprompter/" rel="noopener noreferrer"&gt;https://www.a-devgroup.com/teleprompter/&lt;/a&gt; and its Markdown version is at &lt;a href="https://www.a-devgroup.com/teleprompter/index.md" rel="noopener noreferrer"&gt;https://www.a-devgroup.com/teleprompter/index.md&lt;/a&gt;. The app itself, a free offline teleprompter for iPhone with voice-follow scrolling, is on the App Store: &lt;a href="https://apps.apple.com/ua/app/teleprompter-offline-private/id6808964093?pt=128162591&amp;amp;ct=devto_mdbots&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/ua/app/teleprompter-offline-private/id6808964093?pt=128162591&amp;amp;ct=devto_mdbots&amp;amp;mt=8&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you run WordPress instead of static files, the plugin I build does the negotiation for posts and pages: &lt;a href="https://www.a-devgroup.com/adg-ai-markdown/" rel="noopener noreferrer"&gt;https://www.a-devgroup.com/adg-ai-markdown/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Question for you: do you serve anything special to AI crawlers, or do you leave them the plain HTML?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>ai</category>
      <category>apache</category>
    </item>
    <item>
      <title>Building a voice-follow teleprompter on iPhone without sending audio anywhere</title>
      <dc:creator>Andrew Gnasevych</dc:creator>
      <pubDate>Wed, 11 Nov 2026 16:14:04 +0000</pubDate>
      <link>https://dev.to/andrew_gnasevych_eceaa0ef/building-a-voice-follow-teleprompter-on-iphone-without-sending-audio-anywhere-2gj9</link>
      <guid>https://dev.to/andrew_gnasevych_eceaa0ef/building-a-voice-follow-teleprompter-on-iphone-without-sending-audio-anywhere-2gj9</guid>
      <description>&lt;p&gt;A teleprompter that scrolls as you speak sounds simple: recognize speech, find the words in the script, move the text. I built that for iOS and the recognizing part was the easy part. The hard parts were making the scroll feel calm and not trusting the recognizer too much. Here is what I learned. Disclosure: this is my app, Teleprompter Offline &amp;amp; Private.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Keep recognition on the device
&lt;/h2&gt;

&lt;p&gt;I use Apple's speech recognition and require on-device mode. If the language has no on-device model, the app says so instead of silently falling back to a server. That choice makes the app work in Airplane Mode and means your voice never leaves the phone. The cost: some languages need the offline speech pack, downloaded once in iOS settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Match words, not text
&lt;/h2&gt;

&lt;p&gt;The recognizer returns a stream of partial transcripts. I align them against the script with a fuzzy matcher that searches a lookahead window of 40 to 70 words past the current position. Fuzzy, because people skip words, say "gonna" for "going to", and the recognizer mishears.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The bug that looked like a smoothing problem
&lt;/h2&gt;

&lt;p&gt;The scroll jerked. I spent days tuning easing curves. Then I found the cause was the matcher, not the animation. One stray match on a breath or a noise could jump the cursor across the whole lookahead window, 40 words at once, and the text lurched.&lt;/p&gt;

&lt;p&gt;The fix was boring: cap how far the cursor can advance per update (12 words). If the speaker really did jump ahead, the next updates catch up. A jump that fast is almost always noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Start every animation from zero velocity
&lt;/h2&gt;

&lt;p&gt;My first smoothing carried velocity from one animation segment to the next. It looked elegant and sometimes overshot the target wildly after a noisy update. Now every segment starts at zero velocity and eases toward the target with a critically damped curve. From zero velocity a critically damped ease approaches the target monotonically and never crosses it, so the text cannot overshoot.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Plan for the recognizer going quiet
&lt;/h2&gt;

&lt;p&gt;During video recording the recognizer can lose the audio stream. If the stream stops for more than two seconds, the app falls back to a steady words-per-minute scroll, keeps recording, and tells the user. A teleprompter that stops dead on camera is worse than one that scrolls at the wrong speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Rewind is rarer than you think
&lt;/h2&gt;

&lt;p&gt;Going back needs a stricter rule: the cursor must have been standing still for a couple of updates before I allow a relocation, and a forward jump must match at least four words. Otherwise repeated phrases in a script pull the cursor to the wrong place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself at the start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put the limits in the matcher, not in the animation.&lt;/li&gt;
&lt;li&gt;Prefer a rule you can prove (monotonic approach to target) over a clever one.&lt;/li&gt;
&lt;li&gt;Test with a script that repeats phrases. Real scripts do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app is free to download, works offline and needs no account: &lt;a href="https://apps.apple.com/ua/app/teleprompter-offline-private/id6808964093?pt=128162591&amp;amp;ct=devto_voicefollow&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/ua/app/teleprompter-offline-private/id6808964093?pt=128162591&amp;amp;ct=devto_voicefollow&amp;amp;mt=8&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Comparison with other teleprompter apps: &lt;a href="https://www.a-devgroup.com/teleprompter/best-teleprompter-apps/" rel="noopener noreferrer"&gt;https://www.a-devgroup.com/teleprompter/best-teleprompter-apps/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>swiftui</category>
      <category>speech</category>
    </item>
    <item>
      <title>45% of my app's first downloads came from ChatGPT, not from my website</title>
      <dc:creator>Andrew Gnasevych</dc:creator>
      <pubDate>Mon, 09 Nov 2026 15:18:24 +0000</pubDate>
      <link>https://dev.to/andrew_gnasevych_eceaa0ef/45-of-my-apps-first-downloads-came-from-chatgpt-not-from-my-website-3ag6</link>
      <guid>https://dev.to/andrew_gnasevych_eceaa0ef/45-of-my-apps-first-downloads-came-from-chatgpt-not-from-my-website-3ag6</guid>
      <description>&lt;p&gt;I build Teleprompter Offline &amp;amp; Private, an iPhone teleprompter with voice-follow scrolling that works offline, with no account and no tracking. I'm a solo developer and it earns about $100+ a month right now. Not life-changing, but real, and it is growing slowly.&lt;/p&gt;

&lt;p&gt;What surprised me in App Store Connect: the App Referrer report shows ChatGPT as the source of 45% of the app's first downloads. Not Google. Not my landing page. ChatGPT seems to quote App Store listings, Reddit threads and comparison pages, rather than my own site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I changed because of it&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rewrote the App Store title, subtitle and description to match how people phrase the question ("offline teleprompter", "teleprompter with voice follow", "no account").&lt;/li&gt;
&lt;li&gt;Localized the listing into 12 languages.&lt;/li&gt;
&lt;li&gt;Listed the app on comparison sites (AlternativeTo, SaaSHub).&lt;/li&gt;
&lt;li&gt;Published plain comparison pages with the facts from public App Store listings, and made sure AI crawlers can read them: each page has a Markdown version they get by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check your own app&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;App Store Connect, Analytics, Sources, App Referrer. Look at the share of web referrals that are AI assistants. The numbers are small, so do not over-read them, but the direction was clear for me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I take from it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The listing is the page that gets quoted, so it deserves more work than the landing page. A question like "which teleprompter works offline without an account" gets answered from whatever text states that plainly.&lt;/p&gt;

&lt;p&gt;Question for you: where do your first users actually come from? Have you checked the referrer data lately?&lt;/p&gt;

&lt;p&gt;App: &lt;a href="https://apps.apple.com/ua/app/teleprompter-offline-private/id6808964093?pt=128162591&amp;amp;ct=devto_chatgpt45&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/ua/app/teleprompter-offline-private/id6808964093?pt=128162591&amp;amp;ct=devto_chatgpt45&amp;amp;mt=8&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Side-by-side comparison of six iPhone teleprompter apps (facts from public App Store listings): &lt;a href="https://www.a-devgroup.com/teleprompter/best-teleprompter-apps/" rel="noopener noreferrer"&gt;https://www.a-devgroup.com/teleprompter/best-teleprompter-apps/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ios</category>
      <category>indiehackers</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
