Press "Enter" to skip to content

I Vibe Coded a WordPress Plugin and Shipped it to Production

I haven’t written PHP for over a decade. I haven’t even touched it since 2017. But in the spirit of legitimate peripheral participation, I asked a couple of LLMs to write a WordPress plugin for this blog. Within 15 minutes, I had a proof of concept from ChatGPT. Next, I got Claude to generate an even better version in one shot. I iterated to add a feature and said, “Hey! It compiles! Ship it!

It was a fun experiment: a microcosm of failure, puzzle-solving, success, and iterative improvement.

They write about humans doing less and less of the actual writing of code, and yet producing software far quicker. That’s also happening here. And they write about engineers having great fun along the way. We see a lot of that too.

Dari Amodei, Foreword, Vibe Coding: Building Production-Grade Software with GenAI, Chat Agents, and Beyond, by Gene Kim & Steve Yegge

Motivation

I recently enabled ActivityPub on this blog, which means you can follow me on Mastodon.

Kerrick Long (blog)
Kerrick Long (blog)

Articles about programming, learning, code, books, and teams

12 posts
12 followers
See Fediverse followers of @[email protected]

Now that this blog has both long-form Articles and Twitter-style Micro-Posts, I began posting the kind of short-form content you’d usually find on Threads, X, Bluesky, or Mastodon. The first thing I was motivated to post was this quote:

โ€œHow do you reconcile the lean view that tests are waste with the need for tests in software development?โ€ Maryโ€™s immediate response: โ€œUnit tests are what let you stop the line.โ€

Dottie Acton, Foreword, Leading Lean Software Development: Results Are Not the Point, by Mary and Tom Poppendieck

But when I stuck the following HTML into WordPress’s code editor, I was met with a fallback “classic” UI and its out-of-place toolbar.

<figure>
   <blockquote>
    <p>โ€œHow do you reconcile the lean view that tests are waste with the need for tests in software development?โ€ Maryโ€™s immediate response: โ€œUnit tests are what let you stop the line.โ€</p>
  </blockquote>

  <figcaption>
    Dottie Acton,
    Foreword to
    <cite><a href="https://www.informit.com/store/leading-lean-software-development-results-are-not-the-9780321770370" title="">
      Leading Lean Software Development: Results Are Not the Point
    </a></cite>
    by Mary and Tom Poppendieck
  </figcaption>
</figure>
A screenshot of the Gutenberg editor for the post "Automated Tests as Waste vs. Needed" with a grey bar labeled "Classic"
A screenshot of the Gutenberg editor showing an older toolbar UI with an open dropdown to choose between Paragraph, Heading, and Preformatted styles.

Since I was hoping to post plenty of quotes as I continue reading software books to fill in the gaps in my knowledge, I wanted a better workflow than manually writing HTML and switching to the Code editor temporarily to add it, only to be rewarded with a sub-par editing experience.

First Attempt: ChatGPT (Free)

Let The Vibes Begin

I don’t pay for ChatGPT anymore, and I haven’t gotten permission to use the paid AI tools I have at work to run personal experiments on nights and weekends. Instead, I fired up the free version of ChatGPT for some CHOP, and gave it this prompt:

Give me the complete code for a minimal WordPress plugin to add a Gutenberg Editor block for a “Cited Quote”, in which you type the quoted text and then attach metadata for person (optional), work section (optional), work name (required), and work URL (optional). It should generate something matching the following HTML when all is said and done:

<figure class="wp-cited-quote">
  <blockquote class="wp-cited-quote-text">
    <p>"How do you reconcile the lean view that tests are waste with the need for tests in software development?" Mary's immediate response: "Unit tests are what let you stop the line."</p>
  </blockquote>
  <figcaption class="wp-cited-quote-attribution">
    <span class="wp-cited-quote-person">Dottie Acton</span>, <span cass="wp-cited-quote-work-section">Foreword to</span> <cite class="wp-cited-quote-work"><a class="wp-cited-quote-url" href="https://www.informit.com/store/leading-lean-software-development-results-are-not-the-9780321770370"><i>Leading Lean Software Development: Results Are Not the Point</i></a></cite> by Mary and Tom Poppendieck
  </figcaption>
</figure>

It spat out some reasonable-looking PHP, JavaScript, and JSON, so I zipped it up and installed it. Unfortunately, it didn’t work. I complained to the genie.

It isn’t showing up in my Block Inserter. Go to the internet and check the WordPress 6.8 plugin documentation.

Bad Vibes

ChatGPT told me what I should do and what it would do, but didn’t actually give me new code. I asked for it to do so.

Give me the full version of the plugin now, with all changes incorporated. I’ll just wholesale replace files (or add them, or delete them).

With the second iteration in hand, I installed it to yet another failure. Hoping I could get some extra oomph from the free version of ChatGPT, where you cannot select a model, I tried to get it to use a better model.

The block is still not in the Block Inserter even after compressing, uploading, and activating the plugin. What might be going wrong? Think hard.

In the spirit of vibe coding, I ignored the wall of text it gave me, uploaded the next version of the plugin, and… it worked?

A screenshot shows the Gutenberg editor with a Cited Quote block, including the ability to edit the quote's Person, Work Section, Work Name (Required), and Work URL.

Massive Problems

Unfortunately, it had two fatal bugs and one showstopper of a missing feature.

It kinda works, including the metadata and the place where I can type the quote directly. But there’s a major problem when editing, and a major problem when I save

1. In the editor, I cannot use blocks _inside_ the quote text. I may need to use headings and paragraphs, for example.

2. After I save, in the post itself, the quote text doesn’t render.

3. After I save, when I come back to the editor later, the quote text is gone (but the metadata stays)

Sadly, I had run out of free prompts to reasonably-powerful models. I took a few more turns of conversation attempting to get the weaker model to fix the problem, but it could not. I gave up on vibe coding with ChatGPT free.

Successful Attempt: Claude (Free)

One Shot

Armed with better insights about what to include in my prompts, I went over to the Claude Chat UI (not Claude Code) and prompted Claude Sonnet 4.5.

Give me the complete code for a minimal no-build WordPress 6.8 plugin to add a Gutenberg Editor block for a “Cited Quote”, in which you type the quoted text and then attach metadata for person (optional), work section (optional), work name (required), work author (required), and work URL (optional). Notably, the work text should be typed into the Gutenberg editor itself, and should be allowed to hold e.g. headings, lists, paragraphs inside it. The plugin should generate something matching the following HTML when all is said and done:

<figure class="wp-cited-quote">
  <blockquote class="wp-cited-quote-text">
    <p>"How do you reconcile the lean view that tests are waste with the need for tests in software development?" Mary's immediate response: "Unit tests are what let you stop the line."</p>
  </blockquote>
  <figcaption class="wp-cited-quote-attribution">
    <span class="wp-cited-quote-person">Dottie Acton</span>, <span cass="wp-cited-quote-work-section">Foreword</span>, <cite class="wp-cited-quote-work"><a class="wp-cited-quote-url" href="https://www.informit.com/store/leading-lean-software-development-results-are-not-the-9780321770370"><i>Leading Lean Software Development: Results Are Not the Point</i></a></cite> by <span class="wp-cited-quote-work-author">Mary and Tom Poppendieck</span>
  </figcaption>
</figure>

In one shot, Claude generated its own reasonable-looking PHP, JavaScript, and JSON, so I zipped it up and installed it. It worked a treat!

A screenshot shows the Gutenberg editor with a Cited Quote block, including the ability to edit the quote's Person, Work Section, Work Name (Required), Work Author (Required), and Work URL. The block is in use showing a quote by Dottie Acton, and the block is visible in the List View.

Improving the UX

When using it, though, I noticed a spot that needed sanding. So, I asked Claude to make it better.

This functions perfectly. I’d like a UX improvement. When I’m typing the quote, it’s difficult for me to edit the author etc. because the Gutenberg Editor’s right-hand sidebar shows the block setting for the (e.g.) Paragraph inside, rather than the Cited Quote it’s within. I can use the Document Overview to click the Cited Quote manually, so its settings show in the sidebar, but I’d like Cited Quote to also have a toolbar that is visible when editing anything within the cited quote. Give me all files with those changes incorporated.

I was imagining a little floating UI like Gutenberg has for anchor tags. I mistakenly called it a toolbar, and a toolbar (button) is what I got.

A screenshot of the Gutenberg editor showing a Cited Quote button in the global toolbar, and the Cited Quote settings in a right-hand sidebar.

I hadn’t even considered that as a possibility, since I have no idea what I’m doing. I decided I like the dynamic button, living with the other editor buttons, better than I would like a floating popover blocking part of the text I’m editing.

A Wild Bug Appears!

I noticed a weird bug while writing this post. A new ccc tag showed up in my WordPress dashboard, and it was applied to this post. I searched for that string in the plugin code, and found it.

I swapped out the hex color code for a CSS named color, and the bug went away. I don’t know enough about WordPress to understand why it was interpreting a color in a style attribute as a tag, and I love that I don’t have to find out. :shrug: :shipit:

What This Means For the Future

AI chatbots can now make a WordPress plugin–admittedly small and focused, but still useful. Why would I go looking for existing plugins that are going to repeatedly nag me and just keep changing? For example, one of the plugins I use on this blog has shoved three sparkle buttons (and a placeholder reminder) in my face.

If an app can be a home-cooked meal, an LLM can heat up a freezer dinner for you. I’d rather have a frozen pot pie with questionable ingredients than have a triple sparkle-burger chucked at my head. I’m not the only one.

Vibe coding is a whole new way of working: We should expect to see entirely new, economy-boosting advances in software and engineering as a result. At the very least, a lot more software is going to get written.

Dario Amodei, Foreword, Vibe Coding: Building Production-Grade Software With GenAI, Chat Agents, and Beyond, by Gene Kim & Steve Yegge

If non-programmers start vibe coding to avoid the constant stream of needy software, “a lot more software” is an understatement.

Free Software?

How is your risk tolerance for putting unreviewed AI-generated code into production? Here’s the plugin, be my guest.

Software!

Now, more than ever, I’d like to remind you of this section of the GNU GPL v2:

NO WARRANTY

11. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

12. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

Terms and Conditions for Copying, Distribution, and Modification, GNU General Public License v2.0, by Free Software Foundation, Inc.
cited-quote-block.php
<?php
/**
 * Plugin Name: Cited Quote Block
 * Description: A Gutenberg block for creating cited quotes with attribution metadata
 * Version: 1.1.1
 * Requires at least: 6.8
 * Requires PHP: 7.4
 * Author: Claude Sonnet 4.5
 * License: GPL v2 or later
 */

if (!defined('ABSPATH')) {
    exit;
}

function cited_quote_block_init() {
    register_block_type(__DIR__ . '/block.json');
}
add_action('init', 'cited_quote_block_init');
?>
block.json
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "cited-quote/block",
  "title": "Cited Quote",
  "category": "text",
  "icon": "format-quote",
  "description": "A block for cited quotes with attribution metadata",
  "keywords": ["quote", "citation", "blockquote"],
  "version": "1.1.1",
  "textdomain": "cited-quote-block",
  "editorScript": "file:./block.js",
  "render": "file:./render.php",
  "attributes": {
    "person": {
      "type": "string",
      "default": ""
    },
    "workSection": {
      "type": "string",
      "default": ""
    },
    "workName": {
      "type": "string",
      "default": ""
    },
    "workAuthor": {
      "type": "string",
      "default": ""
    },
    "workUrl": {
      "type": "string",
      "default": ""
    }
  },
  "supports": {
    "html": false
  }
}
block.js
(function(blocks, element, blockEditor, components) {
    const el = element.createElement;
    const { registerBlockType } = blocks;
    const { InnerBlocks, InspectorControls, BlockControls } = blockEditor;
    const { PanelBody, TextControl, ToolbarGroup, ToolbarButton, Popover } = components;
    const { useState } = element;
    
    registerBlockType('cited-quote/block', {
        edit: function(props) {
            const { attributes, setAttributes } = props;
            const [showPopover, setShowPopover] = useState(false);
            const [popoverAnchor, setPopoverAnchor] = useState(null);
            
            return el('div', {},
                el(BlockControls, {},
                    el(ToolbarGroup, {},
                        el(ToolbarButton, {
                            icon: 'edit',
                            label: 'Edit Citation',
                            onClick: function(event) {
                                setPopoverAnchor(event.currentTarget);
                                setShowPopover(!showPopover);
                            },
                            isPressed: showPopover
                        })
                    )
                ),
                showPopover && el(Popover, {
                    anchor: popoverAnchor,
                    onClose: function() {
                        setShowPopover(false);
                    },
                    placement: 'bottom-start'
                },
                    el('div', { style: { padding: '16px', width: '300px' } },
                        el('h3', { style: { marginTop: 0, marginBottom: '12px', fontSize: '13px', fontWeight: 600 } }, 'Citation Details'),
                        el(TextControl, {
                            label: 'Person (optional)',
                            value: attributes.person,
                            onChange: function(value) {
                                setAttributes({ person: value });
                            },
                            style: { marginBottom: '12px' }
                        }),
                        el(TextControl, {
                            label: 'Work Section (optional)',
                            value: attributes.workSection,
                            onChange: function(value) {
                                setAttributes({ workSection: value });
                            },
                            style: { marginBottom: '12px' }
                        }),
                        el(TextControl, {
                            label: 'Work Name (required)',
                            value: attributes.workName,
                            onChange: function(value) {
                                setAttributes({ workName: value });
                            },
                            style: { marginBottom: '12px' }
                        }),
                        el(TextControl, {
                            label: 'Work Author (required)',
                            value: attributes.workAuthor,
                            onChange: function(value) {
                                setAttributes({ workAuthor: value });
                            },
                            style: { marginBottom: '12px' }
                        }),
                        el(TextControl, {
                            label: 'Work URL (optional)',
                            value: attributes.workUrl,
                            onChange: function(value) {
                                setAttributes({ workUrl: value });
                            }
                        })
                    )
                ),
                el(InspectorControls, {},
                    el(PanelBody, { title: 'Citation Details', initialOpen: true },
                        el(TextControl, {
                            label: 'Person (optional)',
                            value: attributes.person,
                            onChange: function(value) {
                                setAttributes({ person: value });
                            }
                        }),
                        el(TextControl, {
                            label: 'Work Section (optional)',
                            value: attributes.workSection,
                            onChange: function(value) {
                                setAttributes({ workSection: value });
                            }
                        }),
                        el(TextControl, {
                            label: 'Work Name (required)',
                            value: attributes.workName,
                            onChange: function(value) {
                                setAttributes({ workName: value });
                            }
                        }),
                        el(TextControl, {
                            label: 'Work Author (required)',
                            value: attributes.workAuthor,
                            onChange: function(value) {
                                setAttributes({ workAuthor: value });
                            }
                        }),
                        el(TextControl, {
                            label: 'Work URL (optional)',
                            value: attributes.workUrl,
                            onChange: function(value) {
                                setAttributes({ workUrl: value });
                            }
                        })
                    )
                ),
                el('figure', { className: 'wp-cited-quote', style: { border: '1px dashed lightgray', padding: '20px' } },
                    el('blockquote', { className: 'wp-cited-quote-text' },
                        el(InnerBlocks, {
                            allowedBlocks: ['core/paragraph', 'core/heading', 'core/list'],
                            template: [['core/paragraph', { placeholder: 'Enter quote text...' }]]
                        })
                    ),
                    (attributes.person || attributes.workName || attributes.workAuthor) && 
                    el('figcaption', { className: 'wp-cited-quote-attribution', style: { marginTop: '10px', fontStyle: 'italic' } },
                        [
                            attributes.person && el('span', {}, attributes.person),
                            attributes.person && (attributes.workSection || attributes.workName) && ', ',
                            attributes.workSection && el('span', {}, attributes.workSection),
                            attributes.workSection && attributes.workName && ', ',
                            attributes.workName && el('cite', {}, el('i', {}, attributes.workName)),
                            attributes.workAuthor && ' by ',
                            attributes.workAuthor && el('span', {}, attributes.workAuthor)
                        ]
                    )
                )
            );
        },
        save: function() {
            return el(InnerBlocks.Content);
        }
    });
})(
    window.wp.blocks,
    window.wp.element,
    window.wp.blockEditor,
    window.wp.components
);
render.php
<?php
$person = isset($attributes['person']) ? esc_html($attributes['person']) : '';
$workSection = isset($attributes['workSection']) ? esc_html($attributes['workSection']) : '';
$workName = isset($attributes['workName']) ? esc_html($attributes['workName']) : '';
$workAuthor = isset($attributes['workAuthor']) ? esc_html($attributes['workAuthor']) : '';
$workUrl = isset($attributes['workUrl']) ? esc_url($attributes['workUrl']) : '';

$quote_text = $content;
?>

<figure class="wp-cited-quote">
    <blockquote class="wp-cited-quote-text">
        <?php echo $quote_text; ?>
    </blockquote>
    
    <?php if ($person || $workSection || $workName || $workAuthor): ?>
        <figcaption class="wp-cited-quote-attribution">
            <?php
            $parts = [];
            
            if ($person) {
                $parts[] = '<span class="wp-cited-quote-person">' . $person . '</span>';
            }
            
            if ($workSection) {
                $parts[] = '<span class="wp-cited-quote-work-section">' . $workSection . '</span>';
            }
            
            if ($workName) {
                $work_html = '<cite class="wp-cited-quote-work">';
                if ($workUrl) {
                    $work_html .= '<a class="wp-cited-quote-url" href="' . $workUrl . '"><i>' . $workName . '</i></a>';
                } else {
                    $work_html .= '<i>' . $workName . '</i>';
                }
                $work_html .= '</cite>';
                $parts[] = $work_html;
            }
            
            if ($workAuthor) {
                $parts[] = 'by <span class="wp-cited-quote-work-author">' . $workAuthor . '</span>';
            }
            
            echo implode(', ', $parts);
            ?>
        </figcaption>
    <?php endif; ?>
</figure>

Free?

Claude generated it with the license “GPL v2 or later,” which I found amusing. Assertions of unearned copyright are not new, and different jurisdictions have different laws.

  • The use of AI tools to assist rather than stand in for human creativity does not affect the availability of copyright protection for the output.
  • Copyright protects the original expression in a work created by a human author, even if the work also includes AI-generated material.
  • Copyright does not extend to purely AI-generated material, or material where there is insufficient human control over the expressive elements.
  • Whether human contributions to AI-generated outputs are sufficient to constitute authorship must be analyzed on a case-by-case basis.
  • Based on the functioning of current generally available technology, prompts do not alone provide sufficient control.
Executive Summary, Copyright and Artificial Intelligence Part 2: Copyrightability, by the United States Copyright Office

If your jurisdiction recognizes an AI-generated work’s self-copyright, Claude licensed it under the GPL v2 or later. If your jurisdiction recognizes an AI-generated work as copyrighted by the prompter, I also license it under the GPL v2 or later. And if your jurisdiction does not recognize AI-generated work as copyrightable, I think you can do whatever you’d like with the code. But I’m not a lawyer, I’m just a programmer trying to figure out the future.

Be First to Comment

Likes

Reposts

Leave a Reply

Your email address will not be published. Required fields are marked *


To respond on your own website, enter the URL of your response which should contain a link to this post's permalink URL. Your response will then appear (possibly after moderation) on this page. Want to update or remove your response? Update or delete your post and re-enter your post's URL again. (Find out more about Webmentions.)