<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://nasratulnayem.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://nasratulnayem.github.io/" rel="alternate" type="text/html" /><updated>2026-08-24T17:35:19+00:00</updated><id>https://nasratulnayem.github.io/feed.xml</id><title type="html">Nasratul Nayem — Full-Stack Developer | WordPress &amp;amp; Shopify Expert</title><subtitle>Nasratul Nayem — Full-Stack Web Developer, WordPress Expert &amp; Automation Engineer from Bangladesh. High-performance WooCommerce, Shopify &amp; AI workflows for global brands.</subtitle><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><entry><title type="html">I got tired of Canva so I built a thumbnail engine with Playwright</title><link href="https://nasratulnayem.github.io/blog/autofacebookpost-case-study/" rel="alternate" type="text/html" title="I got tired of Canva so I built a thumbnail engine with Playwright" /><published>2026-05-25T10:19:44+00:00</published><updated>2026-05-25T10:19:44+00:00</updated><id>https://nasratulnayem.github.io/blog/autofacebookpost-case-study</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/autofacebookpost-case-study/"><![CDATA[<p>sitemap: false</p>

<section class="codex-block">
    <h2>The manual design grind was killing me</h2>
    <p>I spend way too much time staring at blank canvases in Canva or Photoshop. It starts with one post, then ten, then twenty, and suddenly I have spent four hours moving text layers three pixels to the left. It is a massive waste of time for someone who actually knows how to code. I wanted a way to just dump a list of titles and images into a folder and have a script spit out professional-looking social media graphics without me touching a mouse.</p>
    <p>That is where autofacebookpost started. I did not want to deal with complex image processing libraries in Python like PIL or OpenCV because styling text with code is a nightmare. I already know CSS. I can build a layout in HTML in ten minutes that looks better than anything I can make in a dedicated design tool. So the goal was simple: use HTML as my design engine and use a headless browser to take screenshots of it.</p>
    <p>I am 21, I do not have a budget for expensive API credits or high-end servers. I needed something that could run in a container, handle bulk uploads, and eventually push those images straight to a Facebook page because logging into Meta&#8217;s Business Suite is its own circle of hell. This project was about reclaiming my time and proving that I could automate the boring parts of being online.</p>
</section>

<section class="codex-block">
    <h2>How the engine actually works</h2>
    <p>The stack is pretty straightforward but effective. I am using Flask for the web interface and Playwright to handle the heavy lifting of rendering. The core idea is that every thumbnail is just an HTML template. When I upload a CSV or enter data manually, the app injects those strings into the template, spins up a headless Chromium instance, and snaps a high-resolution screenshot.</p>
    <p>In <code>app.py</code>, I set up the basic routes to handle the dashboard and the file management. I decided to use a local <code>db.json</code> file instead of a full SQL database. Is it scalable for a million users? No. Does it work perfectly for a single developer running it on a VPS? Absolutely. It keeps the project lightweight and portable.</p>
    <p>The logic for actually generating the image is tucked away in <code>makethumb.py</code>. It uses Playwright&#8217;s async API to launch the browser. Here is a simplified look at how I handle that rendering process:</p>
<pre><code>import asyncio
from playwright.async_api import async_playwright

async def generate_screenshot(html_content, output_path):
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page(viewport={'width': 1280, 'height': 720})
        await page.set_content(html_content)
        await page.screenshot(path=output_path)
        await browser.close()</code></pre>
    <p>By using <code>page.set_content()</code>, I can pass the fully rendered HTML string directly to the browser. I do not have to host the template on a public URL or deal with local file path permissions inside the browser context. It is fast, and because it is Chromium, I can use modern CSS features like Flexbox, Grid, and even complex text shadows or filters that would be impossible in a standard image library.</p>
</section>

<section class="codex-block">
    <h2>The Docker nightmare</h2>
    <p>I thought containerizing this would be easy. I was wrong. Playwright requires a massive list of system dependencies to run Chromium inside a Linux environment. My first <code>Dockerfile</code> was failing constantly because of missing shared libraries like <code>libnss3</code> and <code>libgbm1</code>. I spent an entire afternoon just trial-and-erroring the <code>apt-get install</code> list.</p>
    <p>I eventually settled on using <code>python:3.11-slim-bullseye</code> as the base image to keep the size down, but even then, the final image is nearly 1GB because of the browser binaries. I had to be very specific in the Dockerfile to only install Chromium and not the entire Playwright suite of Firefox and Webkit, which saved me a few hundred megabytes of disk space on my cheap VPS.</p>
    <pre><code># Install system dependencies required for Playwright
RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
    libnss3 libnspr4 libdbus-1-3 libatk1.0-0 \
    libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 \
    libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
    libgbm1 libasound2 &amp;&amp; rm -rf /var/lib/apt/lists/*

# Install only the necessary browser
RUN playwright install chromium</code></pre>
    <p>Even after getting the dependencies right, I ran into memory limits. Chromium is a resource hog. Running multiple concurrent screenshot requests would occasionally crash the container. I had to implement some basic queueing logic in the Flask app to ensure I was not trying to open twenty browser tabs at once on a machine with only 2GB of RAM.</p>
</section>

<section class="codex-block">
    <h2>Templates and design decisions</h2>
    <p>I did not just want one look. I wanted variety. The <code>thumbnail_templates/</code> folder is filled with different HTML files like <code>template_gaming.html</code>, <code>template_corporate.html</code>, and <code>template_minimalist.html</code>. Each of these uses specific Google Fonts and CSS variables for quick color swaps.</p>
    <p>One of the tougher decisions was how to handle images within the thumbnails. Initially, I tried to upload them directly, but managing paths inside the templates was messy. I shifted to using external URLs or a dedicated <code>image_uploads</code> folder that the Flask app serves. This allows me to use a standard <code>&lt;img src="..."&gt;</code> tag in the HTML and let the browser handle the scaling and cropping via <code>object-fit: cover</code>.</p>
    <p>I also built a &#8220;Manual Entry&#8221; mode. Sometimes I don&#8217;t have a CSV; I just have three ideas in my head. I created a UI where I can paste a list of titles and badges into separate textareas. The app splits these by newlines and matches them up. If I provide 5 titles and 5 images, it generates 5 thumbnails in a single click. It is a small feature, but it is the one I use the most.</p>
</section>

<section class="codex-block">
    <h2>The Facebook Graph API struggle</h2>
    <p>Getting the images generated was only half the battle. I wanted to post them. The Facebook Graph API is notoriously annoying to work with. You need a User Token, which you then have to exchange for a Long-Lived Page Access Token. If you get the scopes wrong, the API just returns a cryptic error message.</p>
    <p>I wrote a helper function in <code>app.py</code> called <code>get_page_access_token</code> to handle this exchange. It was a headache to debug because Meta&#8217;s documentation doesn&#8217;t always match the actual behavior of their v19.0 endpoints. I had to deal with permissions like <code>pages_manage_posts</code> and <code>pages_read_engagement</code> just to get a simple photo upload working.</p>
    <pre><code>def get_page_access_token(user_token, page_id):
    url = f"https://graph.facebook.com/v19.0/{page_id}"
    params = {"fields": "access_token", "access_token": user_token}
    try:
        r = requests.get(url, params=params)
        r.raise_for_status()
        data = r.json()
        return data.get("access_token"), None
    except Exception as e:
        return None, str(e)</code></pre>
    <p>The app now saves these credentials in the <code>db.json</code> file. Once set up, I can pick any generated thumbnail from my library, write a caption, and hit &#8220;Post&#8221;. It even supports a &#8220;First Comment&#8221; feature, which is great for putting hashtags or links without cluttering the main post caption.</p>
</section>

<section class="codex-block">
    <h2>How to run this yourself</h2>
    <p>If you want to use this, you need Docker. I wouldn&#8217;t recommend trying to install the Playwright dependencies on your local machine unless you&#8217;re on a clean Linux distro, otherwise you will mess up your system libraries.</p>
    <ul>
        <li>Clone the repo to your server or local machine.</li>
        <li>Create a <code>db.json</code> file if it is not already there with the basic structure <code>{"thumbnails": [], "social_media_credentials": {}}</code>.</li>
        <li>Build the image: <code>docker build -t autofacebookpost .</code></li>
        <li>Run the container: <code>docker run -p 5002:5002 autofacebookpost</code></li>
        <li>Access the dashboard at <code>http://localhost:5002</code>.</li>
    </ul>
    <p>You will need to go to the Settings page to add your Facebook Access Token and Page ID if you want to use the posting features. For the templates, you can either use the ones I built or upload your own HTML files to the <code>thumbnail_templates</code> directory. Just make sure your HTML uses the variables the app expects, like <code>{{ main_title }}</code> and <code>{{ image_url }}</code>.</p>
</section>

<section class="codex-block">
    <h2>Who this is for</h2>
    <p>This is for the developer who is running a side project or a niche news site and cannot justify hiring a designer. It is for people who believe that if you have to do something more than twice, you should probably write a script for it. It is not a replacement for a real creative director, but it is a massive upgrade over default, boring social media posts.</p>
    <p>I built this for me. I needed a way to maintain a social presence for my projects without it becoming a second full-time job. It is grounded, it is a bit rough around the edges, and it solves exactly one problem: making and sharing graphics fast.</p>
</section>

<section class="codex-block">
    <h2>What I would change next</h2>
    <p>The biggest thing missing is a proper scheduling system. Right now, it posts immediately. I have some UI code in <code>facebook_post.html</code> for a scheduler, but the backend logic for a task runner like Celery or Redis isn&#8217;t there yet. I did not want to add that complexity in version one, but as I use it more, I realize that being able to batch-create and schedule a week&#8217;s worth of posts on Sunday night is the ultimate goal.</p>
    <p>I also want to add support for more platforms. Instagram is the obvious next step, but their API is even more restrictive about third-party uploads. I might have to look into using Playwright to actually automate the web-based upload flow if the API continues to be a bottleneck. Finally, I&#8217;d like to integrate a basic AI prompt that can generate the titles based on a URL, so I don&#8217;t even have to think of the headlines myself.</p>
</section>

<section class="codex-block">
    <a href="https://github.com/nasratulnayem/autofacebookpost" class="cx-btn cx-btn-primary">View GitHub Repo</a>
</section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="Python Automations" /><summary type="html"><![CDATA[The manual design grind was killing me I spend way too much time staring at blank canvases in Canva or Photoshop. It starts with one...]]></summary></entry><entry><title type="html">How I Built a Lightweight WebP Converter for WordPress Images</title><link href="https://nasratulnayem.github.io/blog/effortless-webp-converter-for-wordpress/" rel="alternate" type="text/html" title="How I Built a Lightweight WebP Converter for WordPress Images" /><published>2026-05-25T09:22:21+00:00</published><updated>2026-05-25T09:22:21+00:00</updated><id>https://nasratulnayem.github.io/blog/effortless-webp-converter-for-wordpress</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/effortless-webp-converter-for-wordpress/"><![CDATA[<p>sitemap: false</p>

<p></p>

<section class="codex-block">
<h2>Why I built this instead of relying on another plugin</h2>
<p>A while ago, I was working on a WordPress site with more than 4,000 images in the media library. Most of them were old JPEG and PNG files, and many were much heavier than they needed to be. When I tested the site in PageSpeed Insights, the result was not great. The score was sitting around the low 30s, and the image recommendations were mostly pointing to one thing: serve images in a next-gen format.</p>

<p>I checked a few popular plugins, but most of them came with a subscription model or required the images to be processed through an external cloud service. That did not feel right for this project. I wanted a tool that could run directly on the server, keep the workflow simple, and avoid adding another monthly cost.</p>

<p>The other issue was control. Some image optimisation tools can be too aggressive. They rewrite database values, remove original files, or make changes that are hard to reverse when something goes wrong. I have seen websites break because an optimisation process failed halfway through. So my main rule was simple: keep the original files untouched and only serve the WebP version when it exists and works properly.</p>

<p>That is how the idea for Effortless WebP Converter started. It became a dashboard-based WordPress tool that scans the media library, converts images in small batches, and uses WordPress filters to serve the WebP version on the front end. The goal was not to build a huge optimisation suite. I wanted something lightweight, predictable, and safe enough to use on real client sites.</p>

<p>The project is available on GitHub for anyone who wants to review the code, test it, or improve it further: <a href="https://github.com/nasratulnayem/effortless-webp-converter" target="_blank" rel="noopener noreferrer">Explore the Effortless WebP Converter source code</a>.</p>
</section>

<section class="codex-block">
<h2>The timeout problem that shaped the whole workflow</h2>
<p>The hardest part was not the WebP conversion itself. The real problem was server limits. Most shared hosting environments do not allow long-running PHP scripts. If a process takes more than 30 or 60 seconds, the server usually stops it.</p>

<p>That becomes a serious issue when you are trying to process thousands of images. If you try to convert everything in one request, the process will fail quickly. I tested that approach early on, and it was clear that increasing memory limits or execution time was not a reliable answer. On client hosting, you often do not control those settings.</p>

<p>So I built the conversion process around small batches. The admin dashboard sends a request to the server, the server processes a few images, saves the progress, and then the dashboard sends the next request. This keeps each request short and reduces the chance of a timeout.</p>

<p>The useful part is that the process can continue from where it stopped. If the browser tab closes, the internet drops, or the server pauses for a moment, the plugin still knows which images are pending. That required a clean state system using the WordPress Options API, but it made the whole workflow much more dependable.</p>
</section>

<section class="codex-block">
<h2>How the plugin is structured</h2>
<p>The plugin is built around a simple structure. There is a main loader file, a core class that handles the main logic, and a small set of admin assets for the dashboard interface. I kept the JavaScript plain and avoided build tools because this plugin did not need extra complexity.</p>

<p>The main class uses a singleton pattern so the hooks are managed from one place. That keeps the code easier to follow and prevents filters or actions from being registered more than once. For a small WordPress utility plugin, that approach works well.</p>

<p>For image conversion, the plugin checks the server environment first. It looks for Imagick, then falls back to GD when Imagick is not available. Imagick usually gives better results, but GD is available on many hosting setups. If neither extension exists, the plugin does not try to force anything. It simply shows that the server cannot handle the conversion.</p>

<h3>The conversion logic</h3>

<div class="codex-codebox">
  <div class="codex-codebox-header">
    <div class="codex-codebox-left">
      <div class="codex-codebox-dots">
        <span></span><span></span><span></span>
      </div>
      <div class="codex-codebox-title">PHP · Batch conversion handler</div>
    </div>


  </div>

  <pre><code>public function ajax_convert_batch(): void {
    check_ajax_referer('webp_migrator_admin', 'nonce');

    $state = $this-&gt;get_state();
    $batch_size = 5;
    $attachments = array_slice($state['pending'], 0, $batch_size);

    foreach ($attachments as $attachment_id) {
        $this-&gt;process_attachment($attachment_id);
    }

    // Update the stored state and return the batch response.
}</code></pre>
</div>

<p>This is the basic idea behind the batching system. The plugin takes the pending attachment IDs, processes a small group, updates the saved state, and returns a response to the dashboard. It is not fancy, but it is stable. On lower-end hosting, that matters more than trying to process too much at once.</p>
</section>

<section class="codex-block">
<h2>Serving WebP images without breaking the front end</h2>
<p>Creating the WebP files is only part of the job. The site also needs to serve those files to visitors without breaking existing images. I did not want to depend on .htaccess rules because they can be difficult to debug, especially across different hosting environments.</p>

<p>Instead, I used WordPress filters. The plugin works with common image output points such as wp_get_attachment_url, wp_calculate_image_srcset, and the_content. This gives the plugin a safer way to replace image URLs only when a WebP version is available.</p>

<p>The srcset handling needed extra care. Responsive images in WordPress can include several URLs and width values in one attribute. If that string is changed incorrectly, the browser can ignore it or load the wrong image. So the plugin checks each possible replacement carefully before changing the output.</p>

<p>For images inside post content, the plugin uses a straightforward replacement approach. It is lighter than parsing the whole HTML document and works well for common WordPress content output. The priority is set so the filter runs after most content changes, but before the final markup reaches the browser.</p>

<h3>How the URL filter works</h3>

<div class="codex-codebox">
  <div class="codex-codebox-header">
    <div class="codex-codebox-left">
      <div class="codex-codebox-dots">
        <span></span><span></span><span></span>
      </div>
      <div class="codex-codebox-title">PHP · Safe WebP URL fallback</div>
    </div>


  </div>

  <pre><code>public function filter_attachment_url($url, $post_id) {
    if (is_admin()) {
        return $url;
    }

    $webp_url = str_replace(['.jpg', '.jpeg', '.png'], '.webp', $url);
    $path = str_replace(content_url(), WP_CONTENT_DIR, $webp_url);

    if (file_exists($path)) {
        return $webp_url;
    }

    return $url;
}</code></pre>
</div>

<p>This is the safety check that makes the plugin reliable. It only changes the image URL when the matching WebP file exists on the server. If the WebP file is missing or the conversion failed for that image, the original file stays in place. The visitor never sees a broken image because the plugin always has a fallback.</p>
</section>

<section class="codex-block">
<h2>What I chose not to add</h2>
<p>I kept the first version focused. One thing I did not include was automatic CSS background image replacement. Finding and replacing background images inside CSS files can get messy very quickly. Themes and builders handle those images in different ways, and parsing CSS through PHP would add more risk than value for this version.</p>

<p>I also skipped WP-CLI support for the first release. I know it would be useful for developers, but the main goal was to create a dashboard-first workflow. Most clients and site owners are more comfortable running a tool from the WordPress admin area than using the command line.</p>

<p>I also decided not to delete original images. Some tools offer that feature to save disk space, but I do not think it is worth the risk by default. Storage is usually cheaper than losing an original file that cannot be recovered later.</p>

<p>The interface is intentionally simple as well. It uses familiar WordPress admin styling instead of a heavy custom UI. I spent more time making the conversion logic safe than making the dashboard look overly polished. For this kind of plugin, that felt like the right tradeoff.</p>
</section>

<section class="codex-block">
<h2>Who this plugin is useful for</h2>
<p>This plugin is useful for WordPress sites that need better image performance without adding a heavy optimisation service. It is especially helpful for older websites with large media libraries full of JPEG and PNG files.</p>

<ul>
<li>Site owners who want to convert existing media library images to WebP.</li>
<li>Developers who want a transparent server-side tool without cloud processing.</li>
<li>WordPress users on hosting environments where rewrite rules are not easy to manage.</li>
<li>Anyone who wants to keep original files as a safety backup.</li>
</ul>

<p>It is not trying to replace every image optimisation platform. It is built for a specific job: scan the media library, create WebP versions, and serve them safely when they are available.</p>
</section>

<section class="codex-block">
<h2>How to install and use it</h2>
<p>The setup is standard for a WordPress plugin. There are no API keys, external accounts, or subscription settings. The server only needs PHP 7.4 or higher with either GD or Imagick enabled.</p>

<p>You can download the plugin directly from the release file here: <a href="https://github.com/nasratulnayem/effortless-webp-converter/releases/download/v0.1.0/effortless-webp-converter.zip" target="_blank" rel="noopener noreferrer">Download the Effortless WebP Converter plugin</a>.</p>

<ol>
<li>Download the plugin ZIP file from the release link.</li>
<li>Open your WordPress dashboard and go to Plugins.</li>
<li>Click Add New, then Upload Plugin.</li>
<li>Upload the effortless-webp-converter.zip file.</li>
<li>Activate the plugin after installation.</li>
<li>Go to Tools and open the Effortless WebP Converter screen.</li>
<li>Click Scan Library to find JPEG and PNG images in the media library.</li>
<li>Start the conversion and keep the browser tab open while the batches run.</li>
<li>Check the front end in an incognito window to confirm that WebP images are loading.</li>
</ol>

<p>After conversion, I usually test the homepage, a blog post, and any page with large visual sections. Opening an image in a new tab is a quick way to confirm whether the WebP version is being served. Once everything looks right, the plugin continues handling the URL replacement automatically.</p>
</section>

<section class="codex-block">
<h2>What I learned while building it</h2>
<p>This project reminded me how useful a focused tool can be. The plugin does not need a complex framework or an external API to solve the problem. It only needs a reliable way to scan files, convert them safely, track progress, and avoid breaking the existing media library.</p>

<p>The most important work was in the edge cases. Some files had unusual names. Some servers had permission issues. Some images failed conversion. The plugin had to handle those problems quietly without damaging the site.</p>

<p>Seeing the process reach 100 percent and then watching the site serve lighter image files felt like a proper win. It is a small improvement on the surface, but those small improvements add up fast when you care about performance, user experience, and clean WordPress development.</p>
</section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="Custom Plugins" /><summary type="html"><![CDATA[Why I built this instead of relying on another plugin A while ago, I was working on a WordPress site with more than 4,000 images in the...]]></summary></entry><entry><title type="html">Add video to WooCommerce gallery for free</title><link href="https://nasratulnayem.github.io/blog/add-video-to-woocommerce-gallery-for-free/" rel="alternate" type="text/html" title="Add video to WooCommerce gallery for free" /><published>2026-03-10T18:18:32+00:00</published><updated>2026-03-10T18:18:32+00:00</updated><id>https://nasratulnayem.github.io/blog/add-video-to-woocommerce-gallery-for-free</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/add-video-to-woocommerce-gallery-for-free/"><![CDATA[<p>sitemap: false</p>

<p></p>
<section class="codex-block">
<h2>The problem with paid plugins</h2>
<p>I was working on a project last week for a client who wanted a simple video in their WooCommerce product gallery. It sounds like a basic feature, right? But when I started looking for solutions, everything was behind a paywall. People are charging seventy or eighty dollars a year for a plugin that just adds a video tag to a slider. It felt like a total rip-off, especially for a small business just starting out. I have this thing where I hate adding bloat to a site if I can just write the logic myself. Every plugin you add is another potential security hole or something that will break during the next WordPress update. I decided I would rather spend a few hours struggling with the code than make my client pay for a subscription they do not really need.</p>
<p>The pressure was on because the site was supposed to go live in two days. I had a choice. I could tell them to buy the plugin and be done with it in ten minutes, or I could stay up late and build a custom solution. I chose the latter. It was not just about saving money. I wanted to learn how WooCommerce handles its gallery internally. I wanted to see if I could make something that felt native to the dashboard without making the interface look messy. It was a bit of a gamble because if I could not get it working, I would have wasted a whole night and still had to buy the plugin anyway.</p>
</section>
<section class="codex-block">
<h2>What this solves</h2>
<p>This snippet is designed to bridge the gap between a standard image gallery and a professional product showcase. Most themes only allow images. If you want a video, you usually have to put it in the long description where nobody sees it. This code does a few specific things:</p>
<ul>
<li>It adds a new section to the product edit page so you can upload a video file.</li>
<li>It lets you choose a specific thumbnail for that video so it does not look like a broken play button in the gallery.</li>
<li>It automatically allows your WordPress site to accept video formats like MP4 and WebM if they were restricted before.</li>
<li>It hooks directly into the gallery slider to inject the video right alongside the product photos.</li>
<li>It uses native WordPress media tools, so it feels like it belongs there.</li>
</ul>
<p>The main goal was to keep it lightweight. No extra database tables, no massive CSS libraries, just pure PHP and a bit of jQuery to handle the admin UI. I also wanted to make sure it worked with themes that use the Splide slider, which is common in a lot of custom WooCommerce builds lately. Standard WooCommerce hooks sometimes fail with these themes, so I had to find a way to target the HTML output directly.</p>
</section>
<section class="codex-block">
<h2>The struggle of building it</h2>
<p>The hardest part was definitely the admin interface. I am twenty-one, and I grew up with modern web apps, so working with the WordPress media library in JavaScript always feels a bit like stepping back in time. I spent about two hours just trying to get the &#8220;Select Video&#8221; button to open the media frame correctly. I kept getting these weird console errors because I forgot to enqueue the media scripts properly. It is those small, stupid mistakes that really get to you when it is 2 AM and you just want to go to sleep. I felt a lot of doubt. I kept thinking that maybe the eighty-dollar plugin was worth it just to avoid this headache.</p>
<p>Then there was the issue of the frontend. The theme I was using did not use the standard WooCommerce gallery hooks. It used a custom template from a developer named TemplateMela. My code kept working on the default Storefront theme but would disappear the moment I switched back to the client&#8217;s theme. I had to dig through the theme&#8217;s source code, looking for filter names. That is when I found the filters for the main image and the thumbnail list. Once I had those, I could finally inject my video HTML into the right spot. It was a huge win. Seeing that play button finally show up in the gallery felt better than any paycheck.</p>
<p>I also had to deal with the reality of video file sizes. If a user uploads a 50MB video, it is going to ruin the site&#8217;s performance. I could not solve that with just code, so I had to make sure the snippet used the &#8220;metadata&#8221; preload setting. This ensures the browser only downloads the video info instead of the whole file when the page loads. It is a small tradeoff, but it keeps the site fast while still giving the user what they want.</p>
</section>
<section class="codex-block">
<h2>Security and risks</h2>
<p>When you are writing code that handles file uploads and saves data to the database, you have to be careful. I have seen too many snippets online that are just wide open to attacks. I made sure to include some basic but essential security measures in this code.</p>
<ul>
<li><strong>Nonces:</strong> The code uses a nonce (number used once) to verify that the person saving the product video is actually the authorized user and not some random script.</li>
<li><strong>Capabilities:</strong> I added a check to ensure only people with the &#8216;edit_post&#8217; permission can actually change the video data.</li>
<li><strong>Sanitization:</strong> Every piece of data coming from the user is passed through functions like absint() or sanitize_text_field(). This prevents people from trying to inject malicious scripts into your database.</li>
<li><strong>File types:</strong> While the code allows video uploads, it still relies on the WordPress core to handle the actual file processing. It does not bypass the main security filters for the media library.</li>
</ul>
<p>The biggest risk with this snippet is not actually security, but server resources. If you are on a very cheap shared hosting plan, hosting your own videos can be tough. Videos take up a lot of bandwidth. If you have ten people watching a high-definition product video at the same time, it might slow down your site. This snippet is safe to use, but you should always try to compress your videos before uploading them. Keep them under 5MB if you can.</p>
</section>
<section class="codex-block">
<h2>How to use</h2>
<p>Getting this working on your site is pretty straightforward. You do not need to be a developer to do it, just follow these steps carefully. I always recommend using a child theme or a snippet plugin so you do not lose your changes when you update your main theme.</p>
<ol>
<li>Install the <strong>WPCode</strong> or Code Snippets plugin from the WordPress repository.</li>
<li>Create a new snippet and set the type to <strong>PHP Snippet</strong>.</li>
<li>Copy the entire block of code provided below.</li>
<li>Paste it into the code editor in the plugin.</li>
<li>Set the snippet to run everywhere and hit save.</li>
<li>Go to one of your products in the dashboard. You will see a new section in the right-hand sidebar or at the bottom of the image gallery box that says &#8220;Gallery video&#8221;.</li>
<li>Select your video and a thumbnail, then update the product.</li>
</ol>
<p>If you do not see the video on the front of the site, check if your theme uses a custom gallery. This code is specifically tailored for themes using the TemplateMela structure or standard WooCommerce hooks. If your theme is very unique, you might need to adjust the filter names in the code.</p>
<div class="codex-codebox">
<div class="codex-codebox-header">
<div class="codex-codebox-left">
<div class="codex-codebox-dots">
        <span></span><span></span><span></span>
      </div>
<div class="codex-codebox-title">PHP · WooCommerce product gallery video snippet</div>
&lt;/p&gt;</div>
<p>
  &lt;/div&gt;
<pre><code>&lt;?php
/**
 * Plugin Name: WC Product Gallery Media
 * Description: Adds video controls inside WooCommerce Product Gallery and renders one video item in single-product gallery.
 */

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

const AWI_PRODUCT_VIDEO_META_KEY       = '_awi_product_gallery_video_id';
const AWI_PRODUCT_VIDEO_THUMB_META_KEY = '_awi_product_gallery_video_thumb_id';

/**
 * Allow common video uploads.
 */
add_filter(
	'upload_mimes',
	static function ( $mimes ) {
		$mimes['mp4']  = 'video/mp4';
		$mimes['m4v']  = 'video/mp4';
		$mimes['mov']  = 'video/quicktime';
		$mimes['webm'] = 'video/webm';
		$mimes['ogv']  = 'video/ogg';
		$mimes['ogg']  = 'video/ogg';
		return $mimes;
	}
);

/**
 * Inject video controls into the existing WooCommerce Product Gallery metabox.
 */
add_action(
	'admin_enqueue_scripts',
	static function ( $hook_suffix ) {
		if ( ! in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true ) ) {
			return;
		}

		$screen = get_current_screen();
		if ( ! $screen || 'product' !== $screen-&gt;id ) {
			return;
		}

		$post_id  = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
		$video_id = $post_id ? (int) get_post_meta( $post_id, AWI_PRODUCT_VIDEO_META_KEY, true ) : 0;
		$thumb_id = $post_id ? (int) get_post_meta( $post_id, AWI_PRODUCT_VIDEO_THUMB_META_KEY, true ) : 0;

		$video_url      = $video_id ? wp_get_attachment_url( $video_id ) : '';
		$video_filename = $video_url ? wp_basename( $video_url ) : '';
		$thumb_url      = $thumb_id ? wp_get_attachment_image_url( $thumb_id, 'thumbnail' ) : '';

		wp_enqueue_media();
		wp_register_script( 'awi-product-gallery-video-admin', '', array( 'jquery' ), '1.1.0', true );
		wp_enqueue_script( 'awi-product-gallery-video-admin' );

		$config = array(
			'nonce'         =&gt; wp_create_nonce( 'awi_save_product_video' ),
			'videoId'       =&gt; $video_id,
			'thumbId'       =&gt; $thumb_id,
			'videoFilename' =&gt; $video_filename,
			'thumbUrl'      =&gt; $thumb_url,
		);

		$js = 'window.awiProductGalleryVideo = ' . wp_json_encode( $config ) . ';';
		wp_add_inline_script( 'awi-product-gallery-video-admin', $js, 'before' );

		$inline_js = &lt;&lt;&lt;'JS'
jQuery(function($) {
	var cfg = window.awiProductGalleryVideo || {};
	var $box = $('#woocommerce-product-images .inside');
	var $form = $('#post');

	if (!$box.length || !$form.length) {
		return;
	}

	if ($('#awi-product-gallery-video-wrap').length) {
		return;
	}

	if (!$('#awi_product_video_id').length) {
		$form.append('&lt;input type="hidden" id="awi_product_video_id" name="awi_product_video_id" value="' + (cfg.videoId || '') + '"&gt;');
	}
	if (!$('#awi_product_video_thumb_id').length) {
		$form.append('&lt;input type="hidden" id="awi_product_video_thumb_id" name="awi_product_video_thumb_id" value="' + (cfg.thumbId || '') + '"&gt;');
	}
	if (!$('#awi_product_video_nonce').length) {
		$form.append('&lt;input type="hidden" id="awi_product_video_nonce" name="awi_product_video_nonce" value="' + (cfg.nonce || '') + '"&gt;');
	}

	var ui = '' +
		'&lt;div id="awi-product-gallery-video-wrap"&gt;' +
			'&lt;hr /&gt;' +
			'&lt;p&gt;&lt;strong&gt;Gallery video&lt;/strong&gt;&lt;/p&gt;' +
			'&lt;p&gt;Select one video and one thumbnail image.&lt;/p&gt;' +
			'&lt;div class="awi-pgv-row"&gt;' +
				'&lt;button type="button" class="button" id="awi-select-product-video"&gt;Select video&lt;/button&gt; ' +
				'&lt;button type="button" class="button" id="awi-remove-product-video"&gt;Remove&lt;/button&gt;' +
			'&lt;/div&gt;' +
			'&lt;div class="awi-pgv-file" id="awi-product-video-filename"&gt;&lt;/div&gt;' +
			'&lt;div class="awi-pgv-row"&gt;' +
				'&lt;button type="button" class="button" id="awi-select-product-video-thumb"&gt;Select thumbnail&lt;/button&gt; ' +
				'&lt;button type="button" class="button" id="awi-remove-product-video-thumb"&gt;Remove&lt;/button&gt;' +
			'&lt;/div&gt;' +
			'&lt;div class="awi-pgv-thumb-wrap"&gt;&lt;img id="awi-product-video-thumb-image" alt="Video thumbnail" /&gt;&lt;/div&gt;' +
		'&lt;/div&gt;';

	var $anchor = $box.find('p.add_product_images');
	if ($anchor.length) {
		$anchor.after(ui);
	} else {
		$box.append(ui);
	}

	var $videoId = $('#awi_product_video_id');
	var $thumbId = $('#awi_product_video_thumb_id');
	var $videoName = $('#awi-product-video-filename');
	var $thumbImg = $('#awi-product-video-thumb-image');
	var videoFrame = null;
	var thumbFrame = null;

	function setVideoLabel(name) {
		$videoName.html(name ? name : '&lt;em&gt;No video selected&lt;/em&gt;');
	}

	function setThumb(url) {
		if (url) {
			$thumbImg.attr('src', url).show();
		} else {
			$thumbImg.attr('src', '').hide();
		}
	}

	setVideoLabel(cfg.videoFilename || '');
	setThumb(cfg.thumbUrl || '');

	$('#awi-select-product-video').on('click', function(e) {
		e.preventDefault();
		if (videoFrame) {
			videoFrame.open();
			return;
		}

		videoFrame = wp.media({
			title: 'Select product gallery video',
			button: { text: 'Use this video' },
			library: { type: ['video'] },
			multiple: false
		});

		videoFrame.on('select', function() {
			var attachment = videoFrame.state().get('selection').first().toJSON();
			if (!attachment || !attachment.id) {
				return;
			}
			$videoId.val(String(attachment.id));
			setVideoLabel(attachment.filename || ('ID: ' + attachment.id));
		});

		videoFrame.open();
	});

	$('#awi-remove-product-video').on('click', function(e) {
		e.preventDefault();
		$videoId.val('');
		setVideoLabel('');
	});

	$('#awi-select-product-video-thumb').on('click', function(e) {
		e.preventDefault();
		if (thumbFrame) {
			thumbFrame.open();
			return;
		}

		thumbFrame = wp.media({
			title: 'Select product video thumbnail',
			button: { text: 'Use this image' },
			library: { type: ['image'] },
			multiple: false
		});

		thumbFrame.on('select', function() {
			var attachment = thumbFrame.state().get('selection').first().toJSON();
			if (!attachment || !attachment.id) {
				return;
			}
			$thumbId.val(String(attachment.id));
			var thumbUrl = attachment.sizes &amp;&amp; attachment.sizes.thumbnail ? attachment.sizes.thumbnail.url : attachment.url;
			setThumb(thumbUrl || '');
		});

		thumbFrame.open();
	});

	$('#awi-remove-product-video-thumb').on('click', function(e) {
		e.preventDefault();
		$thumbId.val('');
		setThumb('');
	});
});
JS;

		$inline_css = &lt;&lt;&lt;'CSS'
#awi-product-gallery-video-wrap {
	margin: 12px 0 0;
	padding: 12px 8px 0;
	border-top: 1px solid #dcdcde;
	box-sizing: border-box;
}

#awi-product-gallery-video-wrap .awi-pgv-file {
	margin-top: 8px;
	word-break: break-all;
	color: #1d2327;
}

#awi-product-gallery-video-wrap .awi-pgv-thumb-wrap {
	margin-top: 8px;
}

#awi-product-gallery-video-wrap #awi-product-video-thumb-image {
	display: none;
	max-width: 100%;
	height: auto;
	border: 1px solid #dcdcde;
}
CSS;

		wp_add_inline_script( 'awi-product-gallery-video-admin', $inline_js );
		wp_register_style( 'awi-product-gallery-video-admin-style', false, array(), '1.1.0' );
		wp_enqueue_style( 'awi-product-gallery-video-admin-style' );
		wp_add_inline_style( 'awi-product-gallery-video-admin-style', $inline_css );
	}
);

/**
 * Save video and thumbnail IDs.
 */
add_action(
	'save_post_product',
	static function ( $post_id ) {
		if ( defined( 'DOING_AUTOSAVE' ) &amp;&amp; DOING_AUTOSAVE ) {
			return;
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		if ( ! isset( $_POST['awi_product_video_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['awi_product_video_nonce'] ) ), 'awi_save_product_video' ) ) {
			return;
		}

		$video_id = isset( $_POST['awi_product_video_id'] ) ? absint( wp_unslash( $_POST['awi_product_video_id'] ) ) : 0;
		$thumb_id = isset( $_POST['awi_product_video_thumb_id'] ) ? absint( wp_unslash( $_POST['awi_product_video_thumb_id'] ) ) : 0;

		if ( $video_id &gt; 0 ) {
			update_post_meta( $post_id, AWI_PRODUCT_VIDEO_META_KEY, $video_id );
		} else {
			delete_post_meta( $post_id, AWI_PRODUCT_VIDEO_META_KEY );
		}

		if ( $thumb_id &gt; 0 ) {
			update_post_meta( $post_id, AWI_PRODUCT_VIDEO_THUMB_META_KEY, $thumb_id );
		} else {
			delete_post_meta( $post_id, AWI_PRODUCT_VIDEO_THUMB_META_KEY );
		}
	}
);

/**
 * Get video data for current product gallery.
 *
 * @return array&lt;string, string|int&gt;|null
 */
function awi_get_product_gallery_video_data() {
	if ( ! function_exists( 'wc_get_product' ) ) {
		return null;
	}

	global $product;
	if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
		return null;
	}

	$product_id = $product-&gt;get_id();
	$video_id   = (int) get_post_meta( $product_id, AWI_PRODUCT_VIDEO_META_KEY, true );
	if ( $video_id &lt;= 0 ) {
		return null;
	}

	$video_url = wp_get_attachment_url( $video_id );
	if ( ! $video_url ) {
		return null;
	}

	$mime = (string) get_post_mime_type( $video_id );
	if ( 0 !== strpos( $mime, 'video/' ) ) {
		$mime = 'video/mp4';
	}

	$thumb_id  = (int) get_post_meta( $product_id, AWI_PRODUCT_VIDEO_THUMB_META_KEY, true );
	$thumb_url = $thumb_id ? wp_get_attachment_image_url( $thumb_id, 'woocommerce_gallery_thumbnail' ) : '';
	if ( ! $thumb_url ) {
		$thumb_url = wp_mime_type_icon( $video_id );
	}

	return array(
		'product_id' =&gt; $product_id,
		'video_url'  =&gt; $video_url,
		'mime'       =&gt; $mime,
		'thumb_url'  =&gt; (string) $thumb_url,
	);
}

/**
 * Add video slide to TemplateMela custom gallery main list.
 */
add_filter(
	'base_single_product_image_main_html',
	static function ( $html, $slide_id ) {
		static $added_for_product = array();

		$video_data = awi_get_product_gallery_video_data();
		if ( ! $video_data ) {
			return $html;
		}

		$product_id = (int) $video_data['product_id'];
		if ( ! empty( $added_for_product[ $product_id ] ) ) {
			return $html;
		}

		$added_for_product[ $product_id ] = true;

		$video_html  = '&lt;li class="splide__slide awi-woo-video-slide"&gt;';
		$video_html .= '&lt;video class="awi-product-gallery-video" controls preload="metadata" playsinline poster="' . esc_url( (string) $video_data['thumb_url'] ) . '"&gt;';
		$video_html .= '&lt;source src="' . esc_url( (string) $video_data['video_url'] ) . '" type="' . esc_attr( (string) $video_data['mime'] ) . '" /&gt;';
		$video_html .= '&lt;/video&gt;';
		$video_html .= '&lt;/li&gt;';

		return $html . $video_html;
	},
	20,
	2
);

/**
 * Add video thumbnail item to TemplateMela custom gallery thumbnails list.
 */
add_filter(
	'base_single_product_image_thumbnail_html',
	static function ( $html, $slide_id ) {
		static $added_for_product = array();

		$video_data = awi_get_product_gallery_video_data();
		if ( ! $video_data ) {
			return $html;
		}

		$product_id = (int) $video_data['product_id'];
		if ( ! empty( $added_for_product[ $product_id ] ) ) {
			return $html;
		}

		$added_for_product[ $product_id ] = true;

		$thumb_html  = '&lt;li class="bt-woo-gallery-thumbnail splide__slide awi-woo-video-thumb"&gt;';
		$thumb_html .= '&lt;img src="' . esc_url( (string) $video_data['thumb_url'] ) . '" alt="Product video" /&gt;';
		$thumb_html .= '&lt;/li&gt;';

		return $html . $thumb_html;
	},
	20,
	2
);

/**
 * Frontend styling for gallery video slide.
 */
add_action(
	'wp_enqueue_scripts',
	static function () {
		if ( ! function_exists( 'is_product' ) || ! is_product() ) {
			return;
		}

		wp_register_style( 'awi-product-gallery-video', false, array(), '1.1.0' );
		wp_enqueue_style( 'awi-product-gallery-video' );
		wp_add_inline_style(
			'awi-product-gallery-video',
			'.single-product .woocommerce-product-gallery__image--video,.single-product .awi-woo-video-slide{background:#000;border-radius:6px;overflow:hidden}.single-product .woocommerce-product-gallery__image--video .awi-product-gallery-video,.single-product .awi-woo-video-slide .awi-product-gallery-video{display:block;width:100%;height:auto;aspect-ratio:1/1;object-fit:contain;background:#000}.single-product .awi-woo-video-thumb img{object-fit:cover;width:100%;height:100%}'
		);
	}
);
</code></pre>
&lt;/div&gt;
&lt;/section&gt;
<section class="codex-block">
<h2>What I learned from this</h2>
<p>Looking back, I am glad I did this. It was frustrating at times, but I now have a snippet I can reuse for any project. I also saved my client money and kept their site lean.</p>
<p>If you use this, test it on a staging site first. Every theme is different, and while I have tried to make this as compatible as possible, there is always a chance of a conflict.</p>
<p>If something does not work, start by checking your browser console for JavaScript errors or your server logs for PHP issues. Most problems are usually simple typos, missing hooks, or theme-specific gallery markup.</p>
</section>

</p></div></div></section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="Custom Plugins" /><summary type="html"><![CDATA[The problem with paid plugins I was working on a project last week for a client who wanted a simple video in their WooCommerce product...]]></summary></entry><entry><title type="html">Watch Product Video’ Option Can Be Added from the Product Edit Page | WooCommerce</title><link href="https://nasratulnayem.github.io/blog/product-link-below-title-custom-plugin-case-study/" rel="alternate" type="text/html" title="Watch Product Video’ Option Can Be Added from the Product Edit Page | WooCommerce" /><published>2026-02-21T12:50:37+00:00</published><updated>2026-02-21T12:50:37+00:00</updated><id>https://nasratulnayem.github.io/blog/product-link-below-title-custom-plugin-case-study</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/product-link-below-title-custom-plugin-case-study/"><![CDATA[<p>sitemap: false</p>

<section class="codex-block">
  <h2>The problem with default layouts</h2>
  <p>I was looking at the WooCommerce edit screen a few weeks ago and it really started to get on my nerves. If you want to add any extra data to a product, the default way is to create a meta box. Usually, these meta boxes end up at the very bottom of the page, or you have to hide them inside one of those vertical tabs in the product data section. It is a lot of clicking and scrolling for something that should be simple.</p>

  <p>I had a specific need for this project. I wanted a way to add a product video link, but I wanted it to be the first thing I saw after the title. When you are managing hundreds of products, every second you spend hunting for a text field adds up. I felt like the standard WordPress way of doing things was actually slowing me down. I started wondering why there is so much empty space under the title input in the admin area and why more people do not use it.</p>

  <p>The goal was simple. I wanted a field for a URL right under the title. Then, on the actual website, I wanted a link to appear right next to the product title that says (Watch Product Video). No fancy buttons, no heavy scripts, just a clean link that people can click. This is how I ended up building this specific extension.</p>
</section>

<section class="codex-block">
  <h2>Real world constraints</h2>
  <p>I did not have weeks to build some massive video management suite. I just needed something that worked. One of the biggest issues I ran into was how WordPress handles the edit screen. There is a hook called edit_form_after_title, but it behaves differently depending on what post type you are using. Since I was targeting WooCommerce products, I had to make sure I was not accidentally injecting my custom field into regular blog posts or pages.</p>

  <p>Money was also a factor, in the sense that I did not want to buy a heavy plugin like ACF Pro just for one single field. It felt like overkill. I wanted a lightweight, standalone file that I could drop into any site without adding more bloat. I also had to think about the learning curve for the person actually using the site. If I put the field in a weird spot, they would forget to fill it out. By putting it right under the title, it becomes part of the natural workflow: type the name, paste the link, move on.</p>

  <p>I struggled a bit with the front-end display. Different themes handle titles in different ways. Some themes use the standard the_title hook, while others use custom templates. And then there is Elementor. Elementor is a pain because it often bypasses standard WordPress filters. I had to spend a good few hours figuring out how to get my link to show up inside an Elementor product title widget without breaking the layout.</p>
</section>

<section class="codex-block">
  <h2>How the code is structured</h2>
  <p>I decided to wrap everything in a final class called AW_Product_Title_Video_Link. I like using static methods for these kinds of small plugins because it keeps the global namespace clean and I do not have to worry about instantiating objects everywhere. It is straightforward and it works.</p>

  <p>The plugin uses a single meta key called _aw_product_video_link to store the URL. I kept it simple. I did not need a complex database table or anything like that. Just one row in the postmeta table per product. Here is a look at how I initialized the hooks:</p>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">PHP · Plugin hook initialization</div>
      </div>


    </div>

    <pre><code>public static function init(): void {
    add_action( 'edit_form_after_title', array( __CLASS__, 'render_admin_field_below_title' ) );
    add_action( 'save_post_product', array( __CLASS__, 'save_admin_field' ) );

    add_action( 'wp', array( __CLASS__, 'replace_single_product_title' ) );
    add_action( 'wp_head', array( __CLASS__, 'print_inline_styles' ) );
    add_action( 'wp_footer', array( __CLASS__, 'print_frontend_fallback_script' ), 99 );
    add_filter( 'elementor/widget/render_content', array( __CLASS__, 'filter_elementor_widget_render_content' ), 10, 2 );
}</code></pre>
  </div>

  <p>The render_admin_field_below_title method is what actually draws the input box on the backend. I added some basic styling to it so it looks like it belongs in the WordPress dashboard. I used a simple border and some padding. I also added a nonce field for security. You should never save data in WordPress without checking a nonce first, otherwise you are just asking for trouble.</p>
</section>

<section class="codex-block">
  <h2>Dealing with the frontend and Elementor</h2>
  <p>The frontend part was tricky. I wanted the link to appear right after the title text. Initially, I tried using a filter on the_title, but that caused issues in the menu and other places where the title is displayed. I only wanted it on the single product page.</p>

  <p>To fix this, I used the wp action to check if we are on a single product page before doing anything. For Elementor support, I had to use the elementor/widget/render_content filter. This looks at the content being rendered and, if it is a product title widget, it appends my custom HTML link to the title string. It is a bit of a workaround, but page builders often require these kinds of specific fixes.</p>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">PHP · Elementor title filter</div>
      </div>


    </div>

    <pre><code>public static function filter_elementor_widget_render_content( $content, $widget ) {
    if ( 'wc-product-title' !== $widget-&gt;get_name() &amp;&amp; 'heading' !== $widget-&gt;get_name() ) {
        return $content;
    }

    $post_id = get_the_ID();
    $video_url = get_post_meta( $post_id, self::META_KEY, true );

    if ( ! empty( $video_url ) ) {
        $video_html = ' &lt;a href="' . esc_url( $video_url ) . '" class="aw-video-link" target="_blank"&gt;(Watch Product Video)&lt;/a&gt;';
        $content = str_replace( '&lt;/h1&gt;', $video_html . '&lt;/h1&gt;', $content );
    }

    return $content;
}</code></pre>
  </div>

  <p>I also added a fallback script in the footer. This script is a bit of insurance. If the PHP filters fail for some reason or if the theme layout is really weird, the JavaScript looks for the product title on the page and manually injects the link. It might seem redundant, but in the world of WordPress themes, you can never be too sure. It is better to have a fallback than to have a client complain that the feature is missing on their specific setup.</p>
</section>

<section class="codex-block">
  <h2>Technical tradeoffs</h2>
  <p>I made a few specific choices that some developers might disagree with. First, I put the CSS in the wp_head instead of a separate file. Why? Because the CSS is only about five lines long. Making the browser fetch an entirely new .css file for five lines of code is a waste of a request. It is faster to just print it inline.</p>

  <p>Second, I hardcoded the link text as (Watch Product Video). In a perfect world, I would have made this a setting in the admin area so it could be changed. But again, constraints. I needed this done quickly, and the user did not need to change the text. I chose speed of development over total flexibility. If I need to change it later, I can just open the file and edit one line. It is not a big deal for a custom project.</p>

  <p>I also decided to keep the input as a URL type. This provides some basic browser level validation. If someone tries to paste something that is not a link, the browser will complain before the form even submits. It saves me from writing a bunch of custom validation logic in PHP.</p>
</section>

<section class="codex-block">
  <h2>Who this is for</h2>
  <ul>
    <li>Store owners who want to highlight product videos prominently.</li>
    <li>Developers who need a simple way to add data fields without using heavy plugins.</li>
    <li>Sites using Elementor or custom themes where standard hooks might be unreliable.</li>
    <li>Anyone who prefers a clean, direct admin interface over cluttered meta boxes.</li>
  </ul>
</section>

<section class="codex-block">
  <h2>How to install</h2>
  <p>Installing this is just like any other WordPress plugin. Since it is just a single folder with a PHP file inside, you can zip it up and upload it. Here is the exact process:</p>

  <ul>
    <li>Download the product-link-below-title.zip file.</li>
    <li>Go to your WordPress admin dashboard.</li>
    <li>Navigate to Plugins then Add New.</li>
    <li>Click Upload Plugin and select the zip file.</li>
    <li>Click Install Now and then Activate.</li>
  </ul>

  <p>Once it is active, go to any WooCommerce product. You will see the new field right below the product title. Paste a YouTube or Vimeo link there, save the product, and check the front end. The link should appear right next to the title. If you do not see it, check your theme settings or make sure the product actually has a link saved.</p>
</section>

<section class="codex-block">
  <h2>Why this matters to me</h2>
  <p>I think a lot of people overcomplicate WordPress development. They think you need a massive framework or a dozen third party libraries to do anything useful. This project reminded me that you can solve real problems with about 150 lines of PHP. It is not about how complex the code is, it is about whether or not it makes someone&#8217;s life easier. For me, not having to scroll to the bottom of the page every time I want to add a video is a win. It is a small win, but those are the ones that make the day to day work tolerable.</p>

  <p>There is also a sense of control when you write your own tools. I know exactly how this plugin works. I know exactly where to go if it breaks. I do not have to wait for a developer to release an update or worry about a license key expiring. It is just my code running on my site, exactly how I want it. That is why I like building these small custom extensions.</p>
</section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="Custom Plugins" /><summary type="html"><![CDATA[The problem with default layouts I was looking at the WooCommerce edit screen a few weeks ago and it really started to get on my...]]></summary></entry><entry><title type="html">Import Products from Browser Pages to WooCommerce</title><link href="https://nasratulnayem.github.io/blog/importon-bridge-product-import-workflow/" rel="alternate" type="text/html" title="Import Products from Browser Pages to WooCommerce" /><published>2026-02-21T12:28:01+00:00</published><updated>2026-02-21T12:28:01+00:00</updated><id>https://nasratulnayem.github.io/blog/importon-bridge-product-import-workflow</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/importon-bridge-product-import-workflow/"><![CDATA[<p>sitemap: false</p>

<section class="codex-block">
  <p>Importon Bridge is a lightweight WordPress plugin that connects a browser companion with WooCommerce. It captures permitted product data from supported supplier pages and sends it directly into your WordPress store through a clean, authenticated workflow.</p>

  <p>The goal is simple. Instead of copying product titles, descriptions, prices, images, attributes, and variations by hand, Importon Bridge gives you a faster way to move product data from the browser into WooCommerce.</p>

  <p>It is built for store owners and developers who want control. No bloated import dashboard. No complicated SaaS layer. Just a focused bridge between the product page you are viewing and the WooCommerce store you manage.</p>

  <p>See the current product details, requirements, and purchase options on the <a href="https://nasratulnayem.github.io/plugins/importon-bridge/">Importon Bridge product page</a>.</p>
</section>

<section class="codex-block">
  <h2>What Importon Bridge does</h2>
  <p>Importon Bridge is designed around a practical WooCommerce import workflow. The browser companion captures product information from the page, then the WordPress plugin receives it through a secured REST API and creates or updates the product inside WooCommerce.</p>

  <ul>
    <li>Imports product title, description, price, images, attributes, and variations.</li>
    <li>Supports simple and variable WooCommerce products.</li>
    <li>Creates or updates products through authenticated REST API endpoints.</li>
    <li>Adds product video support when a video URL is available.</li>
    <li>Stores import history and failed-item logs for review.</li>
    <li>Includes a batch URL queue for importing multiple product links.</li>
    <li>Supports optional AI rewriting through OpenAI or Google Gemini.</li>
  </ul>

  <p>Importon Bridge is an independent browser-assisted product workflow. Store owners are responsible for importing only data they are permitted to use.</p>
</section>

<section class="codex-block">
  <h2>Why I rebuilt it as Importon Bridge</h2>
  <p>The original idea was narrowly focused on importing supplier product data into WooCommerce. But the plugin grew into something broader. It now works more like a bridge between a browser-based product capture flow and a WordPress-powered store.</p>

  <p>That is why the new name makes more sense. Importon Bridge describes the actual purpose of the tool better. It connects the browser companion, the WooCommerce product system, the REST API, import monitoring, batch URLs, and optional AI rewriting in one focused workflow.</p>

  <p>I also cleaned up the branding, moved more of the setup flow into the plugin admin page, and made the connection process easier. The goal is to make the tool feel more complete without making it heavy.</p>
</section>

<section class="codex-block">
  <h2>How the workflow works</h2>
  <p>The system has two main parts. The first part is the browser companion. It runs in Chrome or any Chromium-based browser and captures product data from the page you are already viewing.</p>

  <p>The second part is the WordPress plugin. It adds the admin screen, connection settings, REST API endpoints, product import logic, history logs, batch URL import tools, and optional AI rewrite settings.</p>

  <ol>
    <li>Open a supported product page in your browser.</li>
    <li>Use the Importon Bridge browser companion to capture the product data.</li>
    <li>Send the product data to WordPress through the authenticated REST API.</li>
    <li>Create or update the WooCommerce product.</li>
    <li>Review import logs, failed items, and optional AI rewrite results inside WordPress.</li>
  </ol>
</section>

<section class="codex-block">
  <h2>REST API endpoints</h2>
  <p>Importon Bridge exposes authenticated endpoints under <code>importonbridge/v1</code>. These endpoints handle connection testing, settings, categories, and product import requests.</p>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">REST API · Importon Bridge endpoints</div>
      </div>


    </div>

    <pre><code>POST  /wp-json/importonbridge/v1/import      Create or update a WooCommerce product
GET   /wp-json/importonbridge/v1/ping        Confirm authentication
GET   /wp-json/importonbridge/v1/categories  List WooCommerce product categories
GET   /wp-json/importonbridge/v1/settings    Read connection settings
POST  /wp-json/importonbridge/v1/settings    Save connection settings
POST  /wp-json/importonbridge/v1/connect     Return connection details for the browser companion</code></pre>
  </div>
</section>

<section class="codex-block">
  <h2>AI rewriting support</h2>
  <p>AI rewriting is optional. It only runs when the administrator enables it from the plugin settings. This keeps the plugin useful for normal imports without forcing AI into every workflow.</p>

  <ul>
    <li>Supports OpenAI and Google Gemini.</li>
    <li>Stores API keys server-side in WordPress options.</li>
    <li>Allows provider order and model selection from the settings screen.</li>
    <li>Can rewrite titles and descriptions before saving product content.</li>
  </ul>
</section>

<section class="codex-block">
  <h2>Security choices</h2>
  <p>Because this plugin creates and updates WooCommerce products, the security layer matters. Importon Bridge uses WordPress-native protections instead of trying to invent its own system.</p>

  <ul>
    <li>Admin actions use WordPress nonces.</li>
    <li>REST endpoints check authentication and user capabilities.</li>
    <li>User input is sanitized before storage or processing.</li>
    <li>Output is escaped before rendering in the admin or frontend.</li>
    <li>External AI calls only run when an administrator configures them.</li>
  </ul>
</section>

<section class="codex-block">
  <h2>Requirements</h2>
  <p>Importon Bridge is built for modern WooCommerce stores and a Chromium-based browser workflow.</p>

  <ul>
    <li>WordPress 6.0 or higher.</li>
    <li>WooCommerce 8.0 or higher.</li>
    <li>PHP 7.4 or higher.</li>
    <li>Google Chrome or another Chromium-based browser for the browser companion.</li>
  </ul>
</section>

<section class="codex-block">
  <h2>How to install</h2>
  <p>The setup has two parts: the WordPress plugin and the browser companion. The plugin handles the WooCommerce side. The browser companion handles product capture from supported product pages.</p>

  <h3>WordPress side</h3>
  <ol>
    <li>After purchase, download the current plugin ZIP from your Freemius purchase email or customer portal.</li>
    <li>Upload the plugin to <code>/wp-content/plugins/</code>.</li>
    <li>Activate the plugin from the WordPress dashboard.</li>
    <li>Make sure WooCommerce is installed and active.</li>
    <li>Open Importon Bridge in the WordPress admin.</li>
  </ol>

  <h3>Browser side</h3>
  <ol>
    <li>Download the browser companion from the Importon Bridge settings page.</li>
    <li>Open <code>chrome://extensions</code> in Chrome.</li>
    <li>Turn on Developer Mode.</li>
    <li>Click Load unpacked and select the browser companion folder.</li>
    <li>Create a WordPress Application Password from your user profile.</li>
    <li>Paste your Site URL, username, and Application Password into the connection panel.</li>
    <li>Use the Test Connection option before importing products.</li>
  </ol>
</section>

<section class="codex-block">
  <h2>File structure</h2>
  <p>The plugin is organized into a small set of focused files. The main plugin file loads the system, while the includes folder handles admin screens, REST API logic, frontend behavior, and batch URL importing.</p>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">Project structure · Importon Bridge</div>
      </div>


    </div>

    <pre><code>importon-bridge/
├── importon-bridge.php
├── README.txt
├── README.md
├── license.txt
├── assets/
│   └── url-import-admin.js
└── includes/
    ├── class-importonbridge-admin.php
    ├── class-importonbridge-frontend.php
    ├── class-importonbridge-rest.php
    └── class-importonbridge-url-import.php</code></pre>
  </div>
</section>

<section class="codex-block">
  <h2>Who this is for</h2>
  <p>Importon Bridge is for WooCommerce store owners, product upload teams, and developers who want a faster import workflow without relying on a heavy subscription tool.</p>

  <p>It is especially useful when you need to move product data from browser-based supplier pages into WooCommerce, but you still want control over what gets imported, rewritten, reviewed, and published.</p>
</section>

<section class="codex-block">
  <h2>What the current 0.2.2 release includes</h2>
  <p>The current release uses Importon Bridge branding throughout the plugin, WordPress admin, REST connection, browser companion, and release package.</p>

  <ul>
    <li>Browser companion product import flow.</li>
    <li>WooCommerce product creation and update support.</li>
    <li>Simple and variable product handling.</li>
    <li>Optional AI rewriting.</li>
    <li>Batch URL import queue.</li>
    <li>Failed-run logging and admin monitoring.</li>
    <li>Freemius-ready delivery and a bundled SDK in the official release package.</li>
  </ul>
</section>

<section class="codex-block">
  <h2>Why this plugin matters</h2>
  <p>Importing products should not feel like a full-time data entry job. Importon Bridge makes the process faster by connecting the product page, the browser, and WooCommerce in one direct workflow.</p>

  <p>It does not try to be a giant dropshipping platform. It focuses on the part that actually slows people down: collecting product data, sending it into WooCommerce, reviewing it, and improving the copy when needed.</p>

  <p>That focus is what makes the plugin useful. It is a practical bridge for people who want a cleaner product import process without giving up control of their WordPress store.</p>
</section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="Custom Plugins" /><summary type="html"><![CDATA[Importon Bridge is a lightweight WordPress plugin that connects a browser companion with WooCommerce. It lets you capture product data...]]></summary></entry><entry><title type="html">A faster way to edit WooCommerce products</title><link href="https://nasratulnayem.github.io/blog/a-faster-way-to-edit-woocommerce-products/" rel="alternate" type="text/html" title="A faster way to edit WooCommerce products" /><published>2026-02-05T20:12:27+00:00</published><updated>2026-02-05T20:12:27+00:00</updated><id>https://nasratulnayem.github.io/blog/a-faster-way-to-edit-woocommerce-products</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/a-faster-way-to-edit-woocommerce-products/"><![CDATA[<p>sitemap: false</p>

<section class="codex-block">
  <h2>The problem with bulk editing</h2>
  <p>I spent most of last Tuesday staring at the spinning loading icon in the WooCommerce dashboard. I had about sixty products that needed price updates and category shifts. If you have ever used the default bulk edit tool in WordPress, you know how clunky it feels. You select the items, you click edit, you apply the changes, and then you pray that the server does not time out. It is slow. It feels like software built fifteen years ago. I did not want to buy a seventy dollar plugin just to change some numbers and text. I am a developer, so I figured I should just build the solution myself. I wanted something that looked like a spreadsheet but worked directly inside my admin panel without any extra bloat.</p>

  <p>The biggest issue was time. I did not have three days to build a full React based interface with a REST API. I needed something that worked right now. I had a deadline for a client and my own shop was falling behind. I needed a tool that let me see everything on one screen, type in the changes, and hit save once. No jumping between pages. No individual product screens. Just a clean list that lets me get the work done so I can go back to actually building things.</p>
</section>

<section class="codex-block">
  <h2>What this solves</h2>
  <p>This snippet creates a dedicated page in your WooCommerce menu called Inline Product Editor. It focuses on the three things people change most often. These are the title, the product category, and the regular price. I purposely left out things like weight or dimensions because adding too many fields makes the UI messy and hard to use on a laptop screen. Here is what this tool actually handles for you:</p>

  <ul>
    <li>It lets you search for products by name so you do not have to scroll through thousands of items.</li>
    <li>It provides a category filter to narrow down your list to a specific group.</li>
    <li>It allows you to change the number of products shown per page.</li>
    <li>It gives you a text input for the title and the price that you can edit instantly.</li>
    <li>It includes a category dropdown that replaces the current category with a new one.</li>
    <li>It highlights rows in green as soon as you change a value so you know what you have touched.</li>
  </ul>

  <p>Basically, it turns a thirty minute chore into a two minute task. You check the boxes for the rows you want to update, hit the save button at the top or bottom, and the script handles the database updates in the background. It is straightforward and does not try to be anything it is not.</p>
</section>

<section class="codex-block">
  <h2>The struggle of building a clean UI</h2>
  <p>I am not a designer. I usually stick to the backend because CSS makes me want to put my head through a wall. When I started writing this, the table looked terrible. It was just a bunch of inputs smashed together. I had to spend a couple of hours tweaking the styles to make it feel modern. I used a lot of flexbox and sticky positioning. I wanted the save button to stay visible even when you are scrolling through a long list of products. That sticky header was a pain to get right with the WordPress admin bar, but it makes a huge difference in how the tool feels. If you have to scroll all the way back to the top to save, the tool is broken in my opinion.</p>

  <p>I also had to think about mobile. Most people do not manage their shops on a phone, but sometimes you are on the train and you notice a typo in a price. I wrote some media queries that stack the table cells vertically on small screens. It is not perfect, but it is usable. The real win was the JavaScript logic for marking changed rows. I did not want the script to try and update every single product on the page every time you hit save. That is a waste of resources. By adding a CSS class to the row when an input changes, I can visually track my progress. I also added a feature where you can click the product ID cell to toggle the checkbox. It sounds small, but clicking those tiny checkboxes over and over is annoying. Making the whole cell clickable makes the experience feel much more fluid.</p>
</section>

<section class="codex-block">
  <h2>The technical logic and tradeoffs</h2>
  <p>I made some specific choices with the PHP logic here. For the price cleaning, I had to handle different formats. Some people use commas for decimals and others use dots. I wrote a small helper function called price_clean that strips out the garbage and ensures the database gets a clean float. If you leave a price field blank, the script just ignores it instead of setting your product price to zero. That was a bug I hit in the first version and it nearly ruined my day. Checking for empty strings versus actual numerical zeros is a classic PHP headache.</p>

  <p>I decided to use the admin_post hook for the saving logic. Some people would argue for an AJAX save every time a field loses focus. I thought about that, but AJAX in the WordPress admin can be flaky if you have other plugins interfering. I went with a standard form submission. It is more robust. When you hit save, it processes the data, redirects you back to the page, and shows a success notice. It feels solid. You know for a fact that the data went through. The tradeoff is a page reload, but for a bulk tool, I think that is a fair exchange for reliability. I also made sure to clear the WooCommerce product transients. If you do not do that, the old prices might still show up on your front end for a while because of caching. That is one of those small details that separate a quick hack from a real tool.</p>
</section>

<section class="codex-block">
  <h2>The code</h2>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">PHP · WooCommerce inline bulk product editor</div>
      </div>


    </div>

    <pre><code>&lt;?php
/**
 * Plugin Name: WPCup Inline Bulk Product Editor (WooCommerce)
 * Description: Edit WooCommerce product Title, Category, and Regular Price directly from a list UI in WP Admin.
 * Version: 1.1.0
 * Author: WPCup
 */

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

class WPCUP_Inline_Bulk_Product_Editor {
  const SLUG = 'wpcup-inline-bulk-product-editor';
  const NONCE_ACTION = 'wpcup_inline_bpe_save';

  public function __construct() {
    add_action('admin_menu', array($this, 'menu'));
    add_action('admin_post_wpcup_inline_bpe_save', array($this, 'handle_save'));
    add_action('admin_enqueue_scripts', array($this, 'assets'));
  }

  public function menu() {
    add_menu_page(
      'Inline Product Editor',
      'Inline Product Editor',
      'manage_woocommerce',
      self::SLUG,
      array($this, 'page'),
      'dashicons-edit',
      56
    );
  }

  public function assets($hook) {
    if (empty($_GET['page']) || $_GET['page'] !== self::SLUG) return;

    $css = "
      .wpcup-wrap{max-width:1280px;}
      .wpcup-header{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-top:8px;}
      .wpcup-title{margin:0;line-height:1.15;}
      .wpcup-sub{color:#6b7280;margin:6px 0 0;font-size:13px;}
      .wpcup-badge{display:inline-block;padding:4px 10px;border-radius:999px;background:#f3f4f6;border:1px solid #e5e7eb;font-size:12px;}
      .wpcup-card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:16px;margin:16px 0;box-shadow:0 1px 0 rgba(0,0,0,.02);}
      .wpcup-row{display:flex;gap:12px;flex-wrap:wrap;align-items:end;}
      .wpcup-row &gt; div{flex:1 1 240px;}
      .wpcup-label{display:block;font-weight:600;margin-bottom:6px;}
      .wpcup-input,.wpcup-select{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:12px;background:#fff;}
      .wpcup-input:focus,.wpcup-select:focus{outline:none;box-shadow:0 0 0 3px rgba(59,130,246,.18);border-color:#93c5fd;}
      .wpcup-btn{padding:10px 14px;border-radius:12px;border:1px solid #111827;background:#111827;color:#fff;cursor:pointer;}
      .wpcup-btn:hover{opacity:.92;}
      .wpcup-btn-secondary{background:#fff;color:#111827;border:1px solid #d1d5db;}
      .wpcup-btn-secondary:hover{background:#f9fafb;}
      .wpcup-note{color:#6b7280;font-size:13px;margin-top:6px;}
      .wpcup-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
      .wpcup-toolbar-left{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
      .wpcup-pill{display:inline-flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid #e5e7eb;border-radius:999px;background:#fafafa;color:#111827;font-size:13px;}
      .wpcup-table{width:100%;border-collapse:separate;border-spacing:0;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;}
      .wpcup-table th,.wpcup-table td{padding:12px 12px;border-bottom:1px solid #e5e7eb;vertical-align:top;}
      .wpcup-table th{background:#f9fafb;text-align:left;font-weight:700;position:sticky;top:0;z-index:1;}
      .wpcup-muted{color:#6b7280;font-size:12px;margin-top:6px;}
      .wpcup-changed{outline:2px solid rgba(34,197,94,.25);background:rgba(34,197,94,.06);}
      .wpcup-price{max-width:160px;}
      .wpcup-title-input{min-width:260px;}
      .wpcup-sticky{position:sticky;top:32px;z-index:5;}
      .wpcup-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
      .wpcup-divider{height:1px;background:#e5e7eb;margin:12px 0;}
      @media (max-width: 900px){
        .wpcup-sticky{position:static;}
        .wpcup-table thead{display:none;}
        .wpcup-table, .wpcup-table tbody, .wpcup-table tr, .wpcup-table td{display:block;width:100%;}
        .wpcup-table tr{border:1px solid #e5e7eb;border-radius:14px;margin-bottom:12px;overflow:hidden;}
        .wpcup-table td{border-bottom:1px solid #e5e7eb;}
        .wpcup-table td:last-child{border-bottom:none;}
        .wpcup-table td[data-label]::before{
          content: attr(data-label);
          display:block;
          font-weight:700;
          color:#111827;
          margin-bottom:6px;
        }
        .wpcup-price{max-width:100%;}
      }
    ";
    wp_register_style('wpcup_inline_bpe_css', false);
    wp_enqueue_style('wpcup_inline_bpe_css');
    wp_add_inline_style('wpcup_inline_bpe_css', $css);

    $js = "
      document.addEventListener('DOMContentLoaded', function(){
        var selectAll = document.getElementById('wpcup_select_all');
        if(selectAll){
          selectAll.addEventListener('change', function(){
            var cbs = document.querySelectorAll('input[name=\"product_ids[]\"]');
            for (var i=0; i&lt;cbs.length; i++) cbs[i].checked = selectAll.checked;
            updateSelectedCount();
          });
        }

        function updateSelectedCount(){
          var cbs = document.querySelectorAll('input[name=\"product_ids[]\"]');
          var count = 0;
          for (var i=0; i&lt;cbs.length; i++) if (cbs[i].checked) count++;
          var el = document.getElementById('wpcup_selected_count');
          if(el) el.textContent = count;
        }

        var rowInputs = document.querySelectorAll('.wpcup-row-input');
        for (var i=0; i&lt;rowInputs.length; i++){
          rowInputs[i].addEventListener('input', markChanged);
          rowInputs[i].addEventListener('change', markChanged);
        }

        function markChanged(e){
          var tr = e.target.closest('tr');
          if(tr) tr.classList.add('wpcup-changed');
        }

        var checkboxes = document.querySelectorAll('input[name=\"product_ids[]\"]');
        for (var i=0; i&lt;checkboxes.length; i++){
          checkboxes[i].addEventListener('change', updateSelectedCount);
        }

        var toggles = document.querySelectorAll('[data-toggle-check]');
        for (var i=0; i&lt;toggles.length; i++){
          toggles[i].addEventListener('click', function(){
            var tr = this.closest('tr');
            if(!tr) return;
            var cb = tr.querySelector('input[type=\"checkbox\"][name=\"product_ids[]\"]');
            if(cb){ cb.checked = !cb.checked; updateSelectedCount(); }
          });
        }

        updateSelectedCount();
      });
    ";
    wp_register_script('wpcup_inline_bpe_js', false);
    wp_enqueue_script('wpcup_inline_bpe_js');
    wp_add_inline_script('wpcup_inline_bpe_js', $js);
  }

  private function categories() {
    $terms = get_terms(array(
      'taxonomy' =&gt; 'product_cat',
      'hide_empty' =&gt; false,
      'orderby' =&gt; 'name',
      'order' =&gt; 'ASC'
    ));
    if (is_wp_error($terms)) return array();
    return $terms;
  }

  private function price_clean($v) {
    $v = trim((string)$v);
    if ($v === '') return '';
    $v = str_replace(',', '.', $v);
    if (!is_numeric($v)) return '';
    $n = (float)$v;
    if ($n &lt; 0) $n = 0;
    return number_format($n, 2, '.', '');
  }

  public function page() {
    if (!current_user_can('manage_woocommerce')) wp_die('No permission.');
    if (!class_exists('WooCommerce')) {
      echo '&lt;div class="wrap"&gt;&lt;h1&gt;Inline Product Editor&lt;/h1&gt;&lt;div class="notice notice-error"&gt;&lt;p&gt;WooCommerce is not active.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;';
      return;
    }

    $paged = isset($_GET['paged']) ? max(1, (int)$_GET['paged']) : 1;
    $per_page = isset($_GET['per_page']) ? max(10, min(200, (int)$_GET['per_page'])) : 25;
    $s = isset($_GET['s']) ? sanitize_text_field(wp_unslash($_GET['s'])) : '';
    $cat = isset($_GET['cat']) ? (int)$_GET['cat'] : 0;

    $args = array(
      'post_type' =&gt; 'product',
      'post_status' =&gt; array('publish','draft','private'),
      'posts_per_page' =&gt; $per_page,
      'paged' =&gt; $paged,
      'orderby' =&gt; 'date',
      'order' =&gt; 'DESC',
      's' =&gt; $s
    );

    if ($cat &gt; 0) {
      $args['tax_query'] = array(
        array(
          'taxonomy' =&gt; 'product_cat',
          'field' =&gt; 'term_id',
          'terms' =&gt; array($cat)
        )
      );
    }

    $q = new WP_Query($args);
    $cats = $this-&gt;categories();

    $notice = '';
    if (!empty($_GET['wpcup_saved'])) {
      $notice = '&lt;div class="notice notice-success is-dismissible"&gt;&lt;p&gt;&lt;strong&gt;Saved.&lt;/strong&gt; Selected products updated.&lt;/p&gt;&lt;/div&gt;';
    } elseif (!empty($_GET['wpcup_error'])) {
      $notice = '&lt;div class="notice notice-error is-dismissible"&gt;&lt;p&gt;&lt;strong&gt;Error:&lt;/strong&gt; ' . esc_html($_GET['wpcup_error']) . '&lt;/p&gt;&lt;/div&gt;';
    }

    echo '&lt;div class="wrap wpcup-wrap"&gt;';

    echo '&lt;div class="wpcup-header"&gt;';
    echo '&lt;div&gt;';
    echo '&lt;h1 class="wpcup-title"&gt;Inline Product Editor &lt;span class="wpcup-badge"&gt;Title • Category • Regular Price&lt;/span&gt;&lt;/h1&gt;';
    echo '&lt;p class="wpcup-sub"&gt;Edit directly in the list. Tick the products you want, then click &lt;strong&gt;Save Selected&lt;/strong&gt;.&lt;/p&gt;';
    echo '&lt;/div&gt;';
    echo '&lt;div class="wpcup-actions"&gt;';
    echo '&lt;span class="wpcup-pill"&gt;Selected: &lt;strong id="wpcup_selected_count"&gt;0&lt;/strong&gt;&lt;/span&gt;';
    echo '&lt;/div&gt;';
    echo '&lt;/div&gt;';

    echo $notice;

    echo '&lt;div class="wpcup-card"&gt;';
    echo '&lt;form method="get" class="wpcup-row"&gt;';
    echo '&lt;input type="hidden" name="page" value="' . esc_attr(self::SLUG) . '"&gt;';

    echo '&lt;div&gt;&lt;label class="wpcup-label"&gt;Search&lt;/label&gt;&lt;input class="wpcup-input" name="s" value="' . esc_attr($s) . '" placeholder="Search product title..."&gt;&lt;/div&gt;';

    echo '&lt;div&gt;&lt;label class="wpcup-label"&gt;Category filter&lt;/label&gt;&lt;select class="wpcup-select" name="cat"&gt;';
    echo '&lt;option value="0"&gt;All categories&lt;/option&gt;';
    foreach ($cats as $t) {
      echo '&lt;option value="' . esc_attr($t-&gt;term_id) . '"' . selected($cat, (int)$t-&gt;term_id, false) . '&gt;' . esc_html($t-&gt;name) . '&lt;/option&gt;';
    }
    echo '&lt;/select&gt;&lt;/div&gt;';

    echo '&lt;div&gt;&lt;label class="wpcup-label"&gt;Per page&lt;/label&gt;&lt;select class="wpcup-select" name="per_page"&gt;';
    $opts = array(25, 50, 100, 200);
    foreach ($opts as $pp) {
      echo '&lt;option value="' . esc_attr($pp) . '"' . selected($per_page, $pp, false) . '&gt;' . esc_html($pp) . '&lt;/option&gt;';
    }
    echo '&lt;/select&gt;&lt;/div&gt;';

    echo '&lt;div&gt;';
    echo '&lt;button class="wpcup-btn" type="submit"&gt;Apply&lt;/button&gt; ';
    echo '&lt;a class="button wpcup-btn-secondary" href="' . esc_url(admin_url('admin.php?page=' . self::SLUG)) . '"&gt;Reset&lt;/a&gt;';
    echo '&lt;/div&gt;';

    echo '&lt;/form&gt;';
    echo '&lt;div class="wpcup-note"&gt;Tip: click the product cell to quickly tick/untick a row. Green highlight means you changed something.&lt;/div&gt;';
    echo '&lt;/div&gt;';

    echo '&lt;form method="post" action="' . esc_url(admin_url('admin-post.php')) . '"&gt;';
    echo '&lt;input type="hidden" name="action" value="wpcup_inline_bpe_save"&gt;';
    wp_nonce_field(self::NONCE_ACTION, '_wpcup_nonce');
    echo '&lt;input type="hidden" name="return_page" value="' . esc_attr(wp_unslash($_SERVER['REQUEST_URI'])) . '"&gt;';

    echo '&lt;div class="wpcup-card wpcup-sticky"&gt;';
    echo '&lt;div class="wpcup-toolbar"&gt;';
    echo '&lt;div class="wpcup-toolbar-left"&gt;';
    echo '&lt;strong&gt;Ready to save?&lt;/strong&gt;';
    echo '&lt;span class="wpcup-note"&gt;Only checked products will be saved.&lt;/span&gt;';
    echo '&lt;/div&gt;';
    echo '&lt;div class="wpcup-actions"&gt;';
    echo '&lt;button class="wpcup-btn" type="submit"&gt;Save Selected&lt;/button&gt;';
    echo '&lt;/div&gt;';
    echo '&lt;/div&gt;';
    echo '&lt;/div&gt;';

    echo '&lt;div class="wpcup-card"&gt;';

    if (!$q-&gt;have_posts()) {
      echo '&lt;p&gt;No products found.&lt;/p&gt;';
      echo '&lt;/div&gt;&lt;/form&gt;&lt;/div&gt;';
      return;
    }

    echo '&lt;table class="wpcup-table"&gt;';
    echo '&lt;thead&gt;&lt;tr&gt;';
    echo '&lt;th&gt;&lt;input id="wpcup_select_all" type="checkbox" title="Select all"&gt;&lt;/th&gt;';
    echo '&lt;th&gt;Product (edit title)&lt;/th&gt;';
    echo '&lt;th&gt;Category (dropdown)&lt;/th&gt;';
    echo '&lt;th&gt;Regular price&lt;/th&gt;';
    echo '&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;';

    while ($q-&gt;have_posts()) {
      $q-&gt;the_post();
      $id = get_the_ID();
      $title = get_the_title();

      $terms = get_the_terms($id, 'product_cat');
      $current_cat_id = 0;
      if (!is_wp_error($terms) &amp;&amp; !empty($terms)) {
        $current_cat_id = (int)$terms[0]-&gt;term_id;
      }

      $price = get_post_meta($id, '_regular_price', true);
      $price = ($price !== '') ? $price : '';

      echo '&lt;tr&gt;';
      echo '&lt;td&gt;&lt;input type="checkbox" name="product_ids[]" value="' . esc_attr($id) . '"&gt;&lt;/td&gt;';

      echo '&lt;td data-toggle-check&gt;';
      echo '&lt;div class="wpcup-muted"&gt;ID: ' . esc_html($id) . ' (tap/click to tick)&lt;/div&gt;';
      echo '&lt;input class="wpcup-input wpcup-row-input wpcup-title-input" type="text" name="title[' . esc_attr($id) . ']" value="' . esc_attr($title) . '"&gt;';
      echo '&lt;/td&gt;';

      echo '&lt;td&gt;';
      echo '&lt;select class="wpcup-select wpcup-row-input" name="cat[' . esc_attr($id) . ']"&gt;';
      echo '&lt;option value="0"&gt;No change&lt;/option&gt;';
      foreach ($cats as $t) {
        $sel = selected($current_cat_id, (int)$t-&gt;term_id, false);
        echo '&lt;option value="' . esc_attr($t-&gt;term_id) . '"' . $sel . '&gt;' . esc_html($t-&gt;name) . '&lt;/option&gt;';
      }
      echo '&lt;/select&gt;';
      echo '&lt;div class="wpcup-note"&gt;When saved, this replaces existing categories with the selected one.&lt;/div&gt;';
      echo '&lt;/td&gt;';

      echo '&lt;td&gt;';
      echo '&lt;input class="wpcup-input wpcup-row-input wpcup-price" type="text" name="price[' . esc_attr($id) . ']" value="' . esc_attr($price) . '" placeholder="e.g. 19.99"&gt;';
      echo '&lt;div class="wpcup-note"&gt;Regular price only.&lt;/div&gt;';
      echo '&lt;/td&gt;';

      echo '&lt;/tr&gt;';
    }

    wp_reset_postdata();

    echo '&lt;/tbody&gt;&lt;/table&gt;';

    $total_pages = (int)$q-&gt;max_num_pages;
    if ($total_pages &gt; 1) {
      $base = add_query_arg(
        array(
          'page' =&gt; self::SLUG,
          's' =&gt; $s,
          'cat' =&gt; $cat,
          'per_page' =&gt; $per_page
        ),
        admin_url('admin.php')
      );

      echo '&lt;div class="wpcup-divider"&gt;&lt;/div&gt;';
      echo '&lt;div class="wpcup-toolbar"&gt;';
      echo '&lt;div class="wpcup-note"&gt;Page ' . esc_html($paged) . ' of ' . esc_html($total_pages) . '&lt;/div&gt;';
      echo '&lt;div class="wpcup-actions"&gt;';
      if ($paged &gt; 1) {
        echo '&lt;a class="button wpcup-btn-secondary" href="' . esc_url(add_query_arg('paged', $paged - 1, $base)) . '"&gt;Prev&lt;/a&gt;';
      }
      if ($paged &lt; $total_pages) {
        echo '&lt;a class="button wpcup-btn-secondary" href="' . esc_url(add_query_arg('paged', $paged + 1, $base)) . '"&gt;Next&lt;/a&gt;';
      }
      echo '&lt;/div&gt;&lt;/div&gt;';
    }

    echo '&lt;/div&gt;';
    echo '&lt;/form&gt;';
    echo '&lt;/div&gt;';
  }

  public function handle_save() {
    if (!current_user_can('manage_woocommerce')) wp_die('No permission.');

    $nonce = isset($_POST['_wpcup_nonce']) ? sanitize_text_field(wp_unslash($_POST['_wpcup_nonce'])) : '';
    if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) wp_die('Security check failed.');

    $return = isset($_POST['return_page']) ? esc_url_raw(wp_unslash($_POST['return_page'])) : admin_url('admin.php?page=' . self::SLUG);

    $ids = isset($_POST['product_ids']) ? (array)$_POST['product_ids'] : array();
    $ids = array_filter(array_map('intval', $ids));

    if (empty($ids)) {
      wp_safe_redirect(add_query_arg(array('wpcup_error' =&gt; rawurlencode('No products selected.')), $return));
      exit;
    }

    $titles = (isset($_POST['title']) &amp;&amp; is_array($_POST['title'])) ? $_POST['title'] : array();
    $cats   = (isset($_POST['cat']) &amp;&amp; is_array($_POST['cat'])) ? $_POST['cat'] : array();
    $prices = (isset($_POST['price']) &amp;&amp; is_array($_POST['price'])) ? $_POST['price'] : array();

    foreach ($ids as $product_id) {
      $post = get_post($product_id);
      if (!$post || $post-&gt;post_type !== 'product') continue;

      if (isset($titles[$product_id])) {
        $new_title = sanitize_text_field(wp_unslash($titles[$product_id]));
        if ($new_title !== '' &amp;&amp; $new_title !== $post-&gt;post_title) {
          wp_update_post(array(
            'ID' =&gt; $product_id,
            'post_title' =&gt; $new_title
          ));
        }
      }

      if (isset($cats[$product_id])) {
        $new_cat_id = (int) sanitize_text_field(wp_unslash($cats[$product_id]));
        if ($new_cat_id &gt; 0) {
          wp_set_object_terms($product_id, array($new_cat_id), 'product_cat', false);
        }
      }

      if (isset($prices[$product_id])) {
        $raw = sanitize_text_field(wp_unslash($prices[$product_id]));
        $clean = $this-&gt;price_clean($raw);

        if ($raw !== '' &amp;&amp; $clean !== '') {
          update_post_meta($product_id, '_regular_price', $clean);
          $sale = get_post_meta($product_id, '_sale_price', true);
          if ($sale === '' || !is_numeric($sale)) {
            update_post_meta($product_id, '_price', $clean);
          }
        }
      }

      if (function_exists('wc_delete_product_transients')) {
        wc_delete_product_transients($product_id);
      }
    }

    wp_safe_redirect(add_query_arg(array('wpcup_saved' =&gt; 1), $return));
    exit;
  }
}

new WPCUP_Inline_Bulk_Product_Editor();
?&gt;</code></pre>
  </div>
</section>

<section class="codex-block">
  <h2>Security &amp; risks</h2>
  <p>Whenever you write a script that updates the database, you have to be careful. I put in several layers of protection here to make sure this does not break your store or open it up to hackers. Here is what you need to know about safety.</p>

  <ul>
    <li><strong>User Permissions:</strong> I used the manage_woocommerce capability check. This means only admins or shop managers can see this page. A regular subscriber or a customer cannot access this menu or trigger the save function.</li>
    <li><strong>Nonces:</strong> The save form uses a WordPress nonce. This prevents cross site request forgery. Basically, it ensures that the request actually came from your admin panel and not from some external site trying to mess with your data.</li>
    <li><strong>Sanitization:</strong> Every single input is sanitized using sanitize_text_field and wp_unslash. I am not letting any raw HTML or weird characters into your database.</li>
    <li><strong>Safe Redirects:</strong> The script uses wp_safe_redirect to return you to the editor page. This is a standard security practice to prevent malicious redirects.</li>
    <li><strong>Risk:</strong> The biggest risk is human error. This tool replaces the category for the selected product. If you accidentally select the wrong category and hit save on fifty products, they will all move to that category. There is no undo button. Always make a database backup before doing large bulk edits. That is just common sense.</li>
  </ul>
</section>

<section class="codex-block">
  <h2>How to use</h2>
  <p>Getting this running is easy. You do not even need to create a plugin file if you do not want to. You can just use a snippet manager.</p>

  <ol>
    <li>Install the <strong>WPCode</strong> plugin or Code Snippets on your WordPress site.</li>
    <li>Create a new snippet and choose <strong>PHP Snippet</strong>.</li>
    <li>Copy the code provided above and paste it into the editor.</li>
    <li>Set the snippet to run everywhere or specifically in the admin area.</li>
    <li>Hit save and activate the snippet.</li>
    <li>Look for the <strong>Inline Product Editor</strong> link in your sidebar menu, usually near the WooCommerce icon.</li>
    <li>Search for your products, check the boxes for the ones you want to change, and click <strong>Save Selected</strong>.</li>
  </ol>

  <p>If you prefer to make it a standalone plugin, just save the code as a .php file in your plugins folder and activate it. It is self contained and does not require any external libraries or files to work.</p>
</section>

<section class="codex-block">
  <h2>What I learned from the build</h2>
  <p>I feel pretty good about how this turned out. It is not a revolutionary piece of software, but it solved a real problem I was having. I learned a lot about how WooCommerce stores prices and how to efficiently clear transients.</p>

  <p>The real win for me was getting the CSS to look decent without using a library like Bootstrap. It keeps the page load fast and the code footprint small. If you find yourself spending way too much time in the standard WooCommerce bulk editor, give this a try.</p>

  <p>It is free, simple, and useful for the exact job it was built for. I might add more fields later like stock status or SKU, but for now, this handles the bulk of my work.</p>
</section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="PHP Snippets" /><summary type="html"><![CDATA[The problem with bulk editing I spent most of last Tuesday staring at the spinning loading icon in the WooCommerce dashboard. I had...]]></summary></entry><entry><title type="html">Building a simple tool to move content to WordPress</title><link href="https://nasratulnayem.github.io/blog/wordpress-content-importer-case-study/" rel="alternate" type="text/html" title="Building a simple tool to move content to WordPress" /><published>2026-02-04T18:03:25+00:00</published><updated>2026-02-04T18:03:25+00:00</updated><id>https://nasratulnayem.github.io/blog/wordpress-content-importer-case-study</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/wordpress-content-importer-case-study/"><![CDATA[<p>sitemap: false</p>

<section class="codex-block">
  <h2>Why I started this project</h2>
  <p>I was helping a friend move a bunch of articles from an old site to a new WordPress setup. If you have ever done this, you know it is a total pain. You either spend hours copying and pasting or you install a shady plugin that might break your site or charge you fifty dollars for a basic feature. I am twenty one and do not have that kind of money to throw away on simple tasks. I figured I could build a cleaner version myself. It is called wordpress-content-importer, or WPCup for short.</p>

  <p>The goal was simple: make a tool where you put in a URL and get back a WordPress-ready XML file. I wanted it to be fast, typed, and easy to look at. I did not want any extra bloat. Just a direct path from point A to point B. This project is my attempt at solving that without the corporate overhead or the clunky interfaces of the early 2000s that most WordPress tools still use.</p>
</section>

<section class="codex-block">
  <h2>The technical stack I chose</h2>
  <p>I went with React and TypeScript. I know some people say it is overkill for a small tool, but I hate debugging runtime errors that could have been caught while I was typing. TypeScript is like a safety net when I am tired and coding at 2 AM. For the build tool, I used Vite. It is so much faster than the old stuff. I do not have the patience to wait ten seconds for a dev server to start up anymore. I want it to be instant.</p>

  <p>For the styling, I used Tailwind CSS. I am not a designer by trade, so being able to just throw classes like &#8220;flex items-center justify-center&#8221; onto a div is a life saver. It keeps the CSS file small and prevents the whole &#8220;global style collision&#8221; nightmare. I also added Lucide React for icons because they are lightweight and look sharp on high-resolution screens.</p>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">JSON · package dependencies</div>
      </div>


    </div>

    <pre><code>// From package.json
"dependencies": {
  "@supabase/supabase-js": "^2.57.4",
  "lucide-react": "^0.344.0",
  "react": "^18.3.1",
  "react-dom": "^18.3.1"
}</code></pre>
  </div>

  <p>You might notice Supabase in the dependencies. Right now, I am using the frontend for the core logic, but I kept Supabase there because I plan to add a database later to save import history. For now, it stays as a placeholder while I focus on the main UI and the XML generation logic.</p>
</section>

<section class="codex-block">
  <h2>How the code actually works</h2>
  <p>The core of the app lives in App.tsx. I wanted to simulate the feeling of a real import process so I could test the user experience before I fully hooked up a backend scraper. I built a state-driven UI that tracks the progress of the &#8220;import&#8221; and gives the user feedback at every step. This matters because if a user clicks a button and nothing happens for ten seconds, they think it is broken and leave.</p>

  <p>Here is how the main import function looks right now:</p>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">TypeScript · import progress handler</div>
      </div>


    </div>

    <pre><code>const handleImport = async () =&gt; {
  if (!url.trim()) return;

  setIsImporting(true);
  setIsComplete(false);
  setProgress(0);

  const steps = [
    { progress: 15, text: 'Connecting to website...' },
    { progress: 35, text: 'Analyzing content structure...' },
    { progress: 55, text: 'Extracting posts and pages...' },
    { progress: 75, text: 'Processing media files...' },
    { progress: 90, text: 'Generating WordPress XML...' },
    { progress: 100, text: 'Import complete!' }
  ];

  for (const step of steps) {
    await new Promise(resolve =&gt; setTimeout(resolve, 800));
    setProgress(step.progress);
    setProgressText(step.text);
  }

  setIsImporting(false);
  setIsComplete(true);
};</code></pre>
  </div>

  <p>It uses an array of steps to update the progress bar. This gives the user a sense of what is happening under the hood. Even though the current version is a simulation, it sets the stage for the real asynchronous calls I will be making to a scraping service later on. I used a simple loop with a timeout to handle the timing.</p>
</section>

<section class="codex-block">
  <h2>Handling the WordPress XML format</h2>
  <p>WordPress expects a very specific XML structure. If you miss one tag or have a weird character in there, the whole import fails. For the initial prototype, I built a function that generates a basic XML header. It is not a full export yet, but it proves the concept of generating a file directly in the browser using a Blob. This saves money on server costs because I am not processing files on a backend yet. Everything happens on the client side.</p>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">TypeScript · XML download handler</div>
      </div>


    </div>

    <pre><code>const handleDownload = () =&gt; {
  const element = document.createElement('a');
  element.setAttribute('href', 'data:text/xml;charset=utf-8,' + encodeURIComponent('&lt;?xml version="1.0" encoding="UTF-8"?&gt;\n&lt;!-- WordPress Export for ' + url + ' --&gt;'));
  element.setAttribute('download', 'wordpress-import.xml');
  element.style.display = 'none';
  document.body.appendChild(element);
  element.click();
  document.body.removeChild(element);
};</code></pre>
  </div>

  <p>This approach has its limits. Browsers can be weird about large files. But for a few dozen blog posts, this is the most efficient way to do it. No login required, no data leaving the user&#8217;s computer until they decide to download it. It is privacy-first by accident, but I like it that way.</p>
</section>

<section class="codex-block">
  <h2>Real struggles and tradeoffs</h2>
  <p>The biggest struggle was deciding where to stop. I wanted to build a full web scraper that could bypass things like Cloudflare and handle JavaScript-rendered sites. But that takes a lot of time and a lot of money for proxy servers. As a solo dev, I had to be realistic. I decided to focus on the UI and the local file generation first. I chose to build a &#8220;shell&#8221; that looks and feels right, knowing I can plug in the heavy-duty scraping logic later.</p>

  <p>Another issue was the CSS. I spent way too much time fiddling with the progress bar colors. I wanted a dark mode look that didn&#8217;t feel depressing. I ended up with a deep slate background and blue accents. It is clean and doesn&#8217;t distract from the main task. I also had to make sure it worked on my phone because I find myself checking my projects on the bus all the time. I added specific media queries in the index.css file to shrink the headings and padding on smaller screens.</p>

  <p>I also struggled with the TypeScript config. Getting the module resolution right for Vite can sometimes be a headache. I had to look through the tsconfig.json and tsconfig.app.json to make sure everything was playing nice with the bundler. It is the kind of work nobody sees but it makes the development experience so much better.</p>
</section>

<section class="codex-block">
  <h2>The design decisions</h2>
  <p>I chose the Poppins font because it looks modern and is very readable. I imported it from Google Fonts in the index.html. I also used a wrapper class for the app container that uses flexbox to keep everything perfectly centered. It feels like a premium tool even though it is just a side project. I also made sure to include smooth transitions for the buttons and inputs. It is a small detail but it makes the app feel less &#8220;stiff.&#8221;</p>

  <p>I decided not to use a heavy UI library like Material UI or Mantine. They are great but they add so much extra weight. I wanted this to load fast. Tailwind gives me exactly what I need without the overhead. The whole project is very light as a result.</p>
</section>

<section class="codex-block">
  <h2>Who this is for</h2>
  <ul>
    <li>Developers who need a quick way to generate a WordPress XML skeleton.</li>
    <li>People moving small sites who do not want to use heavy plugins.</li>
    <li>Anyone interested in how to build a progress-based UI in React.</li>
    <li>Me, when I inevitably have to move another site in six months.</li>
  </ul>
</section>

<section class="codex-block">
  <h2>How to run it locally</h2>
  <p>If you want to play with the code, it is pretty standard. You will need Node.js installed. Follow these steps:</p>

  <ul>
    <li>Clone the repo from GitHub.</li>
    <li>Run <code>npm install</code> to get all the packages.</li>
    <li>Run <code>npm run dev</code> to start the Vite server.</li>
    <li>Open your browser to the local address provided.</li>
  </ul>

  <p>You can then edit App.tsx and see the changes instantly. The linting is handled by ESLint, so it will yell at you if you do something weird with the types.</p>
</section>

<section class="codex-block">
  <h2>What I would change next</h2>
  <p>If I had more time and a budget for a server, I would build a Node.js backend using Puppeteer. That would allow the tool to actually visit the URL the user provides, find the blog posts, and extract the content automatically. Right now, it is a manual-input simulation. Real scraping is hard because every website has a different structure. I would probably need to use some basic AI or a set of rules to find the title and the body content of a post.</p>

  <p>I would also improve the XML export to include categories, tags, and featured images. Right now it is just a header. A real WordPress import file is hundreds of lines of XML. Mapping the scraped data to those specific fields is the next big hurdle. But I am happy with the foundation I have here. It is a solid starting point for a tool that I actually need.</p>

  <p>I also want to add a way to preview the content before downloading the XML. A simple table or list showing what was found would give users more confidence. It is all about building trust that the tool is doing the right thing. For now, the clean UI and the progress feedback are a good start toward that goal.</p>
</section>

<section class="codex-block">
  <a href="https://github.com/nasratulnayem/wordpress-content-importer" class="cx-btn cx-btn-primary" target="_blank" rel="noopener noreferrer">View GitHub Repo</a>
</section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="Python Automations" /><summary type="html"><![CDATA[Why I started this project I was helping a friend move a bunch of articles from an old site to a new WordPress setup. If you have ever...]]></summary></entry><entry><title type="html">How to Stop Spam Comments in WordPress Without a Plugin</title><link href="https://nasratulnayem.github.io/blog/killing-wordpress-comments-with-code/" rel="alternate" type="text/html" title="How to Stop Spam Comments in WordPress Without a Plugin" /><published>2026-02-01T10:37:10+00:00</published><updated>2026-02-01T10:37:10+00:00</updated><id>https://nasratulnayem.github.io/blog/killing-wordpress-comments-with-code</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/killing-wordpress-comments-with-code/"><![CDATA[<p>sitemap: false</p>

<section class="codex-block">
  <p>Spam comments are one of those small WordPress problems that quickly become annoying. A few fake comments turn into hundreds of bot submissions, random links, pingbacks, moderation emails, and dashboard clutter.</p>

  <p>For a normal business website, portfolio, landing page, or service site, comments usually do not add any real value. They just create another place for bots to attack and another thing for the site owner to manage.</p>

  <p>This is why I prefer disabling the WordPress comment system properly instead of only hiding the comment form with CSS. The goal is simple: stop spam comments before they reach the dashboard, remove unnecessary comment areas, and keep the site cleaner without installing a heavy plugin.</p>
</section>

<section class="codex-block">
  <h2>Why spam comments happen in WordPress</h2>
  <p>WordPress includes comments by default because it started as a blogging platform. That is useful for blogs and communities, but it becomes a problem when the site does not need public discussion.</p>

  <p>Spam bots look for comment forms, pingback endpoints, and trackback behaviour because those areas can be used to push junk links or trigger unwanted notifications.</p>

  <p>Even if comments are not visible in your design, some themes or old settings can still leave comment logic active. That is why a proper snippet should close comments, block pings, clear displayed comment arrays, and remove comment UI from the dashboard.</p>
</section>

<section class="codex-block">
  <h2>WordPress spam comments disable snippet</h2>
  <p>Use this PHP snippet if you want to stop spam comments and pingbacks across the whole WordPress site. It is best for websites where comments are not needed at all.</p>

  <div class="codex-codebox">
    <div class="codex-codebox-header">
      <div class="codex-codebox-left">
        <div class="codex-codebox-dots">
          <span></span><span></span><span></span>
        </div>
        <div class="codex-codebox-title">PHP · Stop WordPress spam comments</div>
      </div>


    </div>

    <pre><code>add_filter('comments_open', '__return_false', 20, 2);
add_filter('pings_open', '__return_false', 20, 2);

add_filter('comments_array', '__return_empty_array', 10, 2);

add_action('admin_menu', function () {
    remove_menu_page('edit-comments.php');
});

add_action('init', function () {
    if (is_admin_bar_showing()) {
        remove_action('admin_bar_menu', 'wp_admin_bar_comments_menu', 60);
    }
});</code></pre>
  </div>
</section>

<section class="codex-block">
  <h2>What this snippet does</h2>
  <p>The <code>comments_open</code> filter tells WordPress that comments are closed across the site. This stops the comment form from being treated as active.</p>

  <p>The <code>pings_open</code> filter disables pingbacks and trackbacks. This helps reduce those random notification emails that often look confusing to clients.</p>

  <p>The <code>comments_array</code> filter returns an empty comments list. This prevents old or existing comments from being displayed by themes that still try to output them.</p>

  <p>The <code>admin_menu</code> action removes the Comments menu from the WordPress dashboard. The <code>init</code> action removes the comments icon from the admin bar for logged-in users.</p>
</section>

<section class="codex-block">
  <h2>Why this helps reduce comment spam</h2>
  <p>This snippet reduces the places where spam comments can appear or be shown. It also cleans up the admin area so the site owner is not distracted by comment menus and moderation links they do not need.</p>

  <p>It is a lightweight option compared with installing a full plugin just to switch off comments. There is no extra settings screen, no upsell banner, and no plugin dependency to maintain.</p>

  <p>It is also non-destructive. The snippet does not delete existing comments from the database. It simply stops comments and pings from being active and visible.</p>
</section>

<section class="codex-block">
  <h2>How to add this to WordPress</h2>
  <p>The safest way to add this code is through a snippet manager such as WPCode or Code Snippets. This is safer than editing your theme file directly, especially if you are not comfortable with PHP.</p>

  <ol>
    <li>Install WPCode or Code Snippets.</li>
    <li>Create a new PHP snippet.</li>
    <li>Paste the code from this page.</li>
    <li>Set the snippet to run everywhere.</li>
    <li>Save and activate it.</li>
    <li>Check the dashboard and frontend to confirm comments are removed.</li>
  </ol>
</section>

<section class="codex-block">
  <h2>When not to use this snippet</h2>
  <p>This is a global solution. It is not right for websites that still need blog comments, product reviews, testimonials submitted through comments, or community discussion.</p>

  <p>WooCommerce product reviews can depend on the WordPress comment system. So if your store uses reviews, test this carefully on staging before using it on a live store.</p>

  <p>For a simple business website that only wants to stop WordPress spam comments and keep the dashboard clean, this snippet is a strong lightweight option.</p>
</section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="PHP Snippets" /><summary type="html"><![CDATA[Spam comments are one of those small WordPress problems that quickly become annoying. A few fake comments turn into hundreds of bot...]]></summary></entry><entry><title type="html">Video Automation Engine That Posts Instagram Reels While You Sleep</title><link href="https://nasratulnayem.github.io/blog/automated-content-creator-case-study/" rel="alternate" type="text/html" title="Video Automation Engine That Posts Instagram Reels While You Sleep" /><published>2026-02-01T06:14:45+00:00</published><updated>2026-02-01T06:14:45+00:00</updated><id>https://nasratulnayem.github.io/blog/automated-content-creator-case-study</id><content type="html" xml:base="https://nasratulnayem.github.io/blog/automated-content-creator-case-study/"><![CDATA[<p>sitemap: false</p>

<section class="codex-block">
  <p>I built this Python automation engine because creating short videos every day manually was taking too much time. The process was always the same: choose a topic, write a script, create visuals, generate voiceover, add captions, render the video, upload it, and then save the publishing details somewhere.</p>

  <p>Now the system does that workflow for me. It reads topics from Google Sheets, generates the short video, creates captions, uploads it to Instagram Reels and YouTube Shorts, then saves the result back into the sheet.</p>

  <p>The best part is the schedule logic. I set the publishing schedule one time, and after that the engine follows it automatically. If I set it to post once a day, it runs once a day. If I set multiple publishing slots, it checks the sheet before each slot, picks the next pending topic, creates the video, publishes it, and logs the result.</p>

  <p>You can view the project here: <a href="https://github.com/nasratulnayem/VideoAutomation" target="_blank" rel="noopener noreferrer">VideoAutomation on GitHub</a>.</p>
</section>

<section class="codex-block">
  <h2>What this Python automation engine does</h2>
  <p>This is not just a script generator or a video renderer. It is a full short-form content pipeline built to move from topic to published video with as little manual work as possible.</p>

  <div class="cx-grid">
    <div class="cx-stat">
      <strong>01</strong>
      <span>Reads the next pending topic from a live Google Sheet.</span>
    </div>
    <div class="cx-stat">
      <strong>02</strong>
      <span>Generates script, voiceover, visuals, captions, and final MP4.</span>
    </div>
    <div class="cx-stat">
      <strong>03</strong>
      <span>Publishes to Instagram Reels and YouTube Shorts on schedule.</span>
    </div>
  </div>

  <p>The system uses Python as the main automation layer, GitHub Actions for scheduled runs, Google Sheets as the topic queue, and publishing APIs to push the final video online.</p>
</section>

<section class="codex-block">
  <h2>The schedule only needs to be set once</h2>
  <p>The schedule is handled through GitHub Actions cron. That means I do not need to open the dashboard every morning or keep my computer running in the background.</p>

  <p>Once the schedule is configured, the workflow runs according to that timing. Each scheduled run wakes up the pipeline, checks the Google Sheet, finds the next topic marked as pending, processes one video, uploads it, then updates the sheet with the result.</p>

  <p>This is what makes the system feel different from a normal video tool. A normal tool waits for you to click a button. This engine follows the schedule I already set and keeps publishing based on that rule.</p>
</section>

<section class="codex-block">
  <h2>Google Sheets controls the daily topics</h2>
  <p>The sheet is where the whole workflow starts. I add video topics there, set the status as pending, and the automation picks the next item when the scheduled run starts.</p>

  <div class="cx-proof-img">
    <img src="https://codex.nayem.dev/wp-content/uploads/2026/05/Screenshot-2026-05-25-105244.webp" alt="Google Sheet showing daily topics and publishing results for Python Instagram Reels automation" />
  </div>
  <p class="cx-caption">This sheet shows how the automation reads daily topics and saves publishing details after each scheduled run.</p>

  <p>This makes the system easy to control. I do not need to open the codebase every day. I only need to update the sheet, and the engine knows what to process next.</p>
</section>

<section class="codex-block">
  <h2>It posts Instagram Reels automatically</h2>
  <p>The Instagram side is already part of the workflow. The automation creates the Reel, uploads it, and logs the result. That means the video does not just sit inside a folder waiting for me to upload it manually.</p>



  <p>The feed above shows the daily Instagram Reels output created by this automation while I am offline or sleeping.</p>
</section>

<section class="codex-block">
  <h2>How the video pipeline works</h2>
  <p>The pipeline starts with a topic from Google Sheets. Then the system generates a script, creates scene visuals, produces the voiceover, trims silence, adds captions, applies background music, renders the final MP4, and uploads the video.</p>

  <p>For script generation, the engine can use Gemini. For visuals, it can use Imagen through Vertex AI. For voice, it supports providers like ElevenLabs, Murf.ai, and Cartesia Sonic. For captions, it uses word-level timing so the text feels synced instead of randomly placed.</p>

  <p>The system also supports scheduled daily publishing through GitHub Actions. That is what makes it useful. My computer does not need to stay on for the workflow to run.</p>
</section>

<section class="codex-block">
  <h2>Why this saves real time</h2>
  <p>Short-form content looks easy until you try to publish every day. The boring work is not one big task. It is ten small tasks repeated over and over.</p>

  <p>This automation removes most of that repetitive work. I still control the ideas, topics, and direction, but I do not have to manually build and upload every single Reel.</p>

  <p>That is the result I wanted: wake up, check the sheet, and see that the video was already created, posted, and logged.</p>
</section>

<section class="codex-block">
  <h2>Same video examples from the pipeline</h2>
  <p>These examples show the kind of short-form videos the engine is designed to produce. The focus is tight pacing, readable captions, voice flow, and simple vertical video output.</p>

  <div class="cx-wrap" id="cxPlaylist2">
    <div class="cx-shell">
      <div class="cx-player">
        <div class="cx-player-frame">
          <iframe id="cxMainPlayer2" src="https://www.youtube-nocookie.com/embed/PEEUrCk7tC4" title="They Lied About Power | The Unbreakable Path of Silence" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen=""></iframe>
        </div>
      </div>

      <div class="cx-playlist-panel">
        <div class="cx-panel-head">
          <div>Playlist</div>
          <div class="cx-pill">6 videos</div>
        </div>

        <div class="cx-list" id="cxList2">
          <div class="cx-item" role="button" tabindex="0" aria-current="true">
            <div class="cx-thumb">
              <img src="https://i.ytimg.com/vi/PEEUrCk7tC4/hqdefault.jpg" alt="They Lied About Power thumbnail" />
            </div>
            <div>
              <p class="cx-item-title">They Lied About Power | The Unbreakable Path of Silence</p>
              <p class="cx-item-sub">Tight pacing. No wasted seconds.</p>
            </div>
          </div>

          <div class="cx-item" role="button" tabindex="0" aria-current="false">
            <div class="cx-thumb">
              <img src="https://i.ytimg.com/vi/KHgiz39tGAA/hqdefault.jpg" alt="The 3 Unseen Pillars thumbnail" />
            </div>
            <div>
              <p class="cx-item-title">The 3 Unseen Pillars of Mental Dominance</p>
              <p class="cx-item-sub">Hook clarity test.</p>
            </div>
          </div>

          <div class="cx-item" role="button" tabindex="0" aria-current="false">
            <div class="cx-thumb">
              <img src="https://i.ytimg.com/vi/TfCB3bqjb-k/hqdefault.jpg" alt="How Manipulators Control Your Mind thumbnail" />
            </div>
            <div>
              <p class="cx-item-title">How Manipulators Control Your Mind</p>
              <p class="cx-item-sub">Caption rhythm and timing.</p>
            </div>
          </div>

          <div class="cx-item" role="button" tabindex="0" aria-current="false">
            <div class="cx-thumb">
              <img src="https://i.ytimg.com/vi/U_zo7XQueoE/hqdefault.jpg" alt="The Silent Prison of Desire thumbnail" />
            </div>
            <div>
              <p class="cx-item-title">The Silent Prison of Desire</p>
              <p class="cx-item-sub">Voice cadence and flow.</p>
            </div>
          </div>

          <div class="cx-item" role="button" tabindex="0" aria-current="false">
            <div class="cx-thumb">
              <img src="https://i.ytimg.com/vi/DWPxr1V24go/hqdefault.jpg" alt="When Everything Goes Wrong thumbnail" />
            </div>
            <div>
              <p class="cx-item-title">WHEN EVERYTHING GOES WRONG: The Art of Rising in Silence</p>
              <p class="cx-item-sub">Scene transitions without being cheesy.</p>
            </div>
          </div>

          <div class="cx-item" role="button" tabindex="0" aria-current="false">
            <div class="cx-thumb">
              <img src="https://i.ytimg.com/vi/bMFMX5VqoLg/hqdefault.jpg" alt="Your Suffering Is Meaningless thumbnail" />
            </div>
            <div>
              <p class="cx-item-title">Your Suffering Is Meaningless. This Is Your Only Path to Power.</p>
              <p class="cx-item-sub">Punchy delivery test.</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>


</section>

<section class="codex-block">
  <h2>Who this is for</h2>
  <p>This Python automation engine is for creators, developers, and channel builders who want to publish short-form videos consistently without doing the same manual editing and uploading work every day.</p>

  <p>It is not a no-code tool. You still need API keys, setup, and accounts connected. But once the workflow is ready, it can run on schedule and create real content while you are offline.</p>
</section>

<section class="codex-block">
  <h2>Final result</h2>
  <p>The final result is simple. I add topics to Google Sheets, set the schedule once, and the automation handles the rest according to that schedule. It creates videos, posts them as Instagram Reels and YouTube Shorts, then saves the run details back into the sheet.</p>

  <p>That is what makes this useful. It is not just a generator. It is a scheduled publishing system that keeps working after I close the laptop.</p>

  <p><a href="https://github.com/nasratulnayem/VideoAutomation" class="cx-btn cx-btn-primary" target="_blank" rel="noopener noreferrer">View GitHub Repo</a></p>
</section>]]></content><author><name>Nasratul Nayem</name><email>devnayem30@gmail.com</email><uri>https://nasratulnayem.github.io</uri></author><category term="Python Automations" /><summary type="html"><![CDATA[I built this Python automation engine because creating short videos every day manually was taking too much time. The process was always...]]></summary></entry></feed>