<?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: PabloDevRel</title>
    <description>The latest articles on DEV Community by PabloDevRel (@pablodevrel).</description>
    <link>https://dev.to/pablodevrel</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%2F4084826%2Fa10abad9-c495-4e14-ba46-61589aded8f3.png</url>
      <title>DEV Community: PabloDevRel</title>
      <link>https://dev.to/pablodevrel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pablodevrel"/>
    <language>en</language>
    <item>
      <title>Tabnas: How to define a grammar</title>
      <dc:creator>PabloDevRel</dc:creator>
      <pubDate>Sat, 31 Oct 2026 00:05:29 +0000</pubDate>
      <link>https://dev.to/pablodevrel/tabnas-how-to-define-a-grammar-243i</link>
      <guid>https://dev.to/pablodevrel/tabnas-how-to-define-a-grammar-243i</guid>
      <description>&lt;h2&gt;
  
  
  What is a Grammar?
&lt;/h2&gt;

&lt;p&gt;A grammar is a set of structural rules. Every language has one.&lt;/p&gt;

&lt;p&gt;For example, plain English grammar follows a &lt;code&gt;Subject–Verb–Object (SVO)&lt;/code&gt; order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ I am Pablo 
❌ Am Pablo I
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is everywhere. In mathematics we also defined a grammar &lt;code&gt;Number symbol Number&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ 5 + 6
❌ + 5 6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A snippet of the JSON grammar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;value   = object / array / string / number / "true" / "false" / "null"
member  = string : value
object  = {[  member  *( , member )  ]}
array   = [[  value  *( , value )  ]]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In computing this is very important. If you are going to write a parser of a given format, first it must be clear what the grammar is.&lt;/p&gt;

&lt;p&gt;There are thousands of JSON parsers and you can easily write your own since it's well documented.&lt;/p&gt;

&lt;p&gt;But what if you're creating your own grammar? Or you want to build a parser of a proprietary and undocumented format?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Abaqus &lt;code&gt;.inp&lt;/code&gt; files
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Abaqus" rel="noopener noreferrer"&gt;Abaqus&lt;/a&gt; is proprietary but widely used software that runs finite element simulations in aerospace, automotive, etc.&lt;/p&gt;

&lt;p&gt;Abaqus &lt;code&gt;.inp&lt;/code&gt; project files are plain text. Anyone can read them in a simple text editor.&lt;/p&gt;

&lt;p&gt;For example, this &lt;code&gt;.inp&lt;/code&gt; file describes this &lt;strong&gt;🧊 cube&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*HEADING
Single C3D8 hexahedral element - 100 mm cube
*NODE, NSET=NALL
1, 0.0, 0.0, 0.0
2, 100.0, 0.0, 0.0
3, 100.0, 100.0, 0.0
4, 0.0, 100.0, 0.0
5, 0.0, 0.0, 100.0
6, 100.0, 0.0, 100.0
7, 100.0, 100.0, 100.0
8, 0.0, 100.0, 100.0
*ELEMENT, TYPE=C3D8, ELSET=ECUBE
1, 1, 2, 3, 4, 5, 6, 7, 8
*MATERIAL, NAME=STEEL
*ELASTIC
210000., 0.3
*BOUNDARY
1, 1, 3
2, 1, 3
5, 1, 3
6, 1, 3
7, 1, 3
8, 1, 3
*STEP
*STATIC
*CLOAD
3, 3, -1000.
4, 3, -1000.
*END STEP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fqqqymju9mcvmq7kj32yo.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%2Fqqqymju9mcvmq7kj32yo.png" alt="Render of the cube that cube.inp describes, solved with CalculiX" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And yet their language has no official parser or API documentation. There isn't even an official manual we can link: Abaqus keeps theirs behind a paywall, so we work with the documentation of &lt;a href="https://en.wikipedia.org/wiki/Calculix" rel="noopener noreferrer"&gt;CalculiX&lt;/a&gt;, an open-source Abaqus clone that reads the same &lt;code&gt;.inp&lt;/code&gt; files. Hundreds of pages of prose, and no grammar in sight: &lt;a href="https://dhondt.de/ccx_2.23.pdf" rel="noopener noreferrer"&gt;https://dhondt.de/ccx_2.23.pdf&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Abaqus .inp parser
&lt;/h2&gt;

&lt;p&gt;We're going to write our own open source &lt;code&gt;.inp&lt;/code&gt; files parser library, so anyone can use it to read &lt;code&gt;.inp&lt;/code&gt; files and deal with them in their own software.&lt;/p&gt;

&lt;p&gt;We take as example our 🧊 &lt;code&gt;cube.inp&lt;/code&gt; which starts with the &lt;code&gt;*HEADING&lt;/code&gt;. What is this?&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%2Fbidvj5s5tgoet18ztnez.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%2Fbidvj5s5tgoet18ztnez.png" alt="The HEADING keyword in the CalculiX manual" width="800" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Page 527 of the CalculiX manual&lt;/em&gt;&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%2Fyuphgwxa9a782iboj773.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%2Fyuphgwxa9a782iboj773.png" alt="The HEADING reference page of the CalculiX manual" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OK! Here it is. Now we understand what a &lt;code&gt;HEADING&lt;/code&gt; looks like. We can parse it.&lt;/p&gt;

&lt;p&gt;Now we do the same with &lt;code&gt;NODE&lt;/code&gt;, with &lt;code&gt;ELEMENT&lt;/code&gt;, &lt;code&gt;MATERIAL&lt;/code&gt; ...&lt;br&gt;
New keyword, open the 800-page PDF manual, define it in the parser, repeat ...&lt;/p&gt;

&lt;p&gt;And this with all the &lt;strong&gt;keywords&lt;/strong&gt;, good luck with it and with your tokens.&lt;/p&gt;

&lt;p&gt;And what if we want someone else to expand or collaborate with this project? Is this huge PDF manual really the only source of truth for our &lt;code&gt;.inp&lt;/code&gt; parsing project? That looks really overkill and costly.&lt;/p&gt;

&lt;p&gt;So you know what this project needs, right? A &lt;strong&gt;grammar&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Tabnas
&lt;/h2&gt;

&lt;p&gt;With &lt;a href="https://tabnas.dev" rel="noopener noreferrer"&gt;Tabnas&lt;/a&gt;, we can write the grammar of 🧊 &lt;code&gt;cube.inp&lt;/code&gt; in a &lt;code&gt;.abnf&lt;/code&gt; file&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deck      = *( kwcard / cmtcard / valuecard / blank )
kwcard    = %x2A KWNAME [ KWITEMS ] crlf    
KWNAME    = TX                               
KWITEMS   = %x2C [ KWITEM [ KWITEMS ] ]      
KWITEM    = TX                               
cmtcard   = %x2A %x2A [ 1*( TXTCH ) ] crlf  
valuecard = 1*( TXTCH ) crlf                
TXTCH     = TX / NR / ST / %x2C / %x3A / %x2A
blank     = crlf                             
crlf      = [ %x0D ] %x0A                   
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;.inp&lt;/code&gt; line you will ever see is one of four shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keyword card (&lt;code&gt;*NODE...&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;comment card (&lt;code&gt;**...&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;data line (&lt;code&gt;1, 0.0, 0.0, 0.0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;empty line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check the &lt;a href="https://tabnas.dev/docs/" rel="noopener noreferrer"&gt;Tabnas documentation&lt;/a&gt;; it explains that block of code completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can do with your Tabnas Grammar?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Validate an &lt;code&gt;.inp&lt;/code&gt; file
&lt;/h3&gt;

&lt;p&gt;Does this &lt;strong&gt;Abaqus&lt;/strong&gt; &lt;code&gt;.inp&lt;/code&gt; file have a valid format?&lt;/p&gt;

&lt;p&gt;We run our &lt;code&gt;read-deck-keywords.mjs&lt;/code&gt; script against two files: &lt;code&gt;cube.inp&lt;/code&gt;, and &lt;code&gt;truncated.inp&lt;/code&gt;, which is broken on purpose.&lt;/p&gt;

&lt;h4&gt;
  
  
  ✅ Valid &lt;code&gt;cube.inp&lt;/code&gt; file
&lt;/h4&gt;

&lt;p&gt;If we run it against our valid 🧊 &lt;code&gt;cube.inp&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node read-deck-keywords.mjs cube.inp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ACCEPTED cube.inp — 29 cards, 9 keywords:
   *HEADING
   *NODE,NSET=NALL
   *ELEMENT,TYPE=C3D8,ELSET=ECUBE
   *MATERIAL,NAME=STEEL
   *ELASTIC
   *BOUNDARY
   *STEP
   *STATIC
   *CLOAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  ❌ Invalid &lt;code&gt;truncated.inp&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;But if we run it against the invalid &lt;code&gt;truncated.inp&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;truncated.inp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*HEADING
Single C3D8 hexahedral element - 100 mm cube
*NODE, NSET=NALL
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node read-deck-keywords.mjs truncated.inp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REJECTED truncated.inp
SyntaxError: [tabnas/unexpected]: unexpected character(s): 1
  --&amp;gt; &amp;lt;no-file&amp;gt;:1:72
  1 | Single C3D8 hexahedral element - 100 mm cube
  2 | *NODE, NSET=NALL
  3 | 1
      ^ unexpected character(s): 1
  4 |
  5 |

  The character(s) 1 do not match any rule alternative active at
  this position.

  --internal: tag=-; rule=_gen1_group~o; token=#NR; plugins=abnf--
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deck is rejected right away and Tabnas tells us exactly where the problem is.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Inspect your file keywords
&lt;/h3&gt;

&lt;p&gt;An &lt;code&gt;.inp&lt;/code&gt; file can have a lot of noise: data, comments, sets... Maybe for your parser it's interesting to know beforehand which keywords are being used in that &lt;code&gt;.inp&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;With a grammar, extracting every keyword your &lt;code&gt;.inp&lt;/code&gt; file uses is a piece of cake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tn&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;Tabnas&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;IGNORE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#SP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;tn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;abnf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// the grammar is in cube.abnf&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;abnfSrc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cube.abnf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;compiled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;abnfSrc&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;tn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abnf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compiled&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;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cube.inp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Filter tabnas parse to detect the different keywords&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;cards&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&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;keywords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACCEPTED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;—&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cards&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cards,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keywords&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keywords:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
   &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We run it again and the output is the same as demo 1: &lt;code&gt;ACCEPTED cube.inp — 29 cards, 9 keywords:&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;With this information, anyone can easily tell that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There are 9 different &lt;strong&gt;keywords&lt;/strong&gt; and 29 &lt;strong&gt;cards&lt;/strong&gt; (recognized lines)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;*NODE,NSET=NALL&lt;/code&gt;: this deck has a group of nodes, NALL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;*ELEMENT,TYPE=C3D8,ELSET=ECUBE&lt;/code&gt;: an element of type C3D8 with its connectivity, kept in the set ECUBE&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;*MATERIAL,NAME=STEEL&lt;/code&gt;: a material called STEEL&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Turn the file into JSON
&lt;/h3&gt;

&lt;p&gt;There's a parser already in Tabnas. We can use it directly with our grammar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tn&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;Tabnas&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;IGNORE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#SP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;tn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;abnf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;tn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abnf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compiled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// the grammar, loaded from cube.abnf like in the section above&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cube.inp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is very verbose, because the tree is built straight from the grammar: every rule in the &lt;code&gt;.abnf&lt;/code&gt; becomes a node (&lt;code&gt;"rule"&lt;/code&gt;), every node repeats its raw source text (&lt;code&gt;"src"&lt;/code&gt;), and every reused category (&lt;code&gt;TXTCH&lt;/code&gt;, &lt;code&gt;crlf&lt;/code&gt;) brings its own kid.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node parse-deck.mjs cube.inp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deck"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*HEADING&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;SingleC3D8hexahedralelement-100mmcube&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;*NODE,NSET=NALL&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;1,0.0,0.0,0.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;2,100.0,0.0,0.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;3,100.0,100.0,0.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;4,0.0,100.0,0.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;5,0.0,0.0,100.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;6,100.0,0.0,100.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;7,100.0,100.0,100.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;8,0.0,100.0,100.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"kids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kwcard"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*HEADING&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"kids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"KWNAME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HEADING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"crlf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"kids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]}&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valuecard"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SingleC3D8hexahedralelement-100mmcube&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"kids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TXTCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Single"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"kids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TXTCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C3D8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"kids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TXTCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hexahedral"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"kids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why a grammar?
&lt;/h2&gt;

&lt;p&gt;Why not just write a parser from the PDF documentation?&lt;/p&gt;

&lt;p&gt;Because the parser you would write from the PDF is exactly what a grammar gives you:&lt;/p&gt;

&lt;h3&gt;
  
  
  Lightweight and reliable source of truth
&lt;/h3&gt;

&lt;p&gt;If you write a parser, you don't want yourself or your agents to keep reopening the 800-page PDF for every keyword. It's a mess: your agents might hallucinate, and they will burn a lot of tokens to rebuild context that a 10-line grammar file provides in one shot.&lt;/p&gt;

&lt;p&gt;In this project, the same &lt;code&gt;cube.abnf&lt;/code&gt; is the backbone of all three tools you saw above: validation, keyword inspection and JSON parsing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Error proof
&lt;/h3&gt;

&lt;p&gt;This adds a security layer because the grammar is executable: validation is a one-liner, &lt;code&gt;tn.parse(...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In a pipeline (or an agent session) that touches hundreds of &lt;code&gt;.inp&lt;/code&gt; files, this mechanism detects errors early and in a deterministic way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding a keyword does not touch the grammar
&lt;/h3&gt;

&lt;p&gt;The grammar is keyword-agnostic by design: &lt;code&gt;*ANYTHING, PARAM=VALUE&lt;/code&gt; is already a valid keyword card, whatever the name is. &lt;/p&gt;

&lt;p&gt;Production &lt;code&gt;.inp&lt;/code&gt; files use dozens of keywords and new ones show up every release; a new keyword becomes a new case, not a grammar update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are there other tools to generate a grammar?
&lt;/h2&gt;

&lt;p&gt;There are many: &lt;a href="https://www.antlr.org/" rel="noopener noreferrer"&gt;ANTLR&lt;/a&gt;, &lt;a href="https://pegjs.org/" rel="noopener noreferrer"&gt;PEG.js&lt;/a&gt; and its maintained fork &lt;a href="https://peggyjs.org/" rel="noopener noreferrer"&gt;Peggy&lt;/a&gt; ... I checked &lt;a href="https://ohmjs.org/" rel="noopener noreferrer"&gt;Ohm&lt;/a&gt; and spotted very interesting things in this library.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ohm vs Tabnas
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Maturity
&lt;/h4&gt;

&lt;p&gt;To begin with, this project has 11 more years of history behind it: the repository dates to May 2015, it has 5.5k stars and 48 open issues, and it was last updated this June. Tabnas is the opposite case: its first published npm package appeared in June 2026, so the Tabnas ecosystem is a few months old.&lt;/p&gt;

&lt;h4&gt;
  
  
  The webpage
&lt;/h4&gt;

&lt;p&gt;Their webpage explains well what it does. I did not completely understand what the Tabnas project is and what a grammar does for it until I saw the awesome &lt;a href="https://ohmjs.org/editor/" rel="noopener noreferrer"&gt;Ohm editor&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9lpqhe8dcqj87xk6yd9i.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9lpqhe8dcqj87xk6yd9i.gif" alt="The Ohm editor: grammar, sample input and live parse on one screen" width="707" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At a glance you can clearly see what a grammar is and how this library works. Clear and simple.&lt;/p&gt;

&lt;p&gt;The Tabnas homepage, on the other hand... it's just hard.&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%2F3ah0f7v09zfyxydrhega.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%2F3ah0f7v09zfyxydrhega.png" alt="The Tabnas homepage: &amp;quot;an extensible parsing engine, and a compile target for agents&amp;quot;" width="800" height="838"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It asks the reader to handle complex phrases like "behaviour binds out of band, through names the compiler already assigns"&lt;/p&gt;

&lt;p&gt;Every term is eventually defined, but the buzzwords are just too much at once. If you are not already familiar with this style of grammar tool, you read the docs very slowly.&lt;/p&gt;

&lt;p&gt;The Docs and How to sections don't help much at the beginning; they just add more noise.&lt;/p&gt;

&lt;p&gt;Something I have to say is, after understanding the grammar context, and especially after learning from Ohm's webpage, I came back to Tabnas and everything was much clearer. So I'd say this is not for beginners.&lt;/p&gt;

&lt;p&gt;Another positive thing to say is that the agents understood Tabnas quickly and could build the grammar and the examples of the Abaqus &lt;code&gt;.inp&lt;/code&gt; file in seconds, and they helped me to understand what this is.&lt;/p&gt;

&lt;h4&gt;
  
  
  The syntax
&lt;/h4&gt;

&lt;p&gt;The syntax of Ohm is really clear. This is a definition of some basic arithmetic operations:&lt;/p&gt;

&lt;p&gt;In Ohm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Arithmetic {
  Exp
    = AddExp

  AddExp
    = AddExp "+" MulExp  -- plus
    | AddExp "-" MulExp  -- minus
    | MulExp

  MulExp
    = MulExp "*" ExpExp  -- times
    | MulExp "/" ExpExp  -- divide
    | ExpExp

  ExpExp
    = PriExp "^" ExpExp  -- power
    | PriExp

  PriExp
    = "(" Exp ")"  -- paren
    | "+" PriExp   -- pos
    | "-" PriExp   -- neg
    | ident
    | number

  ident  (an identifier)
    = letter alnum*

  number  (a number)
    = digit* "." digit+  -- fract
    | digit+             -- whole
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same in &lt;code&gt;abnf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exp       = AddExp

AddExp    = AddExp PLUS MulExp / AddExp MINUS MulExp / MulExp     ; plus | minus | mul-level
MulExp    = MulExp STAR Exps / MulExp SLASH Exps / Exps            ; times | divide | pow-level
Exps      = PriExp CARET Exps / PriExp                            ; power (tightest, right-assoc)

PriExp    = LP Exp RP / PLUS PriExp / MINUS PriExp / name / number

name      = TX                        ; an identifier (engine text token)
number    = NR

PLUS      = %x2B                      ; +
MINUS     = %x2D                      ; -
STAR      = %x2A                      ; *
SLASH     = %x2F                      ; /
CARET     = %x5E                      ; ^
LP        = %x28                      ; (
RP        = %x29                      ; )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my opinion, Ohm is friendlier to humans because its grammar has more readable labels (&lt;code&gt;-- plus&lt;/code&gt;, &lt;code&gt;-- times&lt;/code&gt;) and the editor runs a live parse next to your grammar, which makes the feedback loop instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The idea behind Tabnas holds up, and the engine works. But there's too much friction from the beginning; I would not hand it to a beginner.&lt;/p&gt;

&lt;p&gt;My coding agents, on the other hand, were happy with the tool: they understood it fast and built the grammar and the demos in no time.&lt;/p&gt;

&lt;p&gt;That is exactly the project's own thesis:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"An extensible parsing engine, and a compile target for &lt;strong&gt;agents&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>grammar</category>
      <category>tabnas</category>
      <category>ohmjs</category>
      <category>abnf</category>
    </item>
    <item>
      <title>So... Vercel doesn't have built-in email?</title>
      <dc:creator>PabloDevRel</dc:creator>
      <pubDate>Thu, 20 Aug 2026 12:51:32 +0000</pubDate>
      <link>https://dev.to/pablodevrel/so-vercel-doesnt-have-built-in-email-2dpd</link>
      <guid>https://dev.to/pablodevrel/so-vercel-doesnt-have-built-in-email-2dpd</guid>
      <description>&lt;p&gt;Old-school hosting always came with email. GoDaddy, Hostinger, IONOS. &lt;br&gt;
You picked a plan, you got a mailbox, done.&lt;/p&gt;

&lt;p&gt;But Vercel doesn't. And honestly? That's a good thing. Vercel is great at what it does, deploying and hosting web apps. But email is a completely different beast. &lt;/p&gt;

&lt;p&gt;If you have a side project on a custom domain and want a professional address like &lt;code&gt;hello@yourdomain.com&lt;/code&gt;, you need an external mail provider.&lt;/p&gt;

&lt;p&gt;One I found that's worth a look: &lt;a href="https://amelu.org" rel="noopener noreferrer"&gt;Amelu&lt;/a&gt;. It's not super well-known yet, but they have a solid free tier: 3 mailboxes, full IMAP/SMTP support and they're hosted in Europe, which is a plus if data residency matters to you. &lt;/p&gt;

&lt;p&gt;Here's how I set it up for my side project &lt;code&gt;moviekombat.app&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The DNS problem
&lt;/h2&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%2Fll6k5mf45r657wn4q4yd.gif" 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%2Fll6k5mf45r657wn4q4yd.gif" alt="DNS" width="320" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Right now, DNS routes all &lt;code&gt;moviekombat.app&lt;/code&gt; traffic to Vercel. That's fine for the website, but I need email to go somewhere else. The goal: tell DNS that any mail sent to &lt;code&gt;@moviekombat.app&lt;/code&gt; should be handled by Amelu, not Vercel.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1. Create an Amelu account
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://app.amelu.org" rel="noopener noreferrer"&gt;app.amelu.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Login with Ordnary account"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Log in with your email. A first-time login automatically creates your account and organization.&lt;/li&gt;
&lt;li&gt;You land on the &lt;strong&gt;Email Domains&lt;/strong&gt; page, empty and ready&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Step 2. Add your domain
&lt;/h2&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%2Fixp9dblpmw86n5x68r3k.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%2Fixp9dblpmw86n5x68r3k.png" alt="Add domain" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Email Domains → New domain&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Fill in:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain name:&lt;/strong&gt; your domain (just &lt;code&gt;yourdomain.com&lt;/code&gt;, no &lt;code&gt;www&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS Nameservers:&lt;/strong&gt; "Use external nameservers" (this doesn't change anything, you keep your existing DNS provider)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default Email Addresses:&lt;/strong&gt; leave checked. This creates &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;postmaster&lt;/code&gt;, and &lt;code&gt;abuse&lt;/code&gt; (required by email standards)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Add Email Domain"&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&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%2F0abdzzfjd7raphw9c87m.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%2F0abdzzfjd7raphw9c87m.png" alt="Generated mails" width="799" height="678"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Amelu creates the domain and three default mailboxes. &lt;strong&gt;Copy the passwords, they're shown only once.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3. Download and fix the DNS Zone file
&lt;/h2&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%2Fhyzu5cq61x7yyadgrzcb.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%2Fhyzu5cq61x7yyadgrzcb.png" alt="Amelu DNS" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open &lt;strong&gt;DNS Configuration &amp;gt; DNS Config&lt;/strong&gt; in Amelu. Those are the records you need to add to your domain's DNS provider. In our case, Vercel.&lt;/p&gt;

&lt;p&gt;We could add each record one by one in Vercel's DNS Records form. Tedious for 19 records. So let's use the zone file import feature instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Download the zone file from Amelu (BIND format &lt;code&gt;.zone&lt;/code&gt; file)&lt;/li&gt;
&lt;li&gt;If you upload the zone file as-is in Vercel, you'll get an error.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;[!CAUTION]&lt;br&gt;
Error: No fully qualified domain name found in &lt;code&gt;$ORIGIN&lt;/code&gt; directive or &lt;code&gt;SOA&lt;/code&gt; record.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To fix that, open the zone file with a text editor and add this line at the top (after the comment header) &lt;code&gt;$ORIGIN moviekombat.app&lt;/code&gt;. This is how my zone file looks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;; Amelu DNS zone file for moviekombat.app
; Generated 2026-08-15T12:02:27Z
; Import this into your DNS provider (e.g. Cloudflare &amp;gt; DNS &amp;gt; Import and Export).

$ORIGIN moviekombat.app.

@ IN SOA marduk.mx.amelu.org. postmaster.moviekombat.app. (
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4. Import the zone file into Vercel
&lt;/h2&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%2Fvzt156v3wj0ksp7l0wxd.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%2Fvzt156v3wj0ksp7l0wxd.png" alt="DNS in Vercel" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Vercel, go to &lt;strong&gt;Domains&lt;/strong&gt; → select &lt;code&gt;moviekombat.app&lt;/code&gt; → &lt;strong&gt;DNS Records&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;Upload Zone File&lt;/strong&gt; button and upload the zone file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Done! All records appear in your DNS table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5. Verify DNS in Amelu
&lt;/h2&gt;

&lt;p&gt;Go back to Amelu &lt;strong&gt;DNS Configuration → DNS Config&lt;/strong&gt; and click &lt;strong&gt;Recheck DNS&lt;/strong&gt;. Most records come back &lt;strong&gt;Matched&lt;/strong&gt; immediately.&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%2Fz0birnenytb8psbstmtt.gif" 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%2Fz0birnenytb8psbstmtt.gif" alt="Check DNS" width="567" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SRV records show "Not verified"&lt;/strong&gt;: that's normal. Amelu only live-checks MX, TXT, and CNAME. SRV/CAA are unchecked by design and don't block activation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSA DKIM might show mismatch&lt;/strong&gt;: if Vercel's importer mangled the multi-line TXT record (split it with a literal &lt;code&gt;" "&lt;/code&gt; in the middle), delete that record in Vercel and re-add it as a single concatenated string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once all required records match, the domain goes &lt;strong&gt;active&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6. Configure your mail client
&lt;/h2&gt;

&lt;p&gt;This is where Amelu's admin panel stops and your mail client starts. Amelu isn't a mail client, it manages domains and mailboxes. To read and send mail, you need IMAP/SMTP settings.&lt;/p&gt;

&lt;p&gt;Find them in Amelu: &lt;strong&gt;Mailboxes → Usage Instructions&lt;/strong&gt;:&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%2Fh83g5rewh82leohk0c67.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%2Fh83g5rewh82leohk0c67.png" alt="Usage instructions" width="800" height="673"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IMAP:&lt;/strong&gt; &lt;code&gt;marduk.mx.amelu.org&lt;/code&gt;, port &lt;code&gt;993&lt;/code&gt;, SSL/TLS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMTP:&lt;/strong&gt; &lt;code&gt;marduk.mx.amelu.org&lt;/code&gt;, port &lt;code&gt;465&lt;/code&gt;, SSL/TLS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username:&lt;/strong&gt; full email address (&lt;code&gt;admin@yourdomain.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password:&lt;/strong&gt; the mailbox password&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What else is out there?
&lt;/h2&gt;

&lt;p&gt;Before landing on Amelu, I looked at the usual suspects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Email Routing&lt;/strong&gt;: free, but it only forwards. You can receive mail, but you can't send from your domain. No IMAP/SMTP, no inbox. It looks easy until you realize it's a one-way street.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zoho Mail&lt;/strong&gt;: has a free tier, but blocks IMAP on the free plan. You're stuck using their web client or mobile app. If you want to use Outlook or Thunderbird, you're paying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migadu&lt;/strong&gt;: closest competitor. Full IMAP/SMTP, unlimited mailboxes, flat pricing. But they killed their free tier in 2020, and the cheapest plan is $19/year.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Amelu isn't perfect. There's no web client, so you read and send mail through Outlook, Thunderbird, or your phone's mail app. Auto-configuration doesn't work yet, so you'll set up the client manually the first time. But the free tier is real, the protocols are open, and the setup takes about 5 minutes.&lt;/p&gt;

&lt;p&gt;For a Vercel side project where you just need &lt;code&gt;hello@yourdomain.com&lt;/code&gt; to work, it gets the job done.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>devops</category>
      <category>webdev</category>
      <category>email</category>
    </item>
    <item>
      <title>So... Vercel doesn't have built-in email?</title>
      <dc:creator>PabloDevRel</dc:creator>
      <pubDate>Thu, 20 Aug 2026 10:03:46 +0000</pubDate>
      <link>https://dev.to/pablodevrel/how-to-set-up-email-on-vercel-with-amelu-free-tier-34oe</link>
      <guid>https://dev.to/pablodevrel/how-to-set-up-email-on-vercel-with-amelu-free-tier-34oe</guid>
      <description>&lt;p&gt;Vercel is great for deploying web apps. But there's one thing it doesn't do: email.&lt;/p&gt;

&lt;p&gt;If you have a side project on a custom domain and want a professional address like &lt;code&gt;hello@yourdomain.com&lt;/code&gt;, you need an external mail provider. I went through the options. Cloudflare only forwards (no sending), Zoho locks you into their web client, and Migadu killed their free tier.&lt;/p&gt;

&lt;p&gt;Then I found &lt;a href="https://amelu.org" rel="noopener noreferrer"&gt;Amelu&lt;/a&gt;. Free tier, 3 mailboxes, full IMAP/SMTP support, hosted in Europe. Here's how I set it up for &lt;code&gt;moviekombat.app&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DNS problem
&lt;/h2&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%2Fll6k5mf45r657wn4q4yd.gif" 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%2Fll6k5mf45r657wn4q4yd.gif" alt="DNS" width="320" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Right now, DNS routes all &lt;code&gt;moviekombat.app&lt;/code&gt; traffic to Vercel. That's fine for the website, but I need email to go somewhere else. The goal: tell DNS that any mail sent to &lt;code&gt;@moviekombat.app&lt;/code&gt; should be handled by Amelu, not Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1. Create an Amelu account
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://app.amelu.org" rel="noopener noreferrer"&gt;app.amelu.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Login with Ordnary account"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Log in with your email. A first-time login automatically creates your account and organization.&lt;/li&gt;
&lt;li&gt;You land on the &lt;strong&gt;Email Domains&lt;/strong&gt; page, empty and ready&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 2. Add your domain
&lt;/h2&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%2Fixp9dblpmw86n5x68r3k.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%2Fixp9dblpmw86n5x68r3k.png" alt="Add domain" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Email Domains → New domain&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Fill in:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain name:&lt;/strong&gt; your domain (just &lt;code&gt;yourdomain.com&lt;/code&gt;, no &lt;code&gt;www&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS Nameservers:&lt;/strong&gt; "Use external nameservers" (this doesn't change anything, you keep your existing DNS provider)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default Email Addresses:&lt;/strong&gt; leave checked. This creates &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;postmaster&lt;/code&gt;, and &lt;code&gt;abuse&lt;/code&gt; (required by email standards)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Add Email Domain"&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&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%2F0abdzzfjd7raphw9c87m.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%2F0abdzzfjd7raphw9c87m.png" alt="Generated mails" width="799" height="678"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Amelu creates the domain and three default mailboxes. &lt;strong&gt;Copy the passwords, they're shown only once.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3. Download and fix the DNS Zone file
&lt;/h2&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%2Fhyzu5cq61x7yyadgrzcb.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%2Fhyzu5cq61x7yyadgrzcb.png" alt="Amelu DNS" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open &lt;strong&gt;DNS Configuration &amp;gt; DNS Config&lt;/strong&gt; in Amelu. Those are the records you need to add to your domain's DNS provider. In our case, Vercel.&lt;/p&gt;

&lt;p&gt;We could add each record one by one in Vercel's DNS Records form. Tedious for 19 records. So let's use the zone file import feature instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Download the zone file from Amelu (BIND format &lt;code&gt;.zone&lt;/code&gt; file)&lt;/li&gt;
&lt;li&gt;If you upload the zone file as-is in Vercel, you'll get an error.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;[!CAUTION]&lt;br&gt;
Error: No fully qualified domain name found in &lt;code&gt;$ORIGIN&lt;/code&gt; directive or &lt;code&gt;SOA&lt;/code&gt; record.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To fix that, open the zone file with a text editor and add this line at the top (after the comment header) &lt;code&gt;$ORIGIN moviekombat.app&lt;/code&gt;. This is how my zone file looks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;; Amelu DNS zone file for moviekombat.app
; Generated 2026-08-15T12:02:27Z
; Import this into your DNS provider (e.g. Cloudflare &amp;gt; DNS &amp;gt; Import and Export).

$ORIGIN moviekombat.app.

@ IN SOA marduk.mx.amelu.org. postmaster.moviekombat.app. (
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4. Import the zone file into Vercel
&lt;/h2&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%2Fvzt156v3wj0ksp7l0wxd.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%2Fvzt156v3wj0ksp7l0wxd.png" alt="DNS in Vercel" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Vercel, go to &lt;strong&gt;Domains&lt;/strong&gt; → select &lt;code&gt;moviekombat.app&lt;/code&gt; → &lt;strong&gt;DNS Records&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;Upload Zone File&lt;/strong&gt; button and upload the zone file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Done! All records appear in your DNS table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5. Verify DNS in Amelu
&lt;/h2&gt;

&lt;p&gt;Go back to Amelu &lt;strong&gt;DNS Configuration → DNS Config&lt;/strong&gt; and click &lt;strong&gt;Recheck DNS&lt;/strong&gt;. Most records come back &lt;strong&gt;Matched&lt;/strong&gt; immediately.&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%2Fz0birnenytb8psbstmtt.gif" 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%2Fz0birnenytb8psbstmtt.gif" alt="Check DNS" width="567" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SRV records show "Not verified"&lt;/strong&gt;: that's normal. Amelu only live-checks MX, TXT, and CNAME. SRV/CAA are unchecked by design and don't block activation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSA DKIM might show mismatch&lt;/strong&gt;: if Vercel's importer mangled the multi-line TXT record (split it with a literal &lt;code&gt;" "&lt;/code&gt; in the middle), delete that record in Vercel and re-add it as a single concatenated string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once all required records match, the domain goes &lt;strong&gt;active&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6. Configure your mail client
&lt;/h2&gt;

&lt;p&gt;This is where Amelu's admin panel stops and your mail client starts. Amelu isn't a mail client, it manages domains and mailboxes. To read and send mail, you need IMAP/SMTP settings.&lt;/p&gt;

&lt;p&gt;Find them in Amelu: &lt;strong&gt;Mailboxes → Usage Instructions&lt;/strong&gt;:&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%2Fh83g5rewh82leohk0c67.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%2Fh83g5rewh82leohk0c67.png" alt="Usage instructions" width="800" height="673"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IMAP:&lt;/strong&gt; &lt;code&gt;marduk.mx.amelu.org&lt;/code&gt;, port &lt;code&gt;993&lt;/code&gt;, SSL/TLS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMTP:&lt;/strong&gt; &lt;code&gt;marduk.mx.amelu.org&lt;/code&gt;, port &lt;code&gt;465&lt;/code&gt;, SSL/TLS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username:&lt;/strong&gt; full email address (&lt;code&gt;admin@yourdomain.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password:&lt;/strong&gt; the mailbox password&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What else is out there?
&lt;/h2&gt;

&lt;p&gt;Before landing on Amelu, I looked at the usual suspects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Email Routing&lt;/strong&gt;: free, but it only forwards. You can receive mail, but you can't send from your domain. No IMAP/SMTP, no inbox. It looks easy until you realize it's a one-way street.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zoho Mail&lt;/strong&gt;: has a free tier, but blocks IMAP on the free plan. You're stuck using their web client or mobile app. If you want to use Outlook or Thunderbird, you're paying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migadu&lt;/strong&gt;: closest competitor. Full IMAP/SMTP, unlimited mailboxes, flat pricing. But they killed their free tier in 2020, and the cheapest plan is $19/year.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Amelu isn't perfect. There's no web client, so you read and send mail through Outlook, Thunderbird, or your phone's mail app. Auto-configuration doesn't work yet, so you'll set up the client manually the first time. But the free tier is real, the protocols are open, and the setup takes about 5 minutes.&lt;/p&gt;

&lt;p&gt;For a Vercel side project where you just need &lt;code&gt;hello@yourdomain.com&lt;/code&gt; to work, it gets the job done.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>devops</category>
      <category>webdev</category>
      <category>email</category>
    </item>
  </channel>
</rss>
