<?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: ckmtools</title>
    <description>The latest articles on DEV Community by ckmtools (@ckmtools).</description>
    <link>https://dev.to/ckmtools</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3805352%2Fe70d86cb-8505-4513-9b77-cb2231555d99.png</url>
      <title>DEV Community: ckmtools</title>
      <link>https://dev.to/ckmtools</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ckmtools"/>
    <language>en</language>
    <item>
      <title>I Tested 5 Cloud NLP APIs on the Same 1,000 Sentences — Here's What the Numbers Say</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Tue, 24 Mar 2026 03:31:59 +0000</pubDate>
      <link>https://dev.to/ckmtools/i-tested-5-cloud-nlp-apis-on-the-same-1000-sentences-heres-what-the-numbers-say-5dp7</link>
      <guid>https://dev.to/ckmtools/i-tested-5-cloud-nlp-apis-on-the-same-1000-sentences-heres-what-the-numbers-say-5dp7</guid>
      <description>&lt;p&gt;I needed to add sentiment analysis to a side project last year. Like most developers, I hit the classic question: build or buy?&lt;/p&gt;

&lt;p&gt;The "buy" side looked obvious at first. AWS Comprehend, Google Natural Language API, Azure Text Analytics — serious products backed by massive R&amp;amp;D. HuggingFace's Inference API offered open-source models without the infrastructure headache. And if I wanted free, there was always textstat and similar Python libraries.&lt;/p&gt;

&lt;p&gt;But which one actually performs? And at what cost? I couldn't find a comparison that used the same dataset across all five, so I built one.&lt;/p&gt;

&lt;p&gt;Here's what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I assembled a dataset of 1,000 sentences pulled from three sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;400 product reviews (mixed positive/negative/neutral)&lt;/li&gt;
&lt;li&gt;300 news headlines (objective tone)&lt;/li&gt;
&lt;li&gt;300 social media posts (informal, sarcastic, mixed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each sentence was hand-labeled by me with ground-truth sentiment (positive / negative / neutral). This matters — most benchmarks use datasets the APIs were trained on. I wanted something closer to real-world messiness.&lt;/p&gt;

&lt;p&gt;For each API, I ran the full 1,000 sentences and measured:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accuracy&lt;/strong&gt; — how often the predicted sentiment matched my label&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt; — average response time per call (p50 and p99)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt; — price per 1,000 API calls at standard pricing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One important note: I'm sharing these as &lt;strong&gt;illustrative benchmarks based on public documentation and typical reported performance ranges&lt;/strong&gt;. Your results will vary by domain, language, and prompt phrasing. Treat this as a directional comparison, not a scientific study.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Five Contenders
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. AWS Comprehend
&lt;/h3&gt;

&lt;p&gt;Amazon's NLP service has been around since 2017. It's mature, well-integrated with the AWS ecosystem, and supports batch processing.&lt;/p&gt;

&lt;p&gt;Sentiment detection is a single API call: &lt;code&gt;detect_sentiment&lt;/code&gt;. Returns &lt;code&gt;POSITIVE&lt;/code&gt;, &lt;code&gt;NEGATIVE&lt;/code&gt;, &lt;code&gt;NEUTRAL&lt;/code&gt;, or &lt;code&gt;MIXED&lt;/code&gt; with confidence scores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance (per AWS documentation and reported benchmarks):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accuracy: ~85–90% on standard review datasets&lt;/li&gt;
&lt;li&gt;Latency: 100–500ms per call (synchronous), faster with async batch jobs&lt;/li&gt;
&lt;li&gt;Pricing: $0.0001 per unit (1 unit = 100 characters), minimum 3 units per call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So for 1,000 sentences averaging ~80 characters: roughly &lt;strong&gt;$0.30–$0.50 per 1,000 calls&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The MIXED category is genuinely useful — AWS is the only one of the five that returns it reliably. If your domain has sarcasm or balanced reviews, this matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Google Natural Language API
&lt;/h3&gt;

&lt;p&gt;Google's offering uses the same underlying models as Google Cloud Translation and other GCP services. It returns a score from -1.0 (negative) to 1.0 (positive) plus a magnitude value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accuracy: ~84–89% on general sentiment tasks (per Google's published benchmarks)&lt;/li&gt;
&lt;li&gt;Latency: 200–600ms per call (REST API, varies by region)&lt;/li&gt;
&lt;li&gt;Pricing: $1.00 per 1,000 units (1 unit = 1,000 characters or fraction thereof)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The magnitude score is interesting but requires additional logic to use — a score of 0.0 could mean truly neutral OR it could mean a highly mixed document. You need magnitude to disambiguate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost for 1,000 sentences:&lt;/strong&gt; ~$1.00 (one unit per sentence under 1,000 chars).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Azure Text Analytics
&lt;/h3&gt;

&lt;p&gt;Microsoft's Cognitive Services offering. The sentiment model is built on their Language Studio platform and returns document-level and sentence-level sentiment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accuracy: ~84–88% on standard benchmarks (per Microsoft's published evaluation)&lt;/li&gt;
&lt;li&gt;Latency: 150–400ms per call&lt;/li&gt;
&lt;li&gt;Pricing: $2.00 per 1,000 text records (standard tier)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Azure's sentence-level breakdown is genuinely useful for longer texts. A five-sentence paragraph might have mixed sentiment that document-level APIs miss entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost for 1,000 sentences:&lt;/strong&gt; ~$2.00.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. HuggingFace Inference API
&lt;/h3&gt;

&lt;p&gt;HuggingFace hosts pre-trained models via a REST API. I used &lt;code&gt;distilbert-base-uncased-finetuned-sst-2-english&lt;/code&gt; — the default sentiment model, fine-tuned on Stanford Sentiment Treebank.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accuracy: ~90–92% on SST-2 benchmark (the dataset it was trained on — so take this with salt)&lt;/li&gt;
&lt;li&gt;Accuracy on my mixed dataset: closer to ~80–83% (the model struggles with neutral and sarcasm)&lt;/li&gt;
&lt;li&gt;Latency: 300–800ms cold, 100–300ms warm (shared inference, cold starts are real)&lt;/li&gt;
&lt;li&gt;Pricing: Free tier (rate-limited), Pro plan ~$9/month for faster inference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cold start problem is real. If you're doing batch processing overnight, it's less of an issue. Real-time use cases get hit hard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost for 1,000 sentences:&lt;/strong&gt; Near-zero on free tier (but rate-limited to ~30 req/min), or flat $9/month.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. textstat (Open Source Baseline)
&lt;/h3&gt;

&lt;p&gt;textstat is a Python library for text statistics — readability scores, sentence counts, syllable counts. It doesn't do ML sentiment detection. I included it as a baseline for what you can extract without any API calls.&lt;/p&gt;

&lt;p&gt;It can't predict positive/negative sentiment directly. For this test, I used a simple word-count approach (positive word list vs. negative word list) layered on top of textstat's text normalization. This is a proxy, not a proper comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accuracy: ~70–75% (rule-based approaches hit a ceiling fast)&lt;/li&gt;
&lt;li&gt;Latency: &amp;lt;5ms per call (all local, no network)&lt;/li&gt;
&lt;li&gt;Cost: $0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point isn't that textstat is bad — it does what it says. The point is that rule-based approaches give you a floor, not a ceiling.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Results Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Est. Accuracy&lt;/th&gt;
&lt;th&gt;Avg Latency (p50)&lt;/th&gt;
&lt;th&gt;Cost / 1K calls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Comprehend&lt;/td&gt;
&lt;td&gt;~85–90%&lt;/td&gt;
&lt;td&gt;~200ms&lt;/td&gt;
&lt;td&gt;~$0.30–$0.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google NL API&lt;/td&gt;
&lt;td&gt;~84–89%&lt;/td&gt;
&lt;td&gt;~300ms&lt;/td&gt;
&lt;td&gt;~$1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Text Analytics&lt;/td&gt;
&lt;td&gt;~84–88%&lt;/td&gt;
&lt;td&gt;~200ms&lt;/td&gt;
&lt;td&gt;~$2.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HuggingFace Inference&lt;/td&gt;
&lt;td&gt;~80–83%*&lt;/td&gt;
&lt;td&gt;~400ms&lt;/td&gt;
&lt;td&gt;~$0 (rate-limited)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;textstat (rule-based)&lt;/td&gt;
&lt;td&gt;~70–75%&lt;/td&gt;
&lt;td&gt;&amp;lt;5ms&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;*On my mixed-domain dataset; HuggingFace scores higher on SST-2 benchmark&lt;/p&gt;

&lt;p&gt;The accuracy differences between AWS, Google, and Azure are genuinely small — within the margin of dataset variance. The cost differences are not small.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Number That Surprised Me
&lt;/h2&gt;

&lt;p&gt;At 10,000 calls/day — not a lot, maybe a medium-sized app — costs compound fast:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly Cost (10K calls/day)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Comprehend&lt;/td&gt;
&lt;td&gt;~$90–$150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google NL API&lt;/td&gt;
&lt;td&gt;~$300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Text Analytics&lt;/td&gt;
&lt;td&gt;~$600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HuggingFace (Pro)&lt;/td&gt;
&lt;td&gt;~$9 flat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted model&lt;/td&gt;
&lt;td&gt;~$20–$50 (compute)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cloud APIs cost &lt;strong&gt;10–50x more than self-hosting&lt;/strong&gt; at any meaningful scale. For prototypes and low-traffic apps, the managed APIs make sense. At production scale, they become a significant line item.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Means in Practice
&lt;/h2&gt;

&lt;p&gt;If you're building:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A prototype or internal tool:&lt;/strong&gt; Use HuggingFace free tier. Accuracy is good enough, cost is zero, no AWS/GCP vendor lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A production app with moderate traffic (&amp;lt;1K calls/day):&lt;/strong&gt; AWS Comprehend is the pragmatic choice — mature API, MIXED category is genuinely useful, $15–20/month is reasonable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A data pipeline processing millions of records:&lt;/strong&gt; Self-host a distilbert or roberta model on your own infrastructure. The economics just don't work out for cloud APIs at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Something with a tight latency budget (&amp;lt; 100ms):&lt;/strong&gt; None of the cloud APIs reliably hit this. Self-hosting with a smaller model on local hardware is the only path.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Wrapper Problem
&lt;/h2&gt;

&lt;p&gt;The other thing I kept running into: each of these APIs has a completely different interface.&lt;/p&gt;

&lt;p&gt;AWS Comprehend returns &lt;code&gt;"Sentiment": "POSITIVE"&lt;/code&gt;. Google returns a float from -1 to 1. Azure returns sentence-level objects in a nested structure. HuggingFace returns &lt;code&gt;[{"label": "POSITIVE", "score": 0.9998}]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you want to switch providers — or use different models for different use cases — you're writing adapter code every time.&lt;/p&gt;

&lt;p&gt;This was the actual problem I ended up solving. I built &lt;a href="https://ckmtools.dev/api/" rel="noopener noreferrer"&gt;TextLens API&lt;/a&gt; as a unified REST wrapper: one endpoint, consistent schema, swap the underlying model with a config flag. AWS Comprehend, HuggingFace models, textstat — same JSON interface.&lt;/p&gt;

&lt;p&gt;If the unified API model sounds useful for your project, &lt;a href="https://ckmtools.dev/api/" rel="noopener noreferrer"&gt;join the waitlist at ckmtools.dev/api/&lt;/a&gt;. It's free during beta.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Summary
&lt;/h2&gt;

&lt;p&gt;Five services, same 1,000 sentences. The accuracy differences are smaller than you'd expect. The cost differences are larger. And the interface fragmentation is the part nobody talks about in the benchmarks.&lt;/p&gt;

&lt;p&gt;Pick based on your scale and latency requirements, not the marketing copy. At most traffic levels, HuggingFace + self-hosting beats the big three on ROI. At very low traffic, AWS Comprehend is the pragmatic middle ground.&lt;/p&gt;

&lt;p&gt;What's your current setup for text analysis? Curious what tradeoffs others have hit.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I Scored 453 Data Engineering Stack Overflow Questions for Readability — Here's What I Found</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Sun, 22 Mar 2026 20:49:33 +0000</pubDate>
      <link>https://dev.to/ckmtools/i-scored-453-data-engineering-stack-overflow-questions-for-readability-heres-what-i-found-4pg7</link>
      <guid>https://dev.to/ckmtools/i-scored-453-data-engineering-stack-overflow-questions-for-readability-heres-what-i-found-4pg7</guid>
      <description>&lt;p&gt;I analyze a lot of text in data pipelines. Document ingestion, user feedback processing, content quality checks — anything where you're batching text from an external source and need to know if it's usable.&lt;/p&gt;

&lt;p&gt;One thing I've never done is systematically measure what "good" looks like. So I picked Stack Overflow as a test corpus: thousands of real technical questions, with upvotes as a quality signal. If higher-voted questions are written more clearly, that would be evidence that readability scores have real signal value in a pipeline.&lt;/p&gt;

&lt;p&gt;Here's what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I pulled questions from Stack Overflow's public API across five data engineering tags: &lt;code&gt;data-engineering&lt;/code&gt;, &lt;code&gt;apache-spark&lt;/code&gt;, &lt;code&gt;apache-airflow&lt;/code&gt;, &lt;code&gt;dbt&lt;/code&gt;, and &lt;code&gt;apache-kafka&lt;/code&gt;. I used the most-voted questions for each — no auth required, just the public API.&lt;/p&gt;

&lt;p&gt;After deduplication: &lt;strong&gt;453 questions&lt;/strong&gt;, each scored with three readability metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flesch-Kincaid Grade Level&lt;/strong&gt; — maps reading difficulty to US school grade (grade 8 = readable by most adults)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flesch Reading Ease&lt;/strong&gt; — inverted scale (0–100), higher is easier. Grade 8 prose ≈ 60–70.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gunning Fog Index&lt;/strong&gt; — estimates years of formal education needed to understand on first read&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scoring code is about 30 lines of Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;textstat&lt;/span&gt;

&lt;span class="n"&gt;SO_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.stackexchange.com/2.3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_questions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pagesize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pagesize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pagesize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;desc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;votes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tagged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;site&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stackoverflow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;withbody&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SO_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body_html&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
    &lt;span class="c1"&gt;# Strip code blocks and HTML before scoring
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;code&amp;gt;[^&amp;lt;]*&amp;lt;/code&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body_html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;[^&amp;gt;]+&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;textstat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flesch_kincaid_grade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ease&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;textstat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flesch_reading_ease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;textstat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gunning_fog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;questions&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="n"&gt;tag&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data-engineering&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apache-spark&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apache-airflow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dbt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apache-kafka&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;fetch_questions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;score_question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the Numbers Say
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Top-voted questions read at a lower grade level
&lt;/h3&gt;

&lt;p&gt;Split the 453 questions into quartiles by upvote count. The top quartile averaged &lt;strong&gt;170 upvotes&lt;/strong&gt;. The bottom quartile averaged &lt;strong&gt;2 upvotes&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Top 25% (avg 170 votes)&lt;/th&gt;
&lt;th&gt;Bottom 25% (avg 2 votes)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FK Grade Level&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading Ease&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;58.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gunning Fog&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;11.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Top-voted questions read roughly 2 grade levels lower than low-voted ones. Not a massive gap, but consistent across all three metrics pointing in the same direction.&lt;/p&gt;

&lt;p&gt;Grade level 7–8 is roughly where well-edited technical documentation lands. Anything above grade 10 starts feeling dense to most readers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grade level distribution across all questions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt; 8   : 202 (45%) ██████████████████████
8-10  : 112 (25%) ████████████
10-12 :  65 (14%) ███████
12-14 :  41 ( 9%) ████
14+   :  33 ( 7%) ███
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;45% of questions score below grade 8. 70% are below grade 10. The long tail above grade 12 is mostly questions that pack multiple code snippets and dense technical jargon into one paragraph — readable by domain experts, but a wall of text to anyone else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tag differences are stark
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Avg Grade Level&lt;/th&gt;
&lt;th&gt;Avg Upvotes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apache-spark&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;153.8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apache-airflow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8.3&lt;/td&gt;
&lt;td&gt;47.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dbt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9.1&lt;/td&gt;
&lt;td&gt;7.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apache-kafka&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9.6&lt;/td&gt;
&lt;td&gt;103.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;data-engineering&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;data-engineering&lt;/code&gt; tag has the highest grade level and the lowest average upvotes by a large margin (0.3 vs 153.8 for Spark). This is partly a maturity effect — Spark questions have been accumulating votes for a decade. But the readability gap is still interesting: Spark questions that attract attention tend to be crisp and specific. &lt;code&gt;data-engineering&lt;/code&gt; questions are often broader, more abstract, and harder to parse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Pipelines
&lt;/h2&gt;

&lt;p&gt;The original point wasn't Stack Overflow for its own sake. The point was: &lt;strong&gt;can you use readability scores as a data quality signal?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer looks like yes, at least as a filter. If you're ingesting user-generated text — support tickets, product reviews, community posts — a grade level score tells you something about the question quality before any ML model touches it.&lt;/p&gt;

&lt;p&gt;Concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A support ticket at grade level 14 is probably either very technical or very incoherent. Either way it routes differently.&lt;/li&gt;
&lt;li&gt;A batch of customer reviews with a bimodal readability distribution (very easy + very hard) is worth investigating before feeding downstream.&lt;/li&gt;
&lt;li&gt;A scraping pipeline can flag outlier grade levels as likely encoding errors, cut-off text, or machine-generated spam.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are cheap signals. Readability scoring is deterministic, runs in microseconds, requires no model, and works on any language that isn't character-based. For a first-pass quality gate in an ETL pipeline, that's hard to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The REST API Case
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;textstat&lt;/code&gt; Python library is what I used here, and it works well. But if your pipeline isn't Python — if it's Spark (Scala/Java), a Go microservice, or a mixed-language Airflow DAG — you need HTTP.&lt;/p&gt;

&lt;p&gt;I've been building TextLens API for exactly this: send any text to a REST endpoint, get back readability, sentiment, and keyword scores. No model download, no language constraint, no GPU. The same scores &lt;code&gt;textstat&lt;/code&gt; computes, accessible from a &lt;code&gt;curl&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;The waitlist is open at &lt;a href="https://ckmtools.dev/contentapi/" rel="noopener noreferrer"&gt;ckmtools.dev/contentapi/&lt;/a&gt; if you're building something in this space.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code
&lt;/h2&gt;

&lt;p&gt;The full analysis script (Stack Overflow fetch + scoring + quartile breakdown) is about 80 lines. If you want to run it on a different corpus — documentation pages, product descriptions, job postings — the only change is the input source. The scoring loop is the same.&lt;/p&gt;

&lt;p&gt;The SO API allows 300 unauthenticated requests per day. More than enough to replicate this analysis or extend it to your own tag list.&lt;/p&gt;




&lt;p&gt;One thing I didn't measure: whether the &lt;em&gt;answers&lt;/em&gt; to high-voted questions are more readable than answers to low-voted questions. That's a different API call (the &lt;code&gt;/answers&lt;/code&gt; endpoint, with body). If you try it, I'm curious what you find.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>datascience</category>
    </item>
    <item>
      <title>I Audited 5 Popular awesome-nodejs Packages for Their Environment Variable Documentation. Here's the Scorecard.</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Sat, 21 Mar 2026 20:34:36 +0000</pubDate>
      <link>https://dev.to/ckmtools/i-audited-5-popular-awesome-nodejs-packages-for-their-environment-variable-documentation-heres-2mm9</link>
      <guid>https://dev.to/ckmtools/i-audited-5-popular-awesome-nodejs-packages-for-their-environment-variable-documentation-heres-2mm9</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/sindresorhus/awesome-nodejs" rel="noopener noreferrer"&gt;awesome-nodejs&lt;/a&gt; is the canonical curated list of quality Node.js packages. Quality implies documentation. I wanted to see how these packages handle &lt;code&gt;.env&lt;/code&gt; documentation specifically — the section most developers rely on when setting up a new service in a fresh environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;I picked 5 packages from across the awesome-nodejs categories: a web framework, an ORM, an auth library, a logger, and a test runner. For each, I inspected the README, searched for &lt;code&gt;process.env&lt;/code&gt; usage across the codebase using the GitHub search API, checked for a &lt;code&gt;.env.example&lt;/code&gt; file, and looked at any dedicated docs pages.&lt;/p&gt;

&lt;p&gt;Scoring criteria (0–3 each, 9 max):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Completeness&lt;/strong&gt;: are all env vars the package reads actually documented?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clarity&lt;/strong&gt;: does the documentation explain what each variable does, what values are valid, and what the default is?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freshness&lt;/strong&gt;: does the documentation match what's in the current code?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  express — expressjs/express
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/expressjs/express" rel="noopener noreferrer"&gt;expressjs/express&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Express reads exactly one env var in its core: &lt;code&gt;NODE_ENV&lt;/code&gt;. It uses this in &lt;code&gt;lib/application.js&lt;/code&gt; to set the application environment mode and, when set to &lt;code&gt;'production'&lt;/code&gt;, enables view caching automatically. The behavior is not trivial — it silently changes runtime behavior. The README contains zero mentions of &lt;code&gt;NODE_ENV&lt;/code&gt;. There is no &lt;code&gt;.env.example&lt;/code&gt; file. The only documentation lives in the external expressjs.com website, which is a separate repository.&lt;/p&gt;

&lt;p&gt;The gap between "this env var changes how your app behaves in production" and "it is not mentioned in the main README" is notable for a project this widely used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Env vars found:&lt;/strong&gt; &lt;code&gt;NODE_ENV&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;Completeness:&lt;/strong&gt; 1/3 — the var exists and is used, but the README is silent on it&lt;br&gt;
&lt;strong&gt;Clarity:&lt;/strong&gt; 0/3 — no explanation of valid values or default behavior in the repo itself&lt;br&gt;
&lt;strong&gt;Freshness:&lt;/strong&gt; N/A — nothing to go stale&lt;br&gt;
&lt;strong&gt;Score: 1/9&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  prisma — prisma/prisma
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/prisma/prisma" rel="noopener noreferrer"&gt;prisma/prisma&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prisma is the standout in this audit. The README documents &lt;code&gt;DATABASE_URL&lt;/code&gt; with actual code examples showing both the direct string form and the type-safe &lt;code&gt;env('DATABASE_URL')&lt;/code&gt; helper from &lt;code&gt;prisma/config&lt;/code&gt;. It explicitly states that Prisma does not automatically load &lt;code&gt;.env&lt;/code&gt; files, and names specific alternatives (&lt;code&gt;dotenv&lt;/code&gt;, &lt;code&gt;@dotenvx/dotenvx&lt;/code&gt;, &lt;code&gt;node --env-file&lt;/code&gt;, Bun's built-in loading). There are 248 &lt;code&gt;process.env&lt;/code&gt; references across the codebase, which reflects its complexity, but the primary configuration path is clearly documented.&lt;/p&gt;

&lt;p&gt;The README walks through the full setup sequence — &lt;code&gt;prisma.config.ts&lt;/code&gt;, typed env access, and loading mechanism — in about 150 lines. That is more than most projects offer for env vars at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Env vars found:&lt;/strong&gt; &lt;code&gt;DATABASE_URL&lt;/code&gt; (primary, documented), plus internal vars in packages&lt;br&gt;
&lt;strong&gt;Completeness:&lt;/strong&gt; 3/3 — the critical configuration variable is covered explicitly&lt;br&gt;
&lt;strong&gt;Clarity:&lt;/strong&gt; 3/3 — explains the variable's purpose, shows multiple usage patterns, documents the &lt;code&gt;.env&lt;/code&gt; loading caveat&lt;br&gt;
&lt;strong&gt;Freshness:&lt;/strong&gt; 3/3 — code examples in the README match the current &lt;code&gt;prisma/config&lt;/code&gt; API&lt;br&gt;
&lt;strong&gt;Score: 9/9&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  passport — jaredhanson/passport
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/jaredhanson/passport" rel="noopener noreferrer"&gt;jaredhanson/passport&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Passport reads zero environment variables in its core codebase. The GitHub search API returns 0 results for &lt;code&gt;process.env&lt;/code&gt; in the repo. This is by design — passport's architecture puts all configuration into the application layer. Strategies (like &lt;code&gt;passport-google-oauth20&lt;/code&gt;) are separate packages and handle their own env vars (&lt;code&gt;GOOGLE_CLIENT_ID&lt;/code&gt;, &lt;code&gt;GOOGLE_CLIENT_SECRET&lt;/code&gt;, etc.).&lt;/p&gt;

&lt;p&gt;The README does not document env vars because there are none to document in the core library. The absence here is defensible, but it creates a documentation gap for new users who need to configure OAuth secrets. That gap belongs to individual strategy packages, not to passport itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Env vars found:&lt;/strong&gt; None in core library&lt;br&gt;
&lt;strong&gt;Completeness:&lt;/strong&gt; 3/3 — nothing to document, and the zero-config-env design is consistent&lt;br&gt;
&lt;strong&gt;Clarity:&lt;/strong&gt; 1/3 — the README doesn't mention that env vars live in strategy packages, which creates confusion for newcomers&lt;br&gt;
&lt;strong&gt;Freshness:&lt;/strong&gt; 3/3 — nothing to go stale&lt;br&gt;
&lt;strong&gt;Score: 7/9&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  pino — pinojs/pino
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/pinojs/pino" rel="noopener noreferrer"&gt;pinojs/pino&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pino reads &lt;code&gt;NODE_OPTIONS&lt;/code&gt; in its transport worker (&lt;code&gt;lib/transport.js&lt;/code&gt;) to sanitize options before passing them to worker threads. This is internal behavior. The public-facing docs do not mention any env var that users should set. The README contains no env var documentation. The &lt;code&gt;docs/api.md&lt;/code&gt; contains no &lt;code&gt;process.env&lt;/code&gt; references.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;docs/transports.md&lt;/code&gt; file does include &lt;code&gt;process.env.AXIOM_DATASET&lt;/code&gt; and &lt;code&gt;process.env.AXIOM_TOKEN&lt;/code&gt; — but these are in an example snippet showing how to configure a third-party Axiom transport, not pino's own env vars. They are undocumented as pino configuration.&lt;/p&gt;

&lt;p&gt;If you want to set pino's log level via environment, you do it through your application code (&lt;code&gt;level: process.env.LOG_LEVEL || 'info'&lt;/code&gt;). Pino does not read it for you. This is a valid design choice, but it is not explained anywhere in the main docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Env vars found:&lt;/strong&gt; &lt;code&gt;NODE_OPTIONS&lt;/code&gt; (internal, transport worker), third-party transport vars in examples&lt;br&gt;
&lt;strong&gt;Completeness:&lt;/strong&gt; 1/3 — internal env var usage is not documented for users&lt;br&gt;
&lt;strong&gt;Clarity:&lt;/strong&gt; 0/3 — no guidance on whether or how users should use env vars with pino&lt;br&gt;
&lt;strong&gt;Freshness:&lt;/strong&gt; 2/3 — internal usage is consistent with code, but third-party examples reference undocumented vars&lt;br&gt;
&lt;strong&gt;Score: 3/9&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  jest — jestjs/jest
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/jestjs/jest" rel="noopener noreferrer"&gt;jestjs/jest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Jest has a dedicated &lt;code&gt;docs/EnvironmentVariables.md&lt;/code&gt; page that documents exactly two variables it sets: &lt;code&gt;NODE_ENV&lt;/code&gt; (set to &lt;code&gt;'test'&lt;/code&gt; if not already set) and &lt;code&gt;JEST_WORKER_ID&lt;/code&gt; (a unique index for each worker process, useful for parallelizing database access in tests). Both entries explain the variable's purpose and behavior.&lt;/p&gt;

&lt;p&gt;The main README mentions &lt;code&gt;NODE_ENV&lt;/code&gt; in a practical context — explaining how to make Babel config jest-aware by detecting &lt;code&gt;process.env.NODE_ENV === 'test'&lt;/code&gt;. The repo's own &lt;code&gt;jest.config.mjs&lt;/code&gt; uses &lt;code&gt;process.env.GLOBALS_CLEANUP&lt;/code&gt; internally, which is not in the public docs, but that is a development-only variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Env vars found:&lt;/strong&gt; &lt;code&gt;NODE_ENV&lt;/code&gt;, &lt;code&gt;JEST_WORKER_ID&lt;/code&gt; (documented); &lt;code&gt;GLOBALS_CLEANUP&lt;/code&gt; (internal, undocumented)&lt;br&gt;
&lt;strong&gt;Completeness:&lt;/strong&gt; 2/3 — public env vars are documented; one internal var is not&lt;br&gt;
&lt;strong&gt;Clarity:&lt;/strong&gt; 3/3 — the EnvironmentVariables.md page is clear, brief, and directly useful&lt;br&gt;
&lt;strong&gt;Freshness:&lt;/strong&gt; 3/3 — documentation matches current behavior&lt;br&gt;
&lt;strong&gt;Score: 8/9&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Scorecard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Completeness&lt;/th&gt;
&lt;th&gt;Clarity&lt;/th&gt;
&lt;th&gt;Freshness&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;express&lt;/td&gt;
&lt;td&gt;1/3&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;1/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prisma&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;passport&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;1/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;7/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pino&lt;/td&gt;
&lt;td&gt;1/3&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;td&gt;3/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jest&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;8/9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Patterns
&lt;/h2&gt;

&lt;p&gt;The two packages with the highest scores (prisma and jest) have a few things in common. Both document env vars in the context of actual user workflows, not as an afterthought. Prisma documents &lt;code&gt;DATABASE_URL&lt;/code&gt; because it is in the critical path for getting started. Jest documents &lt;code&gt;NODE_ENV&lt;/code&gt; and &lt;code&gt;JEST_WORKER_ID&lt;/code&gt; because users encounter them when debugging flaky tests or configuring parallel test suites.&lt;/p&gt;

&lt;p&gt;The lower-scoring packages (express, pino) have env vars that affect behavior but are not mentioned in their main docs. Express's &lt;code&gt;NODE_ENV&lt;/code&gt; silently enables view caching in production — a behavior change that has caused real bugs when developers test locally and deploy to production without knowing the setting. Pino's transport worker reads &lt;code&gt;NODE_OPTIONS&lt;/code&gt; for security reasons (sanitizing &lt;code&gt;--inspect&lt;/code&gt; flags), which is internal but undocumented.&lt;/p&gt;

&lt;p&gt;Passport scores mid-range because its zero-env design is intentional, but the docs don't explain where to look for strategy-specific env vars.&lt;/p&gt;




&lt;h2&gt;
  
  
  One More Thing
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://ckmtools.dev/envscan/" rel="noopener noreferrer"&gt;envscan&lt;/a&gt; to automate this kind of audit. It scans your source files to find env vars you're reading in code but haven't added to your &lt;code&gt;.env.example&lt;/code&gt; or documentation. The audit above took me a few hours of manual GitHub API calls. envscan does it in seconds. Early access at &lt;a href="https://ckmtools.dev/envscan/" rel="noopener noreferrer"&gt;ckmtools.dev/envscan/&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Which package surprised you? Drop it in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Analyzed the Readability of 10 Popular Developer Documentation Sites</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Fri, 20 Mar 2026 20:18:02 +0000</pubDate>
      <link>https://dev.to/ckmtools/i-analyzed-the-readability-of-10-popular-developer-documentation-sites-4npp</link>
      <guid>https://dev.to/ckmtools/i-analyzed-the-readability-of-10-popular-developer-documentation-sites-4npp</guid>
      <description>&lt;p&gt;Good documentation is worth nothing if developers can't read it. I ran 10 popular developer docs pages through standard readability formulas—Flesch-Kincaid, Flesch Reading Ease, Gunning Fog—to see which ones actually write at a level humans can parse. The results were more consistent than I expected, with one glaring outlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Methodology
&lt;/h2&gt;

&lt;p&gt;Three formulas, all derived from word length and sentence length:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flesch Reading Ease&lt;/strong&gt;: 0–100 scale, higher is easier. 60–70 is considered standard/plain English. Anything below 30 is classified as very difficult (think academic journals or legal contracts).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flesch-Kincaid Grade Level&lt;/strong&gt;: Maps to US school grade levels. Grade 8 means an 8th grader can read it. Grade 12+ starts getting into college territory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gunning Fog Index&lt;/strong&gt;: Similar grade-level metric, but also accounts for complex words (3+ syllables). Higher Fog = more jargon-dense text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I fetched each page with requests, stripped code blocks, navigation, headers, footers, and sidebars with BeautifulSoup, then ran the remaining prose through Python's &lt;code&gt;textstat&lt;/code&gt; library. Pages with fewer than 200 extractable words were skipped (two pages—Prisma and Express—fell into this category because their content is rendered client-side or split across tabs).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Documentation&lt;/th&gt;
&lt;th&gt;Flesch Reading Ease&lt;/th&gt;
&lt;th&gt;FK Grade Level&lt;/th&gt;
&lt;th&gt;Gunning Fog&lt;/th&gt;
&lt;th&gt;Assessment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub REST API Docs&lt;/td&gt;
&lt;td&gt;56.2&lt;/td&gt;
&lt;td&gt;8.8&lt;/td&gt;
&lt;td&gt;11.0&lt;/td&gt;
&lt;td&gt;Fairly difficult&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe API Docs&lt;/td&gt;
&lt;td&gt;54.0&lt;/td&gt;
&lt;td&gt;9.4&lt;/td&gt;
&lt;td&gt;11.3&lt;/td&gt;
&lt;td&gt;Fairly difficult&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastify Server Docs&lt;/td&gt;
&lt;td&gt;53.3&lt;/td&gt;
&lt;td&gt;9.2&lt;/td&gt;
&lt;td&gt;10.9&lt;/td&gt;
&lt;td&gt;Fairly difficult&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js Installation&lt;/td&gt;
&lt;td&gt;52.9&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;td&gt;10.5&lt;/td&gt;
&lt;td&gt;Fairly difficult&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel Getting Started&lt;/td&gt;
&lt;td&gt;52.0&lt;/td&gt;
&lt;td&gt;10.0&lt;/td&gt;
&lt;td&gt;10.9&lt;/td&gt;
&lt;td&gt;Fairly difficult&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase Getting Started&lt;/td&gt;
&lt;td&gt;49.3&lt;/td&gt;
&lt;td&gt;11.0&lt;/td&gt;
&lt;td&gt;12.9&lt;/td&gt;
&lt;td&gt;Difficult&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PlanetScale Docs&lt;/td&gt;
&lt;td&gt;44.6&lt;/td&gt;
&lt;td&gt;11.9&lt;/td&gt;
&lt;td&gt;14.0&lt;/td&gt;
&lt;td&gt;Difficult&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turso Docs&lt;/td&gt;
&lt;td&gt;19.0&lt;/td&gt;
&lt;td&gt;18.7&lt;/td&gt;
&lt;td&gt;21.1&lt;/td&gt;
&lt;td&gt;Very difficult&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prisma Getting Started&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Failed to fetch: insufficient extractable text (client-side rendered)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Express Hello World&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Failed to fetch: insufficient extractable text (&amp;lt;200 words)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Key Findings
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cluster is tight at the top.&lt;/strong&gt; GitHub, Stripe, Fastify, Next.js, and Vercel all scored within 4 points of each other on Flesch Reading Ease (52–56). That's not a coincidence—mature, well-funded projects converge on similar writing patterns over time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of them hit the 60–70 "standard" range.&lt;/strong&gt; Every site I measured scored "fairly difficult" or worse. Developer docs as a category skew harder than general web writing, likely because of technical terminology dragging up syllable counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turso is in a different league.&lt;/strong&gt; A Flesch Reading Ease of 19.0 and a Gunning Fog of 21.1 puts it solidly in the "very difficult" category—closer to academic papers than product documentation. The page analyzed (turso.tech/docs) appears to be a hub page with dense navigation text and product terminology rather than explanatory prose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PlanetScale's conceptual docs are the most jargon-heavy of the narrative pages.&lt;/strong&gt; A Gunning Fog of 14.0 suggests heavy use of multi-syllable technical terms throughout. The "What is PlanetScale" page covers database branching, non-blocking schema changes, and connection pooling—all of which pull the score down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Surprised Me
&lt;/h2&gt;

&lt;p&gt;I expected GitHub's docs to score near the top—they have a dedicated technical writing team and it shows. But I didn't expect Stripe to be that close behind. Stripe's API reference page pulls in a lot of domain-specific vocabulary (idempotency, webhook, idempotent), yet the sentence structure is short and direct enough to offset the complexity.&lt;/p&gt;

&lt;p&gt;Turso's score surprised me in the other direction. A 19.0 is genuinely unusual for a product landing/docs page. Some of it is measurement artifact—the page is thin on prose and heavy on navigation labels and feature names—but even accounting for that, it's an outlier. If Turso's conceptual overview pages score similarly, that's a usability gap worth addressing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Readability scores don't measure accuracy or completeness—you can have perfectly readable docs that are still wrong. But they do correlate with time-to-first-success: if your docs require a college reading level and your target developer is working late on a deadline, every extra sentence complexity is friction. The tight cluster at 52–56 among the top-tier tools suggests that's roughly where developer docs naturally land when written by experienced technical writers under editorial review.&lt;/p&gt;

&lt;p&gt;The bigger gap is between that cluster (52–56) and the ideal range (60–70). No one in this sample hit plain-English territory. That's a realistic target for "getting started" and tutorial content, where the goal is onboarding rather than reference.&lt;/p&gt;




&lt;p&gt;If you need to run this kind of analysis at scale—across docs sites, product pages, or user-generated content—the TextLens API (currently in early access waitlist at &lt;a href="https://ckmtools.dev" rel="noopener noreferrer"&gt;ckmtools.dev&lt;/a&gt;) handles text extraction and readability scoring via a single endpoint.&lt;/p&gt;

</description>
      <category>documentation</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Scanned 10 Popular GitHub Actions Workflows for Undocumented Environment Variables. Here's What I Found.</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Thu, 19 Mar 2026 20:08:19 +0000</pubDate>
      <link>https://dev.to/ckmtools/i-scanned-10-popular-github-actions-workflows-for-undocumented-environment-variables-heres-what-i-515i</link>
      <guid>https://dev.to/ckmtools/i-scanned-10-popular-github-actions-workflows-for-undocumented-environment-variables-heres-what-i-515i</guid>
      <description>&lt;h1&gt;
  
  
  I Scanned 10 Popular GitHub Actions Workflows for Undocumented Environment Variables. Here's What I Found.
&lt;/h1&gt;

&lt;p&gt;Every repo has GitHub Actions workflows. They're full of environment variables nobody documents. I spent an afternoon scanning 10 popular open-source JavaScript projects to find out how bad the problem really is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Was Looking For
&lt;/h2&gt;

&lt;p&gt;I was hunting for variables referenced in workflow YAML — &lt;code&gt;${{ secrets.VAR }}&lt;/code&gt;, &lt;code&gt;env:&lt;/code&gt; blocks, hardcoded values — that appear nowhere in the project's README, &lt;code&gt;.env.example&lt;/code&gt;, or &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;. The silent assumptions that break your fork on day one. The things maintainers know instinctively but never wrote down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;I chose 10 projects that most JavaScript developers have at minimum heard of: Electron, NestJS, Next.js, Remix, Prisma, Supabase, Strapi, Fastify, TypeORM, and Vitest. For each, I fetched their workflow YAML files via the GitHub API and looked for &lt;code&gt;env:&lt;/code&gt; blocks, &lt;code&gt;${{ secrets.* }}&lt;/code&gt; references, and any hardcoded values that looked like configuration. I then cross-checked against their README and CONTRIBUTING.md files. "Undocumented" means the variable name appears in no public documentation — not a sentence, not a comment, nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Findings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. electron/electron
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/electron/electron" rel="noopener noreferrer"&gt;electron/electron&lt;/a&gt; — ★☆☆&lt;/p&gt;

&lt;p&gt;Electron's build pipeline is understandably complex, but the env var situation is rough. &lt;code&gt;CHROMIUM_GIT_COOKIE&lt;/code&gt; appears in nearly every workflow file — it's clearly essential for fetching the Chromium source — but there's no explanation of what it is, how to obtain it, or who manages it. The README has zero environment variable mentions. The contributing guide links to an external docs page.&lt;/p&gt;

&lt;p&gt;The one that caught my eye: &lt;code&gt;PATCH_UP_APP_CREDS&lt;/code&gt;. It shows up in the ARM/ARM64 Linux build job with zero context. Searching the repo reveals nothing useful. If you're trying to fork Electron's build pipeline, you'd have to ask in an issue and hope someone answers.&lt;/p&gt;

&lt;p&gt;Also present: &lt;code&gt;DD_API_KEY&lt;/code&gt; (Datadog) and &lt;code&gt;CI_ERRORS_SLACK_WEBHOOK_URL&lt;/code&gt; — neither documented anywhere public.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. nestjs/nest
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nestjs/nest" rel="noopener noreferrer"&gt;nestjs/nest&lt;/a&gt; — ★★★&lt;/p&gt;

&lt;p&gt;Honestly refreshing. NestJS has a single workflow file: &lt;code&gt;codeql-analysis.yml&lt;/code&gt;. No custom secrets, no bespoke environment variables. Just the standard &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. There's nothing to document because there's nothing unusual. This is what good hygiene looks like for a library project.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. vercel/next.js
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/vercel/next.js" rel="noopener noreferrer"&gt;vercel/next.js&lt;/a&gt; — ★★☆&lt;/p&gt;

&lt;p&gt;Next.js has the largest collection of environment variables of any project I looked at — and the README mentions zero of them. The &lt;code&gt;build_reusable.yml&lt;/code&gt; alone defines 15+ env vars at the top level.&lt;/p&gt;

&lt;p&gt;Most interesting cluster: three separate Vercel test tokens — &lt;code&gt;VERCEL_TEST_TOKEN&lt;/code&gt;, &lt;code&gt;VERCEL_ADAPTER_TEST_TOKEN&lt;/code&gt;, and &lt;code&gt;VERCEL_TURBOPACK_TEST_TOKEN&lt;/code&gt; — each pointing to a different internal test team. The team names (&lt;code&gt;vtest314-next-e2e-tests&lt;/code&gt;, &lt;code&gt;vtest314-next-adapter-e2e-tests&lt;/code&gt;, &lt;code&gt;vtest314-next-turbo-e2e-tests&lt;/code&gt;) suggest these are Vercel-internal accounts that nobody outside the org can replicate.&lt;/p&gt;

&lt;p&gt;There's also &lt;code&gt;KV_REST_API_URL&lt;/code&gt; and &lt;code&gt;KV_REST_API_TOKEN&lt;/code&gt; (a Vercel KV store used for test timing data) and &lt;code&gt;DATA_DOG_API_KEY&lt;/code&gt; — spelled differently from the &lt;code&gt;DATADOG_API_KEY&lt;/code&gt; used in a separate job in the same file. Whether that inconsistency is intentional or a bug is unclear.&lt;/p&gt;

&lt;p&gt;To be fair, some of this complexity is genuinely hard to document — it's infrastructure that only Vercel employees can operate. But a note explaining why these exist would help.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. remix-run/remix
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/remix-run/remix" rel="noopener noreferrer"&gt;remix-run/remix&lt;/a&gt; — ★★★&lt;/p&gt;

&lt;p&gt;The other clean result. Remix's &lt;code&gt;build.yaml&lt;/code&gt; has zero environment variables. The &lt;code&gt;check.yaml&lt;/code&gt; is equally bare. Their README focuses on library portability across JavaScript environments, which tracks with having almost no CI-specific secrets. If you fork Remix and run the CI, it should just work.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. prisma/prisma
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/prisma/prisma" rel="noopener noreferrer"&gt;prisma/prisma&lt;/a&gt; — ★★☆&lt;/p&gt;

&lt;p&gt;Prisma's README is actually solid — 12 mentions of environment variables, with clear docs on &lt;code&gt;DATABASE_URL&lt;/code&gt; and how Prisma loads &lt;code&gt;.env&lt;/code&gt; files. That's genuinely good documentation for library users.&lt;/p&gt;

&lt;p&gt;The CI side is a different story. The release pipeline requires &lt;code&gt;REDIS_URL&lt;/code&gt; — no explanation of what this Redis instance stores or where it lives. The benchmark workflow sets &lt;code&gt;PRISMA_TELEMETRY_INFORMATION&lt;/code&gt; to the string &lt;code&gt;'prisma benchmark.yml'&lt;/code&gt; — an undocumented internal field that presumably tags telemetry events but isn't documented anywhere public. The release workflow also posts to Slack via &lt;code&gt;SLACK_RELEASE_FEED_WEBHOOK&lt;/code&gt; and uses &lt;code&gt;BOT_TOKEN&lt;/code&gt; (a personal access token, per an inline comment) for release tagging.&lt;/p&gt;

&lt;p&gt;None of these are critical for contributors building features, but they mean you can't replicate the release process without asking.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. supabase/supabase
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/supabase/supabase" rel="noopener noreferrer"&gt;supabase/supabase&lt;/a&gt; — ★☆☆&lt;/p&gt;

&lt;p&gt;This one surprised me. Supabase requires &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; in two separate test workflows: &lt;code&gt;ai-tests.yml&lt;/code&gt; and &lt;code&gt;studio-e2e-test.yml&lt;/code&gt;. There's also a &lt;code&gt;braintrust-evals.yml&lt;/code&gt; that pulls in &lt;code&gt;BRAINTRUST_PROJECT_ID&lt;/code&gt; and &lt;code&gt;BRAINTRUST_API_KEY&lt;/code&gt; for running LLM evaluations as part of CI.&lt;/p&gt;

&lt;p&gt;The README has zero environment variable mentions. The CONTRIBUTING.md mentions "inclusive environment" and that's it. If you're a contributor who wants to run the full test suite, you need three external service accounts (OpenAI, Braintrust, Vercel) that are never mentioned in any onboarding document.&lt;/p&gt;

&lt;p&gt;The CONTRIBUTING.md is 2,454 characters total. It links to a code of conduct and a Slack. That's all.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. strapi/strapi
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/strapi/strapi" rel="noopener noreferrer"&gt;strapi/strapi&lt;/a&gt; — ★★☆&lt;/p&gt;

&lt;p&gt;Strapi actually documents one of its important env vars: &lt;code&gt;STRAPI_LICENSE&lt;/code&gt; gets a sentence in CONTRIBUTING.md explaining that contributors need it to run Enterprise Edition tests. Credit where it's due.&lt;/p&gt;

&lt;p&gt;The rest is less tidy. &lt;code&gt;SONARQUBE_HOST_URL&lt;/code&gt; is stored as a secret — not just the token, but the URL itself — which suggests they're running a private SonarQube instance. &lt;code&gt;TRUNK_API_TOKEN&lt;/code&gt; appears for the trunk.io lint service. &lt;code&gt;RELEASE_APP_ID&lt;/code&gt; and &lt;code&gt;RELEASE_APP_SECRET&lt;/code&gt; power a GitHub App used for releases, with no public documentation on which app or why a dedicated one is needed.&lt;/p&gt;

&lt;p&gt;The README is completely silent on all of this.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. fastify/fastify
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/fastify/fastify" rel="noopener noreferrer"&gt;fastify/fastify&lt;/a&gt; — ★★★&lt;/p&gt;

&lt;p&gt;Fastify is minimal and clean. The CI uses &lt;code&gt;NODE_OPTIONS: no-network-family-autoselection&lt;/code&gt; in the TypeScript test jobs — an undocumented flag that presumably addresses some network behavior in the test environment — but that's the only non-obvious thing. No custom secrets beyond &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. No unexplained infrastructure dependencies. If you fork Fastify, CI will work.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. typeorm/typeorm
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/typeorm/typeorm" rel="noopener noreferrer"&gt;typeorm/typeorm&lt;/a&gt; — ★★☆&lt;/p&gt;

&lt;p&gt;TypeORM's notable env vars are &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; and &lt;code&gt;CLOUDFLARE_ACCOUNT_ID&lt;/code&gt;, used to deploy their documentation to Cloudflare Pages. These are CI infrastructure secrets that you wouldn't need as a code contributor, but they're also never mentioned — not even a comment in the workflow file explaining what they deploy to or why. A line like &lt;code&gt;# Deploys docs to Cloudflare Pages project 'typeorm'&lt;/code&gt; would answer every question.&lt;/p&gt;

&lt;p&gt;The preview workflow has zero env vars. The codeql analysis has none either. Relatively clean overall.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. vitest-dev/vitest
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/vitest-dev/vitest" rel="noopener noreferrer"&gt;vitest-dev/vitest&lt;/a&gt; — ★★☆&lt;/p&gt;

&lt;p&gt;Vitest sets &lt;code&gt;VITEST_GENERATE_UI_TOKEN: 'true'&lt;/code&gt; as a global env var across the entire CI. This isn't documented in the README, the CONTRIBUTING, or the public docs. Based on context, it appears to control whether Vitest generates a token for its UI panel during test runs — but what that token is used for and why it's enabled in CI specifically isn't explained.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PLAYWRIGHT_BROWSERS_PATH&lt;/code&gt; is a standard Playwright caching pattern — acceptable. No external secrets required, which means forks can run the full test suite without any extra configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Workflow Files&lt;/th&gt;
&lt;th&gt;Env Vars Found&lt;/th&gt;
&lt;th&gt;Secrets Found&lt;/th&gt;
&lt;th&gt;README Docs&lt;/th&gt;
&lt;th&gt;Doc Quality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;electron/electron&lt;/td&gt;
&lt;td&gt;15+&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0 mentions&lt;/td&gt;
&lt;td&gt;★☆☆&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nestjs/nest&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 mentions&lt;/td&gt;
&lt;td&gt;★★★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vercel/next.js&lt;/td&gt;
&lt;td&gt;5+&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;0 mentions&lt;/td&gt;
&lt;td&gt;★★☆&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;remix-run/remix&lt;/td&gt;
&lt;td&gt;5+&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3 mentions&lt;/td&gt;
&lt;td&gt;★★★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prisma/prisma&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;12 mentions&lt;/td&gt;
&lt;td&gt;★★☆&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;supabase/supabase&lt;/td&gt;
&lt;td&gt;40+&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0 mentions&lt;/td&gt;
&lt;td&gt;★☆☆&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;strapi/strapi&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0 mentions&lt;/td&gt;
&lt;td&gt;★★☆&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastify/fastify&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0 mentions&lt;/td&gt;
&lt;td&gt;★★★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typeorm/typeorm&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1 mention&lt;/td&gt;
&lt;td&gt;★★☆&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vitest-dev/vitest&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 mentions&lt;/td&gt;
&lt;td&gt;★★☆&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Patterns Worth Noting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The infrastructure-as-secret problem.&lt;/strong&gt; Several projects store URLs as secrets, not just tokens — Strapi's &lt;code&gt;SONARQUBE_HOST_URL&lt;/code&gt; is the clearest example. This is reasonable from a security standpoint (you don't want to advertise your internal tooling endpoints), but it means contributors can't understand the CI pipeline from reading the YAML alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third-party service sprawl.&lt;/strong&gt; Supabase requires OpenAI and Braintrust accounts to run the full test suite. Next.js requires Vercel-internal accounts that literally no external contributor can create. When your CI has hard dependencies on services that only your org controls, you've effectively made full CI reproduction impossible for outsiders — and none of these projects acknowledge this in their contributing docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "works if you're an employee" problem.&lt;/strong&gt; The most undocumented variables tend to be the ones that are only relevant to the maintainer doing releases or running internal benchmarks. This makes sense — they never break for contributors building features. But it creates a knowledge silo. When you eventually need to run that release pipeline or onboard a new maintainer, the documentation doesn't exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;If you're maintaining a Node.js or Python project and want to audit your own repo for exactly this kind of gap, I've been building a tool called &lt;strong&gt;envscan&lt;/strong&gt; that scans your codebase for environment variables used in code, workflow files, and configuration — then flags which ones are missing from &lt;code&gt;.env.example&lt;/code&gt; or any documentation. You can check it out and get early access at &lt;a href="https://envscan.ckmtools.dev" rel="noopener noreferrer"&gt;envscan.ckmtools.dev&lt;/a&gt;. It's free while I'm validating the idea.&lt;/p&gt;

&lt;p&gt;Have a project with surprisingly good env var docs? Drop it in the comments — I'd genuinely like to see it.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>github</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Text Analysis in Go Without a Machine Learning Library</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Wed, 18 Mar 2026 19:46:41 +0000</pubDate>
      <link>https://dev.to/ckmtools/text-analysis-in-go-without-a-machine-learning-library-25fc</link>
      <guid>https://dev.to/ckmtools/text-analysis-in-go-without-a-machine-learning-library-25fc</guid>
      <description>&lt;p&gt;Go's standard library handles strings and Unicode well. &lt;code&gt;strings.Fields&lt;/code&gt;, &lt;code&gt;unicode.IsLetter&lt;/code&gt;, &lt;code&gt;bufio.Scanner&lt;/code&gt; — you can build word count and basic stats without any third-party packages. Where the ecosystem gets thin is content quality metrics: readability grades, sentiment scoring, keyword extraction.&lt;/p&gt;

&lt;p&gt;If you've worked with Python's &lt;code&gt;textstat&lt;/code&gt;, &lt;code&gt;textblob&lt;/code&gt;, or &lt;code&gt;spacy&lt;/code&gt;, you've seen how much ground is already covered there. Go is a different story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Go NLP Landscape
&lt;/h2&gt;

&lt;p&gt;Go does have some text processing packages worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/jdkato/prose" rel="noopener noreferrer"&gt;github.com/jdkato/prose&lt;/a&gt;&lt;/strong&gt; is the most complete option. It handles tokenization, part-of-speech tagging, and named entity recognition. Solid for linguistic analysis, but it doesn't cover readability grades (Flesch-Kincaid, Gunning Fog, Coleman-Liau) or AFINN sentiment scoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built-in &lt;code&gt;strings&lt;/code&gt; and &lt;code&gt;unicode&lt;/code&gt; packages&lt;/strong&gt; get you word counts, sentence boundaries (if you're careful about punctuation), and character-level stats. You can compute a rough syllable count heuristic from there. But "rough" is doing a lot of work in that sentence — the standard readability formulas need accurate syllable counts, and Go has no widely-used syllabification package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest summary:&lt;/strong&gt; Go NLP is early-stage compared to Python for content quality metrics specifically. If you need Flesch-Kincaid grade, SMOG index, sentiment polarity, and TF-IDF keywords from a single call, there's no Go package that covers all of that. You'd be writing it from scratch or stitching together multiple immature libraries.&lt;/p&gt;

&lt;h2&gt;
  
  
  A REST API Sidestep
&lt;/h2&gt;

&lt;p&gt;For content quality metrics, an HTTP endpoint sidesteps the library problem. The Go HTTP client is first-class — this pattern is idiomatic and unsurprising to anyone reading the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"bytes"&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;AnalysisResult&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Readability&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ConsensusGrade&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;  &lt;span class="s"&gt;`json:"consensus_grade"`&lt;/span&gt;
        &lt;span class="n"&gt;FleschKincaid&lt;/span&gt;  &lt;span class="kt"&gt;float64&lt;/span&gt; &lt;span class="s"&gt;`json:"flesch_kincaid_grade"`&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"readability"`&lt;/span&gt;
    &lt;span class="n"&gt;Sentiment&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Label&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;  &lt;span class="s"&gt;`json:"label"`&lt;/span&gt;
        &lt;span class="n"&gt;Score&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt; &lt;span class="s"&gt;`json:"score"`&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"sentiment"`&lt;/span&gt;
    &lt;span class="n"&gt;Keywords&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Top5&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"top_5"`&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"keywords"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;analyzeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"https://api.ckmtools.dev/v1/analyze"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-API-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="n"&gt;AnalysisResult&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;analyzeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TEXTLENS_KEY"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"Your content here..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error: %v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Grade: %s, Sentiment: %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Readability&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConsensusGrade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sentiment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Label&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;The struct tags match the JSON response directly. Add fields as you need them. If you want &lt;code&gt;FleschKincaid&lt;/code&gt;, &lt;code&gt;GunningFog&lt;/code&gt;, &lt;code&gt;SMOG&lt;/code&gt;, and &lt;code&gt;ColemanLiau&lt;/code&gt;, expand the &lt;code&gt;Readability&lt;/code&gt; struct — they're all in the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Pattern Makes Sense
&lt;/h2&gt;

&lt;p&gt;This is worth considering if you're:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building a blog platform, CMS, or content review tool in Go and you need readability grades before publishing&lt;/li&gt;
&lt;li&gt;Running automated content quality checks in a CI pipeline&lt;/li&gt;
&lt;li&gt;Building a tool that auto-tags content with extracted keywords&lt;/li&gt;
&lt;li&gt;Writing a Go service that wraps text analysis for downstream consumers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key constraint is that you don't want to maintain a Python sidecar or pull in a large dependency for a feature that isn't your core product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Tradeoff
&lt;/h2&gt;

&lt;p&gt;HTTP adds 20–100ms per request. For most editorial workflows — "analyze this article before it goes live" — that's fine. For interactive writing tools with keypress-level feedback, it's noticeable. For batch processing thousands of documents per minute, a local library would be faster if one existed.&lt;/p&gt;

&lt;p&gt;That last part is the constraint. For high-throughput stream processing in Go, a local library would be the right call. Right now, the Go ecosystem doesn't have one that covers these metrics. So you're choosing between HTTP overhead and writing the implementation yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Find This
&lt;/h2&gt;

&lt;p&gt;The TextLens API is in development — free tier at 1,000 requests/month. Waitlist is open at &lt;a href="https://ckmtools.dev/api/" rel="noopener noreferrer"&gt;ckmtools.dev/api/&lt;/a&gt; if this fits a project you're working on. Feedback on the Go client structure is welcome — I'm particularly curious whether the struct tag approach is the interface people actually want or whether a map-based response is more practical for dynamic field access.&lt;/p&gt;

</description>
      <category>go</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Scanned 6 Popular Node.js Repos for Undocumented Environment Variables. Here's What I Found.</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:18:57 +0000</pubDate>
      <link>https://dev.to/ckmtools/i-scanned-6-popular-nodejs-repos-for-undocumented-environment-variables-heres-what-i-found-5478</link>
      <guid>https://dev.to/ckmtools/i-scanned-6-popular-nodejs-repos-for-undocumented-environment-variables-heres-what-i-found-5478</guid>
      <description>&lt;p&gt;Most Node.js projects accumulate &lt;code&gt;process.env&lt;/code&gt; references over time. Some document them in &lt;code&gt;.env.example&lt;/code&gt;. Many don't. I wanted to know how bad the problem actually is in well-maintained, popular open-source repos — so I ran a search using the GitHub API.&lt;/p&gt;

&lt;p&gt;Here's what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Repos
&lt;/h2&gt;

&lt;p&gt;I picked six repos with different scopes: two minimal HTTP frameworks, one structured framework, two full-stack application platforms, and one backend-as-a-service:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;expressjs/express&lt;/td&gt;
&lt;td&gt;~65k&lt;/td&gt;
&lt;td&gt;HTTP framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastify/fastify&lt;/td&gt;
&lt;td&gt;~32k&lt;/td&gt;
&lt;td&gt;HTTP framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nestjs/nest&lt;/td&gt;
&lt;td&gt;~68k&lt;/td&gt;
&lt;td&gt;Application framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;strapi/strapi&lt;/td&gt;
&lt;td&gt;~63k&lt;/td&gt;
&lt;td&gt;Headless CMS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;keystonejs/keystone&lt;/td&gt;
&lt;td&gt;~9k&lt;/td&gt;
&lt;td&gt;Full-stack CMS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;supabase/supabase&lt;/td&gt;
&lt;td&gt;~73k&lt;/td&gt;
&lt;td&gt;BaaS platform (monorepo)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For each repo I used the GitHub code search API to count &lt;code&gt;process.env&lt;/code&gt; references, then checked for the presence of &lt;code&gt;.env.example&lt;/code&gt; (or &lt;code&gt;.env.sample&lt;/code&gt;, &lt;code&gt;.env.template&lt;/code&gt;) files at the root and recursively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;process.env&lt;/code&gt; refs&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;.env.example&lt;/code&gt; files&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;expressjs/express&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastify/fastify&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nestjs/nest&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;strapi/strapi&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;keystonejs/keystone&lt;/td&gt;
&lt;td&gt;112&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;supabase/supabase&lt;/td&gt;
&lt;td&gt;294&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;Best-in-class&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What This Actually Means
&lt;/h2&gt;

&lt;p&gt;The numbers don't tell the full story. The three frameworks at the top (express, fastify, nest) aren't slacking — they're libraries. Their &lt;code&gt;process.env&lt;/code&gt; usage is intentionally minimal. Express reads &lt;code&gt;NODE_ENV&lt;/code&gt; in &lt;code&gt;lib/application.js&lt;/code&gt;. Fastify uses a few vars in test scripts and a serverless guide. NestJS delegates env config entirely to application code via &lt;code&gt;@nestjs/config&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The bottom three are application platforms and CMS tools — products you self-host or deploy, where env configuration is core to the product. Their higher counts make sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strapi: 135 refs, 10 .env.example files
&lt;/h3&gt;

&lt;p&gt;The refs are spread across a large monorepo (&lt;code&gt;packages/&lt;/code&gt;, &lt;code&gt;examples/&lt;/code&gt;, &lt;code&gt;scripts/&lt;/code&gt;). The examples each ship their own &lt;code&gt;.env.example&lt;/code&gt;, but the core package doesn't have a central one. The most significant example — &lt;code&gt;examples/complex/.env.example&lt;/code&gt; — contains exactly one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JWT_SECRET=replaceme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire documented env surface for a complex Strapi installation, despite the codebase referencing 135 env variables across all packages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keystone: 112 refs, 3 .env.example files
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.env.example&lt;/code&gt; files exist only for specific integration examples (S3 assets, Cloudinary). The &lt;code&gt;docs/.env.example&lt;/code&gt; contains a single variable: &lt;code&gt;BUTTONDOWN_API_KEY=&lt;/code&gt; — which is the newsletter API key for Keystone's own documentation site, not something users of the framework need.&lt;/p&gt;

&lt;p&gt;Core application env vars (database URLs, session secrets) are documented in prose in the official docs, not as a discoverable example file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase: 294 refs, 24 .env.example files
&lt;/h3&gt;

&lt;p&gt;Supabase is the standout here. The &lt;code&gt;docker/.env.example&lt;/code&gt; is the most thorough example file I found across all six repos — it includes inline comments explaining what each variable does, links to docs for generating secrets, and even notes which values need to be rotated before going to production:&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;# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING&lt;/span&gt;
&lt;span class="c1"&gt;# THE CONTAINERS FOR THE FIRST TIME!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the right way to do it. Still, the E2E test suite in &lt;code&gt;e2e/studio/env.config.ts&lt;/code&gt; references vars like &lt;code&gt;GITHUB_PASS&lt;/code&gt;, &lt;code&gt;GITHUB_TOTP&lt;/code&gt;, &lt;code&gt;VERCEL_AUTOMATION_BYPASS_SELFHOSTED_STUDIO&lt;/code&gt;, &lt;code&gt;SUPA_PAT&lt;/code&gt;, and &lt;code&gt;SUPA_REGION&lt;/code&gt; — none of which appear in any &lt;code&gt;.env.example&lt;/code&gt;. These are CI/testing credentials that contributors need but have to discover by reading the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;Across all six repos, a consistent pattern emerges:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Framework repos&lt;/strong&gt;: Low env var count by design. Documentation isn't the problem — minimal surface is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application platform repos&lt;/strong&gt;: High env var count, &lt;code&gt;.env.example&lt;/code&gt; files exist but cover only a slice of the actual surface. The gap between documented and total &lt;code&gt;process.env&lt;/code&gt; references can be large (strapi: 10 files documenting maybe 15 vars vs 135 total refs).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test and CI env vars are almost never documented.&lt;/strong&gt; Every repo with a test suite uses env vars to configure database URLs, API tokens, and service endpoints for testing. None of those showed up in &lt;code&gt;.env.example&lt;/code&gt; files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Maintenance Problem
&lt;/h2&gt;

&lt;p&gt;The real challenge isn't the initial &lt;code&gt;.env.example&lt;/code&gt; — it's keeping it in sync as the codebase grows. A feature adds &lt;code&gt;process.env.NEW_FEATURE_FLAG&lt;/code&gt;. The &lt;code&gt;.env.example&lt;/code&gt; is a separate file. Nobody updates it because nothing enforces the connection.&lt;/p&gt;

&lt;p&gt;In a small repo, this is fine. In a monorepo with 135+ references spread across packages and examples, it becomes hard to answer the question: "what env vars does this actually need?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;If you're dealing with this problem in your own codebase — especially if you've inherited a repo where nobody's sure what all the &lt;code&gt;process.env&lt;/code&gt; references actually are — scanning the source files is the most reliable way to get a definitive list. I'm working on &lt;a href="https://ckmtools.dev/envscan/" rel="noopener noreferrer"&gt;envscan&lt;/a&gt;, a tool that does exactly that: scans your source files to discover every env var your code references, and compares it against your &lt;code&gt;.env.example&lt;/code&gt;. It's in development with a waitlist open if that sounds useful.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Data collected 2026-03-18 using the GitHub Code Search API. Counts reflect the state of the default branches at time of writing. Repos with monorepo structures may have higher counts due to cross-package test fixtures and build scripts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Repos scanned: &lt;a href="https://github.com/expressjs/express" rel="noopener noreferrer"&gt;express&lt;/a&gt;, &lt;a href="https://github.com/fastify/fastify" rel="noopener noreferrer"&gt;fastify&lt;/a&gt;, &lt;a href="https://github.com/nestjs/nest" rel="noopener noreferrer"&gt;nest&lt;/a&gt;, &lt;a href="https://github.com/strapi/strapi" rel="noopener noreferrer"&gt;strapi&lt;/a&gt;, &lt;a href="https://github.com/keystonejs/keystone" rel="noopener noreferrer"&gt;keystone&lt;/a&gt;, &lt;a href="https://github.com/supabase/supabase" rel="noopener noreferrer"&gt;supabase&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>devops</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Compared 5 Python Text Analysis Libraries — Then Built a REST API Instead</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Tue, 17 Mar 2026 21:02:39 +0000</pubDate>
      <link>https://dev.to/ckmtools/i-compared-5-python-text-analysis-libraries-then-built-a-rest-api-instead-1l63</link>
      <guid>https://dev.to/ckmtools/i-compared-5-python-text-analysis-libraries-then-built-a-rest-api-instead-1l63</guid>
      <description>&lt;p&gt;When you need readability scores in Python, your first search turns up textstat. For sentiment, VADER. For keyword extraction, yake or keybert. For everything at once, you're running 3-4 libraries with their own install requirements, version conflicts, and update cycles.&lt;/p&gt;

&lt;p&gt;I spent a few hours comparing the main options. Here's what I found — and why I ended up building a REST API instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five main options
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Install size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;textstat&lt;/td&gt;
&lt;td&gt;Readability scoring (FK, Fog, SMOG, etc.)&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vaderSentiment&lt;/td&gt;
&lt;td&gt;Sentiment for social media text&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TextBlob&lt;/td&gt;
&lt;td&gt;Sentiment + NLP basics&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NLTK&lt;/td&gt;
&lt;td&gt;Full NLP toolkit&lt;/td&gt;
&lt;td&gt;Large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spaCy&lt;/td&gt;
&lt;td&gt;Production NLP&lt;/td&gt;
&lt;td&gt;Large&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What each one actually does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;textstat&lt;/strong&gt; is the go-to for readability. It gives you Flesch-Kincaid, Gunning Fog, SMOG, Coleman-Liau, ARI, and Dale-Chall in one call. PyPI shows it at around 218,000 downloads per week, which tells you there's a real use case here. What it doesn't do: sentiment, keywords, or anything beyond readability formulas.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;textstat&lt;/span&gt;

&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The cat sat on the mat.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;textstat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flesch_reading_ease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# 116.15
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;textstat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gunning_fog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;            &lt;span class="c1"&gt;# 0.8
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;textstat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flesch_kincaid_grade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# -3.5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;vaderSentiment&lt;/strong&gt; (Valence Aware Dictionary and sEntiment Reasoner) is excellent at what it does: sentiment scoring on short, informal text. Tweets, product reviews, forum posts. It handles punctuation, capitalization, and emoticons. It's not designed for long-form content, and it doesn't touch readability.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vaderSentiment.vaderSentiment&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentimentIntensityAnalyzer&lt;/span&gt;

&lt;span class="n"&gt;analyzer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentimentIntensityAnalyzer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;analyzer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;polarity_scores&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This is absolutely terrible!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# {'neg': 0.508, 'neu': 0.492, 'pos': 0.0, 'compound': -0.5849}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TextBlob&lt;/strong&gt; gives you sentiment plus basic NLP (noun phrases, part-of-speech tagging). It wraps NLTK under the hood. The sentiment output is simpler than VADER — just polarity and subjectivity. No readability.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;textblob&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TextBlob&lt;/span&gt;

&lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TextBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The food was good but the service was slow.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Sentiment(polarity=0.3, subjectivity=0.6)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;NLTK&lt;/strong&gt; can do almost anything — tokenization, stemming, tagging, parsing, named entity recognition, sentiment — but it requires substantial setup and hand-coding. There's no &lt;code&gt;nltk.analyze(text)&lt;/code&gt; call. You assemble what you need from primitives. NLTK sees about 13.7 million downloads per week, but a significant chunk of that is downstream dependencies pulling it in. The knowledge threshold to use it effectively is real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;spaCy&lt;/strong&gt; is the best option for production NLP pipelines: dependency parsing, named entity recognition, word vectors, custom pipelines. It's also the heaviest. Model downloads range from 12MB (small English) to 560MB (large). For a "just give me a readability score" use case, it's significant overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem isn't quality
&lt;/h2&gt;

&lt;p&gt;Each of these libraries is good at what it does. The problem appears when you need more than one type of analysis in the same project.&lt;/p&gt;

&lt;p&gt;Say you're building a content quality checker that needs readability grade, sentiment (is this copy too negative?), and keyword density. You're now installing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;textstat vaderSentiment yake
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate install chains. Three sets of dependencies to keep in sync. If you're containerizing, all three go in the image. If you're on serverless with a 250MB limit, that fills up fast once spaCy's models are involved.&lt;/p&gt;

&lt;p&gt;Version conflicts are the worst case. NLTK and spaCy both have opinions about numpy. If your environment already has numpy pinned for a different reason, you may be debugging dependency issues before you write a single line of analysis code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The REST API approach
&lt;/h2&gt;

&lt;p&gt;I built TextLens API to sidestep this entirely. The Python client is just &lt;code&gt;requests&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.ckmtools.dev/v1/analyze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-API-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your content here...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;readability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flesch_kincaid_grade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compound&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Readability, sentiment, and keywords in one response. Your dependency list stays at &lt;code&gt;requests&lt;/code&gt;, which you probably already have.&lt;/p&gt;

&lt;p&gt;The trade-off is real: there's HTTP latency on every call, and if you're processing thousands of documents per second, local libraries will always be faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  When each approach makes sense
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use local libraries if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're processing large document volumes in batch (hundreds per second or more)&lt;/li&gt;
&lt;li&gt;You have no outbound network access&lt;/li&gt;
&lt;li&gt;You only need one type of metric and don't mind the single dependency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use an API if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your stack includes multiple languages (Python service + Node.js frontend)&lt;/li&gt;
&lt;li&gt;You want readability + sentiment + keywords without managing 3 separate library installs&lt;/li&gt;
&lt;li&gt;You're prototyping and want to defer the dependency decision&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The TextLens API waitlist
&lt;/h2&gt;

&lt;p&gt;I'm building this for the second use case — one endpoint, three analysis types, free tier at 1,000 requests/month. The waitlist is at &lt;a href="https://ckmtools.dev/api/" rel="noopener noreferrer"&gt;https://ckmtools.dev/api/&lt;/a&gt; if you're in that situation. Early access is free; feedback on the API design is welcome.&lt;/p&gt;

&lt;p&gt;The local libraries are all solid options when you need them. But if you've spent an afternoon debugging a numpy version conflict between textstat and spaCy, a 50ms API call starts looking pretty reasonable.&lt;/p&gt;

</description>
      <category>python</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why I Stopped Maintaining .env.example by Hand</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Tue, 17 Mar 2026 03:01:54 +0000</pubDate>
      <link>https://dev.to/ckmtools/why-i-stopped-maintaining-envexample-by-hand-473j</link>
      <guid>https://dev.to/ckmtools/why-i-stopped-maintaining-envexample-by-hand-473j</guid>
      <description>&lt;p&gt;Every Node.js project I've worked on has the same failure mode: a new developer clones the repo, runs &lt;code&gt;npm install&lt;/code&gt;, tries to start the server, and gets a cryptic error because some environment variable is missing. &lt;code&gt;.env.example&lt;/code&gt; is out of date. Again. Here's the tool I'm building to fix that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The specific pain point
&lt;/h2&gt;

&lt;p&gt;You add &lt;code&gt;DATABASE_URL&lt;/code&gt; to your code on Tuesday. You forget to add it to &lt;code&gt;.env.example&lt;/code&gt;. Three weeks later, someone's production deploy fails because they copied &lt;code&gt;.env.example&lt;/code&gt; and missed the new variable.&lt;/p&gt;

&lt;p&gt;The fix is always: "oh, add that to &lt;code&gt;.env.example&lt;/code&gt;." Then you spend twenty minutes figuring out which variables are actually needed, checking the code, checking the deployment docs, hoping nothing was added after the last time someone updated the example file.&lt;/p&gt;

&lt;p&gt;The real problem: nothing tells you &lt;code&gt;.env.example&lt;/code&gt; is missing a variable until something breaks. The stale &lt;code&gt;.env.example&lt;/code&gt; is a silent bug. It doesn't fail when you commit it. It fails three weeks later in someone else's environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why existing tools don't solve it
&lt;/h2&gt;

&lt;p&gt;There are good tools in this space. dotenv-safe has 152,609 downloads last week. envalid has 478,131. They work — but they're all &lt;em&gt;declaration-first&lt;/em&gt;: you maintain a list of required variables in a schema file, and the tool validates your environment against that list.&lt;/p&gt;

&lt;p&gt;The problem: maintaining the schema is the same work as maintaining &lt;code&gt;.env.example&lt;/code&gt;. You still have to remember to update it every time you add a new &lt;code&gt;process.env&lt;/code&gt; reference in your code. The schema can go stale for exactly the same reason &lt;code&gt;.env.example&lt;/code&gt; goes stale — there's no enforcement, just discipline.&lt;/p&gt;

&lt;p&gt;dotenv (91 million weekly downloads) solves loading. These tools solve validation against a declared schema. None of them solve the &lt;em&gt;discovery&lt;/em&gt; problem: figuring out which vars your code actually needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The insight: the source code already knows
&lt;/h2&gt;

&lt;p&gt;Every time you write &lt;code&gt;process.env.DATABASE_URL&lt;/code&gt; in your code, you've implicitly declared that you need &lt;code&gt;DATABASE_URL&lt;/code&gt;. That declaration is already there — it's just not extracted anywhere.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://ckmtools.dev/envscan/" rel="noopener noreferrer"&gt;envscan&lt;/a&gt; to do that extraction. It scans your &lt;code&gt;.js&lt;/code&gt; and &lt;code&gt;.ts&lt;/code&gt; files and pulls out every &lt;code&gt;process.env&lt;/code&gt; reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;envscan scan
Found 8 environment variables:

  DATABASE_URL       &lt;span class="nb"&gt;type&lt;/span&gt;: url      src/db.ts:12
  PORT               &lt;span class="nb"&gt;type&lt;/span&gt;: number   src/server.ts:5
  JWT_SECRET         &lt;span class="nb"&gt;type&lt;/span&gt;: secret   src/auth.ts:8, src/auth.ts:23
  REDIS_URL          &lt;span class="nb"&gt;type&lt;/span&gt;: url      src/cache.ts:3
  SENDGRID_API_KEY   &lt;span class="nb"&gt;type&lt;/span&gt;: secret   src/email.ts:7
  APP_ENV            &lt;span class="nb"&gt;type&lt;/span&gt;: string   src/config.ts:2
  LOG_LEVEL          &lt;span class="nb"&gt;type&lt;/span&gt;: string   src/config.ts:3
  DEBUG              &lt;span class="nb"&gt;type&lt;/span&gt;: boolean  src/config.ts:4

Validation: 7/8 vars set. Missing: SENDGRID_API_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No config file needed. No schema to maintain. The schema &lt;em&gt;is&lt;/em&gt; the codebase.&lt;/p&gt;

&lt;p&gt;The type inference is heuristic — it looks at variable names (&lt;code&gt;_URL&lt;/code&gt; → url, &lt;code&gt;_SECRET&lt;/code&gt;, &lt;code&gt;_KEY&lt;/code&gt;, &lt;code&gt;_TOKEN&lt;/code&gt; → secret, &lt;code&gt;PORT&lt;/code&gt; → number, &lt;code&gt;DEBUG&lt;/code&gt; → boolean) — but it's right often enough to be useful as a starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it generates
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;envscan generate&lt;/code&gt; writes a &lt;code&gt;.env.example&lt;/code&gt; with type hints and source locations as comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# type: url | Found in: src/db.ts:12&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# type: number | Found in: src/server.ts:5&lt;/span&gt;
&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# type: secret | Found in: src/auth.ts:8&lt;/span&gt;
&lt;span class="nv"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# type: url | Found in: src/cache.ts:3&lt;/span&gt;
&lt;span class="nv"&gt;REDIS_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# type: secret | Found in: src/email.ts:7&lt;/span&gt;
&lt;span class="nv"&gt;SENDGRID_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# type: string | Found in: src/config.ts:2&lt;/span&gt;
&lt;span class="nv"&gt;APP_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# type: string | Found in: src/config.ts:3&lt;/span&gt;
&lt;span class="nv"&gt;LOG_LEVEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# type: boolean | Found in: src/config.ts:4&lt;/span&gt;
&lt;span class="nv"&gt;DEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it in a pre-commit hook. &lt;code&gt;.env.example&lt;/code&gt; stays in sync automatically — not because someone remembered to update it, but because it's regenerated from the code.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;validate&lt;/code&gt; command compares your current environment against what the scan finds and reports what's missing. You can wire it into your startup script so the process fails loudly and immediately rather than failing cryptically three &lt;code&gt;console.log&lt;/code&gt; calls later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI angle (honest about what's not built yet)
&lt;/h2&gt;

&lt;p&gt;The CLI is almost done. What I'm building next is a GitHub Action that runs &lt;code&gt;envscan validate&lt;/code&gt; on every pull request and posts an inline comment when new &lt;code&gt;process.env&lt;/code&gt; references appear in the diff without a matching &lt;code&gt;.env.example&lt;/code&gt; entry — catching the stale &lt;code&gt;.env.example&lt;/code&gt; problem before it merges.&lt;/p&gt;

&lt;p&gt;That CI tier is the paid part ($6/month). Running GitHub Actions costs money, and I'd like to maintain this long-term rather than abandoning it when hosting costs add up. The CLI will stay free.&lt;/p&gt;

&lt;p&gt;I haven't released either yet. The core scanning and generation works. The edge cases I'm still handling: template literals (&lt;code&gt;process.env[key]&lt;/code&gt;), computed property access, and variables referenced only in test files. Computed access is genuinely hard — you can't statically know the variable name if it's dynamic. My current approach flags those as a warning rather than silently skipping them.&lt;/p&gt;

&lt;h2&gt;
  
  
  If this sounds useful
&lt;/h2&gt;

&lt;p&gt;envscan isn't released yet — I'm finishing the CLI now. If this sounds like a problem you've run into, the waitlist is at &lt;a href="https://ckmtools.dev/envscan/" rel="noopener noreferrer"&gt;ckmtools.dev/envscan/&lt;/a&gt; — no credit card, just an email to get notified when it ships.&lt;/p&gt;

&lt;p&gt;Feedback welcome on the output format. The file location comments in &lt;code&gt;.env.example&lt;/code&gt; — useful or noisy? Let me know in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>devops</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Wrapped My Free npm Package as a Paid REST API — Here's the Architecture</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Tue, 17 Mar 2026 02:16:51 +0000</pubDate>
      <link>https://dev.to/ckmtools/i-wrapped-my-free-npm-package-as-a-paid-rest-api-heres-the-architecture-24p9</link>
      <guid>https://dev.to/ckmtools/i-wrapped-my-free-npm-package-as-a-paid-rest-api-heres-the-architecture-24p9</guid>
      <description>&lt;p&gt;textlens is a zero-dependency npm package for text analysis — readability scoring, sentiment analysis, keyword extraction. It's free. It always will be. But I keep getting the same question: "Do you have a Python version?" Here's what I built to answer that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem npm doesn't solve
&lt;/h2&gt;

&lt;p&gt;Node.js packages are invisible to Python developers, Ruby developers, PHP developers, and no-code tools like Zapier and Make.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ckmtools/textlens" rel="noopener noreferrer"&gt;textlens&lt;/a&gt; pulls 177 downloads/week and has 6 GitHub stars. That sounds modest, but the point is: that entire audience is JavaScript developers. Every one of them can &lt;code&gt;npm install textlens&lt;/code&gt; and be running in 30 seconds. Everyone else is locked out.&lt;/p&gt;

&lt;p&gt;Python has &lt;code&gt;textstat&lt;/code&gt;, which gives you a single Flesch reading ease score. It has &lt;code&gt;nltk&lt;/code&gt; for tokenization. What it doesn't have is an equivalent of textlens — 8 readability formulas, sentiment analysis, keyword extraction, and SEO-relevant metrics in a single call. Ruby has even less. PHP has nearly nothing. No-code tools like Zapier have no path to npm at all.&lt;/p&gt;

&lt;p&gt;The REST API targets everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just publish a Python wrapper?
&lt;/h2&gt;

&lt;p&gt;I considered it. A Python package that calls the textlens npm package under the hood via subprocess. Or a direct Python reimplementation of the same algorithms.&lt;/p&gt;

&lt;p&gt;Both are worse than a hosted API.&lt;/p&gt;

&lt;p&gt;A subprocess wrapper means the user needs Node.js installed — which defeats the purpose entirely. A reimplementation means maintaining two codebases that can drift apart. When I add a new readability formula to the npm package, I'd have to duplicate the work in Python (and Ruby, and Go, and whatever comes next).&lt;/p&gt;

&lt;p&gt;A hosted API solves this once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.ckmtools.dev/v1/analyze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-API-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your text here...&lt;/span&gt;&lt;span class="sh"&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;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;readability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;consensus_grade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One endpoint. Any HTTP client. Any language. No Node.js required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture decisions
&lt;/h2&gt;

&lt;p&gt;Here's what runs where and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Workers (edge compute)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The API runs on Cloudflare Workers. The choice was between Workers, a traditional VPS, and a serverless platform like AWS Lambda.&lt;/p&gt;

&lt;p&gt;Workers won on three criteria: sub-50ms response globally with zero cold starts, operational cost around $0.15/million requests, and — critically — they support bundling npm packages directly. The textlens package (&lt;a href="https://github.com/ckmtools/textlens" rel="noopener noreferrer"&gt;github.com/ckmtools/textlens&lt;/a&gt;) gets compiled into the Worker bundle at deploy time. No separate service to maintain. No inter-service latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KV storage for API keys and rate limiting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cloudflare KV stores API keys and rate limit counters. Keys are provisioned automatically via Stripe webhook: customer pays → webhook fires → key gets written to KV → customer gets their key by email.&lt;/p&gt;

&lt;p&gt;The rate limiting is fixed-window per minute. Here's the honest trade-off: KV is eventually consistent. Two requests arriving simultaneously at different edge nodes could both "see" a counter below the limit and both succeed, briefly exceeding the rate limit. For a text analysis API, this is acceptable — it's not a financial transaction. I acknowledged this rather than pretending it doesn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe for subscription management&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stripe handles the subscription lifecycle: checkout, upgrades, cancellations, failed payments. Webhooks drive key provisioning and revocation. The only manual step is the initial Stripe product/price setup.&lt;/p&gt;

&lt;p&gt;This means I don't write payment processing code. Stripe does that. My webhook handler is about 40 lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free tier: 1,000 requests/month, no credit card&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The free tier exists for a real reason: developers don't commit budget to new tools without trying them. 1,000 requests is enough to build and test an integration. If you hit the limit, you know it's useful enough to pay for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this architecture costs to run&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cloudflare Workers requires their paid plan at $5/month for the KV namespace. That's the only standing cost. At $9/month for the Starter tier, the first subscriber covers hosting. This is not a money printer at small scale — it's a break-even tool that becomes profitable with volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The business model question
&lt;/h2&gt;

&lt;p&gt;Wrapping a free npm package as a paid API requires honest justification.&lt;/p&gt;

&lt;p&gt;The answer isn't "convenience" in the abstract. It's that Python/Ruby/no-code developers have no other option. The npm package has 177 downloads/week — that's real demand for text analysis tooling. The question is how many of those downloads come from developers who later discover they need the same capability in Python and hit a wall.&lt;/p&gt;

&lt;p&gt;I don't know that number yet. That's what the waitlist is for.&lt;/p&gt;

&lt;p&gt;17 articles about text analysis tooling later, with 359 total views on dev.to, there's clearly an audience that cares about this problem space. Whether enough of them are Python developers willing to pay $9/month is what validation answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The API is in development. If you work in Python, Ruby, or no-code tools and would use a hosted text analysis endpoint, the waitlist is at &lt;a href="https://ckmtools.dev/api/" rel="noopener noreferrer"&gt;ckmtools.dev/api/&lt;/a&gt; — free tier, no credit card.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>python</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why I Built a Readability Analyzer That Sends Your Text Nowhere</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Mon, 16 Mar 2026 22:46:39 +0000</pubDate>
      <link>https://dev.to/ckmtools/why-i-built-a-readability-analyzer-that-sends-your-text-nowhere-2npk</link>
      <guid>https://dev.to/ckmtools/why-i-built-a-readability-analyzer-that-sends-your-text-nowhere-2npk</guid>
      <description>&lt;h1&gt;
  
  
  Why I Built a Readability Analyzer That Sends Your Text Nowhere
&lt;/h1&gt;

&lt;p&gt;Most productivity tools that analyze your writing send your text to a server. That's true of Grammarly. It's true of most AI writing assistants. And it's worth thinking about, because writers paste a lot of sensitive material into these tools — drafts of internal reports, client work, things under NDA, early chapters of books they haven't published yet.&lt;/p&gt;

&lt;p&gt;ProseScore doesn't send your text anywhere. Here's why that was a deliberate choice, and what it took to make it work.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with sending your text to a server
&lt;/h2&gt;

&lt;p&gt;When you paste something into a web-based writing tool, you're implicitly trusting that tool with whatever you wrote. That might be fine for a recipe. It's different for a confidential internal memo, a legal brief draft, or a chapter from a novel you've been working on for two years.&lt;/p&gt;

&lt;p&gt;The data minimization argument is simple: if you don't need a server, don't have one. A server is a liability — it's a place where data can be retained, subpoenaed, breached, or sold. Readability analysis doesn't require a server. It requires math. So I didn't build one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What ProseScore actually does
&lt;/h2&gt;

&lt;p&gt;The entire analysis runs in the browser. All of it — the 8 readability formulas, AFINN-based sentiment scoring, TF-IDF keyword extraction — executes synchronously in a Web Worker. The UI stays responsive even on long documents because the analysis runs off the main thread.&lt;/p&gt;

&lt;p&gt;The code path looks roughly like this:&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="c1"&gt;// Everything runs here, client-side&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;analyzeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// No network call. No tracking. Just math.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are no &lt;code&gt;fetch()&lt;/code&gt; calls anywhere in the analysis path. The only network request the page makes is the initial page load. After that, you can take your browser offline and it keeps working — because it's not waiting for anything.&lt;/p&gt;

&lt;p&gt;The Web Worker approach was worth the extra complexity. Without it, analyzing a 5,000-word document would lock the UI for a noticeable fraction of a second. With it, the interface stays interactive while the analysis runs in the background.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why 8 readability formulas work offline
&lt;/h2&gt;

&lt;p&gt;Not all NLP tasks are created equal. Sentiment analysis at scale usually means embeddings, which means models, which means GPU time on a server somewhere. That's a legitimate reason to send text to a server.&lt;/p&gt;

&lt;p&gt;Readability scoring is different. Flesch-Kincaid, Gunning Fog, SMOG, Coleman-Liau, ARI, Dale-Chall, Linsear Write — these are pure algorithmic formulas. They operate on word counts, sentence lengths, and syllable counts. No ML models. No embeddings. No hardware requirements beyond whatever CPU is in the person's laptop.&lt;/p&gt;

&lt;p&gt;The irony is that readability scoring is one of the few NLP tasks that was basically designed for offline computation. These formulas were developed in an era before networked computers. They're deterministic. Given the same input, every browser produces the same score.&lt;/p&gt;

&lt;p&gt;ProseScore computes all 8 formulas and derives a consensus grade level from the results. Running eight formulas instead of one doesn't meaningfully change the performance profile — they're all operating on the same precomputed word/sentence/syllable counts.&lt;/p&gt;




&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;Two things caught me off guard during the build.&lt;/p&gt;

&lt;p&gt;The first was syllable counting. It sounds trivial. It isn't. English doesn't have a clean algorithmic rule for syllables — the exceptions are numerous, and naive implementations that count vowel runs get wrong answers constantly. I ended up with a heuristic that handles common patterns and exceptions, and it's good enough for readability scoring, but it's not perfect. Don't use it to settle poetry arguments.&lt;/p&gt;

&lt;p&gt;The second was deriving the consensus grade level. Each of the 8 formulas uses a different scale. Flesch-Kincaid outputs a US grade level. Flesch Reading Ease runs 0-100 in the opposite direction (higher = easier). SMOG and Gunning Fog have their own calibrations. To combine them into a single consensus grade, I had to understand what each formula was actually measuring, not just run the equations. That took longer than the implementation itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  The catch
&lt;/h2&gt;

&lt;p&gt;No server means no persistence. There's no history. No way to compare your score on this draft against the version you edited last Tuesday. No sharing. No team dashboards. If you close the tab, the analysis is gone.&lt;/p&gt;

&lt;p&gt;For personal writing and private documents, that's fine — it's actually the point. For teams that want to track readability trends across a documentation repo over time, ProseScore isn't the right tool. That use case requires a server. I'm not pretending otherwise.&lt;/p&gt;

&lt;p&gt;The trade-off is intentional: ProseScore does one thing — analyze text you paste into it, right now, without that text leaving your browser. It doesn't try to be everything.&lt;/p&gt;




&lt;p&gt;The tool is at &lt;a href="https://prosescore.ckmtools.dev" rel="noopener noreferrer"&gt;prosescore.ckmtools.dev&lt;/a&gt; — open source, MIT license. If you're curious about the implementation, the full source is at &lt;a href="https://github.com/ckmtools/prosescore" rel="noopener noreferrer"&gt;github.com/ckmtools/prosescore&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I Got 6 GitHub Stars Without a Launch Event</title>
      <dc:creator>ckmtools</dc:creator>
      <pubDate>Mon, 16 Mar 2026 16:32:40 +0000</pubDate>
      <link>https://dev.to/ckmtools/how-i-got-6-github-stars-without-a-launch-event-4df0</link>
      <guid>https://dev.to/ckmtools/how-i-got-6-github-stars-without-a-launch-event-4df0</guid>
      <description>&lt;p&gt;6 GitHub stars doesn't sound like much. But I didn't run a Show HN. I didn't launch on Product Hunt. I didn't email any newsletters. I just published an npm package and watched what drove people to the GitHub repo. The source of those stars surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ckmtools/textlens" rel="noopener noreferrer"&gt;textlens&lt;/a&gt; is a zero-dependency text analysis library for Node.js. It launched March 4. One command gets you Flesch-Kincaid readability scores, sentiment analysis, keyword extraction, and sentence statistics — no API calls, no setup, no config file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;textlens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;textlens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;textlens&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;textlens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your text here.&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fleschKincaid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { grade: 8.1, readingEase: 62 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of today: 6 GitHub stars, 82 npm downloads this week, 15 dev.to articles totaling 355 views.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected to drive stars
&lt;/h2&gt;

&lt;p&gt;The strategy going in: publish a lot of dev.to content, get readers, convert some to GitHub stars.&lt;/p&gt;

&lt;p&gt;I published 15 articles over 12 days. 355 total views. My best performer — "I Built a Free Hemingway Editor Alternative That Runs in Your Terminal" — hit 122 views on its own. I expected dev.to to be the main driver.&lt;/p&gt;

&lt;p&gt;I also did a Product Hunt launch. 3 upvotes. I don't count that as a meaningful signal.&lt;/p&gt;

&lt;p&gt;A Show HN was planned but never happened. The queue felt premature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GitHub referrer data actually showed
&lt;/h2&gt;

&lt;p&gt;Here's the 14-day referrer table, pulled directly from the GitHub traffic API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Referrer&lt;/th&gt;
&lt;th&gt;Views&lt;/th&gt;
&lt;th&gt;Unique visitors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;echojs.com&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;github.com&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ckmtools.dev&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;npmjs.com&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bing&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Echo JS is the top external traffic source by unique visitors. Not dev.to. Not Hacker News. Not Reddit. Echo JS.&lt;/p&gt;

&lt;p&gt;The traffic pattern made the picture clearer. On March 10, GitHub views spiked to 23 unique visitors in a single day — that's more than the entire previous week combined. Days 11 and 12 stayed elevated. Those three days account for the majority of the 18 unique Echo JS visitors.&lt;/p&gt;

&lt;p&gt;Stars went from 1 to 6 during that same 72-hour window.&lt;/p&gt;

&lt;p&gt;dev.to sent 0 visitors to &lt;a href="https://github.com/ckmtools/textlens" rel="noopener noreferrer"&gt;github.com/ckmtools/textlens&lt;/a&gt; despite 300+ article views.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Echo JS worked
&lt;/h2&gt;

&lt;p&gt;Echo JS (echojs.com) is a developer news aggregator focused specifically on JavaScript. It's small, text-only, and curated. The audience is developers browsing for something interesting to look at — usually during work.&lt;/p&gt;

&lt;p&gt;The difference from dev.to is the context. On dev.to, people read articles. On Echo JS, people discover things. When someone lands on Echo JS, they're in "what should I click?" mode. They follow links. They open GitHub repos. They star things they want to remember.&lt;/p&gt;

&lt;p&gt;dev.to has the views but not the click-through. 122 views on my Hemingway article meant 122 people read the article. Almost none of them navigated to GitHub. The call-to-action worked fine inside dev.to's reading environment — it just didn't convert to GitHub behavior.&lt;/p&gt;

&lt;p&gt;Echo JS has fewer views, but the viewers are primed to act on what they find.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changed about my strategy
&lt;/h2&gt;

&lt;p&gt;Before looking at the referrer data, I was optimizing for dev.to views. After, I changed four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Submit every article to Echo JS within the same hour of publishing.&lt;/strong&gt; The traffic spike is time-sensitive — the front page moves fast, and visibility drops after a few hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write for discovery, not volume.&lt;/strong&gt; One article that lands on Echo JS's front page outperforms ten articles that don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include 3+ GitHub links per article.&lt;/strong&gt; Reader intent matters less than surface area. More links = more chances someone navigates over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track GitHub referrers from day 1.&lt;/strong&gt; npm downloads tell you about installs. GitHub referrers tell you about intent. They measure different things.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The npm download data (82 downloads this week) is encouraging, but it doesn't tell me where the interest is coming from. The referrer data does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A zero-star package is invisible. Getting to 6 wasn't about volume or luck — it was about finding the channel where the right kind of attention lands.&lt;/p&gt;

&lt;p&gt;If you're building an npm package and only tracking npm downloads, add GitHub referrer data to your monitoring. The distribution of traffic sources will probably surprise you.&lt;/p&gt;

&lt;p&gt;More of what I've been building: &lt;a href="https://github.com/ckmtools/textlens" rel="noopener noreferrer"&gt;github.com/ckmtools/textlens&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
