<?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: René</title>
    <description>The latest articles on DEV Community by René (@webgaudi).</description>
    <link>https://dev.to/webgaudi</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%2F3977825%2F778cd09b-bad2-40cb-a968-4037f2b08495.png</url>
      <title>DEV Community: René</title>
      <link>https://dev.to/webgaudi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webgaudi"/>
    <language>en</language>
    <item>
      <title>A .docx file is just a ZIP. Word documents in the browser with zero dependencies</title>
      <dc:creator>René</dc:creator>
      <pubDate>Sun, 30 Aug 2026 08:33:00 +0000</pubDate>
      <link>https://dev.to/webgaudi/a-docx-file-is-just-a-zip-word-documents-in-the-browser-with-zero-dependencies-5ab</link>
      <guid>https://dev.to/webgaudi/a-docx-file-is-just-a-zip-word-documents-in-the-browser-with-zero-dependencies-5ab</guid>
      <description>&lt;p&gt;I needed a "Download as Word" button for a free legal-text generator I built. The obvious route is a library like &lt;code&gt;docx&lt;/code&gt; (~500 KB) or server-side generation. But the documents I produce are dead simple. A heading, some bold sub-headings, lines of text. Shipping half a megabyte of dependency for that felt wrong.&lt;/p&gt;

&lt;p&gt;So I looked into what a .docx actually is. It turns out to be a ZIP archive containing a handful of XML files. And if you accept two constraints, you can build one in about 50 lines of vanilla TypeScript.&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%2F81p9h41nj2imlybkyb23.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%2F81p9h41nj2imlybkyb23.png" alt=" " width="514" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two constraints that make it easy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Store, don't compress.&lt;/strong&gt; The ZIP format supports an uncompressed "store" mode (method 0). Your text gets a bit bigger, but you skip implementing DEFLATE entirely. For a two-page document nobody cares about 8 KB vs 3 KB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Minimal OOXML.&lt;/strong&gt; Word is surprisingly forgiving. A valid .docx needs exactly three files.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[Content_Types].xml&lt;/code&gt; — declares what's in the package&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_rels/.rels&lt;/code&gt; — points to the main document part&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;word/document.xml&lt;/code&gt; — your actual content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No styles.xml, no fontTable.xml, no theme. Word, LibreOffice and Google Docs all open it fine and apply their defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ZIP writer
&lt;/h2&gt;

&lt;p&gt;The only non-trivial part of the ZIP spec is the CRC-32 checksum. That's a well-known 8-line bit-twiddling loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;crc32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&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;crc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&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;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;data&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;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;k&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;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&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="mh"&gt;0xedb88320&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;1&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest is writing three record types with the right magic numbers. A local file header (&lt;code&gt;0x04034b50&lt;/code&gt;) followed by the raw bytes comes first for each file, then one central directory entry (&lt;code&gt;0x02014b50&lt;/code&gt;) per file, then a single end-of-central-directory record (&lt;code&gt;0x06054b50&lt;/code&gt;). With store mode, compressed size equals uncompressed size, and all the "advanced" fields stay zero.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makeZip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&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;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&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;u16&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;255&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;u32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nx"&gt;central&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&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;nameB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&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;common&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;crc32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nameB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&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;central&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x02014b50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                 &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                 &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;nameB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x04034b50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
              &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;nameB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;central&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x06054b50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;central&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&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;That's the entire "ZIP library".&lt;/p&gt;

&lt;h2&gt;
  
  
  The Word part
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;word/document.xml&lt;/code&gt; is WordprocessingML. A paragraph is &lt;code&gt;&amp;lt;w:p&amp;gt;&lt;/code&gt;, a run is &lt;code&gt;&amp;lt;w:r&amp;gt;&lt;/code&gt;, text is &lt;code&gt;&amp;lt;w:t&amp;gt;&lt;/code&gt;. Three gotchas cost me the most time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Half-points.&lt;/strong&gt; Font sizes in &lt;code&gt;&amp;lt;w:sz w:val="36"/&amp;gt;&lt;/code&gt; are half-points, so 36 means 18 pt. Everyone hits this once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;xml:space="preserve"&lt;/code&gt;.&lt;/strong&gt; Without it, Word trims leading and trailing whitespace in your &lt;code&gt;&amp;lt;w:t&amp;gt;&lt;/code&gt; elements, which silently eats spaces around line breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape everything.&lt;/strong&gt; You're concatenating XML, so &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt; and quotes in user data must be escaped or the file won't open at all. Word's error message ("unreadable content") tells you nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Line breaks within a paragraph are &lt;code&gt;&amp;lt;w:br/&amp;gt;&lt;/code&gt; between &lt;code&gt;&amp;lt;w:t&amp;gt;&lt;/code&gt; elements, no need for separate paragraphs per line.&lt;/p&gt;

&lt;p&gt;Finally, wrap the bytes in a Blob with the right MIME type and trigger the download.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/vnd.openxmlformats-officedocument.wordprocessingml.document&lt;/span&gt;&lt;span class="dl"&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document.docx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verifying without opening Word
&lt;/h2&gt;

&lt;p&gt;Two command-line tricks saved me a lot of clicking. &lt;code&gt;unzip -l file.docx&lt;/code&gt; confirms the archive structure is valid, and on macOS &lt;code&gt;textutil -convert txt file.docx&lt;/code&gt; round-trips the document through Apple's own OOXML parser, so if that prints your text, Word will open it. &lt;code&gt;xmllint&lt;/code&gt; catches malformed XML before you even zip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it runs in production
&lt;/h2&gt;

&lt;p&gt;The whole thing powers the Word export of a free legal-notice generator for Austrian websites I built. It pulls company data from public registers and outputs the mandatory site imprint, and you can try it at &lt;a href="https://webgaudi.at/impressum-generator-oesterreich/" rel="noopener noreferrer"&gt;https://webgaudi.at/impressum-generator-oesterreich/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The export feature adds about 1.5 KB minified to the bundle. The &lt;code&gt;docx&lt;/code&gt; npm package would have been ~300x that, for documents this simple.&lt;/p&gt;

&lt;p&gt;Would I recommend this for reports with tables, images and headers? No, take the library. But for "text with some bold lines as a .docx download", the format is far less scary than it looks. It's just a ZIP.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Firmenbuch, GISA, VIES: die APIs hinter unserem Impressum-Generator für Österreich</title>
      <dc:creator>René</dc:creator>
      <pubDate>Thu, 20 Aug 2026 10:57:11 +0000</pubDate>
      <link>https://dev.to/webgaudi/firmenbuch-gisa-vies-die-apis-hinter-unserem-impressum-generator-fur-osterreich-1p0l</link>
      <guid>https://dev.to/webgaudi/firmenbuch-gisa-vies-die-apis-hinter-unserem-impressum-generator-fur-osterreich-1p0l</guid>
      <description>&lt;p&gt;Die meisten Impressum-Generatoren sind im Kern ein Formular mit Textbausteinen: Du tippst alles selbst ein, der Generator klebt es zusammen. Ob die Angaben stimmen, ist dein Problem. Dabei liegen die relevanten Daten für österreichische Unternehmen längst in öffentlichen Registern, und die haben APIs.&lt;/p&gt;

&lt;p&gt;Genau darauf haben wir unseren &lt;a href="https://webgaudi.at/impressum-generator-oesterreich/" rel="noopener noreferrer"&gt;Impressum-Generator für Österreich&lt;/a&gt; gebaut. Du suchst dein Unternehmen, der Generator holt die Pflichtangaben aus den offiziellen Quellen und sagt dir, wenn etwas nicht zusammenpasst. In diesem Artikel zeige ich, welche APIs dahinterstecken und was wir aus den Antworten ableiten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register 1: das Firmenbuch
&lt;/h2&gt;

&lt;p&gt;Für Kapitalgesellschaften und eingetragene Unternehmen ist das Firmenbuch die zentrale Quelle. Die Justiz bietet dafür über JustizOnline eine offizielle Abfrage-Schnittstelle an (eine SOAP-API mit API-Schlüssel). Wir nutzen zwei Operationen: die Firmensuche nach Wortlaut oder Firmenbuchnummer und den Auszug in der Stufe „Kurzinformation" zum aktuellen Stichtag.&lt;/p&gt;

&lt;p&gt;Aus dem Auszug kommt alles, was das Impressum an Registerdaten braucht: exakter Firmenwortlaut, Rechtsform, Sitz und Geschäftsanschrift. Der Punkt dabei ist nicht Bequemlichkeit, sondern Korrektheit. Ins Impressum gehört der Wortlaut, wie er im Register steht, nicht die Schreibweise, die man im Alltag verwendet. Ein „e.U." oder ein ausgeschriebenes „Gesellschaft m.b.H." macht da einen Unterschied, und beim Selbst-Eintippen geht genau so etwas verloren.&lt;/p&gt;

&lt;p&gt;Ein Detail aus der Praxis: Die Auszugsdaten enthalten historische Einträge mit einem Aufrecht-Flag. Wir nehmen konsequent den aufrechten Eintrag, sonst landet nach einer Sitzverlegung die alte Adresse im Impressum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register 2: GISA, das Gewerberegister
&lt;/h2&gt;

&lt;p&gt;Gewerbetreibende brauchen im Impressum zusätzlich den Gewerbewortlaut und die zuständige Gewerbebehörde. Beides liefert das GISA (Gewerbeinformationssystem Austria) über eine öffentliche XML-Schnittstelle: Suche nach natürlichen Personen, nach Unternehmen oder direkt per GISA-Zahl.&lt;/p&gt;

&lt;p&gt;Zwei Dinge daran finde ich richtig gut gelöst:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Die Verknüpfung zum Firmenbuch.&lt;/strong&gt; Jeder GISA-Eintrag einer eingetragenen Gesellschaft trägt die Firmenbuchnummer. Wählst du im Generator also deine GmbH aus dem Firmenbuch, suchen wir mit der Firmenbuchnummer automatisch die zugehörigen Gewerbeberechtigungen. Du musst deine GISA-Zahl gar nicht kennen (die kennt erfahrungsgemäß fast niemand auswendig).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Die phonetische Suche als Fallback.&lt;/strong&gt; Findet die exakte Namenssuche nichts, wiederholen wir die Abfrage phonetisch. Das fängt Tippfehler und Schreibvarianten ab, ohne dass die exakte Suche darunter leidet.&lt;/p&gt;

&lt;p&gt;Außerdem verrät der GISA-Eintrag, ob eine Berechtigung ruhend gemeldet ist. Auch so ein Fall, den ein reines Textbaustein-Formular nie bemerken würde.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register 3: VIES für die UID-Nummer
&lt;/h2&gt;

&lt;p&gt;Bei der UID-Nummer prüfen wir zweistufig. Zuerst rechnen wir die Prüfziffer der &lt;code&gt;ATU&lt;/code&gt;-Nummer direkt im Browser nach, ganz ohne API-Call. Ein Zahlendreher fliegt so sofort auf, noch während du tippst.&lt;/p&gt;

&lt;p&gt;Ist die Prüfziffer plausibel, fragen wir das VIES-System der EU-Kommission ab (die REST-Schnittstelle zur Mehrwertsteuer-Validierung). VIES bestätigt aber nicht nur, dass die UID existiert. Für österreichische Nummern liefert es auch den hinterlegten Namen und die Adresse zurück, und damit kommen wir zum spannendsten Teil.&lt;/p&gt;

&lt;h2&gt;
  
  
  Widersprüche erkennen statt still zusammenkleben
&lt;/h2&gt;

&lt;p&gt;Sobald Daten aus mehreren Quellen kommen, kann sich widersprechen, was eigentlich zusammengehören sollte. Genau das gleichen wir ab: Die Adresse aus dem UID-Register wird normalisiert (Groß/Klein, &lt;code&gt;ß&lt;/code&gt;/&lt;code&gt;ss&lt;/code&gt;, Satzzeichen, Abkürzungspunkte) und mit der Anschrift im Formular verglichen. Weicht sie ab, zeigt der Generator einen Hinweis. Meist steckt dahinter eine Übersiedlung, die zwar dem Firmenbuch, aber nie dem Finanzamt gemeldet wurde, oder umgekehrt. Für den Betroffenen ist das Gold wert, weil so eine Inkonsistenz sonst erst auffällt, wenn es unangenehm wird.&lt;/p&gt;

&lt;p&gt;Dazu kommen die Ableitungen aus statischen Datentabellen, die keine API brauchen: Aus der Postleitzahl leiten wir Ort, Bundesland und das zuständige Gericht ab. Klingt trivial, ist es aber nicht überall. Klosterneuburg liegt in Niederösterreich, gehört gerichtlich aber zu Wien. Solche Sonderfälle haben wir einzeln recherchiert und hinterlegt, weil eine simple PLZ-Bereichs-Logik hier falsche Impressen erzeugen würde.&lt;/p&gt;

&lt;p&gt;Noch ein Wort zur Architektur: Alle Register-Abfragen laufen über unsere eigenen Server-Endpoints, nicht direkt aus dem Browser. Die API-Schlüssel bleiben so am Server, und wir können Rate-Limits durchsetzen, damit die Behörden-APIs nicht von unserer Seite aus geflutet werden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Probier es aus
&lt;/h2&gt;

&lt;p&gt;Der &lt;a href="https://webgaudi.at/impressum-generator-oesterreich/" rel="noopener noreferrer"&gt;Generator ist kostenlos und ohne Anmeldung nutzbar&lt;/a&gt;, vom Einzelunternehmer bis zum Verein. Unternehmen suchen, Angaben prüfen, Impressum kopieren oder als Word-Datei herunterladen. Wenn du eine österreichische Website betreibst, wirf einfach mal deinen Firmennamen rein. Und wenn der Generator bei dir eine Abweichung findet, freu dich: Das ist genau der Moment, für den wir ihn gebaut haben.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ich bin René und betreibe &lt;a href="https://webgaudi.at/" rel="noopener noreferrer"&gt;webgaudi&lt;/a&gt;, eine Webdesign-Agentur in Wien. Wir bauen individuell programmierte Websites für Selbstständige und KMU, ohne Baukasten, zum Fixpreis ab 1.500 €.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>austria</category>
      <category>legal</category>
    </item>
    <item>
      <title>Your CI stack ships four YAML parsers and half of them are still on the 2009 spec</title>
      <dc:creator>René</dc:creator>
      <pubDate>Thu, 20 Aug 2026 10:37:09 +0000</pubDate>
      <link>https://dev.to/webgaudi/your-ci-stack-ships-four-yaml-parsers-and-half-of-them-are-still-on-the-2009-spec-15hf</link>
      <guid>https://dev.to/webgaudi/your-ci-stack-ships-four-yaml-parsers-and-half-of-them-are-still-on-the-2009-spec-15hf</guid>
      <description>&lt;p&gt;Everyone knows the Norway problem. &lt;code&gt;country: NO&lt;/code&gt; becomes &lt;code&gt;false&lt;/code&gt;, ha ha, quote your strings.&lt;/p&gt;

&lt;p&gt;What gets skipped is the part that actually costs you an afternoon: &lt;strong&gt;YAML 1.2 fixed this in 2009, and a large share of the parsers in your pipeline never moved.&lt;/strong&gt; So the interesting failure is not one parser being wrong. It is two parsers in the same pipeline being right in different ways, on the same file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take an inventory of your parsers
&lt;/h2&gt;

&lt;p&gt;Most teams have never counted. A fairly ordinary Python-and-Kubernetes shop is running at least four:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Spec it resolves against&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ansible playbooks and vars&lt;/td&gt;
&lt;td&gt;PyYAML&lt;/td&gt;
&lt;td&gt;1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl apply&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sigs.k8s.io/yaml&lt;/code&gt; over go-yaml v2&lt;/td&gt;
&lt;td&gt;1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Go controller or operator you wrote recently&lt;/td&gt;
&lt;td&gt;go-yaml v3&lt;/td&gt;
&lt;td&gt;1.2 core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything Node touches (config loaders, lint plugins, codegen)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;js-yaml&lt;/code&gt; v4 or &lt;code&gt;yaml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.2 core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Java service reading config&lt;/td&gt;
&lt;td&gt;SnakeYAML 1.x&lt;/td&gt;
&lt;td&gt;1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ruby tooling&lt;/td&gt;
&lt;td&gt;Psych&lt;/td&gt;
&lt;td&gt;1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note &lt;code&gt;js-yaml&lt;/code&gt;: &lt;strong&gt;v3 was 1.1 and v4 is 1.2&lt;/strong&gt;. If you have a repo old enough to still be on v3 alongside a newer package on v4, you have the split inside a single &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The values they disagree about
&lt;/h2&gt;

&lt;p&gt;This is the whole list that matters in practice. Everything else is trivia.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;NO&lt;/span&gt;          &lt;span class="c1"&gt;# 1.1 -&amp;gt; false        | 1.2 -&amp;gt; "NO"&lt;/span&gt;
&lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;off&lt;/span&gt;         &lt;span class="c1"&gt;# 1.1 -&amp;gt; false        | 1.2 -&amp;gt; "off"&lt;/span&gt;
&lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="m"&gt;0644&lt;/span&gt;        &lt;span class="c1"&gt;# 1.1 -&amp;gt; 420 (octal)  | 1.2 -&amp;gt; 644 (decimal)&lt;/span&gt;
&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;12:30&lt;/span&gt;       &lt;span class="c1"&gt;# 1.1 -&amp;gt; 750 (base60) | 1.2 -&amp;gt; "12:30"&lt;/span&gt;
&lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;1e5&lt;/span&gt;         &lt;span class="c1"&gt;# 1.1 -&amp;gt; "1e5" (str)  | 1.2 -&amp;gt; 100000&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="m"&gt;1.10&lt;/span&gt;        &lt;span class="c1"&gt;# both -&amp;gt; 1.1 (float)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of those are silent data corruption with no error anywhere.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mode: 0644&lt;/code&gt; is my favourite because it looks so safe. A 1.1 parser reads it as octal and hands you the integer 420, which is the file mode you meant. A 1.2 parser reads it as the decimal number 644, which is mode &lt;code&gt;1204&lt;/code&gt; in octal, which is nothing you wanted. Same six characters, same file, and which one is correct depends entirely on which binary opened it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;12:30&lt;/code&gt; as 750 is the sexagesimal type, a YAML 1.1 feature that existed so you could write durations as &lt;code&gt;1:30:00&lt;/code&gt;. It was removed in 1.2 for the obvious reason. Any time value, any port pair, any &lt;code&gt;HH:MM&lt;/code&gt; in a config is exposed to it.&lt;/p&gt;

&lt;p&gt;Two more that are not version differences but bite just as hard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;1.10&lt;/code&gt; is a float in every parser&lt;/strong&gt;, and floats do not have trailing zeros, so your version pin silently becomes &lt;code&gt;1.1&lt;/code&gt;. There is no spec version where this behaves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate keys&lt;/strong&gt; are an error under 1.2 and a silent last-one-wins under PyYAML. A 400-line values file with the same key at line 40 and line 380 is a normal Tuesday, and Python will not tell you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the two parsers meet
&lt;/h2&gt;

&lt;p&gt;The single-parser case is survivable, because at least the file is consistently wrong. The one that eats a day looks like this.&lt;/p&gt;

&lt;p&gt;Ansible templates a Kubernetes ConfigMap. Someone writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# group_vars/prod.yml&lt;/span&gt;
&lt;span class="na"&gt;feature_legacy_import&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PyYAML resolves that to Python &lt;code&gt;False&lt;/code&gt;. Jinja renders &lt;code&gt;False&lt;/code&gt; into the manifest. The manifest now says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;feature_legacy_import&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;False"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And your Go service reads the ConfigMap, gets the string &lt;code&gt;"False"&lt;/code&gt;, and does &lt;code&gt;strconv.ParseBool&lt;/code&gt; on it, which succeeds, so nothing errors. Except upstream someone "fixed" the vars file by quoting it to &lt;code&gt;"no"&lt;/code&gt;, and now the same code path receives &lt;code&gt;"no"&lt;/code&gt;, and &lt;code&gt;ParseBool("no")&lt;/code&gt; returns an error, which your code treats as &lt;code&gt;false&lt;/code&gt; with a logged warning nobody reads.&lt;/p&gt;

&lt;p&gt;The flag is off in both cases. It is off for two entirely different reasons and only one of them is the one you configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  yamllint will not save you here
&lt;/h2&gt;

&lt;p&gt;Worth being precise, because people assume this is covered. &lt;code&gt;yamllint&lt;/code&gt; has a &lt;code&gt;truthy&lt;/code&gt; rule and it does flag bare &lt;code&gt;yes&lt;/code&gt;/&lt;code&gt;no&lt;/code&gt;/&lt;code&gt;on&lt;/code&gt;/&lt;code&gt;off&lt;/code&gt;. Turn it on, it is free.&lt;/p&gt;

&lt;p&gt;It does not check octal ambiguity, sexagesimals, exponent notation, version-number truncation, or the case you actually care about, which is &lt;em&gt;what two specific parsers each make of this file&lt;/em&gt;. It is a style linter. It reads your YAML as text, not as values.&lt;/p&gt;

&lt;p&gt;There is also a per-implementation layer under the spec version. The YAML 1.1 type repository lists bare &lt;code&gt;y&lt;/code&gt; and &lt;code&gt;n&lt;/code&gt; as booleans, but PyYAML's resolver deliberately leaves them alone. So "it works in my parser" is not even a claim about YAML 1.1, it is a claim about one library's resolver. That is the level at which this stuff has to be checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually holds
&lt;/h2&gt;

&lt;p&gt;In rough order of value:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Quote every string that could be read as something else.&lt;/strong&gt; Not every string, that is unreadable. The ambiguous ones: anything matching &lt;code&gt;y|n|yes|no|on|off|true|false&lt;/code&gt; in any casing, anything with a leading zero, anything with a colon, anything shaped like a number that is not a number. Single quotes, since they pin it as a string under both specs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema-validate the parsed result, not the file.&lt;/strong&gt; A JSON Schema on the loaded object catches &lt;code&gt;420&lt;/code&gt; where you expected &lt;code&gt;"0644"&lt;/code&gt;. Linting the text cannot, because the text is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;yaml.safe_load&lt;/code&gt; plus an explicit type coercion at the boundary.&lt;/strong&gt; If a value must be a string, cast it there and stop trusting the resolver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the parser version in your lockfile and know which spec it is.&lt;/strong&gt; The js-yaml v3 to v4 upgrade is a behaviour change in your config semantics. It is not a patch bump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer JSON for machine-generated config.&lt;/strong&gt; No resolver, no types to guess, no Norway.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Checking a file you did not write
&lt;/h2&gt;

&lt;p&gt;The gap in the tooling is that YAML validators tell you a file parses, which is never the question. The question is what it parses &lt;em&gt;into&lt;/em&gt;, and whether two parsers agree.&lt;/p&gt;

&lt;p&gt;I ended up building &lt;a href="https://devholster.com/testers/yaml-gotcha-checker/" rel="noopener noreferrer"&gt;a linter for exactly that&lt;/a&gt;. Paste a workflow, a playbook or a manifest and every plain scalar that resolves differently under 1.1 and 1.2 comes back with both readings next to each other, plus duplicate keys, tabs and non-breaking spaces. There is a button that emits the file with every ambiguous value quoted. It runs client side, so you can paste a production values file into it without thinking about it.&lt;/p&gt;

&lt;p&gt;The longer background on why the fix landed in 2009 and still has not reached your dependency tree is &lt;a href="https://devholster.com/blog/yaml-norway-problem/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Run it over your &lt;code&gt;group_vars&lt;/code&gt; directory once. My honest expectation is that you find between two and five, and that one of them is a file mode.&lt;/p&gt;

</description>
      <category>yaml</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>python</category>
    </item>
    <item>
      <title>Die unsichtbare Empfehlung</title>
      <dc:creator>René</dc:creator>
      <pubDate>Wed, 10 Jun 2026 13:46:43 +0000</pubDate>
      <link>https://dev.to/webgaudi/die-unsichtbare-empfehlung-4pi5</link>
      <guid>https://dev.to/webgaudi/die-unsichtbare-empfehlung-4pi5</guid>
      <description>&lt;p&gt;Eine Kundin erzählte uns kürzlich, wie sie ihren neuen Steuerberater gefunden hat. Nicht über Google, nicht über eine Empfehlung aus dem Bekanntenkreis. Sie hat ChatGPT gefragt: &lt;em&gt;"Welcher Steuerberater in Münster kennt sich mit E-Commerce aus?"&lt;/em&gt; Drei Namen kamen zurück. Einen davon hat sie angerufen.&lt;/p&gt;

&lt;p&gt;Zwei Dinge sind an dieser Geschichte bemerkenswert. Erstens: Die Kundin fand den Vorgang völlig normal. Zweitens: Die anderen Steuerberater in Münster werden nie erfahren, dass sie an diesem Tag nicht in Frage kamen. Es gab keine Suchergebnisseite, auf der sie weiter unten standen. Sie kamen schlicht nicht vor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eine neue Art, gefunden zu werden
&lt;/h2&gt;

&lt;p&gt;Über zwei Jahrzehnte lang war die Frage klar: Wer bei Google auf Seite eins steht, bekommt die Anfragen. Diese Frage gilt weiterhin — aber sie hat Gesellschaft bekommen. ChatGPT, Perplexity, Googles KI-Übersichten: Immer häufiger spricht ein Assistent eine Empfehlung aus, statt zehn blaue Links anzuzeigen.&lt;/p&gt;

&lt;p&gt;Der Unterschied ist größer, als er zunächst wirkt. Eine Suchergebnisseite zeigt Optionen, und der Mensch wählt. Ein Assistent wählt vor — und nennt drei Namen, manchmal nur einen. Wer nicht genannt wird, existiert für diesen Kunden nicht. Es gibt kein "weiter unten" mehr, in dem man wenigstens noch sichtbar wäre.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warum viele Websites für KI unsichtbar sind
&lt;/h2&gt;

&lt;p&gt;Die gute Nachricht zuerst: KI-Assistenten erfinden ihre Empfehlungen nicht. Sie lesen das Netz — Websites, Verzeichnisse, Bewertungen, Fachartikel. Wer dort klar und maschinenlesbar auftritt, wird gefunden. Wer nicht, bleibt stumm.&lt;/p&gt;

&lt;p&gt;Und genau hier scheitern viele Unternehmensseiten. Die typischen Gründe kennen wir aus unseren Audits:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Die Seite sagt nicht, was sie ist.&lt;/strong&gt; Ein Friseursalon, dessen Startseite nur "Willkommen" und ein Stimmungsbild zeigt, mag Menschen gefallen. Eine Maschine, die in Sekundenbruchteilen entscheidet, ob diese Seite die Frage &lt;em&gt;"guter Friseur für Locken in Köln"&lt;/em&gt; beantwortet, findet darin: nichts. Was Sie anbieten, für wen, und wo — das gehört in Text, nicht nur ins Gefühl.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Es fehlen strukturierte Daten.&lt;/strong&gt; Schema.org-Auszeichnungen (LocalBusiness, Öffnungszeiten, Leistungen, Bewertungen) sind das Vokabular, in dem Maschinen Unternehmen verstehen. Sie kosten nichts, sind unsichtbar für Besucher — und werden von den meisten Baukasten-Seiten schlicht weggelassen. Warum Vorlagen-Websites auch darüber hinaus an ihre Grenzen stoßen, haben wir im Beitrag &lt;a href="https://tinteundpixel.de/journal/website-baukasten-grenzen/" rel="noopener noreferrer"&gt;Tausend Betriebe, eine Vorlage&lt;/a&gt; beschrieben.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Die Seite ist technisch schwer lesbar.&lt;/strong&gt; Was sich nur per JavaScript zusammensetzt, in Sekunden lädt statt in Millisekunden, oder hinter Pop-ups versteckt, wird von Crawlern ungern und unvollständig gelesen. Schnelle, statisch ausgelieferte Seiten haben hier denselben Vorteil, den sie bei Google schon immer hatten — nur dass er jetzt doppelt zählt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Das Unternehmen existiert nur auf der eigenen Website.&lt;/strong&gt; KI-Modelle gewichten, was an mehreren Stellen übereinstimmend steht. Ein gepflegtes Google-Unternehmensprofil, Einträge in seriösen Branchenverzeichnissen, echte Bewertungen, vielleicht eine Erwähnung in der Lokalpresse — jedes dieser Signale bestätigt: Dieses Unternehmen gibt es wirklich, und es ist das, was es behauptet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was sich für Sie nicht ändert
&lt;/h2&gt;

&lt;p&gt;Man könnte nun in Hektik verfallen und nach "KI-Optimierung" suchen wie 2005 nach Meta-Keywords. Unnötig. Denn das Bemerkenswerte ist: Fast alles, was eine Website für KI-Assistenten sichtbar macht, ist seit Jahren guter Handwerksstandard. Klare Inhalte, die Fragen Ihrer Kunden beantworten. Saubere Technik. Strukturierte Daten. Konsistente Einträge. Schnelle Ladezeiten — genau die Punkte, auf die wir bei &lt;a href="https://tinteundpixel.de/" rel="noopener noreferrer"&gt;Tinte&amp;amp;Pixel&lt;/a&gt; bei jedem Projekt von Beginn an achten.&lt;/p&gt;

&lt;p&gt;Wer seine Website in den letzten Jahren ordentlich gebaut hat, ist bereits gut aufgestellt. Wer sie als digitale Visitenkarte ohne Substanz betreibt, war schon bei Google im Nachteil — jetzt wird aus dem Nachteil Unsichtbarkeit.&lt;/p&gt;

&lt;p&gt;Unsere Schwesteragentur &lt;a href="https://webgaudi.at/" rel="noopener noreferrer"&gt;Webgaudi&lt;/a&gt; in Wien hat schon vor Jahren beschrieben, &lt;a href="https://webgaudi.at/blog/suchmaschinen-optimieren/" rel="noopener noreferrer"&gt;was sich an der Suchmaschinenoptimierung durch KI verändert hat&lt;/a&gt; — die Kernaussage gilt unverändert: Es gewinnt nicht, wer trickst, sondern wer verständlich ist. Für Menschen wie für Maschinen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ein einfacher Selbsttest
&lt;/h2&gt;

&lt;p&gt;Fragen Sie heute Abend ChatGPT, was es über Ihr Unternehmen weiß. Fragen Sie es, wen es für Ihre Leistung in Ihrer Stadt empfehlen würde. Die Antwort ist ein ehrlicher Spiegel Ihrer digitalen Präsenz — ehrlicher als jedes Ranking, weil es kein "Platz 8" gibt, mit dem man sich trösten könnte.&lt;/p&gt;

&lt;p&gt;Wenn Ihr Name fällt: gut gemacht. Wenn nicht, wissen Sie jetzt, woran es liegt. Und dass es sich beheben lässt. Erzählen Sie uns einfach &lt;a href="https://tinteundpixel.de/#kontakt" rel="noopener noreferrer"&gt;kurz von Ihrem Vorhaben&lt;/a&gt; — wir geben Ihnen eine ehrliche Einschätzung.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>web</category>
      <category>astro</category>
    </item>
    <item>
      <title>How We Ship 100/100 PageSpeed Sites for Small-Business Clients (Without a CMS)</title>
      <dc:creator>René</dc:creator>
      <pubDate>Wed, 10 Jun 2026 13:41:07 +0000</pubDate>
      <link>https://dev.to/webgaudi/how-we-ship-100100-pagespeed-sites-for-small-business-clients-without-a-cms-2oc6</link>
      <guid>https://dev.to/webgaudi/how-we-ship-100100-pagespeed-sites-for-small-business-clients-without-a-cms-2oc6</guid>
      <description>&lt;p&gt;Most small-business websites are slow. Not because the businesses don't care, but because the default stack they get sold — a heavyweight CMS, a page builder, twelve plugins — fights against performance from day one.&lt;/p&gt;

&lt;p&gt;At &lt;a href="https://webgaudi.at/" rel="noopener noreferrer"&gt;webgaudi.at&lt;/a&gt;, our small web studio in Vienna, we build sites for therapists, lawyers, electricians and restaurants. Tiny budgets, no dev team on the client side, and one hard promise we make: &lt;strong&gt;100/100 on PageSpeed Insights, mobile and desktop.&lt;/strong&gt; Here's the actual playbook — no secrets, just decisions that compound.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Static-first with Astro: ship zero JS by default
&lt;/h2&gt;

&lt;p&gt;The single biggest lever is architectural. A brochure site for a psychotherapist has maybe one interactive element (a contact form, an FAQ accordion). It does not need a client-side framework, and it definitely doesn't need a database rendering pages on every request.&lt;/p&gt;

&lt;p&gt;We use &lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; because its islands architecture makes "zero JavaScript" the default instead of an optimization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// FaqSection.astro — pure HTML/CSS, no JS shipped
const faqs = await getCollection('faq');
---
{faqs.map((faq) =&amp;gt; (
  &amp;lt;details&amp;gt;
    &amp;lt;summary&amp;gt;{faq.data.question}&amp;lt;/summary&amp;gt;
    &amp;lt;div set:html={faq.rendered.html} /&amp;gt;
  &amp;lt;/details&amp;gt;
))}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Native &lt;code&gt;&amp;lt;details&amp;gt;/&amp;lt;summary&amp;gt;&lt;/code&gt; gives you an accessible accordion for free. The temptation to reach for a React component here is real — resist it. Every island you don't ship is hydration cost you never pay.&lt;/p&gt;

&lt;p&gt;When we &lt;em&gt;do&lt;/em&gt; need interactivity, we scope it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;ContactForm client:visible /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;client:visible&lt;/code&gt; means the form's JS only loads when it scrolls into view. On a typical landing page, that's the difference between 0 KB and 40 KB of JS on initial load.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Images: the 80% problem
&lt;/h2&gt;

&lt;p&gt;Almost every failing Lighthouse audit we inherit comes down to images. Our rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Author in highest quality, let the build optimize.&lt;/strong&gt; Astro's &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; component generates AVIF/WebP, correct &lt;code&gt;srcset&lt;/code&gt;, and explicit dimensions (no layout shift):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';

&amp;lt;Image src={hero} alt="..." widths={[400, 800, 1200]} format="avif" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The LCP image gets &lt;code&gt;loading="eager"&lt;/code&gt; and &lt;code&gt;fetchpriority="high"&lt;/code&gt;.&lt;/strong&gt; Everything below the fold gets lazy-loaded. Getting this one attribute right is often worth 0.5–1s of LCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No icon libraries.&lt;/strong&gt; Inline the 6 SVGs you actually use. A 200 KB icon font for a hamburger menu is how good scores die.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Fonts without the FOIT (and without the GDPR headache)
&lt;/h2&gt;

&lt;p&gt;We self-host fonts as subsetted WOFF2 with &lt;code&gt;font-display: swap&lt;/code&gt; and preload only the body font:&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="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/fonts/inter-latin.woff2"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"font"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"font/woff2"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&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;Side note for anyone building for EU clients: loading fonts from Google's CDN has been a legal problem since a 2022 German court ruling (LG München) — a visitor's IP gets transmitted to Google without consent. Self-hosting fixes performance &lt;em&gt;and&lt;/em&gt; compliance in one move. Two birds.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Edge delivery instead of a single origin server
&lt;/h2&gt;

&lt;p&gt;Static output means the whole site can live on a CDN. We deploy to an edge network so a site for a Viennese client loads just as fast for someone visiting from a hotel WiFi in Lisbon. TTFB consistently lands under 100ms — something a shared-hosting PHP origin can't match no matter how much you cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The boring checklist that protects the score
&lt;/h2&gt;

&lt;p&gt;Performance regresses through a thousand small additions. Before every go-live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lighthouse CI run on throttled mobile (the real-world case), not desktop&lt;/li&gt;
&lt;li&gt;No third-party scripts above the fold; analytics loads deferred and only after consent&lt;/li&gt;
&lt;li&gt;One critical CSS file under ~50 KB, no unused framework CSS (we purge aggressively)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;viewport&lt;/code&gt; meta, explicit width/height on all media, no render-blocking resources&lt;/li&gt;
&lt;li&gt;Accessibility pass: semantic landmarks, contrast, focus states — Lighthouse's a11y score is part of the 100/100 promise, not an afterthought&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this means for clients (the part nobody measures)
&lt;/h2&gt;

&lt;p&gt;The interesting outcome isn't the green circle — it's behavior. Average load time across our portfolio is ~0.8s, and the bounce-rate difference compared to the WordPress sites we replace is dramatic. Studies keep showing mobile users abandon pages after roughly 4 seconds; for a small business where the website &lt;em&gt;is&lt;/em&gt; the sales funnel, that's lost revenue, not a vanity metric.&lt;/p&gt;

&lt;p&gt;If you're a freelancer or studio serving small businesses, my honest pitch: static-first isn't a constraint, it's a feature you can sell. Faster sites, lower hosting costs, smaller attack surface, no plugin update treadmill.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I run &lt;a href="https://webgaudi.at/" rel="noopener noreferrer"&gt;webgaudi.at&lt;/a&gt;, a small web studio in Vienna building fast, GDPR-compliant sites for SMBs in Austria, Germany and Switzerland. Happy to answer questions about the stack in the comments — especially if you're fighting your own PageSpeed battles.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>astro</category>
      <category>performance</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
