<?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: DEUS Automations</title>
    <description>The latest articles on DEV Community by DEUS Automations (@deusautomations).</description>
    <link>https://dev.to/deusautomations</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4100149%2Fb32ff895-0ef9-4d13-ad64-94ce62e1050f.png</url>
      <title>DEV Community: DEUS Automations</title>
      <link>https://dev.to/deusautomations</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deusautomations"/>
    <language>en</language>
    <item>
      <title>Why Most "AI Resume Builders" Produce PDFs That Fail ATS Parsers</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 29 Aug 2026 15:56:15 +0000</pubDate>
      <link>https://dev.to/deusautomations/why-most-ai-resume-builders-produce-pdfs-that-fail-ats-parsers-153l</link>
      <guid>https://dev.to/deusautomations/why-most-ai-resume-builders-produce-pdfs-that-fail-ats-parsers-153l</guid>
      <description>&lt;p&gt;If you've ever run your own resume through an ATS (Applicant Tracking System) parser and watched&lt;br&gt;
your job title or bullet points come out garbled, you've hit a problem that has nothing to do with&lt;br&gt;
your content and everything to do with how the PDF was built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: PDF is a layout format, not a text format
&lt;/h2&gt;

&lt;p&gt;A PDF doesn't store "a paragraph followed by another paragraph." It stores absolute-positioned&lt;br&gt;
glyphs on a page. Most polished-looking resume templates get that layout from HTML+CSS rendered&lt;br&gt;
through a headless browser (Puppeteer/Chromium), which is great for visual fidelity and terrible&lt;br&gt;
for text order: multi-column layouts, sidebars, and floated elements often get flattened in&lt;br&gt;
reading order that has nothing to do with visual order. An ATS parser reads the underlying text&lt;br&gt;
stream top-to-bottom, left-to-right in &lt;em&gt;insertion&lt;/em&gt; order — so a two-column template can silently&lt;br&gt;
interleave your work history with your skills list, or drop your most recent job title into the&lt;br&gt;
middle of a bullet point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixes it
&lt;/h2&gt;

&lt;p&gt;Two things matter more than template beauty:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build the PDF with a low-level drawing API, not a browser.&lt;/strong&gt; Libraries like &lt;code&gt;pdfkit&lt;/code&gt; let you
place text in the exact order you write it in code — single-column templates render sidebar
accents as &lt;em&gt;decoration&lt;/em&gt; (colored rectangles, icons) that never enter the text stream, so the
parser only ever sees the real content, in the real order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always keep a resume-only fallback path.&lt;/strong&gt; If you're generating resume content with an LLM
(turning raw notes into structured bullet points, for example), the model call &lt;em&gt;will&lt;/em&gt;
eventually fail or time out. A parser that falls back to a deterministic, rule-based structuring
of the same raw input — instead of failing the whole request — means the user still gets a
correct, ATS-safe PDF even when the AI provider has a bad day.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A concrete example
&lt;/h2&gt;

&lt;p&gt;I ended up building this into &lt;a href="https://shop.lumnika.com/cv-forge/?src=devto" rel="noopener noreferrer"&gt;CV Forge&lt;/a&gt;, a small AI resume generator: raw notes&lt;br&gt;
go in, an LLM (OpenAI-compatible, tested against Groq's Llama 3.3 70B) turns them into a structured&lt;br&gt;
CV JSON, and &lt;code&gt;pdfkit&lt;/code&gt; renders it with a single-column-first "Compact" template designed to stay&lt;br&gt;
ATS-safe, alongside two more visual templates for when you're sending directly to a human. If the&lt;br&gt;
AI call fails, a local parser produces the same structured JSON so the PDF still comes out&lt;br&gt;
correctly formatted.&lt;/p&gt;

&lt;p&gt;The lesson generalizes past resumes: &lt;strong&gt;any time you're generating a PDF that a machine will read&lt;br&gt;
back&lt;/strong&gt;, decide &lt;em&gt;before&lt;/em&gt; picking a rendering approach whether text-stream order matters as much as&lt;br&gt;
visual layout. If it does, skip the headless browser and reach for a library that lets you control&lt;br&gt;
the text stream directly.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>career</category>
      <category>pdf</category>
    </item>
    <item>
      <title>How to generate WCAG-compliant ALT text for WordPress images without sending them to a vendor's black-box API</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 29 Aug 2026 12:55:27 +0000</pubDate>
      <link>https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-2cpc</link>
      <guid>https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-2cpc</guid>
      <description>&lt;p&gt;If you've ever tried to fix accessibility on an old WordPress site, you know the drill: hundreds of images in the Media Library, most with empty &lt;code&gt;alt&lt;/code&gt; attributes, and a WCAG 2.1 audit (or a client demanding one) breathing down your neck. Writing alt text by hand for 400 images is not a fun Tuesday. Every "AI alt text" SaaS I looked at wanted a monthly subscription, routed my images through their own servers, and gave me zero control over which model actually looked at the picture.&lt;/p&gt;

&lt;p&gt;This post is about the plugin I built to fix that for my own sites, and the handful of implementation details that turned out to matter more than expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual problem
&lt;/h2&gt;

&lt;p&gt;WCAG 2.1 Success Criterion 1.1.1 requires non-text content to have a text alternative. In WordPress terms: every &lt;code&gt;attachment&lt;/code&gt; post of MIME type &lt;code&gt;image&lt;/code&gt; should have &lt;code&gt;_wp_attachment_image_alt&lt;/code&gt; set to something meaningful, not "IMG_4821.jpg" and not empty.&lt;/p&gt;

&lt;p&gt;Doing this with a vision-capable LLM is trivial in principle — send the image, ask for a short description, save it as the alt attribute. The part that's &lt;em&gt;not&lt;/em&gt; trivial, if you don't want another recurring SaaS bill and don't want to hand a third party your whole media library, is: whose API key, which model, and where does the image actually go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision: BYOK, not a hosted service
&lt;/h2&gt;

&lt;p&gt;The plugin (&lt;code&gt;Alt Text BYOK&lt;/code&gt;) doesn't call any server of mine. It calls whatever OpenAI-compatible &lt;code&gt;chat/completions&lt;/code&gt; endpoint you configure, with your own API key. That's the entire trust model: your images go from your WordPress install directly to the provider you already chose (OpenAI, or any of the growing list of OpenAI-compatible vision endpoints), and nowhere else.&lt;/p&gt;

&lt;p&gt;The settings are deliberately just four fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_default_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'api_base'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://api.openai.com/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'api_key'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'language'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'English'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'overwrite_existing'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'license_key'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api_base&lt;/code&gt; is the detail that matters most for portability: it's not hardcoded to OpenAI. Point it at any provider that speaks the same &lt;code&gt;chat/completions&lt;/code&gt; shape with image content parts, and it works. That includes several free-tier vision models if you want to run the whole thing at zero cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual API call
&lt;/h2&gt;

&lt;p&gt;Nothing exotic — a single &lt;code&gt;chat/completions&lt;/code&gt; request with a multimodal &lt;code&gt;content&lt;/code&gt; array (text instruction + &lt;code&gt;image_url&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_call_vision_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trailingslashit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_base'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'chat/completions'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$prompt&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Describe this image in %s as a concise, descriptive ALT text for web accessibility. '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'Maximum 125 characters. Do not start with "image of" or "picture of". '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'Reply with the ALT text only, no quotes, no extra commentary.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'language'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'messages'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'role'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="s1"&gt;'type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'image_url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="s1"&gt;'image_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$image_url&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_key'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wp_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... error handling, then trim/sanitize the returned text&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two prompt details earned their place after actually looking at model output on real sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Do not start with 'image of' or 'picture of'"&lt;/strong&gt; — screen readers already announce the element as an image; a generated alt text that starts with "Image of a dog running" is redundant and is explicitly called out as an anti-pattern in WCAG guidance. Without this instruction, GPT-4o-mini defaults to that phrasing constantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard character cap in the prompt, plus a second cap after the fact&lt;/strong&gt; (&lt;code&gt;mb_substr( $alt, 0, 160 )&lt;/code&gt;) — models don't reliably respect "maximum 125 characters" as an instruction, so the code truncates again server-side rather than trusting the model's arithmetic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding the images that actually need it
&lt;/h2&gt;

&lt;p&gt;The query that drives the bulk-fix screen is a plain &lt;code&gt;WP_Query&lt;/code&gt; against attachments missing the meta key entirely — not empty string, &lt;em&gt;missing&lt;/em&gt;, which is a different &lt;code&gt;meta_query compare&lt;/code&gt; (&lt;code&gt;NOT EXISTS&lt;/code&gt; vs &lt;code&gt;=&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_attachments_missing_alt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'post_type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'attachment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_status'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'inherit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_mime_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'posts_per_page'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'meta_query'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'key'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'_wp_attachment_image_alt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'compare'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'NOT EXISTS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;posts&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;&lt;code&gt;post_status =&amp;gt; inherit&lt;/code&gt; is the detail that's easy to get wrong: attachments don't use the normal &lt;code&gt;publish&lt;/code&gt;/&lt;code&gt;draft&lt;/code&gt; statuses, they inherit their parent post's status (or &lt;code&gt;inherit&lt;/code&gt; on their own when unattached). Query for &lt;code&gt;publish&lt;/code&gt; here instead and you silently miss every unattached media-library image — which, in practice on most sites, is most of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating one at a time, on purpose
&lt;/h2&gt;

&lt;p&gt;The admin UI processes images one AJAX call per image instead of batching them server-side into one big request. That's not an accident — a bulk endpoint that loops over 300 images inside a single PHP request runs straight into &lt;code&gt;max_execution_time&lt;/code&gt; on any shared host, and a failure partway through gives you no idea which images actually got done. One request per image, driven by JS, means progress is visible and a timeout on image #214 doesn't lose the 213 that already succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free-tier limit is deliberate, not a paywall trick
&lt;/h2&gt;

&lt;p&gt;WordPress.org doesn't allow selling anything inside a plugin listed there, so the free version ships with a genuine cap — 30 generations/month, tracked with a month-keyed option that resets itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_usage_get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'atbyok_usage'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$ym&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gmdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Y-m'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ym'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$ym&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ym'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$ym&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$data&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;Using the current year-month as the array key instead of a scheduled cron job to reset the counter means there's no maintenance task that can silently stop firing — the counter just naturally starts over the first time &lt;code&gt;atbyok_usage_get()&lt;/code&gt; runs in a new month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways if you're building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you're integrating a vision model for a task with a strict output format (short, no boilerplate prefix, character limit), don't trust the prompt alone — enforce the hard constraints in code after the response comes back.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;meta_query&lt;/code&gt; on attachments: always double check &lt;code&gt;NOT EXISTS&lt;/code&gt; vs &lt;code&gt;= ''&lt;/code&gt; vs plain absence — WordPress doesn't guarantee the meta row exists at all for older uploads.&lt;/li&gt;
&lt;li&gt;Attachment &lt;code&gt;post_status&lt;/code&gt; is not what you'd guess; query &lt;code&gt;inherit&lt;/code&gt;, not &lt;code&gt;publish&lt;/code&gt;, if you want &lt;em&gt;all&lt;/em&gt; media library items regardless of whether they're attached to a published post.&lt;/li&gt;
&lt;li&gt;For any bulk operation on a shared-hosting WordPress site, prefer many small requests over one big one. &lt;code&gt;max_execution_time&lt;/code&gt; will find you eventually.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I'm the author of &lt;a href="https://shop.lumnika.com/alt-text-byok.html?src=devto" rel="noopener noreferrer"&gt;Alt Text BYOK&lt;/a&gt;, a WordPress plugin that does exactly what's described above — bring your own API key, images never touch a third-party server of mine, free tier included.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>a11y</category>
      <category>php</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to generate WCAG-compliant ALT text for WordPress images without sending them to a vendor's black-box API</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 29 Aug 2026 10:50:32 +0000</pubDate>
      <link>https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-3emd</link>
      <guid>https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-3emd</guid>
      <description>&lt;p&gt;If you've ever tried to fix accessibility on an old WordPress site, you know the drill: hundreds of images in the Media Library, most with empty &lt;code&gt;alt&lt;/code&gt; attributes, and a WCAG 2.1 audit (or a client demanding one) breathing down your neck. Writing alt text by hand for 400 images is not a fun Tuesday. Every "AI alt text" SaaS I looked at wanted a monthly subscription, routed my images through their own servers, and gave me zero control over which model actually looked at the picture.&lt;/p&gt;

&lt;p&gt;This post is about the plugin I built to fix that for my own sites, and the handful of implementation details that turned out to matter more than expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual problem
&lt;/h2&gt;

&lt;p&gt;WCAG 2.1 Success Criterion 1.1.1 requires non-text content to have a text alternative. In WordPress terms: every &lt;code&gt;attachment&lt;/code&gt; post of MIME type &lt;code&gt;image&lt;/code&gt; should have &lt;code&gt;_wp_attachment_image_alt&lt;/code&gt; set to something meaningful, not "IMG_4821.jpg" and not empty.&lt;/p&gt;

&lt;p&gt;Doing this with a vision-capable LLM is trivial in principle — send the image, ask for a short description, save it as the alt attribute. The part that's &lt;em&gt;not&lt;/em&gt; trivial, if you don't want another recurring SaaS bill and don't want to hand a third party your whole media library, is: whose API key, which model, and where does the image actually go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision: BYOK, not a hosted service
&lt;/h2&gt;

&lt;p&gt;The plugin (&lt;code&gt;Alt Text BYOK&lt;/code&gt;) doesn't call any server of mine. It calls whatever OpenAI-compatible &lt;code&gt;chat/completions&lt;/code&gt; endpoint you configure, with your own API key. That's the entire trust model: your images go from your WordPress install directly to the provider you already chose (OpenAI, or any of the growing list of OpenAI-compatible vision endpoints), and nowhere else.&lt;/p&gt;

&lt;p&gt;The settings are deliberately just four fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_default_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'api_base'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://api.openai.com/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'api_key'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'language'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'English'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'overwrite_existing'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'license_key'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api_base&lt;/code&gt; is the detail that matters most for portability: it's not hardcoded to OpenAI. Point it at any provider that speaks the same &lt;code&gt;chat/completions&lt;/code&gt; shape with image content parts, and it works. That includes several free-tier vision models if you want to run the whole thing at zero cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual API call
&lt;/h2&gt;

&lt;p&gt;Nothing exotic — a single &lt;code&gt;chat/completions&lt;/code&gt; request with a multimodal &lt;code&gt;content&lt;/code&gt; array (text instruction + &lt;code&gt;image_url&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_call_vision_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trailingslashit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_base'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'chat/completions'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$prompt&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Describe this image in %s as a concise, descriptive ALT text for web accessibility. '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'Maximum 125 characters. Do not start with "image of" or "picture of". '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'Reply with the ALT text only, no quotes, no extra commentary.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'language'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'messages'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'role'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="s1"&gt;'type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'image_url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="s1"&gt;'image_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$image_url&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_key'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wp_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... error handling, then trim/sanitize the returned text&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two prompt details earned their place after actually looking at model output on real sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Do not start with 'image of' or 'picture of'"&lt;/strong&gt; — screen readers already announce the element as an image; a generated alt text that starts with "Image of a dog running" is redundant and is explicitly called out as an anti-pattern in WCAG guidance. Without this instruction, GPT-4o-mini defaults to that phrasing constantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard character cap in the prompt, plus a second cap after the fact&lt;/strong&gt; (&lt;code&gt;mb_substr( $alt, 0, 160 )&lt;/code&gt;) — models don't reliably respect "maximum 125 characters" as an instruction, so the code truncates again server-side rather than trusting the model's arithmetic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding the images that actually need it
&lt;/h2&gt;

&lt;p&gt;The query that drives the bulk-fix screen is a plain &lt;code&gt;WP_Query&lt;/code&gt; against attachments missing the meta key entirely — not empty string, &lt;em&gt;missing&lt;/em&gt;, which is a different &lt;code&gt;meta_query compare&lt;/code&gt; (&lt;code&gt;NOT EXISTS&lt;/code&gt; vs &lt;code&gt;=&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_attachments_missing_alt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'post_type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'attachment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_status'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'inherit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_mime_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'posts_per_page'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'meta_query'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'key'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'_wp_attachment_image_alt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'compare'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'NOT EXISTS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;posts&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;&lt;code&gt;post_status =&amp;gt; inherit&lt;/code&gt; is the detail that's easy to get wrong: attachments don't use the normal &lt;code&gt;publish&lt;/code&gt;/&lt;code&gt;draft&lt;/code&gt; statuses, they inherit their parent post's status (or &lt;code&gt;inherit&lt;/code&gt; on their own when unattached). Query for &lt;code&gt;publish&lt;/code&gt; here instead and you silently miss every unattached media-library image — which, in practice on most sites, is most of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating one at a time, on purpose
&lt;/h2&gt;

&lt;p&gt;The admin UI processes images one AJAX call per image instead of batching them server-side into one big request. That's not an accident — a bulk endpoint that loops over 300 images inside a single PHP request runs straight into &lt;code&gt;max_execution_time&lt;/code&gt; on any shared host, and a failure partway through gives you no idea which images actually got done. One request per image, driven by JS, means progress is visible and a timeout on image #214 doesn't lose the 213 that already succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free-tier limit is deliberate, not a paywall trick
&lt;/h2&gt;

&lt;p&gt;WordPress.org doesn't allow selling anything inside a plugin listed there, so the free version ships with a genuine cap — 30 generations/month, tracked with a month-keyed option that resets itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_usage_get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'atbyok_usage'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$ym&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gmdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Y-m'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ym'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$ym&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ym'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$ym&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$data&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;Using the current year-month as the array key instead of a scheduled cron job to reset the counter means there's no maintenance task that can silently stop firing — the counter just naturally starts over the first time &lt;code&gt;atbyok_usage_get()&lt;/code&gt; runs in a new month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways if you're building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you're integrating a vision model for a task with a strict output format (short, no boilerplate prefix, character limit), don't trust the prompt alone — enforce the hard constraints in code after the response comes back.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;meta_query&lt;/code&gt; on attachments: always double check &lt;code&gt;NOT EXISTS&lt;/code&gt; vs &lt;code&gt;= ''&lt;/code&gt; vs plain absence — WordPress doesn't guarantee the meta row exists at all for older uploads.&lt;/li&gt;
&lt;li&gt;Attachment &lt;code&gt;post_status&lt;/code&gt; is not what you'd guess; query &lt;code&gt;inherit&lt;/code&gt;, not &lt;code&gt;publish&lt;/code&gt;, if you want &lt;em&gt;all&lt;/em&gt; media library items regardless of whether they're attached to a published post.&lt;/li&gt;
&lt;li&gt;For any bulk operation on a shared-hosting WordPress site, prefer many small requests over one big one. &lt;code&gt;max_execution_time&lt;/code&gt; will find you eventually.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Missing alt text is exactly the kind of thing that piles up silently on a store's product catalog too. I built &lt;a href="https://shop.lumnika.com/compliancesnap/?src=devto" rel="noopener noreferrer"&gt;ComplianceSnap&lt;/a&gt;, a free scanner for Shopify/WooCommerce stores that counts how many product images have no alt text and estimates your ADA/WCAG risk exposure.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>a11y</category>
      <category>php</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
