Skip to content

Lando and WordPress Plugin Development

Dear Reader,Lando logo

So I’m working on my next book “Extending the WordPress REST API”. It’s the companion volume to “Using the WordPress REST API”. In this book, I actually have a working WordPress REST API controller and series of endpoints that I am showcasing. This means I’ve got development to do.

One of the hardest things I do in software development is WordPress plugin development. It takes so much to get things stood up and ready that it’s a pain just to start a project. My last few, I would spin up a site on an unused domain I own – don’t look at me funny…you’ve got them too – and then use VS Code to edit the files remotely. Not exactly the best solution but it’s a great excuse for not working when your not connected. (Scuba boat, airplane in times past, etc.)

Now though, that excuse has been taken away from me thanks to Lando. :)

Installing Lando

Installing Lando is not difficult. One of the great things about Lando it’s copious documentation. Anyone familiar with the basics of software development should be able to install Lando in no time regardless of their operating system. Thanks to Microsoft’s new WSL2 and some huge improvements in the file system, Lando now works better under WSL2 than it does on macOS. (I’ve done both)

I wrote up my thoughts on how to get Lando installed, up and running on WSL2 in this post, Making Lando work inside WSL2.

Developing a Plugin

Once you have Lando installed, the next thing we need is a plugin. You can use any plugin you are wanting to do development on. I’m using my wp_podcast_api plugin that I wrote for Voices of the ElePHPant, and that is the sample code for my book.

Once you are in your plugin’s directory, you have to create a .lando.yml file. Lando makes this part easy. Straight from the Lando Docs,

1
2
3
4
5
6
lando init \
--source remote \
--remote-url https://wordpress.org/latest.tar.gz \
--recipe wordpress \
--webroot wordpress \
--name my-first-wordpress-app

Of course you want something more clever than “my-first-wordpress-app” but the rest is exactly what you need. This will create you a basic .lando.yml file. This also downloads and uncompresses the latest version of WordPress. This is necessary for when we fire things up.

Now we start tinkering. You can find the finished product here, in case you are too impatient to read this.

The Basics

The basic .lando.yml that the init process created for us is just that, very basic.

1
2
3
4
name: my-first-wordpress-app
  recipe: wordpress
  config:
  webroot: wordpress

That’s it. It just defines our webroot. By the way, you CAN change that if you want but there’s very little value to be gained unless you’ve got something hard-coded to public.

Config

For our working environment, we want a little more than just the basics. We want ti decked out exactly the way we like to develop, otherwise, whats the point in using Lando to begin with? So I’m going to walk through each part of the finished .lando.yml file explaining why the lines exist and why I set them this way.

1
2
3
4
5
6
7
config:
  via: apache
  php: 7.4
  webroot: wordpress
  ssl: true
  xdebug: false
  database: mariadb

I am now and have always been an Apache fan. So I build with Apache. If you like nginx, that’s cool you can specify that. I’m developing using PHP 7.4 like every good PHP developer should. Also, I like MariaDB over MySQL. I don’t currently use xDebug on this project but I can easily turn it on and then lando rebuild to use it. Of course, everyone should use ssl. It’s not that important for development so it’s fine to leave that off if you like.

Services – Database

Services one gets a bit long so I will break it up into smaller chunks.

1
2
database:
  portforward: true

I want to be able to access my database from outside of the Lando container, so I tell it to port forward. Here’s the thing about Lando and port forwarding you can turn it on or off, but it’s best if you let Lando decide the port to forward. Let me quote the Lando manual for you.

portforward will allow you to access this service externally by assigning a port directly on your host’s localhost. Note that portforward can be set to either true or a specific port but we highly recommend you set it to true unless you have pretty good knowledge of how port assignment works or you have a very compelling reason for needing a locked down port.

portforward: true will prevent inevitable port collisions and provide greater reliability and stability across Lando apps. That said, one downside of portforward: true is that Docker will assign a different port every time you restart your application. You can read more about accessing services externally over here.

I mean seriously, I could expound on it, but why. They said it best.

Services – Appserver (Apache)

This is the big section so let’s break it up into even smaller chunks.

The first is the overrides section. This is where the magic happens for WordPress plugin developers happens. That volume mapping below where we map . to /app/wordpress/wp-content/plugins/wp_podcast_api, that is the secret sauce. See, the .lando.ymlgoes in the root of your plugin directory. This way everything is kept together.

You cd in to your plugin’s working directory (on my laptop I have a directory named Projectsand all of my projects, including all of my plugins, have their own dir there.) and then you lando start. Everything stays together, everything in a single git repo, all nice and tidy. That mapping makes this possible.

1
2
3
4
appserver:
  overrides:
    volumes:
      - '.:/app/wordpress/wp-content/plugins/wp_podcast_api'

Now when lando starts up, you get a directory in /app/wordpress/wp-content/plugins/wp_podcast_apithat is your plugin, just like you need it. Since it’s a mapping, you can still edit the files in the root of your project and your webserver sees those changes.

Literally, after lando startmy next command is code . and I have my editor up and running ready to start building some awesome.

If this were a simple plugin, that would be all I would need. However, this particular example depends on other plugins. I don’t want to have to re-install them every time I lando destroy -y && lando start so let’s use a combination of composer and wp-cli to put them in place and configure them. Of course we don’t do this manually, let let Lando deal with it. :)

Services – Appserver Build and Run

Lando’s .lando.yml has 4 sections under each service where you can define things that are to be executed inside the container.

  • build_as_root
  • build
  • run_as_root
  • run

The *_as root should be obvious from the names, in the case of this sample plugin, we don’t use them, just build and root. These execute every time you lando start from scratch, you lando rebuild, or you lando destroy -y and then lando start. If you just lando stop and then lando start again (or lando restart) these steps do not execute.

build
Build executes before the services have been started. If you need to download stuff, tinker w/config files, etc, build is your friend. (same for build_as_root)

1
2
build:
  - wp core download --force --skip-content

Now I know what you are thinking…WE JUST DOWNLOAD CORE! Yes, we did, for the init. However, if you want to start totally from scratch. (commit this to a repo, delete the dir, clone the rep anew and rebuild it with Lando) then you need this command.

run

run executes after the services have started. If you needs the services up and running to do a task, you want those tasks in run. In our case, we do several tasks here in run.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
run:
  - composer install
  - wp config create --dbname=wordpress --dbuser=wordpress --dbpass=wordpress --dbhost=database --skip-check --force
  - wp core install --url=podcast.lndo.site --title=Podcast --admin_user=admin --admin_password=admin --admin_email=cal@example.com
  - cp assets/.htaccess wordpress
  - perl -p -i -e "s/\/\* That's all, stop editing! Happy publishing. \*\//\ndefine('JWT_AUTH_SECRET_KEY', 'bite-me');\n\/\* That's all, stop editing! Happy publishing. \*\//" wordpress/wp-config.php
  - wp theme install twentytwenty --activate
  - wp post delete 1
  - wp rewrite structure '/%postname%/' --hard
  - wp plugin install classic-editor --activate
  - wp plugin install jwt-auth --activate
  - wp plugin install wordpress-importer --activate
  - wp plugin install wordpress-seo --activate
  - wp plugin install powerpress --activate
  - wp plugin activate wp_podcast_api
  - wp import assets/voicesoftheelephpant.wordpress.2020-07-23.000.xml --authors=create
  - wp user update calevans --rich_editing=false
  - wp user update admin --rich_editing=false

Everything we do falls into two buckets.

  1. composer tasks (exactly 1 of these)
  2. wp-cli tasks

Lando was built for devs. It knows we love our tools. So when you told it you wanted to start with a WordPress recipe, it figured out that you need composer, wp-cli and a few other must-have tools ready to go. So by the time you get to run, they are there and waiting on you.

In out run section we make use of both composer and wp-cli these tools. This plugin requires a couple of other plugins to be installed. We use composer and wpackagist to get those installed properly. After they are installed, we use wp-cli to activate them as well the TwentyTwenty theme.

I’ve also got a few config options in there and yes, I install classic editor and turn off rich-text editing, I’m old skool.

Side note, while I use composer because it’s the tool I am comfortable with, I could have just as easily used wp-cli to download, install, and activate the plugins I a using because they all come from the main WordPress plugin repository. If you are using custom plugins from a private Packagist.com, composer is your best route. If you know how to use both then you can figure out the best tool for the job each time.

Services – mailhog

Finally, one of my favorite things about Lando, I tell it to setup mailhog. This is a real simple email testing service. When you spin up Lando with mailhog it gives you a URL to go to that shows you any email that was sent to anyone. The mails are not sent out over the net but just captured for you to examine.

1
2
3
4
5
mailhog:
  type: mailhog
  portforward: false
  hogfrom:
    - appserver

Since WordPress can send a lot of emails and we want to make sure they are correct, mailhog is a great way to monitor them.

Bring it all together

Ok, if you’ve followed along so far and you’ve tweaked your .lando.yml file, you can now spin it up.

1
$ lando start

That’s all it takes.

If you look at the final lando.yml file, you’ll see that I use the wp importer tool to import a set of posts. You can also use lando db-importto populate your database from a .sql file. If you have awesome friends like Kim Cottrell then you’ve probably got a good companion tool like lando db-download that exports and downloads your production database for you.  Or you can do like I did for this one and just use XML. It doesn’t suck that bad. :)

When you are done, you can stop it with a simple

$ lando stop

Don’t destroy it with lando destroy -y unless you aren’t going to be back for a while. stopping and restarting is a lot faster and preserves you database.

As you can see from my sample plugin’s repo, I keep all of this, .lando.yml, composer.json, and the plugin code, in the same repo. This way, no mater what machine I am on, if Lando is installed, I can git pull and then lando start to be up and running with a full WordPress development site faster than light-speed. (Sorry, couldn’t resit) :)

 

Until next time,
I <3 |<
=C=

WordPress, REST, and RegEx

Dear Reader,

I’m going to add this to “Using the WordPress REST API“, but I thought I would blog it here as well.

I have a tendency to over think things.

Today, I was working on a REST API endpoint for a client and it needed to have RegEx in it. I hate RegEx with a passion usually reserved for XML, but unlike XML, it’s a necessary evil, so I dove into it.

1
/item/(?P<itemId>\d+)

That’s an example.

For the uninitiated, when you are defining a custom WordPress REST endpoint, one of the things you can do is put in Regex into the route definition and WordPress will use that to pull out content and make it a parameter. The code above defines an endpoint for

https://example.com/wp-json/my-namespace/v1/item/4

When run, it will make a parameter named itemId whose value is the 4 from the URI. It’s incredibly handy. They are very easy to work with, especially if you are using numbers like 4, or even 294875.  Strings…well, strings get tricky.

The above example expect a number (so no alpha, just numeric) and numbers are contiguous.  You don’t have numbers with a space in them. Phrases however, have spaces. And what I needed to pull out was a phrase. So, I did what I always do, I pulled out Regex 101, and started figuring this out. This is where the overthinking part comes in.

I got it working in short order, then I started thinking. “What if…”

  • What if There’s a query string at the end
  • What if there’s more to the URI
  • What if there’s a slash at the end
  • …what if

This is where I got into trouble. I lost a good 2-3 hours designing a beautiful piece of RegEx that handled every situation I could think of. It was art, if I do say so myself. The only problem was that once I pasted it into my WordPress REST Controller, it did not work.

So I did what every developer does, I assumed the problem had to be in WordPress. I rolled up my sleeves and found out how WordPress matches routes.

What WordPress does

WordPress matches REST routes in WP_REST_Server::dispatch() (wp-includes/rest-api/class-wp-rest-server.php)

1
$match = preg_match( '@^' . $route . '$@i', $path, $matches );
$path is the URI. IN my case
https://example.com/wp-json/my-namespace/v1/item/this%20item%20name
$route was the route I defined in regex.
1
item/(?P<itemName>[w+].*)[?|/|\$]
(I’m working from memory but I think that was it.)
If – and only if – I could set some pattern modifiers I could make it work…but I couldn’t.
Then I began doing the other thing that PHP developers do a lot, I began throwing var_dump();die(); into WP_REST_Server. I thought I needed to see what was going on. Turns out, the answer was there in front of me all the time.
I was assuming that WordPress was applying my RegEx standalone from everything else. If you look at the line above though, you can see that is uses the route that I define in it’s entirety.
  • It puts a ‘@’ at the beginning of it. This tells PHP that ‘@’ is the regex delimiter, not ‘/’ like usual
  • It adds the caret ‘^’ to match the beginning of the line
  • It concatenates the route I defined
  • It adds the $ to match the end of the line
  • It puts the ‘@’ to signify that this is the end of the RegEx
  • It adds the ‘i’ pattern modifier (the things I needed to tinker with) to indicate that all matches should be case insensitive
WordPress doesn’t worry about the query string, or anything after that because it’s already stripped it off. I don’t have to overthink this thing with subgroups and special characters, WordPress has got my back.
My finished product ended up looking like this:
1
item/(?P<itemName>w+.*)
This gives me a parameter named itemNamethat includes everything past item/ to the end of the line.

Conclusion

Stop over-thinking things. Sometimes just let the framework do it’s job. :)

Did I meantion I hate Regex? :)

Until next time,
I <3 |<
=C=

p.s. the section in the book will be more coherant. I’ve spent the day with RegEx so I’m a bit scatterbrained now. :)

WooCommerce “Who Bought It”

Dear Reader,

WARNING: This is not discussing a WooCommerce report I’ve written, this is raw SQL. If you are not a developer, this isn’t the post you are looking for. 

A Short History of Cal

Back in the early ’90s I was working for my parents company as a programmer. I wrote a report once that would scan the entire order system and list out the customers who had purchased a specific product. At that time, and in our industry, this was revolutionary. We actually had vendors calling us asking us to run it for various reasons. (None of them spam, this was pre-email)

Fast forward to today and I still need this information. These days however, I don’t have to sequentially scan hundreds of files of order history to find the information. We’ve got SQl and RDBMS to help with that. it should be easy, right? Easy is such a relative term.

So What?

For Nomad PHP, we use WooComemrce to handle all order processing. WooCommerce uses WordPress’s internal structure for most of it’s data storage. This means that things like First Name, Last Name, and Email Address are buried in a field named meta_value in the wp_postmeta table. This means that you lose all the advantage to storing things in a RDBMS. Still, I need this data.

Just Give Me the Code

It turns out, it is possible to get this data out of the system if you are patient and willing to write some incredibly ugly SQL. Still, it was fun (for me) since it’s been a long time since I’ve hand-coded a pivot table. (What we in the FoxPro world used to call a Cross Tab)

Here’s the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
DROP TABLE IF EXISTS cal_holding;
SELECT @SKU := "YOUR ITEM'S SKU";
 
SELECT @SKU_ID := post_id 
  FROM wp_postmeta WHERE meta_key="_sku" AND meta_value=@SKU ;
 
CREATE TABLE cal_holding AS
  SELECT order_id, 
          meta_key,
         meta_value
    FROM wp_postmeta
         LEFT JOIN 
           (SELECT order_id
              FROM wp_woocommerce_order_items `items`
                   LEFT JOIN 
                     (SELECT order_item_id
                        FROM wp_woocommerce_order_itemmeta
                       WHERE meta_key='_product_id' 
                             AND meta_value=@SKU_ID) AS `orders`
                    ON `items`.order_item_id = `orders`.order_item_id
             WHERE orders.order_item_id IS NOT NULL) AS `all_orders`
          ON all_orders.order_id = wp_postmeta.post_id
   WHERE order_id IS NOT NULL
         AND (wp_postmeta.meta_key='_billing_email'
              OR wp_postmeta.meta_key='_billing_first_name'
              OR wp_postmeta.meta_key='_billing_last_name');
 
SELECT h0.order_id,
       h1.meta_value AS email,
       h2.meta_value AS first_name,
       h3.meta_value AS last_name
 FROM cal_holding h0
      INNER JOIN cal_holding h1 ON h0.order_id=h1.order_id AND h1.meta_key='_billing_email' 
      INNER JOIN cal_holding h2 ON h0.order_id=h2.order_id AND h2.meta_key='_billing_first_name'
      INNER JOIN cal_holding h3 ON h0.order_id=h3.order_id AND h3.meta_key='_billing_last_name'
GROUP BY order_id;
 
DROP TABLE IF EXISTS cal_holding;

Now It’s Time for the Breakdown

I’m not going to break it down line by line but I’ll hit the highlights.

Line 1 just makes sure that the holding table doesn’t already exist.

Line 2 is very important. Where it says YOUR ITEM’S SKU, put in you item’s SKU.

Lines 7-26 create a table called cal_holding that holds the first name, last name, and email address of everyone who has purchased the SKU. This info is usable but not in the format we want. all the data is contained in individual rows and it’s still a pain to work with. There are 3 selects in that statement and two of them are sub-selects for joins. It is not pretty. However, if you run the first two select statements  (lines 2 and 4) you can run the selects individually to see what they produce.

Lines 28-36 create our pivot table. We take the data stored in the field meta_value and move it into  fields named more appropriately, first_name, last_name and email. We use the holding table but we use three times. Then we group on the order id to give us a single row for each order, that contains each of the three fields.

fClose()

This is not a scalable solution. If you’ve got more than about 5 fields, this gets unwieldy fast. There are articles out there on how to automate this but they required prepared statements and I just didn’t feel like doing that.

Note:

While it is possible to execute this in the MySQL cli client, I would strongly recommend you use a better tool. I like SequelPro for macOS and HeidiSQL for Windows. Both of these are excellent tools, both are free, (but you should donate) and both will make it much easier for you to play with this code and tinker a bit.

License:

The above code is released to the public domain. I claim no copyright on it at all. If you want to use it in a plugin that puts a button on a product that runs this and let’s me export the results to CSV, I’m fine with that. If you want to charge for it, I’m fine with that. If you want to give me a copy for free, I’M FINE WITH THAT.

I wrote it mainly because I’m tired of asking the question “In WooCommerce, how do I…?” and being told that the answer is, “I’m going to need your credit card number…every year.”

Until next time,

I <3 |<

My Journey Into Mautic

Mautic logoDear Reader,

Those that know me know that I have an obsession with marketing. I mean I’m no good at it, but the topic fascinates me. Almost all of the podcasts I listen to on a regular basis are marketing related. One topic in particular that interests me is “Marketing Automation”. Marketing Automation covers a huge swath of topics and since I am not an expert at the, I won’t attempt to explain them. However, three things that are covered by MA that I understand reasonably well are:

  • Lead Generators
  • Landing Pages
  • Email Marketing Campaigns

Even here we have topics so broad that entire books have been written on each of them. Still, these thee topics represent the heart of what is called “Inbound Marketing”.

Inbound Marketing is you trying to convince people to come to your site and buy/join your mailing list. This is as opposed to Outbound Marketing where you contact potential customers directly and try to convince them to buy.

Introducing Mautic

Because I am interested in Marketing Automation and want to start applying the techniques in the projects I run.

I started looking around for vendors who could provide these services. What I found is that most SaaS vendors assume that everybody who wants to use their software has deep pockets.

Side Note: I had a real interesting interview with someone form PostMark this past week after I tweeted that I did not choose them but chose Mail Gun. The subject of price came up and my words to him were “Yes, it’s only $15/month. However, right now I’ve got 7-8 companies wanting just $15-$25 per month. It all ads up quick.

During my research into solutions that may or may not work but I couldn’t afford to try, I cam across a project called Mautic. Mautic had three major things going for it right away.

  1. It is Open Source
  2. It is written in PHP
  3. One of the leads at Mautic is a friend of mine, Don Gilbert

Wow! To me, a long time PHP developer, this was a home run. I began digging deeper into it.

  • It integrates into WordPress, my tool of choice for building websites.
  • I can host it myself. (This is probably more important to me than others. My reasons are partly technical and partly political.)
  • I can contribute back to the project.

So we have a winner and I was able to give a big raspberry to all the other SaaS vendors who wanted me to pony up each month. Well, that’s what I thought at least.

As it turns out Mautic – while it it is most of the things I said – is still open source software. This means that development is at the whim of contributors that have other priorities. This meas that there are problems with Mautic that will get fixed when they are a problem for someone with the knowledge and time to fix them. While this is ok for me because it is possible for me to dive in and fix things if they reach a level of importance to me, it’s probably a downside to most non-developer users.

Where to go from here?

Despite some obvious flaws and at least one huge show stopping bug, I see a bright future for Mautic. So I’m going to invest my time in getting it setup and running. I’ve already run the install twice and I’m happy with the results the second time.

Along the way, I am going to blog what I learn. This is both for me so I can reference it later, and to help anyone else who is working with Mautic.

My setup will be:

Most of what I do can be done without having to worry about hosting your own copy. I am doing it this way so that I can integrate Mautic into my existing infrastructure. So don’t worry if you aren’t a programming, you can still learn from my mistakes. :)

Along the way, I will get things wrong – my definitions above may already be wrong. Leave me a comment and correct me. I’m not claiming that I know what I’m doing. I’m just saying that I’ll tell you what I’ve done, and what I’ve learned.

Posts

Until next time,
I <3 |<
=C=

Silly WordPress Tricks, Part I: Exporting Blog Posts as HTML

Dear Reader,

I’m making some changes to my blog. Specifically, I am moving most of my Public Speaking blog posts to my mailing list. I’ve got more of those than I realized. Being a program, my thought process was of course “Why spend an hour copying and pasting all of these posts when I can spend two hours and write a script?” :) So I wrote a simple script using WP-CLI to gather the information. So I hammered out some bash to get the job done. I am proud that other than wp-cli, I did not have to resort to any additional PHP code to do the job. It was tempting at times, but I did it.

Yes, I am aware that wp-cli will export to a WXR file. I wanted something simpler.

No, this is not a complete solution. It doesn’t deal with attachments, comments, or metadata. I don’t need those for this project.

Purpose

This bash script will export all of the blog posts in a given WordPress category into individual HTML files.  There is no templating to control how they are output, it is not that smart. It takes no parameters, everything is hard coded.

Assumptions

  1. You have wp-cli installed on your machine, it is named wp, and it is in your path.
  2. You have a WordPress blog

Here is the script. Below, I will break it down line-by-line in case it’s not clear.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
HOME_DIR=~
WP_DIR=/path/to/public_html/
CATEGORY=speaking
mkdir -p $HOME_DIR/blog/$CATEGORY
cd $WP_DIR
 
for LINE in $(wp post list --category_name=$CATEGORY --fields=ID,post_name --format=csv| tail -n +2); do
        ID=$(echo $LINE | cut -f1 -d,)
        SLUG=$(echo $LINE | cut -f2 -d,)
        TITLE=$(wp post get $ID --field=post_title)
        POST_DATE=$(date -d "$(wp post get $ID --field=post_date)" +"%Y-%m-%d")
        AUTHOR=$(wp user get $(wp post get $ID --field=author) --field=display_name)
        echo "Processing $TITLE"
        echo "<h1>$TITLE</h1>" > $HOME_DIR/blog/$CATEGORY/$SLUG.blogpost.txt
        echo "<strong>Author: </strong>$AUTHOR</storng><br />"  >> $HOME_DIR/blog/$CATEGORY/$SLUG.blogpost.txt
        echo "<strong>Date Published : </strong>$POST_DATE<br />" >> $HOME_DIR/blog/$CATEGORY/$SLUG.blogpost.txt
        wp post get $ID --field=post_content >> $HOME_DIR/blog/$CATEGORY/$SLUG.blogpost.txt
done

2: This is the home directory. A directory named blog/CATEGORY will be created under this directory. It is set to the user’s home directory.

3: This is the root of your WordPress installation.

4: This is the category that you want to export.

5: Create the directory to hold the posts

8: Get a list of the post IDs for the given category. Execute lines 9-18 once for each post. The wp command in the for loop will return a csv list of ID and post names. (the slug)

9: Get the post ID from the CSV line using cut.

10: Get the slug.

11: Use wp to get the title of the post.

12: Use wp to get the post date. Use date and a format of YYYY-MM-DD to strip off the time.

13: Use wp to get the author id and feed that to wp to get the author’s display name.

14: Let the user know what we are currently processing.

15: Output the Title of the post as an H1

16: Output the By-line.

17: Output the post date

18: Use wp to gather the content of the actual post and output it.

Lather, rinse, repeat.

 

:Conclusion

This is one of those “gets the job done” scripts. It is brittle and it is fragile. There are a lot of ways to break it and there is zero error-handling in it. All that having been said, it gets the job done. More importantly, it illustrates one of the cool things about wp-cli, scriptability of WordPress. I live in the command line, wp-cli has quickly become one of my most used tools. This, however, is the first time I’ve used it as part of a larger script. I think that’s cool. :)

Until next time,
I <3 |<
=C=