<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/rss-style.xsl" type="text/xsl"?>
<rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	    xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	     xmlns:dc="http://purl.org/dc/elements/1.1/"
	   xmlns:atom="http://www.w3.org/2005/Atom"
	     xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	  xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>
<channel>
	<title>HowTo &#8211; Terence Eden’s Blog</title>
	<atom:link href="https://shkspr.mobi/blog/tag/howto/feed/" rel="self" type="application/rss+xml" />
	<link>https://shkspr.mobi/blog</link>
	<description>Regular nonsense about tech and its effects 🙃</description>
	<lastBuildDate>Tue, 14 Apr 2026 22:11:25 +0000</lastBuildDate>
	<language>en-GB</language>
	<sy:updatePeriod>hourly</sy:updatePeriod>
	<sy:updateFrequency>1</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</generator>

<image>
	<url>https://shkspr.mobi/blog/wp-content/uploads/2023/07/cropped-avatar-32x32.jpeg</url>
	<title>HowTo &#8211; Terence Eden’s Blog</title>
	<link>https://shkspr.mobi/blog</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title><![CDATA[Reprojecting Dual Fisheye Videos to Equirectangular (LG 360)]]></title>
		<link>https://shkspr.mobi/blog/2026/04/reprojecting-dual-fisheye-videos-to-equirectangular-lg-360/</link>
					<comments>https://shkspr.mobi/blog/2026/04/reprojecting-dual-fisheye-videos-to-equirectangular-lg-360/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Sun, 19 Apr 2026 11:34:32 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[ffmpeg]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[LG360]]></category>
		<category><![CDATA[linux]]></category>
		<category><![CDATA[video]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=67087</guid>

					<description><![CDATA[I still use my obsolete LG 360 Camera. When copying MP4 videos from its SD card, they come out in &#34;Dual Fisheye&#34; format - which looks like this:    VLC and YouTube will only play &#34;Equirectangular&#34; videos in spherical mode. So, how to convert a dual fisheye to equirectangualr?  The Simple Way  ffmpeg \   -i original.mp4 \   -vf &#34;v360=input=dfisheye:output=equirect:ih_fov=189:iv_fov=189&#34; \  …]]></description>
										<content:encoded><![CDATA[<p>I still use my <a href="https://shkspr.mobi/blog/2021/11/lg-killed-its-360-camera-after-only-4-years-heres-how-to-get-it-back/">obsolete LG 360 Camera</a>. When copying MP4 videos from its SD card, they come out in "Dual Fisheye" format - which looks like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/02/Original.webp" alt="Dual fisheye photo of us and some elephants." width="2560" height="1280" class="aligncenter size-full wp-image-67108">

<p>VLC and YouTube will only play "Equirectangular" videos in spherical mode. So, how to convert a dual fisheye to equirectangualr?</p>

<h2 id="the-simple-way"><a href="https://shkspr.mobi/blog/2026/04/reprojecting-dual-fisheye-videos-to-equirectangular-lg-360/#the-simple-way">The Simple Way</a></h2>

<pre><code class="language-bash">ffmpeg \
  -i original.mp4 \
  -vf "v360=input=dfisheye:output=equirect:ih_fov=189:iv_fov=189" \
  360.mp4
</code></pre>

<p>However, this has some "quirks".</p>

<p>The first part of the video filter is <code>v360=input=dfisheye:output=equirect</code> - that just says to use the 360 filter on an input which is dual fisheye and then output in equirectangular.</p>

<p>The next part is <code>:ih_fov=189:iv_fov=189</code> which says that the input video has a horizontal and vertical field of view of 189°. That's a <em>weird</em> number, right?</p>

<p>You'd kind of expect each lens to be 180°, right? Here's what happens if <code>:ih_fov=180:iv_fov=180</code> is used:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/02/360-180.webp" alt="Flattened image, but there are overlaps at the seams." width="2560" height="1280" class="aligncenter size-full wp-image-67109">

<p>The lenses overlaps a little bit. So using 180° means that certain portions are duplicated.</p>

<p>I <em>think</em> the lenses technically offer 200°, but the physical casing prevents all of that from being viewed. I got to the value of 189° by trial and error. Mostly error! Using <code>:ih_fov=189:iv_fov=189</code> get this image which has less overlap:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/02/360-189.webp" alt="A flattened image which has less overlap at the edges." width="2560" height="1280" class="aligncenter size-full wp-image-67110">

<p>It isn't <em>perfect</em> - but it preserves most of the image coherence.</p>

<h2 id="cut-off-images"><a href="https://shkspr.mobi/blog/2026/04/reprojecting-dual-fisheye-videos-to-equirectangular-lg-360/#cut-off-images">Cut Off Images</a></h2>

<p>There's another thing worth noticing - the top, right, bottom, and left "corners" of the circle are cut off. If the image sensor captured everything, the resultant fisheye would look something like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/02/Repaged.webp" alt="Two circular images with gaps between them." width="2626" height="1313" class="aligncenter size-full wp-image-67111">

<p>I tried repaging the video to include the gaps, but it didn't make any noticeable difference.</p>

<h2 id="making-equirectangular-videos-work-with-vlc"><a href="https://shkspr.mobi/blog/2026/04/reprojecting-dual-fisheye-videos-to-equirectangular-lg-360/#making-equirectangular-videos-work-with-vlc">Making Equirectangular Videos Work With VLC</a></h2>

<p>Sadly, ffmpeg will not write the metadata necessary to let playback devices know the video is spherical. Instead, according to <a href="https://bino3d.org/metadata-for-stereo-3d-and-surround-video.html">Bino3D</a>, you have to use <code>exiftool</code> like so:</p>

<pre><code class="language-bash">exiftool \
        -XMP-GSpherical:Spherical="true" \
        -XMP-GSpherical:Stitched="true" \
        -XMP-GSpherical:ProjectionType="equirectangular" \
        video.mp4
</code></pre>

<h2 id="putting-it-all-together"><a href="https://shkspr.mobi/blog/2026/04/reprojecting-dual-fisheye-videos-to-equirectangular-lg-360/#putting-it-all-together">Putting It All Together</a></h2>

<p>The LG 360 records audio in 5.1 surround using AAC. That's already fairly well compressed, so there's no point squashing it down to Opus.</p>

<p>The default video codec is h264, but the picture is going to be reprojected, so quality is always going to take a bit of a hit. Pick whichever code you like to give the best balance of quality, file size, and encoding time.</p>

<p>Run:</p>

<pre><code class="language-bash">ffmpeg \
  -i original.mp4 \
  -vf "v360=input=dfisheye:output=equirect:ih_fov=189:iv_fov=189" \
  -c:v libx265 -preset fast -crf 28 -c:a copy \
  out.mp4; exiftool \
        -XMP-GSpherical:Spherical="true" \
        -XMP-GSpherical:Stitched="true" \
        -XMP-GSpherical:ProjectionType="equirectangular" \
        out.mp4
</code></pre>

<p>That will produce a reasonable equirectangular file suitable for viewing in VLC or in VR.</p>

<p>If this has been useful to you, please stick a comment in the box!</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=67087&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2026/04/reprojecting-dual-fisheye-videos-to-equirectangular-lg-360/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[RSS Club for WordPress]]></title>
		<link>https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/</link>
					<comments>https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Thu, 16 Apr 2026 11:34:10 +0000</pubDate>
				<category><![CDATA[[RSS Club]]]></category>
		<category><![CDATA[atom]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[rss]]></category>
		<category><![CDATA[RSS Club]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=70024</guid>

					<description><![CDATA[What if I told you there was a secret social network, hidden in plain sight? If you&#039;re reading this message, you&#039;re now a member of RSS Club!  RSS Club is a series of posts which are only visible to RSS / Atom subscribers. Like you 😃  If you want this for your own WordPress site, here&#039;s what you&#039;ll need:   A blog post which is only visible in RSS / Atom. Which has no HTML rendering on your site. A…]]></description>
										<content:encoded><![CDATA[<p>What if I told you there was a <em>secret</em> social network, hidden in plain sight? If you're reading this message, you're now a member of <a href="https://daverupert.com/rss-club/">RSS Club</a>!</p>

<p>RSS Club is a series of posts which are <em>only</em> visible to RSS / Atom subscribers. Like you 😃</p>

<p>If you want this for your own WordPress site, here's what you'll need:</p>

<ol>
<li>A blog post which is <em>only</em> visible in RSS / Atom.</li>
<li>Which has no HTML rendering on your site.</li>
<li>And cannot be found in your site's search.</li>
<li>Nor via search engines.</li>
<li>Also, doesn't appear on your mailing list.</li>
<li>Does not get shared or syndicated to the Fediverse.</li>
</ol>

<p>(This is a <em>bit</em> more strict than <a href="https://daverupert.com/2018/01/welcome-to-rss-club/">the original rules</a> which allow for web rendering and being found via a search engine.)</p>

<h2 id="start-with-a-category"><a href="https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/#start-with-a-category">Start With A Category</a></h2>

<p>The easiest way to do this in WordPress is via a category - <em>not</em> a tag.</p>

<p>After creating a category on your blog, click the edit link. You will see in the URl bar a <code>tag_id</code>.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/04/Category-ID.webp" alt="Screenshot of the WordPress website." width="1283" height="877" class="aligncenter size-full wp-image-70025">

<p>Whenever you want to make an RSS-exclusive post, you select the category before you publish.</p>

<h2 id="disable-display"><a href="https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/#disable-display">Disable Display</a></h2>

<p>This code stops any page in the RSS Club category from being displayed on the web.</p>

<pre><code class="language-php">function rss_club_post_blocker(): void {
    if (    is_singular( "post" )
        &amp;&amp;  has_category( "rss-club" )
        &amp;&amp; !current_user_can( "edit_posts" ) )
    {
        status_header( 403 );
        echo "You must be a member of RSS Club to view this content.";
        exit;
    }
}
add_action( "template_redirect", "rss_club_post_blocker" );
</code></pre>

<p>Editors can still see it, but everyone else gets a blocked message.</p>

<h2 id="remove-from-site-search-and-sitemap"><a href="https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/#remove-from-site-search-and-sitemap">Remove From Site Search and SiteMap</a></h2>

<p>Here's a snippet to stick in your <code>functions.php</code> - it removes the category from any queries unless it is for the admin pages or the RSS feeds.</p>

<pre><code class="language-php">//  Remove the RSS Club category from search results.
//  $query is passed by reference
function rss_club_search_filter( \WP_Query $query ): void {
    //  Ignore admin screens.
    if ( !is_admin() &amp;&amp; !is_feed() ) {
        //  Find the RSS-Club category ID.
        $category = get_category_by_slug( "rss-club" );

        //  Remove it from the search results.
        if ( $category ) {
            $query-&gt;set( "category__not_in", [$category-&gt;term_id] );
        }       
    }
}
add_action( "pre_get_posts", "rss_club_search_filter" );
</code></pre>

<p>This code also redacts that category from the build-in sitemap. Note - the <em>name</em> of the category still shows up in the XML, but it leads to a 404.</p>

<h2 id="exclude-from-email-and-social-media-rss-feeds"><a href="https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/#exclude-from-email-and-social-media-rss-feeds">Exclude From Email and Social Media RSS Feeds</a></h2>

<p>My mailing list and social media posts are fed from RSS. So how do remove an entire category from an RSS feed?</p>

<p>Simple! Append <code>?cat=-1234</code> to the end!</p>

<p>A negative category ID will remove the category from being displayed. So my email subscribers won't see the RSS only content. Of course, they get email-only exclusive posts, so don't feel too bad for them 😊</p>

<h2 id="fediverse-exclusion"><a href="https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/#fediverse-exclusion">Fediverse Exclusion</a></h2>

<p>The manual way is easiest. Assuming you have the <a href="https://github.com/Automattic/wordpress-activitypub/">ActivityPub plugin</a> and a the <a href="https://github.com/janboddez/share-on-mastodon/">Share On Mastodon plugin</a>, you can unselect the sharing options before publishing.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/04/No-Masto.webp" alt="Screenshot showing no sharing selected." width="600" class="aligncenter size-full wp-image-70028">

<p>If you think you might forget to toggle those boxen, there is <a href="https://github.com/janboddez/share-on-mastodon/issues/31">a filter for the share plugin</a>:</p>

<pre><code class="language-php">function rss_club_mastodon_filter( bool $is_enabled, int $post_id ): bool {
    global $exclude;
    if ( has_category( $exclude, $post_id ) ) {
        return false;
    }
    return $is_enabled;
}
add_filter( "share_on_mastodon_enabled", "rss_club_mastodon_filter", 10, 2 );
</code></pre>

<p>Similarly, there's a <a href="https://github.com/Automattic/wordpress-activitypub/blob/730d0ae51ce77be28439969dd9788c745a46681f/includes/functions-post.php#L77">filter for the ActivityPub plugin</a>:</p>

<pre><code class="language-php"><br>function rss_club_activitypub_filter( bool $disabled, \WP_Post $post ): bool 
{
    global $exclude;
    if ( has_category( $exclude, $post ) ) {
        return true;
    }

    return $disabled;
}
add_filter( "activitypub_is_post_disabled", "rss_club_activitypub_filter", 10, 2 );
</code></pre>

<h2 id="enjoy"><a href="https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/#enjoy">Enjoy!</a></h2>

<p>If you've set up your own RSS Club feed, <a href="https://edent.tel/">drop me a line</a> so I can subscribe 😊</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=70024&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2026/04/rss-club-for-wordpress/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Adding "Log In With Mastodon" to Auth0]]></title>
		<link>https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/</link>
					<comments>https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Mon, 02 Mar 2026 12:34:48 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[Auth0]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[mastodon]]></category>
		<category><![CDATA[MastodonAPI]]></category>
		<category><![CDATA[Social Media]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=67308</guid>

					<description><![CDATA[I use Auth0 to provide social logins for the OpenBenches website. I don&#039;t want to deal with creating user accounts, managing passwords, or anything like that, so Auth0 is perfect for my needs.  There are a wide range of social media logins provided by Auth0 - including the usual suspects like Facebook, Twitter, WordPress, Discord, etc. Sadly, there&#039;s no support for Mastodon.  All is not lost…]]></description>
										<content:encoded><![CDATA[<p>I use <a href="https://auth0.com/">Auth0</a> to provide social logins for the <a href="https://openbenches.org">OpenBenches</a> website. I don't want to deal with creating user accounts, managing passwords, or anything like that, so Auth0 is perfect for my needs.</p>

<p>There are a wide range of <a href="https://auth0.com/learn/social-login">social media logins provided by Auth0</a> - including the usual suspects like Facebook, Twitter, WordPress, Discord, etc. Sadly, there's <a href="https://community.auth0.com/t/custom-social-for-mastodon/103356">no support for Mastodon</a><sup id="fnref:blog"><a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#fn:blog" class="footnote-ref" title="Auth0 did blog about Mastodon a few years ago but never bothered implementing it!" role="doc-noteref">0</a></sup>.</p>

<p>All is not lost though. The Auth0 documentation says:</p>

<blockquote><p>However, you can use Auth0’s Connections API to add any OAuth2 Authorization Server as an identity provider.</p></blockquote>

<p>You can manually add a <em>single</em> Mastodon instance, but that doesn't work with the decentralised nature of the Fediverse. Instead, I've come up with a manual solution which works with <em>any</em> Mastodon server!</p>

<h2 id="background"><a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#background">Background</a></h2>

<p>Every Mastodon<sup id="fnref:masto"><a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#fn:masto" class="footnote-ref" title="I do mean Mastodon; not the wider Fediverse. This only works with sites which have implemented Mastodon's APIs." role="doc-noteref">1</a></sup> server is independent. I have an account on <code>mastodon.social</code> you have an account on <code>whatever.chaos</code>. They are separate servers, albeit running similar software. A generic authenticator needs to work with <em>all</em> these servers. There's no point only allowing log ins from a single server.</p>

<p>Fortuitously, Mastodon allows app developers to automatically create new apps. A few simple lines of code and you will have an API key suitable for <em>read-only</em> access to that server. You can <a href="https://shkspr.mobi/blog/2024/12/creating-a-generic-log-in-with-mastodon-service/">read how to instantly create Mastodon API keys</a> or you can <a href="https://github.com/openbenches/openbenches.org/blob/343e4c0169a2af8e567f9444c9cbf5d43d03011a/www/src/Controller/UserController.php#L26">steal my PHP code</a>.</p>

<h2 id="user-experience"><a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#user-experience">User Experience</a></h2>

<p>The user clicks the sign-in button on OpenBenches. They're taken to the Auth0 social login screen:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/01/Auth0-Mastodon.webp" alt="Login screen with several social login buttons." width="1677" height="1258" class="aligncenter size-full wp-image-67317">

<p>The user clicks on Mastodon. This is where Auth0's involvement ends!</p>

<p>The user is asked to provide the URl of their instance:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/01/Enter-server.webp" alt="Screenshot. The site asks for a Mastodon server URl." width="941" height="414" class="aligncenter size-full wp-image-67318">

<p>In the background, my server contacts the Mastodon instance and creates a read-only API key.</p>

<p>The user is asked to sign in to Mastodon.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/01/Masto-login.webp" alt="Screenshot of a login page." width="800" height="900" class="aligncenter size-full wp-image-67319">

<p>The user is asked to authorise read-only access.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/01/Authorisation.webp" alt="Screenshot. Page asks whether the user wants to authorise OpenBenches for read only access." width="720" height="656" class="aligncenter size-full wp-image-67320">

<p>The user is now signed in and OpenBenches can retrieve their name, avatar image, and other useful information. Hurrah!</p>

<h2 id="auth0"><a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#auth0">Auth0</a></h2>

<p>Once you have  <a href="https://shkspr.mobi/blog/2024/12/creating-a-generic-log-in-with-mastodon-service/">created a service to generate API keys</a>, it will need to run on a publicly accessible web server. For example <code>https://example.com/mastodon_login</code>.</p>

<p>Here's what you need to do within your Auth0 tennant:</p>

<ul>
<li>Authentication → Social → Create Connection</li>
<li>At the bottom, choose "Create Custom".</li>
<li>Choose "Authentication" only.</li>
<li>Give your connection a name. This will be visible to users.</li>
<li>"Authorization URL" and "Token URL" have the same value - the URl of your service.</li>
<li>"Client ID" is only visible to you.</li>
<li>"Client Secret" any random password; it won't be used for anything.</li>
<li>Leave everything else in the default state.</li>
</ul>

<p>It should look something like this:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2026/01/Example-Auth0.webp" alt="Screenshot of a form with all the settings filled in." width="891" height="1239" class="aligncenter size-full wp-image-67321">

<p>Click the "Create" button and you're (almost) done.</p>

<h2 id="auth0-icon"><a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#auth0-icon">Auth0 Icon</a></h2>

<p>You will need to <a href="https://shkspr.mobi/blog/2024/12/add-a-custom-icon-to-auth0s-custom-social-integrations/">add a custom icon to the social integration</a>. Annoyingly, there's no way to do it through the web interface, so follow that guide to use the command line.</p>

<h2 id="done"><a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#done">Done!</a></h2>

<p>I'll admit, this isn't the most straightforward thing to implement. Auth0 could make this easier - but it would still rely on users knowing the URl of their home instance.</p>

<p>That said, the Mastodon API is a delight to work with and the read-only permissions reduce risk for all parties.</p>

<div id="footnotes" role="doc-endnotes">
<hr aria-label="Footnotes">
<ol start="0">

<li id="fn:blog">
<p>Auth0 <a href="https://auth0.com/blog/mastdon-for-developers/">did blog about Mastodon a few years ago</a> but never bothered implementing it!&nbsp;<a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#fnref:blog" class="footnote-backref" role="doc-backlink">↩︎</a></p>
</li>

<li id="fn:masto">
<p>I <em>do</em> mean Mastodon; not the wider Fediverse. This only works with sites which have implemented Mastodon's APIs.&nbsp;<a href="https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/#fnref:masto" class="footnote-backref" role="doc-backlink">↩︎</a></p>
</li>

</ol>
</div>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=67308&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2026/03/adding-log-in-with-mastodon-to-auth0/feed/</wfw:commentRss>
			<slash:comments>4</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[A big list of things I disable in WordPress]]></title>
		<link>https://shkspr.mobi/blog/2025/11/a-big-list-of-things-i-disable-in-wordpress/</link>
					<comments>https://shkspr.mobi/blog/2025/11/a-big-list-of-things-i-disable-in-wordpress/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Sun, 30 Nov 2025 12:34:23 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[blog]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[php]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=63344</guid>

					<description><![CDATA[There are many things I like about the WordPress blogging software, and many things I find irritating. The most annoying aspect is that WordPress insists that its way is the best and there shall be no deviance. That means a lot of forced cruft being injected into my site. Headers that bloat my page size, Gutenberg stuff I&#039;ve no use for, and ridiculous editorial decisions.  To double-down on the…]]></description>
										<content:encoded><![CDATA[<p>There are many things I like about the WordPress blogging software, and many things I find irritating. The most annoying aspect is that WordPress insists that its way is the best and there shall be no deviance. That means a <em>lot</em> of forced cruft being injected into my site. Headers that bloat my page size, Gutenberg stuff I've no use for, and <a href="https://developer.wordpress.org/reference/functions/capital_p_dangit/">ridiculous editorial decisions</a>.</p>

<p>To double-down on the annoyance, there's no simple way to turn them off. In part, that is due to the "<a href="https://wordpress.org/about/philosophy/">WordPress Philosophy</a>":</p>

<blockquote><p><strong>Decisions, not options</strong></p>

<p>[…] Every time you give a user an option, you are asking them to make a decision. When a user doesn’t care or understand the option this ultimately leads to frustration.</p></blockquote>

<p>I broadly agree with that. Having hundreds of options is a burden for users and a nightmare for maintainers. Do please read this <a href="https://tommcfarlin.com/wordpress-philosophy-decisions-not-options/">excellent discussion from Tom McFarlin for a more detailed analysis</a>.</p>

<p>But I <em>want</em> to turn things off. Luckily, there is a way. If you're a developer, you can remove a fair number of these "enforced" decisions. Add the following to your theme's <code>functions.php</code> file and watch the mandatory WordPress bloat whither away.  I've commented each removal and, where possible, given a source for more information.  Feel free to leave a comment suggesting how this script can be improved and simplified.</p>

<pre><code class="language-php">//  Remove mandatory classic theme.
function disable_classic_theme_styles() {
    wp_deregister_style( "classic-theme-styles" );
    wp_dequeue_style(    "classic-theme-styles" );
}
add_action( "wp_enqueue_scripts", "disable_classic_theme_styles" );

//  Remove WP Emoji.
//  http://www.denisbouquet.com/remove-wordpress-emoji-code/
remove_action( "wp_head",             "print_emoji_detection_script", 7 );
remove_action( "wp_print_styles",     "print_emoji_styles"              );
remove_action( "admin_print_scripts", "print_emoji_detection_script"    );
remove_action( "admin_print_styles",  "print_emoji_styles"              );
//  https://wordpress.org/support/topic/remove-the-new-dns-prefetch-code/
add_filter( "emoji_svg_url", "__return_false" );

//  Stop emoji replacement with images in RSS / Atom Feeds
//  https://danq.me/2023/09/04/wordpress-stop-emoji-images/
remove_filter( "the_content_feed", "wp_staticize_emoji" );
remove_filter( "comment_text_rss", "wp_staticize_emoji" );

//  Remove automatic formatting.
//  https://css-tricks.com/snippets/wordpress/disable-automatic-formatting/
remove_filter( "the_content",  "wptexturize" );
remove_filter( "the_excerpt",  "wptexturize" );
remove_filter( "comment_text", "wptexturize" );
remove_filter( "the_title",    "wptexturize" );

//  More formatting crap.
add_action("init", function() {
    remove_filter( "the_content", "convert_smilies", 20 );
    foreach ( array( "the_content", "the_title", "wp_title", "document_title" ) as $filter ) {
        remove_filter( $filter, "capital_P_dangit", 11 );
    }
    remove_filter( "comment_text", "capital_P_dangit", 31 );    //  No idea why this is separate
    remove_filter( "the_content",  "do_blocks", 9 );
}, 11);

//  Remove Gutenberg Styles.
//  https://wordpress.org/support/topic/how-to-disable-inline-styling-style-idglobal-styles-inline-css/
remove_action( "wp_enqueue_scripts", "wp_enqueue_global_styles" );

//  Remove Gutenberg editing widgets.
//  From https://wordpress.org/plugins/classic-widgets/
//  Disables the block editor from managing widgets in the Gutenberg plugin.
add_filter( "gutenberg_use_widgets_block_editor", "__return_false" );
//  Disables the block editor from managing widgets.
add_filter( "use_widgets_block_editor", "__return_false" );

//  Remove Gutenberg Block Library CSS from loading on the frontend.
//  https://smartwp.com/remove-gutenberg-css/
function remove_wp_block_library_css() {
    wp_dequeue_style( "wp-block-library"       );
    wp_dequeue_style( "wp-block-library-theme" );
    wp_dequeue_style( "wp-components"          );
}
add_action( "wp_enqueue_scripts", "remove_wp_block_library_css", 100 );

//  Remove hovercards on comment links in admin area.
//  https://wordpress.org/support/topic/how-to-disable-mshots-service/#post-12946617
add_filter( "akismet_enable_mshots", "__return_false" );

//  Remove Unused Plugin code.
function remove_plugin_css_js() {
    wp_dequeue_style( "image-sizes" );
}
add_action( "wp_enqueue_scripts", "remove_plugin_css_js", 100 );

//  Remove WordPress forced image size
//  https://core.trac.wordpress.org/ticket/62413#comment:40
add_filter( "wp_img_tag_add_auto_sizes", "__return_false" );

//  Remove &lt;img&gt; enhancements
//  https://developer.wordpress.org/reference/functions/wp_filter_content_tags/
remove_filter( "the_content",  "wp_filter_content_tags", 12 );

//  Stop rewriting http:// URls for the main domain.
//  https://developer.wordpress.org/reference/hooks/wp_should_replace_insecure_home_url/
remove_filter( "the_content", "wp_replace_insecure_home_url", 10 );

//  Remove the attachment stuff
//  https://developer.wordpress.org/news/2024/01/building-dynamic-block-based-attachment-templates-in-themes/
remove_filter( "the_content", "prepend_attachment" );

//  Remove the block filter
remove_filter( "the_content", "apply_block_hooks_to_content_from_post_object", 8 );

//  Remove browser check from Admin dashboard.
//  https://core.trac.wordpress.org/attachment/ticket/27626/disable-wp-check-browser-version.0.2.php
if ( !empty( $_SERVER["HTTP_USER_AGENT"] ) ) {
    add_filter( "pre_site_transient_browser_" . md5( $_SERVER["HTTP_USER_AGENT"] ), "__return_null" );
}

//  Remove shortlink.
//  https://stackoverflow.com/questions/42444063/disable-wordpress-short-links
remove_action( "wp_head", "wp_shortlink_wp_head" );

//  Remove RSD.
//  https://wpengineer.com/1438/wordpress-header/
remove_action( "wp_head", "rsd_link" );

//  Remove extra feed links.
//  https://developer.wordpress.org/reference/functions/feed_links/
add_filter( "feed_links_show_comments_feed", "__return_false" );
add_filter( "feed_links_show_posts_feed",    "__return_false" );

//  Remove api.w.org link.
//  https://wordpress.stackexchange.com/questions/211467/remove-json-api-links-in-header-html
remove_action( "wp_head", "rest_output_link_wp_head" );
//  https://wordpress.stackexchange.com/questions/211817/how-to-remove-rest-api-link-in-http-headers
//  https://developer.wordpress.org/reference/functions/rest_output_link_header/
remove_action( "template_redirect", "rest_output_link_header", 11, 0 );
</code></pre>

<p>You can find the latest version of <a href="https://gitlab.com/edent/blog-theme/-/blob/master/includes/remove.php">my debloat script</a> in my theme's repo.</p>

<p>If there are other things you find helpful to remove, or a better way to organise this file, please drop a comment in the box.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=63344&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/11/a-big-list-of-things-i-disable-in-wordpress/feed/</wfw:commentRss>
			<slash:comments>14</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[A Self-Hosted Favicon Proxy written in PHP]]></title>
		<link>https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/</link>
					<comments>https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Tue, 28 Oct 2025 12:34:54 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[favicon]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[HTML]]></category>
		<category><![CDATA[php]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=63434</guid>

					<description><![CDATA[In theory, you should be able to get the base favicon of any domain by calling /favicon.ico - but the reality is somewhat more complex than that. Plenty of sites use a wide variety of semi-standardised images which are usually only discoverable from the site&#039;s HTML.  There are several services which allow you to get favicons based on a domain. But they all have their problems.   Google   Exposes…]]></description>
										<content:encoded><![CDATA[<p>In theory, you should be able to get the base favicon of any domain by calling <code>/favicon.ico</code> - but the reality is somewhat more complex than that. Plenty of sites use a wide variety of semi-standardised images which are usually only discoverable from the site's HTML.</p>

<p>There are several services which allow you to get favicons based on a domain. But they all have their problems.</p>

<ul>
<li><a href="https://www.google.com/s2/favicons?domain=shkspr.mobi&amp;sz=256">Google</a>

<ul>
<li>Exposes your user's to Google's tracking.</li>
<li>Relies on redirects.</li>
</ul></li>
<li><a href="https://icons.duckduckgo.com/ip9/shkspr.mobi.ico">DuckDuckGo</a>

<ul>
<li>Not officially supported by DDG.</li>
</ul></li>
<li><a href="https://favicon.is/shkspr.mobi">Favicon.is</a>

<ul>
<li>No privacy policy whatsoever.</li>
</ul></li>
<li><a href="https://icon.horse/">Icons.horse</a>

<ul>
<li>Paid service.</li>
<li>Only small size icons.</li>
</ul></li>
<li><a href="https://favicone.com/shkspr.mobi">Favicone</a>

<ul>
<li>No privacy policy.</li>
<li>Only small size icons.</li>
</ul></li>
</ul>

<p>I want to show favicons next to specific links, but I don't want to expose my visitors to unnecessary tracking. How can I proxy these images so they are stored and served locally?</p>

<p>There are a few existing services. Some use <a href="https://github.com/seadfeng/favicons-proxy">Cloudflare workers</a> or other <a href="https://github.com/shaklain125/gicon">cloud services</a>, there are some local-first ones which are <a href="https://github.com/toolness/favicon-proxy">unmaintained</a>.  But nothing modern, self-hosted, and as easy to deploy as uploading a single PHP file.</p>

<p>So here's my attempt to make something which will preserve user privacy, be reasonably fast, and have moderately up-to-date icons, while remaining fast and efficient.</p>

<p></p><nav role="doc-toc"><menu><li><h2 id="table-of-contents"><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#table-of-contents">Table of Contents</a></h2><menu><li><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#getting-the-domain">Getting the domain</a></li><li><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#getting-the-image">Getting the image</a></li><li><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#getting-the-structure-right">Getting the structure right</a></li><li><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#preventing-abuse">Preventing abuse</a></li><li><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#putting-it-all-together">Putting it all together</a></li></menu></li></menu></nav><p></p>

<h2 id="getting-the-domain"><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#getting-the-domain">Getting the domain</a></h2>

<p>Assuming the request comes in to <code>https://proxy.example.com/?domain=bbc.co.uk</code></p>

<p>PHP has a <a href="https://www.php.net/manual/en/filter.constants.php#constant.filter-validate-domain">handy <code>FILTER_VALIDATE_DOMAIN</code> filter</a> which will determine if the string is a domain.</p>

<pre><code class="language-php">filter_var( $domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME );
</code></pre>

<h3 id="dealing-with-idns"><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#dealing-with-idns">Dealing with IDNs</a></h3>

<p>Some domains contain non-ASCII characters - for example <a href="https://莎士比亚.org/">https://莎士比亚.org/</a> - not all favicon services support International Domain Names.</p>

<p>Using <a href="https://www.php.net/manual/en/function.idn-to-ascii.php">the <code>idn_to_ascii()</code> function</a>, it is possible to get the Punycode domain.</p>

<pre><code class="language-php">$domain = idn_to_ascii("莎士比亚.org");
</code></pre>

<h2 id="getting-the-image"><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#getting-the-image">Getting the image</a></h2>

<ol>
<li>Check if the icon has previously been downloaded.</li>
<li>Rotate randomly between a few different Favicon services.</li>
<li>Download the icon.</li>
<li>Save it somewhere.</li>
</ol>

<h2 id="getting-the-structure-right"><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#getting-the-structure-right">Getting the structure right</a></h2>

<p>I know from my work on OpenBenches that storing tens of thousands of files in a single directory can be problematic. So I'll store the retrieved favicon in: <code>/tld/domain/subdomain/</code></p>

<p>That will make it quick to see if an icon exists. I'll save the file with a filename based on the current timestamp. That will allow me to check if an icon is out of date, and will prevent people downloading the icons directly from me.</p>

<h2 id="preventing-abuse"><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#preventing-abuse">Preventing abuse</a></h2>

<p>I don't want anyone but visitors to my site to be able to use this service. So I'll add a (weak) check to see if the request came from my domain.</p>

<pre><code class="language-php">$referer = parse_url( $_SERVER["HTTP_REFERER"], PHP_URL_HOST );
if ( $referer == "shkspr.mobi") {
   …
}
</code></pre>

<p>Some browsers may not send referers for privacy reasons. So they won't see the favicons. But they probably wouldn't have seen the images loaded from a 3<sup>rd</sup> party service. So I'll serve a default image.</p>

<h2 id="putting-it-all-together"><a href="https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/#putting-it-all-together">Putting it all together</a></h2>

<p>You can grab the code from <a href="https://git.edent.tel/edent/Favicon-Proxy-PHP">my personal git service</a>.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=63434&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/10/a-self-hosted-favicon-proxy-written-in-php/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Quick Swap Data SIM Shortcut on Android]]></title>
		<link>https://shkspr.mobi/blog/2025/07/quick-swap-data-sim-shortcut-on-android/</link>
					<comments>https://shkspr.mobi/blog/2025/07/quick-swap-data-sim-shortcut-on-android/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 16 Jul 2025 11:34:16 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[android]]></category>
		<category><![CDATA[HowTo]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=61942</guid>

					<description><![CDATA[I have a dual SIM Android phone. When I call or text, I get a choice of which SIM to use. But there&#039;s no quick way to swap which SIM is used for data.  There used to be a built-in settings tile on stock Android, and some manufacturers still have it, but Google&#039;s Pixels don&#039;t.  So here&#039;s how to make a (fairly) quick shortcut to swap between data SIMs.  First, get the brilliant open source Activity …]]></description>
										<content:encoded><![CDATA[<p>I have a dual SIM Android phone. When I call or text, I get a choice of which SIM to use. But there's no quick way to swap which SIM is used for data.</p>

<p>There used to be a built-in settings tile on stock Android, and some manufacturers still have it, but Google's Pixels don't.</p>

<p>So here's how to make a (fairly) quick shortcut to swap between data SIMs.</p>

<p>First, get the brilliant <a href="https://github.com/sdex/ActivityManager">open source Activity Manager app</a>. It exposes all of the internal activities available in your apps. This lets you create a deep-link shortcut into a specific part of an app.</p>

<p>In Activity Manager, find the Settings app and tap on it.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/07/ActivityManager.webp" alt="List of apps." width="1008" height="757" class="aligncenter size-full wp-image-61955">

<p>Search or scroll down to "MobileNetworkListActivity".</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/07/Settings.webp" alt="List of settings activities." width="1008" height="756" class="aligncenter size-full wp-image-61954">

<p>Tap the vertical ellipse and then "Create Shortcut".</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/07/Shortcut.webp" alt="Popup showing a shortcut option." width="1008" height="755" class="aligncenter size-full wp-image-61953">

<p>That will place an icon on your home screen. Tapping the icon will take you directly to your SIM settings. At the bottom is the option to choose your data SIM. Tap it to change your data SIM.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/07/Switcher.webp" alt="Switcher allowing you to choose the data SIM." width="1008" height="1172" class="aligncenter size-full wp-image-61952">

<p>It isn't quite a one-tap solution, but the shortcut is a lot easier than remembering exactly which sub-menu you need to find.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=61942&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/07/quick-swap-data-sim-shortcut-on-android/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Convert Shotwell Photo Metadata to Digikam Metadata]]></title>
		<link>https://shkspr.mobi/blog/2025/06/convert-shotwell-photo-metadata-to-digikam-metadata/</link>
					<comments>https://shkspr.mobi/blog/2025/06/convert-shotwell-photo-metadata-to-digikam-metadata/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Fri, 20 Jun 2025 11:34:37 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[cli]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[linux]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=61338</guid>

					<description><![CDATA[Mostly notes to myself.  Shotwell stores most of its information in a database. Which I lost. Because I&#039;m an idiot.  But a bunch of metadata is also stored in the image&#039;s EXIF metadata!  Most importantly is the &#34;Original File Name&#34; which should become the &#34;Description&#34; in DigiKam. Unfortunately, there&#039;s no way to copy those values automatically on import.  So here&#039;s a one-liner which will read…]]></description>
										<content:encoded><![CDATA[<p>Mostly notes to myself.</p>

<p>Shotwell stores most of its information in a database. Which I lost. Because I'm an idiot.</p>

<p>But a bunch of metadata is <em>also</em> stored in the image's EXIF metadata!</p>

<p>Most importantly is the "Original File Name" which should become the "Description" in DigiKam. Unfortunately, there's no way to copy those values automatically on import.</p>

<p>So here's a one-liner which will read the "Original File Name" and store it in the "Title" EXIF - ready for DigiKam to parse!</p>

<pre><code class="language-bash">exiftool "-XMP-dc:Title&lt;XMP-getty:OriginalFileName" whatever.jpg
</code></pre>

<p>If you want to make sure any existing Title isn't overwritten, use:</p>

<pre><code class="language-bash">exiftool "-XMP-dc:Title&lt;${XMP-getty:OriginalFileName}" -if "not defined $XMP-dc:Title" whatever.jpg
</code></pre>

<p>Finally, to do it recursively, across all files:</p>

<pre><code class="language-bash">exiftool -r "-XMP-dc:Title&lt;${XMP-getty:OriginalFileName}" -if "not defined $XMP-dc:Title" /path/to/images
</code></pre>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=61338&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/06/convert-shotwell-photo-metadata-to-digikam-metadata/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[You don't need an API key to archive Twitter Data]]></title>
		<link>https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/</link>
					<comments>https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Mon, 14 Apr 2025 11:34:07 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[api]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[twitter]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=59462</guid>

					<description><![CDATA[Apparently there&#039;s no need for IP laws any more, so here&#039;s a way to archive high-fidelity Twitter data without signing up for an expensive API key.  This is perfect for academics wishing to preserve Tweets, journalists wanting to download evidence, or simply embedding content without leaking user data back to Twitter.  Table of Contentstl;drBackgroundEmbed CodeAPI CallOptionsOutputTweet With…]]></description>
										<content:encoded><![CDATA[<p>Apparently <a href="https://bsky.app/profile/ednewtonrex.bsky.social/post/3lmmv4x7gps2a">there's no need for IP laws any more</a>, so here's a way to archive high-fidelity Twitter data without signing up for an expensive API key.</p>

<p>This is perfect for academics wishing to preserve Tweets, journalists wanting to download evidence, or simply embedding content without leaking user data back to Twitter.</p>

<p></p><nav role="doc-toc"><menu><li><h2 id="table-of-contents"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#table-of-contents">Table of Contents</a></h2><menu><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#tldr">tl;dr</a></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#background">Background</a><menu><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#embed-code">Embed Code</a></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#api-call">API Call</a><menu><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#options">Options</a></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#output">Output</a><menu><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#tweet-with-image">Tweet With Image</a></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#replies">Replies</a></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#quote-tweets">Quote Tweets</a></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#downloading-media">Downloading Media</a></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#other-examples">Other Examples</a></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#limitations">Limitations</a></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#python-code">Python Code</a></li><li><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#have-fun">Have Fun</a></li></menu></li></menu></nav><p></p>

<h2 id="tldr"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#tldr">tl;dr</a></h2>

<p>You can get the full JSON code of any Tweet by using this API:</p>

<p><code>https://cdn.syndication.twimg.com/tweet-result?id=123456789&amp;token=01010101010</code></p>

<p>Add any valid Twitter <code>id</code>, and choose a random number for your <code>token</code>. Done.</p>

<h2 id="background"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#background">Background</a></h2>

<p>Twitter has an "embed" functionality. Websites can import a full copy of a Tweet, including its media and metadata. <a href="https://create.twitter.com/en/products/embedded-tweets">Twitter's documentation is a little lacklustre</a> but here's a brief explanation of how it works.</p>

<h3 id="embed-code"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#embed-code">Embed Code</a></h3>

<p>Using HTML like this:</p>

<pre><code class="language-html">&lt;iframe
   src="https://platform.twitter.com/embed/Tweet.html?id=719484841172054016"
   width=512
   height=768&gt;&lt;/iframe&gt;
</code></pre>

<p>Produces an embeddable which looks like this:</p>

<iframe src="https://platform.twitter.com/embed/Tweet.html?id=719484841172054016" width="512" height="768"></iframe>

<h2 id="api-call"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#api-call">API Call</a></h2>

<p>With a bit of sniffing of the traffic, it's possible to see that the iframe eventually calls a URl like this:</p>

<p><a style="font-family:monospace;" href="https://cdn.syndication.twimg.com/tweet-result?id=719484841172054016&amp;token=123">https://cdn.syndication.twimg.com/tweet-result?id=719484841172054016&amp;token=123</a></p>

<p>Visit that and you'll see the JSON code of a Tweet.</p>

<h3 id="options"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#options">Options</a></h3>

<ul>
<li><code>id=</code> this is the numeric ID of the Tweet.</li>
<li><code>token=</code> this is the API token. It can be set to a random number. It isn't checked.</li>
<li>There's an optional <code>lang=</code> which takes <a href="https://en.wikipedia.org/wiki/IETF_language_tag">BCP47 language codes</a>. For example <code>lang=en</code> or <code>lang=zh</code>. However, they don't seem to make any difference to the output.</li>
</ul>

<h2 id="output"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#output">Output</a></h2>

<p>Here's the JSON of the above Tweet. As you can see, it includes metadata on the number of replies, favourites, and retweets. There are entities, fully expanded links, and media in a variety of formats. There's also information on whether the post has been edited, if the user is stupid enough to pay for a blue-tick, and the language of the message.</p>

<h3 id="tweet-with-image"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#tweet-with-image">Tweet With Image</a></h3>

<pre><code class="language-json">{
    "__typename": "Tweet",
    "lang": "en",
    "favorite_count": 4,
    "possibly_sensitive": false,
    "created_at": "2016-04-11T11:18:48.000Z",
    "display_text_range": [
        0,
        120
    ],
    "entities": {
        "hashtags": [],
        "urls": [],
        "user_mentions": [
            {
                "id_str": "23937508",
                "indices": [
                    20,
                    30
                ],
                "name": "BBC Radio 4",
                "screen_name": "BBCRadio4"
            }
        ],
        "symbols": [],
        "media": [
            {
                "display_url": "pic.x.com/6F3ZSiWuIn",
                "expanded_url": "https://x.com/edent/status/719484841172054016/photo/1",
                "indices": [
                    97,
                    120
                ],
                "url": "https://t.co/6F3ZSiWuIn"
            }
        ]
    },
    "id_str": "719484841172054016",
    "text": "Warning! I'll be on @BBCRadio4's You And Yours shortly.\nPlease tune your wirelesses accordingly. https://t.co/6F3ZSiWuIn",
    "user": {
        "id_str": "14054507",
        "name": "Terence Eden is on Mastodon",
        "profile_image_url_https": "https://pbs.twimg.com/profile_images/1623225628530016260/SW0HsKjP_normal.jpg",
        "screen_name": "edent",
        "verified": false,
        "is_blue_verified": false,
        "profile_image_shape": "Circle"
    },
    "edit_control": {
        "edit_tweet_ids": [
            "719484841172054016"
        ],
        "editable_until_msecs": "1460375328174",
        "is_edit_eligible": true,
        "edits_remaining": "5"
    },
    "mediaDetails": [
        {
            "display_url": "pic.x.com/6F3ZSiWuIn",
            "expanded_url": "https://x.com/edent/status/719484841172054016/photo/1",
            "ext_media_availability": {
                "status": "Available"
            },
            "indices": [
                97,
                120
            ],
            "media_url_https": "https://pbs.twimg.com/media/CfwfpnJWwAEXwe3.jpg",
            "original_info": {
                "height": 1280,
                "width": 960,
                "focus_rects": []
            },
            "sizes": {
                "large": {
                    "h": 1280,
                    "resize": "fit",
                    "w": 960
                },
                "medium": {
                    "h": 1200,
                    "resize": "fit",
                    "w": 900
                },
                "small": {
                    "h": 680,
                    "resize": "fit",
                    "w": 510
                },
                "thumb": {
                    "h": 150,
                    "resize": "crop",
                    "w": 150
                }
            },
            "type": "photo",
            "url": "https://t.co/6F3ZSiWuIn"
        }
    ],
    "photos": [
        {
            "backgroundColor": {
                "red": 204,
                "green": 214,
                "blue": 221
            },
            "cropCandidates": [],
            "expandedUrl": "https://x.com/edent/status/719484841172054016/photo/1",
            "url": "https://pbs.twimg.com/media/CfwfpnJWwAEXwe3.jpg",
            "width": 960,
            "height": 1280
        }
    ],
    "conversation_count": 1,
    "news_action_type": "conversation",
    "isEdited": false,
    "isStaleEdit": false
}
</code></pre>

<h3 id="replies"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#replies">Replies</a></h3>

<p>Here's a more complicated example. This Tweet is in reply to another Tweet - so both messages are included:</p>

<pre><code class="language-json">{
    "__typename": "Tweet",
    "in_reply_to_screen_name": "edent",
    "in_reply_to_status_id_str": "1095653997644574720",
    "in_reply_to_user_id_str": "14054507",
    "lang": "en",
    "favorite_count": 0,
    "created_at": "2019-02-13T12:22:59.000Z",
    "display_text_range": [
        7,
        252
    ],
    "entities": {
        "hashtags": [],
        "urls": [],
        "user_mentions": [
            {
                "id_str": "14054507",
                "indices": [
                    0,
                    6
                ],
                "name": "Terence Eden is on Mastodon",
                "screen_name": "edent"
            }
        ],
        "symbols": []
    },
    "id_str": "1095659600420966400",
    "text": "@edent I can definitely see how this would get in the way of making your day a productive one. Do you find this happens often? If it does, I'd be happy to chat to you about a reliable alternative with us during your lunch break! ☕ PM me for a chat! ^JH",
    "user": {
        "id_str": "20139563",
        "name": "Sky",
        "profile_image_url_https": "https://pbs.twimg.com/profile_images/1674689671006240769/OpfisqRG_normal.jpg",
        "screen_name": "SkyUK",
        "verified": false,
        "verified_type": "Business",
        "is_blue_verified": false,
        "profile_image_shape": "Square"
    },
    "edit_control": {
        "edit_tweet_ids": [
            "1095659600420966400"
        ],
        "editable_until_msecs": "1550062379768",
        "is_edit_eligible": true,
        "edits_remaining": "5"
    },
    "conversation_count": 2,
    "news_action_type": "conversation",
    "parent": {
        "lang": "en",
        "reply_count": 2,
        "retweet_count": 1,
        "favorite_count": 1,
        "possibly_sensitive": false,
        "created_at": "2019-02-13T12:00:43.000Z",
        "display_text_range": [
            0,
            112
        ],
        "entities": {
            "hashtags": [],
            "urls": [],
            "user_mentions": [
                {
                    "id_str": "17872077",
                    "indices": [
                        33,
                        45
                    ],
                    "name": "Virgin Media ❤️",
                    "screen_name": "virginmedia"
                }
            ],
            "symbols": [],
            "media": [
                {
                    "display_url": "pic.x.com/mje6nh38CZ",
                    "expanded_url": "https://x.com/edent/status/1095653997644574720/photo/1",
                    "indices": [
                        113,
                        136
                    ],
                    "url": "https://t.co/mje6nh38CZ"
                }
            ]
        },
        "id_str": "1095653997644574720",
        "text": "Working from home is tricky when @virginmedia goes down so hard even its status page falls over.\nTime for lunch. https://t.co/mje6nh38CZ",
        "user": {
            "id_str": "14054507",
            "name": "Terence Eden is on Mastodon",
            "profile_image_url_https": "https://pbs.twimg.com/profile_images/1623225628530016260/SW0HsKjP_normal.jpg",
            "screen_name": "edent",
            "verified": false,
            "is_blue_verified": false,
            "profile_image_shape": "Circle"
        },
        "edit_control": {
            "edit_tweet_ids": [
                "1095653997644574720"
            ],
            "editable_until_msecs": "1550061043962",
            "is_edit_eligible": true,
            "edits_remaining": "5"
        },
        "mediaDetails": [
            {
                "display_url": "pic.x.com/mje6nh38CZ",
                "expanded_url": "https://x.com/edent/status/1095653997644574720/photo/1",
                "ext_alt_text": "Oops! something's broken! ",
                "ext_media_availability": {
                    "status": "Available"
                },
                "indices": [
                    113,
                    136
                ],
                "media_url_https": "https://pbs.twimg.com/media/DzSLf6sWsAAGWWH.jpg",
                "original_info": {
                    "height": 797,
                    "width": 1080,
                    "focus_rects": [
                        {
                            "x": 0,
                            "y": 192,
                            "w": 1080,
                            "h": 605
                        },
                        {
                            "x": 142,
                            "y": 0,
                            "w": 797,
                            "h": 797
                        },
                        {
                            "x": 191,
                            "y": 0,
                            "w": 699,
                            "h": 797
                        },
                        {
                            "x": 341,
                            "y": 0,
                            "w": 399,
                            "h": 797
                        },
                        {
                            "x": 0,
                            "y": 0,
                            "w": 1080,
                            "h": 797
                        }
                    ]
                },
                "sizes": {
                    "large": {
                        "h": 797,
                        "resize": "fit",
                        "w": 1080
                    },
                    "medium": {
                        "h": 797,
                        "resize": "fit",
                        "w": 1080
                    },
                    "small": {
                        "h": 502,
                        "resize": "fit",
                        "w": 680
                    },
                    "thumb": {
                        "h": 150,
                        "resize": "crop",
                        "w": 150
                    }
                },
                "type": "photo",
                "url": "https://t.co/mje6nh38CZ"
            }
        ],
        "photos": [
            {
                "accessibilityLabel": "Oops! something's broken! ",
                "backgroundColor": {
                    "red": 204,
                    "green": 214,
                    "blue": 221
                },
                "cropCandidates": [
                    {
                        "x": 0,
                        "y": 192,
                        "w": 1080,
                        "h": 605
                    },
                    {
                        "x": 142,
                        "y": 0,
                        "w": 797,
                        "h": 797
                    },
                    {
                        "x": 191,
                        "y": 0,
                        "w": 699,
                        "h": 797
                    },
                    {
                        "x": 341,
                        "y": 0,
                        "w": 399,
                        "h": 797
                    },
                    {
                        "x": 0,
                        "y": 0,
                        "w": 1080,
                        "h": 797
                    }
                ],
                "expandedUrl": "https://x.com/edent/status/1095653997644574720/photo/1",
                "url": "https://pbs.twimg.com/media/DzSLf6sWsAAGWWH.jpg",
                "width": 1080,
                "height": 797
            }
        ],
        "isEdited": false,
        "isStaleEdit": false
    },
    "isEdited": false,
    "isStaleEdit": false
}
</code></pre>

<h3 id="quote-tweets"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#quote-tweets">Quote Tweets</a></h3>

<p>Here's an example where I have quoted a Tweet:</p>

<pre><code class="language-json">{
    "__typename": "Tweet",
    "lang": "en",
    "favorite_count": 9,
    "possibly_sensitive": false,
    "created_at": "2022-08-19T13:36:44.000Z",
    "display_text_range": [
        0,
        182
    ],
    "entities": {
        "hashtags": [],
        "urls": [
            {
                "display_url": "gu.com",
                "expanded_url": "http://gu.com",
                "indices": [
                    17,
                    40
                ],
                "url": "https://t.co/Skj7FB7Tyt"
            }
        ],
        "user_mentions": [],
        "symbols": []
    },
    "id_str": "1560621791470448642",
    "text": "Whoever buys the https://t.co/Skj7FB7Tyt domain will effectively get to rewrite history.\nThey can redirect links like these - and change the nature of the content being commented on.",
    "user": {
        "id_str": "14054507",
        "name": "Terence Eden is on Mastodon",
        "profile_image_url_https": "https://pbs.twimg.com/profile_images/1623225628530016260/SW0HsKjP_normal.jpg",
        "screen_name": "edent",
        "verified": false,
        "is_blue_verified": false,
        "profile_image_shape": "Circle"
    },
    "edit_control": {
        "edit_tweet_ids": [
            "1560621791470448642"
        ],
        "editable_until_msecs": "1660918004000",
        "is_edit_eligible": true,
        "edits_remaining": "5"
    },
    "conversation_count": 4,
    "news_action_type": "conversation",
    "quoted_tweet": {
        "lang": "en",
        "reply_count": 131,
        "retweet_count": 1337,
        "favorite_count": 2789,
        "possibly_sensitive": false,
        "created_at": "2018-11-27T15:56:19.000Z",
        "display_text_range": [
            0,
            279
        ],
        "entities": {
            "hashtags": [],
            "urls": [
                {
                    "display_url": "gu.com/p/axa7k/stw",
                    "expanded_url": "https://gu.com/p/axa7k/stw",
                    "indices": [
                        256,
                        279
                    ],
                    "url": "https://t.co/UulPL1CtcK"
                }
            ],
            "user_mentions": [],
            "symbols": []
        },
        "id_str": "1067447032363794432",
        "text": "The Steele Dossier asserted Russian hacking of the DNC was \"conducted with the full knowledge &amp;amp; support of Trump &amp;amp; senior members of his campaign.” Trump's war against the FBI &amp;amp; efforts to obstruct make sense if he thought they could prove it. https://t.co/UulPL1CtcK",
        "user": {
            "id_str": "548384458",
            "name": "Joyce Alene",
            "profile_image_url_https": "https://pbs.twimg.com/profile_images/952257848301498371/5s24RH-g_normal.jpg",
            "screen_name": "JoyceWhiteVance",
            "verified": false,
            "is_blue_verified": true,
            "profile_image_shape": "Circle"
        },
        "edit_control": {
            "edit_tweet_ids": [
                "1067447032363794432"
            ],
            "editable_until_msecs": "1543335979379",
            "is_edit_eligible": true,
            "edits_remaining": "5"
        },
        "isEdited": false,
        "isStaleEdit": false
    },
    "isEdited": false,
    "isStaleEdit": false
}
</code></pre>

<h3 id="downloading-media"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#downloading-media">Downloading Media</a></h3>

<p>Videos are also available to download, with no restrictions, in a variety of resolutions:</p>

<pre><code class="language-json">   "mediaDetails": [
        {
            "type": "video",
            "url": "https://t.co/Qw1IFom7Fh",
            "video_info": {
                "aspect_ratio": [
                    3,
                    4
                ],
                "duration_millis": 13578,
                "variants": [
                    {
                        "content_type": "application/x-mpegURL",
                        "url": "https://video.twimg.com/ext_tw_video/1432767873504718850/pu/pl/DiIKFNNZLWbLmECm.m3u8?tag=12"
                    },
                    {
                        "bitrate": 632000,
                        "content_type": "video/mp4",
                        "url": "https://video.twimg.com/ext_tw_video/1432767873504718850/pu/vid/320x426/oq2p-t0RJEEKuDD6.mp4?tag=12"
                    },
                    {
                        "bitrate": 950000,
                        "content_type": "video/mp4",
                        "url": "https://video.twimg.com/ext_tw_video/1432767873504718850/pu/vid/480x640/3X8ZsBmXmmaaakmM.mp4?tag=12"
                    },
                    {
                        "bitrate": 2176000,
                        "content_type": "video/mp4",
                        "url": "https://video.twimg.com/ext_tw_video/1432767873504718850/pu/vid/720x960/sS9cLdGn93eUmvKC.mp4?tag=12"
                    }
                ]
            }
        }
    ],
</code></pre>

<h3 id="other-examples"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#other-examples">Other Examples</a></h3>

<ul>
<li><a href="https://cdn.syndication.twimg.com/tweet-result?id=909106648928718848&amp;lang=en&amp;token=123456">Multiple Images</a></li>
<li><a href="https://cdn.syndication.twimg.com/tweet-result?id=670060095972245504&amp;lang=en&amp;token=123456">Polls</a></li>
<li><a href="https://cdn.syndication.twimg.com/tweet-result?id=83659275024601088&amp;lang=en&amp;token=123456">Deleted Message</a></li>
<li><a href="https://cdn.syndication.twimg.com/tweet-result?id=1131218926493413377&amp;lang=en&amp;token=123456">Summary Cards</a></li>
</ul>

<h2 id="limitations"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#limitations">Limitations</a></h2>

<p>There are a few small limitations with this approach.</p>

<ul>
<li>It doesn't capture replies

<ul>
<li>If the Tweet is in reply to something, it will capture the parent.</li>
<li>If the Tweet quotes something, it will capture the quoted Tweet.</li>
</ul></li>
<li>The counts for replies, retweets, and favourites may not be accurate

<ul>
<li>Older messages seem worse for this, but that's a natural part of digital decay.</li>
</ul></li>
<li>Reduced metadata

<ul>
<li>The official API used to tell you which device was used to post the message, user's timezone, and other bits of useful information.</li>
</ul></li>
<li>You need to know the ID of the Tweet

<ul>
<li>There's no way to automatically grab every Tweet by a user, or from a search.</li>
</ul></li>
<li>Sometimes the API stops responding

<ul>
<li>Change the token to another random number.</li>
</ul></li>
<li>Occasionally replies and quotes won't be included

<ul>
<li>Calling the API again often recovers the data.</li>
</ul></li>
</ul>

<h2 id="python-code"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#python-code">Python Code</a></h2>

<p>If you're technically inclined, I've <a href="https://github.com/edent/Tweet2Embed">written some Python code to automate turning the JSON into HTML</a>.</p>

<h2 id="have-fun"><a href="https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/#have-fun">Have Fun</a></h2>

<p>Remember, the owner of Twitter no longer believes in IP law. So I guess you can go nuts and download all of Twitter's data and use it for any purpose?</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=59462&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/04/you-dont-need-an-api-key-to-archive-twitter-data/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		<enclosure url="https://video.twimg.com/ext_tw_video/1432767873504718850/pu/vid/320x426/oq2p-t0RJEEKuDD6.mp4?tag=12" length="416756" type="video/mp4" />
<enclosure url="https://video.twimg.com/ext_tw_video/1432767873504718850/pu/vid/480x640/3X8ZsBmXmmaaakmM.mp4?tag=12" length="786328" type="video/mp4" />
<enclosure url="https://video.twimg.com/ext_tw_video/1432767873504718850/pu/vid/720x960/sS9cLdGn93eUmvKC.mp4?tag=12" length="1546364" type="video/mp4" />

			</item>
		<item>
		<title><![CDATA[An opinionated HTML Serializer for PHP 8.4]]></title>
		<link>https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/</link>
					<comments>https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 02 Apr 2025 11:34:36 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[HTML5]]></category>
		<category><![CDATA[php]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=59322</guid>

					<description><![CDATA[A few days ago, I wrote a shitty pretty-printer for PHP 8.4&#039;s new Dom\HTMLDocument class.  I&#039;ve since re-written it to be faster and more stylistically correct.  It turns this:  &#60;html lang=&#34;en-GB&#34;&#62;&#60;head&#62;&#60;title id=&#34;something&#34;&#62;Test&#60;/title&#62;&#60;/head&#62;&#60;body&#62;&#60;h1 class=&#34;top upper&#34;&#62;Testing&#60;/h1&#62;&#60;main&#62;&#60;p&#62;Some &#60;em&#62;HTML&#60;/em&#62; and an &#60;img src=&#34;example.png&#34; alt=&#34;Alternate Text&#34;&#62;&#60;/p&#62;Text not in an…]]></description>
										<content:encoded><![CDATA[<p>A few days ago, <a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/">I wrote a shitty pretty-printer</a> for PHP 8.4's new <a href="https://www.php.net/manual/en/class.dom-htmldocument.php">Dom\HTMLDocument class</a>.</p>

<p>I've since re-written it to be faster and more stylistically correct.</p>

<p>It turns this:</p>

<pre><code class="language-html">&lt;html lang="en-GB"&gt;&lt;head&gt;&lt;title id="something"&gt;Test&lt;/title&gt;&lt;/head&gt;&lt;body&gt;&lt;h1 class="top upper"&gt;Testing&lt;/h1&gt;&lt;main&gt;&lt;p&gt;Some &lt;em&gt;HTML&lt;/em&gt; and an &lt;img src="example.png" alt="Alternate Text"&gt;&lt;/p&gt;Text not in an element&lt;ol&gt;&lt;li&gt;List&lt;/li&gt;&lt;li&gt;Another list&lt;/li&gt;&lt;/ol&gt;&lt;/main&gt;&lt;/body&gt;&lt;/html&gt;
</code></pre>

<p>Into this:</p>

<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang=en-GB&gt;
    &lt;head&gt;
        &lt;title id=something&gt;Test&lt;/title&gt;
    &lt;/head&gt;
    &lt;body&gt;
        &lt;h1 class="top upper"&gt;Testing&lt;/h1&gt;
        &lt;main&gt;
            &lt;p&gt;
                Some 
                &lt;em&gt;HTML&lt;/em&gt;
                 and an 
                &lt;img src=example.png alt="Alternate Text"&gt;
            &lt;/p&gt;
            Text not in an element
            &lt;ol&gt;
                &lt;li&gt;List&lt;/li&gt;
                &lt;li&gt;Another list&lt;/li&gt;
            &lt;/ol&gt;
        &lt;/main&gt;
    &lt;/body&gt;
&lt;/html&gt;
</code></pre>

<p>I say it is "opinionated" because it does the following:</p>

<ul>
<li>Attributes are unquoted unless necessary.</li>
<li>Every element is logically indented.</li>
<li>Text content of CSS and JS is unaltered. No pretty-printing, minification, or checking for correctness.</li>
<li>Text content of elements <em>may</em> have extra newlines and tabs. Browsers will tend to ignore multiple whitespaces unless the CSS tells them otherwise.

<ul>
<li>This fucks up <code>&lt;pre&gt;</code> blocks which contain markup.</li>
</ul></li>
</ul>

<p>It is primarily designed to make the <em>markup</em> easy to read. Because <a href="https://libraries.mit.edu/150books/2011/05/11/1985/">according to the experts</a>:</p>

<blockquote><p>A computer language is not just a way of getting a computer to perform operations but rather … it is a novel formal medium for expressing ideas about methodology. Thus, programs must be written for people to read, and only incidentally for machines to execute.</p></blockquote>

<p>I'm <em>fairly</em> sure this all works properly. But feel free to argue in the comments or <a href="https://gitlab.com/edent/pretty-print-html-using-php/">send me a pull request</a>.</p>

<p>Here's how it works.</p>

<h2 id="when-is-an-element-not-an-element-when-it-is-a-void"><a href="https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/#when-is-an-element-not-an-element-when-it-is-a-void">When is an element not an element? When it is a void!</a></h2>

<p>Modern HTML has the concept of "<a href="https://developer.mozilla.org/en-US/docs/Glossary/Void_element">Void Elements</a>". Normally, something like <code>&lt;a&gt;</code> <em>must</em> eventually be followed by a closing <code>&lt;/a&gt;</code>.  But Void Elements don't need closing.</p>

<p>This keeps a list of elements which must not be explicitly closed.</p>

<pre><code class="language-php">$void_elements = [
    "area",
    "base",
    "br",
    "col",
    "embed",
    "hr",
    "img",
    "input",
    "link",
    "meta",
    "param",
    "source",
    "track",
    "wbr",
];
</code></pre>

<h2 id="tabs-%f0%9f%86%9a-space"><a href="https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/#tabs-%f0%9f%86%9a-space">Tabs 🆚 Space</a></h2>

<p>Tabs, obviously. Users can set their tab width to their personal preference and it won't get confused with semantically significant whitespace.</p>

<pre><code class="language-php">$indent_character = "\t";
</code></pre>

<h2 id="setting-up-the-dom"><a href="https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/#setting-up-the-dom">Setting up the DOM</a></h2>

<p>The new HTMLDocument should be broadly familiar to anyone who has used the previous one.</p>

<pre><code class="language-php">$html = '&lt;html lang="en-GB"&gt;&lt;head&gt;&lt;title id="something"&gt;Test&lt;/title&gt;&lt;/head&gt;&lt;body&gt;&lt;h1 class="top upper"&gt;Testing&lt;/h1&gt;&lt;main&gt;&lt;p&gt;Some &lt;em&gt;HTML&lt;/em&gt; and an &lt;img src="example.png" alt="Alternate Text"&gt;&lt;/p&gt;Text not in an element&lt;ol&gt;&lt;li&gt;List&lt;/li&gt;&lt;li&gt;Another list&lt;/li&gt;&lt;/ol&gt;&lt;/main&gt;&lt;/body&gt;&lt;/html&gt;&gt;'
$dom = Dom\HTMLDocument::createFromString( $html, LIBXML_NOERROR, "UTF-8" );
</code></pre>

<p>This automatically adds <code>&lt;head&gt;</code> and <code>&lt;body&gt;</code> elements. If you don't want that, use the <a href="https://www.php.net/manual/en/libxml.constants.php#constant.libxml-html-noimplied"><code>LIBXML_HTML_NOIMPLIED</code> flag</a>:</p>

<pre><code class="language-php">$dom = Dom\HTMLDocument::createFromString( $html, LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED, "UTF-8" );
</code></pre>

<h2 id="to-quote-or-not-to-quote"><a href="https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/#to-quote-or-not-to-quote">To Quote or Not To Quote?</a></h2>

<p>Traditionally, HTML attributes needed quotes:</p>

<pre><code class="language-html">&lt;img src="example.png" class="avatar no-border" id="user-123"&gt;
</code></pre>

<p>Modern HTML allows those attributes to be <em>un</em>quoted as long as they don't contain <a href="https://infra.spec.whatwg.org/#ascii-whitespace">ASCII Whitespace</a> or <a href="https://html.spec.whatwg.org/multipage/syntax.html#unquoted">certain other characters</a></p>

<p>For example, the above becomes:</p>

<pre><code class="language-html">&lt;img src=example.png class="avatar no-border" id=user-123&gt;
</code></pre>

<p>This function looks for the presence of those characters:</p>

<pre><code class="language-php">function value_unquoted( $haystack )
{
    //  Must not contain specific characters

    $needles = [ 
        //  https://infra.spec.whatwg.org/#ascii-whitespace
        "\t", "\n", "\f", "\n", " ", 
        //  https://html.spec.whatwg.org/multipage/syntax.html#unquoted 
        "\"", "'", "=", "&lt;", "&gt;", "`" ];
    foreach ( $needles as $needle )
    {
        if ( str_contains( $haystack, $needle ) )
        {
            return false;
        }
    }
    //  Must not be null
    if ( $haystack == null ) { return false; }
    return true;
}
</code></pre>

<h2 id="re-re-re-recursion"><a href="https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/#re-re-re-recursion">Re-re-re-recursion</a></h2>

<p>I've tried to document this as best I can.</p>

<p>It traverses the DOM tree, printing out correctly indented opening elements and their attributes. If there's text content, that's printed. If an element needs closing, that's printed with the appropriate indentation.</p>

<pre><code class="language-php">function serializeHTML( $node, $treeIndex = 0, $output = "")
{
    global $indent_character, $preserve_internal_whitespace, $void_elements;

    //  Manually add the doctype to start.
    if ( $output == "" ) {
        $output .= "&lt;!doctype html&gt;\n";
    }

    if( property_exists( $node, "localName" ) ) {
        //  This is an Element.

        //  Get all the Attributes (id, class, src, &amp;c.).
        $attributes = "";
        if ( property_exists($node, "attributes")) {
            foreach( $node-&gt;attributes as $attribute ) {
                $value = $attribute-&gt;nodeValue;
                //  Only add " if the value contains specific characters.
                $quote = value_unquoted( $value ) ? "" : "\"";

                $attributes .= " {$attribute-&gt;nodeName}={$quote}{$value}{$quote}";
            }
        }

        //  Print the opening element and all attributes.
        $output .= "&lt;{$node-&gt;localName}{$attributes}&gt;";

    } else if( property_exists( $node, "nodeName" ) &amp;&amp;  $node-&gt;nodeName == "#comment" ) {
        //  Comment
        $output .= "&lt;!-- {$node-&gt;textContent} --&gt;";
    }

    //  Increase indent.
    $treeIndex++;
    $tabStart = "\n" . str_repeat( $indent_character, $treeIndex ); 
    $tabEnd   = "\n" . str_repeat( $indent_character, $treeIndex - 1);

    //  Does this node have children?
    if( property_exists( $node, "childElementCount" ) &amp;&amp; $node-&gt;childElementCount &gt; 0 ) {

        //  Loop through the children.
        $i=0;
        while( $childNode = $node-&gt;childNodes-&gt;item( $i++ ) ) {

            //  Is this a text node?
            if ($childNode-&gt;nodeType == 3 ) {
                //  Only print output if there's no HTML inside the content.
                //  Ignore Void Elements.
                if ( 
                      !str_contains( $childNode-&gt;textContent, "&lt;" ) &amp;&amp; 
                    property_exists( $childNode, "localName" ) &amp;&amp; 
                          !in_array( $childNode-&gt;localName, $void_elements ) ) 
                {
                    $output .= $tabStart . $childNode-&gt;textContent;
                }
            } else {
                $output .= $tabStart;
            }

            //  Recursively indent all children.
            $output = serializeHTML( $childNode, $treeIndex, $output );
        };

        //  Suffix with a "\n" and a suitable number of "\t"s.
        $output .= "{$tabEnd}"; 

    } else if ( property_exists( $node, "childElementCount" ) &amp;&amp; property_exists( $node, "innerHTML" ) ) {
        //  If there are no children and the node contains content, print the contents.
        $output .= $node-&gt;innerHTML;
    }

    //  Close the element, unless it is a void.
    if( property_exists( $node, "localName" ) &amp;&amp; !in_array( $node-&gt;localName, $void_elements ) ) {
        $output .= "&lt;/{$node-&gt;localName}&gt;";
    }

    //  Return a string of fully indented HTML.
    return $output;
}
</code></pre>

<h2 id="print-it-out"><a href="https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/#print-it-out">Print it out</a></h2>

<p>The serialized string hardcodes the <code>&lt;!doctype html&gt;</code> - which is probably fine.  The full HTML is shown with:</p>

<pre><code class="language-php">echo serializeHTML( $dom-&gt;documentElement );
</code></pre>

<h2 id="next-steps"><a href="https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/#next-steps">Next Steps</a></h2>

<p>Please <a href="https://gitlab.com/edent/pretty-print-html-using-php/">raise any issues on GitLab</a> or leave a comment.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=59322&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/04/an-opinionated-html-serializer-for-php-8-4/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Pretty Print HTML using PHP 8.4's new HTML DOM]]></title>
		<link>https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/</link>
					<comments>https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Mon, 31 Mar 2025 11:34:54 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[HTML]]></category>
		<category><![CDATA[php]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=59238</guid>

					<description><![CDATA[Those whom the gods would send mad, they first teach recursion.  PHP 8.4 introduces a new Dom\HTMLDocument class it is a modern HTML5 replacement for the ageing XHTML based DOMDocument.  You can read more about how it works - the short version is that it reads and correctly sanitises HTML and turns it into a nested object. Hurrah!  The one thing it doesn&#039;t do is pretty-printing.  When you call…]]></description>
										<content:encoded><![CDATA[<p>Those whom the gods would send mad, they first teach recursion.</p>

<p>PHP 8.4 introduces a new <a href="https://www.php.net/manual/en/class.dom-htmldocument.php">Dom\HTMLDocument class</a> it is a modern HTML5 replacement for the ageing XHTML based DOMDocument.  You can <a href="https://wiki.php.net/rfc/domdocument_html5_parser">read more about how it works</a> - the short version is that it reads and correctly sanitises HTML and turns it into a nested object. Hurrah!</p>

<p>The one thing it <em>doesn't</em> do is pretty-printing.  When you call <code>$dom-&gt;saveHTML()</code> it will output something like:</p>

<pre><code class="language-html">&lt;html lang="en-GB"&gt;&lt;head&gt;&lt;title&gt;Test&lt;/title&gt;&lt;/head&gt;&lt;body&gt;&lt;h1&gt;Testing&lt;/h1&gt;&lt;main&gt;&lt;p&gt;Some &lt;em&gt;HTML&lt;/em&gt; and an &lt;img src="example.png"&gt;&lt;/p&gt;&lt;ol&gt;&lt;li&gt;List&lt;/li&gt;&lt;li&gt;Another list&lt;/li&gt;&lt;/ol&gt;&lt;/main&gt;&lt;/body&gt;&lt;/html&gt;
</code></pre>

<p>Perfect for a computer to read, but slightly tricky for humans.</p>

<p>As was <a href="https://libraries.mit.edu/150books/2011/05/11/1985/">written by the sages</a>:</p>

<blockquote><p>A computer language is not just a way of getting a computer to perform operations but rather … it is a novel formal medium for expressing ideas about methodology. Thus, programs must be written for people to read, and only incidentally for machines to execute.</p></blockquote>

<p>HTML <em>is</em> a programming language. Making markup easy to read for humans is a fine and noble goal.  The aim is to turn the single line above into something like:</p>

<pre><code class="language-html">&lt;html lang="en-GB"&gt;
    &lt;head&gt;
        &lt;title&gt;Test&lt;/title&gt;
    &lt;/head&gt;
    &lt;body&gt;
        &lt;h1&gt;Testing&lt;/h1&gt;
        &lt;main&gt;
            &lt;p&gt;Some &lt;em&gt;HTML&lt;/em&gt; and an &lt;img src="example.png"&gt;&lt;/p&gt;
            &lt;ol&gt;
                &lt;li&gt;List&lt;/li&gt;
                &lt;li&gt;Another list&lt;/li&gt;
            &lt;/ol&gt;
        &lt;/main&gt;
    &lt;/body&gt;
&lt;/html&gt;
</code></pre>

<p>Cor! That's much better!</p>

<p>I've cobbled together a script which is <em>broadly</em> accurate. There are a million-and-one edge cases and about twice as many personal preferences. This aims to be quick, simple, and basically fine. I am indebted to <a href="https://topic.alibabacloud.com/a/php-domdocument-recursive-formatting-of-indented-html-documents_4_86_30953142.html">this random Chinese script</a> and to <a href="https://github.com/wasinger/html-pretty-min">html-pretty-min</a>.</p>

<h2 id="step-by-step"><a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#step-by-step">Step By Step</a></h2>

<p>I'm going to walk through how everything works. This is as much for my benefit as for yours! This is beta code. It sorta-kinda-works for me. Think of it as a first pass at an attempt to prove that something can be done. Please don't use it in production!</p>

<h3 id="setting-up-the-dom"><a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#setting-up-the-dom">Setting up the DOM</a></h3>

<p>The new HTMLDocument should be broadly familiar to anyone who has used the previous one.</p>

<pre><code class="language-php">$html = '&lt;html lang="en-GB"&gt;&lt;head&gt;&lt;title&gt;Test&lt;/title&gt;&lt;/head&gt;&lt;body&gt;&lt;h1&gt;Testing&lt;/h1&gt;&lt;main&gt;&lt;p&gt;Some &lt;em&gt;HTML&lt;/em&gt; and an &lt;img src="example.png"&gt;&lt;/p&gt;&lt;ol&gt;&lt;li&gt;List&lt;li&gt;Another list&lt;/body&gt;&lt;/html&gt;'
$dom = Dom\HTMLDocument::createFromString( $html, LIBXML_NOERROR, "UTF-8" );
</code></pre>

<p>This automatically adds <code>&lt;head&gt;</code> and <code>&lt;body&gt;</code> elements. If you don't want that, use the <a href="https://www.php.net/manual/en/libxml.constants.php#constant.libxml-html-noimplied"><code>LIBXML_HTML_NOIMPLIED</code> flag</a>:</p>

<pre><code class="language-php">$dom = Dom\HTMLDocument::createFromString( $html, LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED, "UTF-8" );
</code></pre>

<h3 id="where-not-to-indent"><a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#where-not-to-indent">Where <em>not</em> to indent</a></h3>

<p>There are certain elements whose contents shouldn't be pretty-printed because it might change the meaning or layout of the text. For example, in a paragraph:</p>

<pre><code class="language-html">&lt;p&gt;
    Some 
    &lt;em&gt;
        HT
        &lt;strong&gt;M&lt;/strong&gt;
        L
    &lt;/em&gt;
&lt;/p&gt;
</code></pre>

<p>I've picked these elements from <a href="https://html.spec.whatwg.org/multipage/text-level-semantics.html#text-level-semantics">text-level semantics</a> and a few others which I consider sensible. Feel free to edit this list if you want.</p>

<pre><code class="language-php">$preserve_internal_whitespace = [
    "a", 
    "em", "strong", "small", 
    "s", "cite", "q", 
    "dfn", "abbr", 
    "ruby", "rt", "rp", 
    "data", "time", 
    "pre", "code", "var", "samp", "kbd", 
    "sub", "sup", 
    "b", "i", "mark", "u",
    "bdi", "bdo", 
    "span",
    "h1", "h2", "h3", "h4", "h5", "h6",
    "p",
    "li",
    "button", "form", "input", "label", "select", "textarea",
];
</code></pre>

<p>The function has an option to <em>force</em> indenting every time it encounters an element.</p>

<h3 id="tabs-%f0%9f%86%9a-spaces"><a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#tabs-%f0%9f%86%9a-spaces">Tabs 🆚 Spaces</a></h3>

<p>Tabs, obviously. Users can set their tab width to their personal preference and it won't get confused with semantically significant whitespace.</p>

<pre><code class="language-php">$indent_character = "\t";
</code></pre>

<h3 id="recursive-function"><a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#recursive-function">Recursive Function</a></h3>

<p>This function reads through each node in the HTML tree. If the node should be indented, the function inserts a new node with the requisite number of tabs before the existing node. It also adds a suffix node to indent the next line appropriately. It then goes through the node's children and recursively repeats the process.</p>

<p><strong>This modifies the existing Document</strong>.</p>

<pre><code class="language-php">function prettyPrintHTML( $node, $treeIndex = 0, $forceWhitespace = false )
{    
    global $indent_character, $preserve_internal_whitespace;

    //  If this node contains content which shouldn't be separately indented
    //  And if whitespace is not forced
    if ( property_exists( $node, "localName" ) &amp;&amp; in_array( $node-&gt;localName, $preserve_internal_whitespace ) &amp;&amp; !$forceWhitespace ) {
        return;
    }

    //  Does this node have children?
    if( property_exists( $node, "childElementCount" ) &amp;&amp; $node-&gt;childElementCount &gt; 0 ) {
        //  Move in a step
        $treeIndex++;
        $tabStart = "\n" . str_repeat( $indent_character, $treeIndex ); 
        $tabEnd   = "\n" . str_repeat( $indent_character, $treeIndex - 1);

        //  Remove any existing indenting at the start of the line
        $node-&gt;innerHTML = trim($node-&gt;innerHTML);

        //  Loop through the children
        $i=0;

        while( $childNode = $node-&gt;childNodes-&gt;item( $i++ ) ) {
            //  Was the *previous* sibling a text-only node?
            //  If so, don't add a previous newline
            if ( $i &gt; 0 ) {
                $olderSibling = $node-&gt;childNodes-&gt;item( $i-1 );

                if ( $olderSibling-&gt;nodeType == XML_TEXT_NODE  &amp;&amp; !$forceWhitespace ) {
                    $i++;
                    continue;
                }
                $node-&gt;insertBefore( $node-&gt;ownerDocument-&gt;createTextNode( $tabStart ), $childNode );
            }
            $i++; 
            //  Recursively indent all children
            prettyPrintHTML( $childNode, $treeIndex, $forceWhitespace );
        };

        //  Suffix with a node which has "\n" and a suitable number of "\t"
        $node-&gt;appendChild( $node-&gt;ownerDocument-&gt;createTextNode( $tabEnd ) ); 
    }
}
</code></pre>

<h3 id="printing-it-out"><a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#printing-it-out">Printing it out</a></h3>

<p>First, call the function.  <strong>This modifies the existing Document</strong>.</p>

<pre><code class="language-php">prettyPrintHTML( $dom-&gt;documentElement );
</code></pre>

<p>Then call <a href="https://www.php.net/manual/en/dom-htmldocument.savehtml.php">the normal <code>saveHtml()</code> serialiser</a>:</p>

<pre><code class="language-php">echo $dom-&gt;saveHTML();
</code></pre>

<p>Note - this does not print a <code>&lt;!doctype html&gt;</code> - you'll need to include that manually if you're intending to use the entire document.</p>

<h2 id="licence"><a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#licence">Licence</a></h2>

<p>I consider the above too trivial to licence - but you may treat it as MIT if that makes you happy.</p>

<h2 id="thoughts-comments-next-steps"><a href="https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/#thoughts-comments-next-steps">Thoughts? Comments? Next steps?</a></h2>

<p>I've not written any formal tests, nor have I measured its speed, there may be subtle-bugs, and catastrophic errors. I know it doesn't work well if the HTML is already indented. It mysteriously prints double newlines for some unfathomable reason.</p>

<p>I'd love to know if you find this useful. Please <a href="https://gitlab.com/edent/pretty-print-html-using-php/">get involved on GitLab</a> or drop a comment here.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=59238&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/03/pretty-print-html-using-php-8-4s-new-html-dom/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Create a Table of Contents based on HTML Heading Elements]]></title>
		<link>https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/</link>
					<comments>https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 26 Mar 2025 12:34:31 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[HTML]]></category>
		<category><![CDATA[php]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=59105</guid>

					<description><![CDATA[Some of my blog posts are long. They have lots of HTML headings like &#60;h2&#62; and &#60;h3&#62;. Say, wouldn&#039;t it be super-awesome to have something magically generate a Table of Contents?  I&#039;ve built a utility which runs server-side using PHP. Give it some HTML and it will construct a Table of Contents.  Let&#039;s dive in!  Table of ContentsBackgroundHeading ExampleWhat is the purpose of a table of…]]></description>
										<content:encoded><![CDATA[<p>Some of my blog posts are long<sup id="fnref:too"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#fn:too" class="footnote-ref" title="Too long really, but who can be bothered to edit?" role="doc-noteref">0</a></sup>. They have lots of HTML headings like <code>&lt;h2&gt;</code> and <code>&lt;h3&gt;</code>. Say, wouldn't it be super-awesome to have something magically generate a Table of Contents?  I've built a utility which runs server-side using PHP. Give it some HTML and it will construct a Table of Contents.</p>

<p>Let's dive in!</p>

<p></p><nav role="doc-toc"><menu><li><h2 id="table-of-contents"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#table-of-contents">Table of Contents</a></h2><menu><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#background">Background</a><menu><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#heading-example">Heading Example</a></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#what-is-the-purpose-of-a-table-of-contents">What is the purpose of a table of contents?</a></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#code">Code</a><menu><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#load-the-html">Load the HTML</a><menu><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#using-php-8-4">Using PHP 8.4</a></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#parse-the-html">Parse the HTML</a><menu><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#php-8-4-queryselectorall">PHP 8.4 querySelectorAll</a></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#recursive-looping">Recursive looping</a><menu><li><a href="#"></a><menu><li><a href="#"></a><menu><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#missing-content">Missing content</a></li></menu></li></menu></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#converting-to-html">Converting to HTML</a></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#semantic-correctness">Semantic Correctness</a><menu><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#epub-example">ePub Example</a></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#split-the-difference-with-a-menu">Split the difference with a menu</a></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#where-should-the-heading-go">Where should the heading go?</a></li></menu></li><li><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#conclusion">Conclusion</a></li></menu></li></menu></nav><p></p>

<h2 id="background"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#background">Background</a></h2>

<p>HTML has <a href="https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements">six levels of headings</a><sup id="fnref:beatles"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#fn:beatles" class="footnote-ref" title="Although Paul McCartney disagrees." role="doc-noteref">1</a></sup> - <code>&lt;h1&gt;</code> is the main heading for content, <code>&lt;h2&gt;</code> is a sub-heading, <code>&lt;h3&gt;</code> is a sub-sub-heading, and so on.</p>

<p>Together, they form a hierarchy.</p>

<h3 id="heading-example"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#heading-example">Heading Example</a></h3>

<p>HTML headings are expected to be used a bit like this (I've nested this example so you can see the hierarchy):</p>

<pre><code class="language-html">&lt;h1&gt;The Theory of Everything&lt;/h1&gt;
   &lt;h2&gt;Experiments&lt;/h2&gt;
      &lt;h3&gt;First attempt&lt;/h3&gt;
      &lt;h3&gt;Second attempt&lt;/h3&gt;
   &lt;h2&gt;Equipment&lt;/h2&gt;
      &lt;h3&gt;Broken equipment&lt;/h3&gt;
         &lt;h4&gt;Repaired equipment&lt;/h4&gt;
      &lt;h3&gt;Working Equipment&lt;/h3&gt;
…
</code></pre>

<h3 id="what-is-the-purpose-of-a-table-of-contents"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#what-is-the-purpose-of-a-table-of-contents">What is the purpose of a table of contents?</a></h3>

<p>Wayfinding. On a long document, it is useful to be able to see an overview of the contents and then immediately navigate to the desired location.</p>

<p>The ToC has to provide a hierarchical view of all the headings and then link to them.</p>

<h2 id="code"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#code">Code</a></h2>

<p>I'm running this as part of a WordPress plugin. You may need to adapt it for your own use.</p>

<h3 id="load-the-html"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#load-the-html">Load the HTML</a></h3>

<p>This uses <a href="https://www.php.net/manual/en/class.domdocument.php">PHP's DOMdocument</a>. I've manually added a <code>UTF-8</code> header so that Unicode is preserved. If your HTML already has that, you can remove the addition from the code.</p>

<pre><code class="language-php">//  Load it into a DOM for manipulation
$dom = new DOMDocument();
//  Suppress warnings about HTML errors
libxml_use_internal_errors( true );
//  Force UTF-8 support
$dom-&gt;loadHTML( "&lt;!DOCTYPE html&gt;&lt;html&gt;&lt;head&gt;&lt;meta charset=UTF-8&gt;&lt;/head&gt;&lt;body&gt;" . $content, LIBXML_NOERROR | LIBXML_NOWARNING );
libxml_clear_errors();
</code></pre>

<h4 id="using-php-8-4"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#using-php-8-4">Using PHP 8.4</a></h4>

<p>The latest version of PHP contains <a href="https://www.php.net/manual/en/class.dom-htmldocument.php">a better HTML-aware DOM</a>. It can be used like this:</p>

<pre><code class="language-php">$dom = Dom\HTMLDocument::createFromString( $content, LIBXML_NOERROR , "UTF-8" );
</code></pre>

<h3 id="parse-the-html"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#parse-the-html">Parse the HTML</a></h3>

<p>It is not a good idea to use Regular Expressions to parse HTML - no matter how well-formed you think it is. Instead, use <a href="https://www.php.net/manual/en/class.domxpath.php">XPath</a> to extract data from the DOM.</p>

<pre><code class="language-php">//  Parse with XPath
$xpath = new DOMXPath( $dom );

//  Look for all h* elements
$headings = $xpath-&gt;query( "//h1 | //h2 | //h3 | //h4 | //h5 | //h6" );
</code></pre>

<p>This produces an array with all the heading elements in the order they appear in the document.</p>

<h4 id="php-8-4-queryselectorall"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#php-8-4-queryselectorall">PHP 8.4 querySelectorAll</a></h4>

<p>Rather than using XPath, modern versions of PHP can use <a href="https://www.php.net/manual/en/dom-parentnode.queryselectorall.php">querySelectorAll</a>:</p>

<pre><code class="language-php">$headings = $dom-&gt;querySelectorAll( "h1, h2, h3, h4, h5, h6" );
</code></pre>

<h3 id="recursive-looping"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#recursive-looping">Recursive looping</a></h3>

<p>This is a bit knotty. It produces a nested array of the elements, their <code>id</code> attributes, and text.  The end result should be something like:</p>

<pre><code class="language-_">array (
  array (
    'text' =&gt; '&lt;h2&gt;Table of Contents&lt;/h2&gt;',
    'raw' =&gt; true,
  ),
  array (
    'text' =&gt; 'The Theory of Everything',
    'id' =&gt; 'the-theory-of-everything',
    'children' =&gt; 
    array (
      array (
        'text' =&gt; 'Experiments',
        'id' =&gt; 'experiments',
        'children' =&gt; 
        array (
          array (
            'text' =&gt; 'First attempt',
            'id' =&gt; 'first-attempt',
          ),
          array (
            'text' =&gt; 'Second attempt',
            'id' =&gt; 'second-attempt',
</code></pre>

<p>The code is moderately complex, but I've commented it as best as I can.</p>

<pre><code class="language-php">//  Start an array to hold all the headings in a hierarchy
$root = [];
//  Add an h2 with the title
$root[] = [
    "text"     =&gt; "&lt;h2&gt;Table of Contents&lt;/h2&gt;", 
    "raw"      =&gt; true, 
    "children" =&gt; []
];

// Stack to track current hierarchy level
$stack = [&amp;$root]; 

//  Loop through the headings
foreach ($headings as $heading) {

    //  Get the information
    //  Expecting &lt;h2 id="something"&gt;Text&lt;/h2&gt;
    $element = $heading-&gt;nodeName;  //  e.g. h2, h3, h4, etc
    $text    = trim( $heading-&gt;textContent );   
    $id      = $heading-&gt;getAttribute( "id" );

    //  h2 becomes 2, h3 becomes 3 etc
    $level = (int) substr($element, 1);

    //  Get data from element
    $node = array( 
        "text"     =&gt; $text, 
        "id"       =&gt; $id , 
        "children" =&gt; [] 
    );

    //  Ensure there are no gaps in the heading hierarchy
    while ( count( $stack ) &gt; $level ) {
        array_pop( $stack );
    }

    //  If a gap exists (e.g., h4 without an immediately preceding h3), create placeholders
    while ( count( $stack ) &lt; $level ) {
        //  What's the last element in the stack?
        $stackSize = count( $stack );
        $lastIndex = count( $stack[ $stackSize - 1] ) - 1;
        if ($lastIndex &lt; 0) {
            //  If there is no previous sibling, create a placeholder parent
            $stack[$stackSize - 1][] = [
                "text"     =&gt; "",   //  This could have some placeholder text to warn the user?
                "children" =&gt; []
            ];
            $stack[] = &amp;$stack[count($stack) - 1][0]['children'];
        } else {
            $stack[] = &amp;$stack[count($stack) - 1][$lastIndex]['children'];
        }
    }

    //  Add the node to the current level
    $stack[count($stack) - 1][] = $node;
    $stack[] = &amp;$stack[count($stack) - 1][count($stack[count($stack) - 1]) - 1]['children'];
}
</code></pre>

<h6 id="missing-content"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#missing-content">Missing content</a></h6>

<p>The trickiest part of the above is dealing with missing elements in the hierarchy. If you're <em>sure</em> you don't ever skip from an <code>&lt;h3&gt;</code> to an <code>&lt;h6&gt;</code>, you can get rid of some of the code dealing with that edge case.</p>

<h3 id="converting-to-html"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#converting-to-html">Converting to HTML</a></h3>

<p>OK, there's a hierarchical array, how does it become HTML?</p>

<p>Again, a little bit of recursion:</p>

<pre><code class="language-php">function arrayToHTMLList( $array, $style = "ul" )
{
    $html = "";

    //  Loop through the array
    foreach( $array as $element ) {
        //  Get the data of this element
        $text     = $element["text"];
        $id       = $element["id"];
        $children = $element["children"];
        $raw      = $element["raw"] ?? false;

        if ( $raw ) {
            //  Add it to the HTML without adding an internal link
            $html .= "&lt;li&gt;{$text}";
        } else {
            //  Add it to the HTML
            $html .= "&lt;li&gt;&lt;a href=#{$id}&gt;{$text}&lt;/a&gt;";
        }

        //  If the element has children
        if ( sizeof( $children ) &gt; 0 ) {
            //  Recursively add it to the HTML
            $html .=  "&lt;{$style}&gt;" . arrayToHTMLList( $children, $style ) . "&lt;/{$style}&gt;";
        } 
    }

    return $html;
}
</code></pre>

<h2 id="semantic-correctness"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#semantic-correctness">Semantic Correctness</a></h2>

<p>Finally, what should a table of contents look like in HTML?  There is no <code>&lt;toc&gt;</code> element, so what is most appropriate?</p>

<h3 id="epub-example"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#epub-example">ePub Example</a></h3>

<p>Modern eBooks use the ePub standard which is based on HTML. Here's how <a href="https://kb.daisy.org/publishing/docs/navigation/toc.html">an ePub creates a ToC</a>.</p>

<pre><code class="language-html">&lt;nav role="doc-toc" epub:type="toc" id="toc"&gt;
&lt;h2&gt;Table of Contents&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;a href="s01.xhtml"&gt;A simple link&lt;/a&gt;
  &lt;/li&gt;
  …
&lt;/ol&gt;
&lt;/nav&gt;
</code></pre>

<p>The modern(ish) <code>&lt;nav&gt;</code> element!</p>

<blockquote><p>The nav element represents a section of a page that links to other pages or to parts within the page: a section with navigation links.
<a href="https://html.spec.whatwg.org/multipage/sections.html#the-nav-element">HTML Specification</a></p></blockquote>

<p>But there's a slight wrinkle. The ePub example above use <code>&lt;ol&gt;</code> an ordered list. The HTML example in the spec uses <code>&lt;ul&gt;</code> an <em>un</em>ordered list.</p>

<p>Which is right? Well, that depends on whether you think the contents on your page should be referred to in order or not. There is, however, a secret third way.</p>

<h3 id="split-the-difference-with-a-menu"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#split-the-difference-with-a-menu">Split the difference with a menu</a></h3>

<p>I decided to use <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu">the <code>&lt;menu&gt;</code> element</a> for my navigation. It is semantically the same as <code>&lt;ul&gt;</code> but just feels a bit closer to what I expect from navigation. Feel free to argue with me in the comments.</p>

<h3 id="where-should-the-heading-go"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#where-should-the-heading-go">Where should the heading go?</a></h3>

<p>I've put the title of the list into the list itself. That's valid HTML and, if my understanding is correct, should announce itself as the title of the navigation element to screen-readers and the like.</p>

<h2 id="conclusion"><a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#conclusion">Conclusion</a></h2>

<p>I've used <em>slightly</em> more heading in this post than I would usually, but hopefully the <a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#table-of-contents">Table of Contents at the top</a> demonstrates how this works.</p>

<p>If you want to reuse this code, I consider it too trivial to licence. But, if it makes you happy, you can treat it as MIT.</p>

<p>Thoughts? Comments? Feedback? Drop a note in the box.</p>

<div id="footnotes" role="doc-endnotes">
<hr aria-label="Footnotes">
<ol start="0">

<li id="fn:too">
<p>Too long really, but who can be bothered to edit?&nbsp;<a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#fnref:too" class="footnote-backref" role="doc-backlink">↩︎</a></p>
</li>

<li id="fn:beatles">
<p>Although <a href="https://www.nme.com/news/music/paul-mccartney-12-1188735">Paul McCartney disagrees</a>.&nbsp;<a href="https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/#fnref:beatles" class="footnote-backref" role="doc-backlink">↩︎</a></p>
</li>

</ol>
</div>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=59105&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/03/create-a-table-of-contents-based-on-html-heading-elements/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[ManyTag Colour eInk Badge SDK - Minimum Viable Example for Android]]></title>
		<link>https://shkspr.mobi/blog/2025/02/manytag-colour-eink-badge-sdk-minimum-viable-example-for-android/</link>
					<comments>https://shkspr.mobi/blog/2025/02/manytag-colour-eink-badge-sdk-minimum-viable-example-for-android/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Fri, 28 Feb 2025 12:34:30 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[android]]></category>
		<category><![CDATA[app]]></category>
		<category><![CDATA[eink]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[Kotlin]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=58487</guid>

					<description><![CDATA[Last year, I reviewed a Four-Colour eInk Name Badge - the ManyTag HSN371. The hardware itself is perfectly fine, but the Android app isn&#039;t great. It is complicated, crash-prone, and not available in the app-store.  After some back-and-forth with the manufacturer, they agreed to send me their Android SDK and documentation. Sadly, the PDF they sent me was riddled with errors and the software…]]></description>
										<content:encoded><![CDATA[<p>Last year, I reviewed a <a href="https://shkspr.mobi/blog/2024/11/review-four-colour-eink-name-badge-hsn371-plus-linux-and-android-tips/">Four-Colour eInk Name Badge</a> - the ManyTag HSN371. The hardware itself is perfectly fine, but the Android app isn't great. It is complicated, crash-prone, and not available in the app-store.</p>

<p>After some back-and-forth with the manufacturer, they agreed to send me their Android SDK and documentation. Sadly, the PDF they sent me was riddled with errors and the software library is also a bit dodgy. So, with the help of <a href="https://hades.omg.lol/">Edward Toroshchyn</a> and a hefty amount of automated boiler-plate, I managed to get it working.</p>

<p>The <a href="https://codeberg.org/edent/eInk-SDK/">full code is open source</a>, but here's a quick walk-through of the important bits.</p>

<p>First, the AAR library needs to be imported into the project. Place it in <code>app/libs</code> and then include it in the Gradle build file:</p>

<pre><code class="language-java">dependencies {
    implementation(files("libs/badge_nfc_api-release.aar"))
}
</code></pre>

<p>The key to getting it working is in the Android permissions. It needs Bluetooth, NFC, and location. So the manifest has to contain:</p>

<pre><code class="language-xml">&lt;uses-permission android:name="android.permission.BLUETOOTH"/&gt;
&lt;uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/&gt;
&lt;uses-permission android:name="android.permission.BLUETOOTH_SCAN"/&gt;
&lt;uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/&gt;
&lt;uses-permission android:name="android.permission.NFC"/&gt;
&lt;uses-permission android:name="android.permission.NFC_TRANSACTION_EVENT"/&gt;
&lt;uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/&gt;
&lt;uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/&gt;
</code></pre>

<p>The following imports are needed from the Android Archive library:</p>

<pre><code class="language-java">import cn.manytag.badge_nfc_api.manager.BadgeWriteManager
import cn.manytag.badge_nfc_api.manager.OnNFCReaderCallback
import cn.manytag.badge_nfc_api.manager.OnBluetoothCallBack
import cn.manytag.badge_nfc_api.manager.OnSendImageCallback
</code></pre>

<p>The library needs to be initialised with:</p>

<pre><code class="language-java">val state = BadgeWriteManager.getInstance().init(this)
</code></pre>

<p>When the phone reads the NFC tag, it gets a bunch of information:</p>

<pre><code class="language-java">BadgeWriteManager.getInstance().setOnNFCReaderCallback(object : OnNFCReaderCallback {
    override fun onReaderMessage(i: Int, tag: Tag) {
        if (i == 0) {
            BadgeWriteManager.getInstance().readNFC(tag)
        }
    }

    //  Get the data from the badge
    override fun onReaderType(tag: Tag, isodep: IsoDep, i: Int, type: String, size: String, color: String) {
        if (i == 0) {
            nfcData = """
                NFC Tag Detected!!!
                Tag: $tag
                IsoDep: $isodep
                Type: $type
                Size: $size
                Color: $color
            """.trimIndent()
            colorFromNFC = color
            tagObject = tag
            isoDepObject = isodep
            badgeType = type
            badgeSize = size
        }
    }
})
</code></pre>

<p>The <code>color</code> is most important right now. It says whether the badge is black and white, or black and white and red, or black and white and red and yellow.</p>

<p>After picking an image from the filesystem, it needs to be dithered into the correct colour format:</p>

<pre><code class="language-java">processedBitmap = originalBitmap?.let { bitmap -&gt;
    colorFromNFC?.let { color -&gt;
        BadgeWriteManager.getInstance().processImage(bitmap, color)
    }
}
</code></pre>

<p>Finally, the processed image needs to be converted to bytes and then sent to the badge via Bluetooth:</p>

<pre><code class="language-java">if (processedBitmap != null &amp;&amp; badgeType != null &amp;&amp; badgeSize != null &amp;&amp; colorFromNFC != null) {
    val imgData = BadgeWriteManager.getInstance().getImageData(processedBitmap!!, colorFromNFC!!)

    BadgeWriteManager.getInstance().sentImageResource(
        tagObject!!, isoDepObject!!, imgData, badgeType!!, badgeSize!!, colorFromNFC!!
    )
}
</code></pre>

<p>I realise this is a bit "<a href="https://knowyourmeme.com/memes/how-to-draw-an-owl">draw the rest of the owl</a>" but that should be enough to get you started on building an app which can communicate with these badges.</p>

<p>The app I've built isn't the prettiest in the world but at least it works. It scans a badge, gets its info, picks an image, dithers it, then sends it to the badge.</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/02/badge-app-fs8.png" alt="Screenshot of an app." width="504" height="957" class="aligncenter size-full wp-image-58494">

<p>You can <a href="https://codeberg.org/edent/eInk-SDK/">play with the code on CodeBerg</a>.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=58487&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/02/manytag-colour-eink-badge-sdk-minimum-viable-example-for-android/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Change the way dates are presented in WordPress's admin view]]></title>
		<link>https://shkspr.mobi/blog/2025/02/change-the-way-dates-are-presented-in-wordpresss-admin-view/</link>
					<comments>https://shkspr.mobi/blog/2025/02/change-the-way-dates-are-presented-in-wordpresss-admin-view/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 26 Feb 2025 12:34:21 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[blogging]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[php]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=58427</guid>

					<description><![CDATA[WordPress does not respect an admin&#039;s preferred date format.  Here&#039;s how the admin list of posts looks to me:    I don&#039;t want it to look like that. I want it in RFC3339 format.  I know what you&#039;re thinking, just change the default date display - but that only seems to work in some areas of WordPress. It doesn&#039;t change the column-date format.  Here&#039;s what mine is set to:    So that doesn&#039;t work. …]]></description>
										<content:encoded><![CDATA[<p>WordPress does not respect an admin's preferred date format.</p>

<p>Here's how the admin list of posts looks to me:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/02/WP-Date-Wrong-fs8.png" alt="Column with the date format separated by slashes." width="420" height="674" class="aligncenter size-full wp-image-58437">

<p>I don't want it to look like that. I want it in RFC3339 format.</p>

<p>I know what you're thinking, <a href="https://wordpress.org/documentation/article/customize-date-and-time-format/">just change the default date display</a> - but that only seems to work in some areas of WordPress. It doesn't change the <code>column-date</code> format.  Here's what mine is set to:</p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/02/WP-date-format-fs8.png" alt="Settings screen showing date format set to dashes." width="940" height="414" class="aligncenter size-full wp-image-58432">

<p>So that doesn't work.</p>

<p>Instead, you need to use <a href="https://developer.wordpress.org/reference/hooks/post_date_column_time/">the slightly obscure <code>post_date_column_time</code> filter</a></p>

<p>Add this to your theme's <code>functions.php</code>:</p>

<pre><code class="language-php">//  Admin view - change date format
function rfc3339_post_date_time( $time, $post ) {
    //  Modify the default time format
    $rfc3339_time = date( "Y-m-d H:i", strtotime( $post-&gt;post_date ) );
    return $rfc3339_time;
}
add_filter( "post_date_column_time", "rfc3339_post_date_time", 10, 2 );
</code></pre>

<p>And, hey presto, your date column will look like this:
<img src="https://shkspr.mobi/blog/wp-content/uploads/2025/02/WP-Date-Rigth-fs8.png" alt="Column with the date format separated by dashes." width="420" height="670" class="aligncenter size-full wp-image-58438"></p>

<p>Obviously, you can change that code to whichever date format you prefer.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=58427&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2025/02/change-the-way-dates-are-presented-in-wordpresss-admin-view/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Add a custom icon to Auth0's Custom Social integrations]]></title>
		<link>https://shkspr.mobi/blog/2024/12/add-a-custom-icon-to-auth0s-custom-social-integrations/</link>
					<comments>https://shkspr.mobi/blog/2024/12/add-a-custom-icon-to-auth0s-custom-social-integrations/#respond</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Mon, 09 Dec 2024 12:34:56 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[Auth0]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[oauth]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=54309</guid>

					<description><![CDATA[This is so fucking stupid.  There is no way to update the logo of a custom social connection on Auth0 without using the command line.  On literally every other service I&#039;ve used, there&#039;s a little box to upload a logo. But Okta have a funny idea of what developers want.  And, to make matters worse, their documentation contains an error! They don&#039;t listen to community requests or take bug reports,…]]></description>
										<content:encoded><![CDATA[<p>This is <em>so</em> fucking stupid.</p>

<p>There is no way to update the logo of a custom social connection on Auth0 without using the command line.  On literally every other service I've used, there's a little box to upload a logo. But Okta have a funny idea of what developers want.</p>

<p>And, to make matters worse, <a href="https://auth0.com/docs/authenticate/identity-providers/social-identity-providers/oauth2">their documentation contains an error</a>! They don't listen to community requests or take bug reports, so I'm blogging in the hope that this is useful to you.</p>

<h2 id="the-command"><a href="https://shkspr.mobi/blog/2024/12/add-a-custom-icon-to-auth0s-custom-social-integrations/#the-command">The Command</a></h2>

<pre><code class="language-bash">curl --request PATCH \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Authorization: Bearer eyJhb...ZEQ' \
  --url 'https://whatever.eu.auth0.com/api/v2/connections/con_qwerty123456' \
  --data ' ... '
</code></pre>

<p>You will also need to supply some JSON in the <code>data</code> parameter. I've formatted it to be easier to read than the garbage documentation. <em>All</em> of these fields are mandatory.</p>

<pre><code class="language-json">{
  "options": {
    "client_id": "your-app-id",
    "client_secret": "Shhhhhh!",
    "icon_url": "https://example.com/image.svg",
    "scripts": {
      "fetchUserProfile": "???"
    },
    "authorizationURL": "https://example.com/oauth2/authorize",
    "tokenURL": "https://example.com/oauth2/token",
    "scope": "auth"
  },
  "display_name": "Whatever"
}
</code></pre>

<p>OK, but how do you get all those values?</p>

<ul>
<li>Bearer token:

<ul>
<li><a href="https://auth0.com/docs/secure/tokens/access-tokens/management-api-access-tokens">Create a management token</a></li>
<li>The only scope it needs is <code>update:connections</code></li>
</ul></li>
<li>URl

<ul>
<li>This is your normal Auth0 domain name.</li>
<li>The Connection ID at the end can be found in the dashboard of your social connection<br><img src="https://shkspr.mobi/blog/wp-content/uploads/2024/11/social-fs8.png" alt="Screenshot showing an ID field." width="800" height="211" class="aligncenter size-full wp-image-54310"></li>
</ul></li>
<li>Client ID &amp; Secret

<ul>
<li>You set these in the social connection's dashboard.</li>
</ul></li>
<li><code>icon_url</code>

<ul>
<li>Public link to an image. It can be an SVG.</li>
</ul></li>
<li><code>fetchUserProfile</code>

<ul>
<li>Whatever code you want to run. If you don't want any, you can't leave it blank. So type in a couple of characters.</li>
</ul></li>
<li><code>authorizationURL</code> and <code>tokenURL</code>

<ul>
<li>Wherever you want to redirect users to</li>
</ul></li>
<li><code>display_name</code>

<ul>
<li>What you want to show to the user</li>
</ul></li>
</ul>

<p>This is <em>such</em> a load of bollocks! Is it really that hard for the Okta team to put an input field with "type the URl of your logo"?</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=54309&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/12/add-a-custom-icon-to-auth0s-custom-social-integrations/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Change WordPress Fragment Links in RSS Feeds to be Permalinks]]></title>
		<link>https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks/</link>
					<comments>https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Fri, 06 Dec 2024 12:34:14 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[HTML]]></category>
		<category><![CDATA[php]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=54080</guid>

					<description><![CDATA[Here&#039;s a knotty problem. Lots of my posts use URl Fragments. Those are links which start with #. They allow me to write:  &#60;a href=&#34;#where-is-this-a-problem&#62;Jump to heading&#60;/a&#62;   So when someone clicks on a link, they go straight to the relevant section.  For example, they might want to skip straight to how to fix it.  Isn&#039;t that clever?  Where is this a problem?  This works great when someone is…]]></description>
										<content:encoded><![CDATA[<p>Here's a knotty problem. Lots of my posts use <a href="https://developer.mozilla.org/en-US/docs/Web/URI/Fragment">URl Fragments</a>. Those are links which start with <code>#</code>. They allow me to write:</p>

<pre><code class="language-html">&lt;a href="https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks/#where-is-this-a-problem&gt;Jump%20to%20heading&lt;/a&gt;/code/prepSo%20when%20someone%20clicks%20on%20a%20link,%20they%20a%20href="#where-is-this-a-problem">go straight to the relevant section</a>.  For example, they might want to skip straight to <a href="https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks/#how-to-fix-it">how to fix it</a>.</p>

<p>Isn't that clever?</p>

<h2 id="where-is-this-a-problem"><a href="https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks/#where-is-this-a-problem">Where is this a problem?</a></h2>

<p>This works great when someone is on my website. They're on the page, and a fragment links straight to the correct section of that page.</p>

<p>But some people view this blog in RSS &amp; Atom feeds - and those feeds also power my newsletter.</p>

<p>When those people see a fragment, it is devoid of its original context. So they end up going to some random location, or my homepage.</p>

<h2 id="how-to-fix-it"><a href="https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks/#how-to-fix-it">How to fix it?</a></h2>

<p>Stick this into your WordPress theme's <code>functions.php</code> file:</p>

<pre><code class="language-php">//  In the RSS feed, change #whatever to &lt;permalink&gt;#whatever
function rewrite_fragment_links_in_rss($content) {
    global $post;

    //  Ensure this is a feed
    if ( is_feed() &amp;&amp; $post instanceof WP_Post ) {
        //  Get the permalink
        $base_url = get_permalink( $post );

        //  Regex to get href="https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks/#%20%20%20%20%20%20%20%20$content%20=%20preg_replace_callback(%20%20%20%20%20%20%20%20%20%20%20%20"/href=["\']#([^"\']+)["\']/',
            function ( $matches ) use ( $base_url ) {
                return 'href="' . esc_url( $base_url . '#' . $matches[1] ) . '"';
            },
            $content
        );
    }

    return $content;
}

//  Hook into feed filters for both excerpts and full content
add_filter( "the_excerpt_rss",  "rewrite_fragment_links_in_rss" );
add_filter( "the_content_feed", "rewrite_fragment_links_in_rss" );
</code></pre>

<p>That listens out for the RSS feed being generated and replaces <code>#whatever</code> with <code>https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks#whatever</code></p>

<p>Nifty!</p>

<p>Hopefully, if you click on the links in my emails and feeds, it should take you to the right place now.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=54080&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/12/change-wordpress-fragment-links-in-rss-feeds-to-be-permalinks/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[An Easy Guide To BlueSky Verification]]></title>
		<link>https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/</link>
					<comments>https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Tue, 19 Nov 2024 12:34:21 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[BlueSky]]></category>
		<category><![CDATA[BSky]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[tutorial]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=54022</guid>

					<description><![CDATA[The new Twitter-Wannabe BlueSky has an interesting approach to verifying accounts. Rather than you sending in your passport, or paying a 3rd party, or bribing an employee - you can self-verify for free!  This opens up verification to small organisations, individuals, and anyone who wants to prove who they are. Brilliant!  Verification means that your @username will change to @Your.Website.com -…]]></description>
										<content:encoded><![CDATA[<p>The new Twitter-Wannabe <a href="https://bsky.app/">BlueSky</a> has an interesting approach to verifying accounts. Rather than you sending in your passport, or paying a 3rd party, or bribing an employee - you can self-verify <em>for free</em>!</p>

<p>This opens up verification to small organisations, individuals, and anyone who wants to prove who they are. Brilliant!</p>

<p>Verification means that your <code>@username</code> will change to <code>@Your.Website.com</code> - this means that everyone can see your BlueSky account is owned by that specific website. When you change your name, you keep all your followers and posts.</p>

<p>Here are some organisations and people at risk of impersonation who have already done this:</p>

<ul>
<li>UK Newspaper <a href="https://bsky.app/profile/theguardian.com">https://bsky.app/profile/theguardian.com</a></li>
<li>Trade Union <a href="https://bsky.app/profile/utaw.tech">https://bsky.app/profile/utaw.tech</a></li>
<li>Labour MPs <a href="https://bsky.app/profile/sarahowen.org.uk">https://bsky.app/profile/sarahowen.org.uk</a></li>
<li>Small Publisher <a href="https://bsky.app/profile/canongate.co.uk">https://bsky.app/profile/canongate.co.uk</a></li>
<li>Fun Website <a href="https://bsky.app/profile/openbenches.org">https://bsky.app/profile/openbenches.org</a></li>
</ul>

<p>There is an easy way to get verified and <a href="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial">a hard way</a>.  Let's do the easy way!</p>

<h2 id="1-sign-up-for-bluesky"><a href="https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/#1-sign-up-for-bluesky">1) Sign Up For BlueSky</a></h2>

<p>Sign up and register a username. This can be anything you want. For example, I registered <code>edent.bsky.social</code></p>

<h2 id="2-change-your-user-id"><a href="https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/#2-change-your-user-id">2) Change Your User ID</a></h2>

<p>Follow these steps:</p>

<ol>
<li>Visit <a href="https://bsky.app/settings">https://bsky.app/settings</a></li>
<li>Scroll down and select "Change Handle"</li>
<li>Click "I have my own domain"</li>
<li>Select "No DNS Panel". The screen should look like this:

<ul>
<li><img src="https://shkspr.mobi/blog/wp-content/uploads/2024/11/Change-Handle-fs8.png" alt="Change Handle screen." width="1440" height="1408" class="aligncenter size-full wp-image-54026"></li>
</ul></li>
<li>Type in the domain name you want to verify</li>
<li>Click "Copy File Contents"</li>
</ol>

<p>Keep this web page open.</p>

<h2 id="3-copy-and-save-your-did"><a href="https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/#3-copy-and-save-your-did">3) Copy and Save Your DID</a></h2>

<p>On your clipboard, you will have a bit of text which looks like this <code>did:plc:dip7ueksh627fxacagfrdyz2</code></p>

<p>Save it in a text file called <code>atproto-did</code></p>

<img src="https://shkspr.mobi/blog/wp-content/uploads/2024/11/atproto-fs8.png" alt="Screenshot of a text editor." width="623" height="183" class="aligncenter size-full wp-image-54024">

<p>It is very important that the file doesn't end with <code>.txt</code> - it must be called <code>atproto-did</code> and nothing else.</p>

<p>The file should only contain the text you copied. Nothing else.</p>

<h2 id="4-upload-the-file-to-your-website"><a href="https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/#4-upload-the-file-to-your-website">4) Upload The File To Your Website</a></h2>

<p>This is the only technical bit of the process.  You need the ability to upload a file to your website.  I don't know whether you use FTP, a control panel, or email things to the person who manages your site.</p>

<p>You need to save the <code>atproto-did</code> file in a folder called <code>/.well-known/</code></p>

<p>If that folder doesn't exist, create it. The folder name <em>must</em> be typed exactly like that, with the dot at the start.</p>

<p>You can check it has worked by visiting <code>YourWebsite.com/.well-known/atproto-did</code></p>

<p>If you can see your DID, it worked!</p>

<h2 id="5-change-your-username"><a href="https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/#5-change-your-username">5) Change Your Username</a></h2>

<p>Go back to the "Change Handle" web page you opened in Step 2.</p>

<p>Click "Verify Text File" and then "Update".</p>

<h2 id="6-thats-it"><a href="https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/#6-thats-it">6) That's It!</a></h2>

<p>Feel free to share this guide with people and organisations who want to get verified on BSky.</p>

<p>Leave a comment if you found it useful or want me to clarify something.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=54022&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/11/an-easy-guide-to-bluesky-verification/feed/</wfw:commentRss>
			<slash:comments>14</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[A simple and free way to post RSS feeds to Threads]]></title>
		<link>https://shkspr.mobi/blog/2024/11/a-simple-and-free-way-to-post-rss-feeds-to-threads/</link>
					<comments>https://shkspr.mobi/blog/2024/11/a-simple-and-free-way-to-post-rss-feeds-to-threads/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 06 Nov 2024 12:30:00 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[api]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[php]]></category>
		<category><![CDATA[rss]]></category>
		<category><![CDATA[Threads]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=53776</guid>

					<description><![CDATA[Threads is Meta&#039;s attempt to disrupt the social media landscape. Whether you care for it or not, there are a lot of users there. And, sometimes, you have to go where the audience is.  Here&#039;s how I build a really simple PHP tool to post to Threads using their official API.  This allows you to send a single status update programatically, or regularly send new items from your RSS feed to an account. …]]></description>
										<content:encoded><![CDATA[<p><a href="https://threads.net">Threads</a> is Meta's attempt to disrupt the social media landscape. Whether you care for it or not, there are a lot of users there. And, sometimes, you have to go where the audience is.</p>

<p>Here's how I build a really simple PHP tool to post to Threads using their official API.  This allows you to send a single status update programatically, or regularly send new items from your RSS feed to an account.</p>

<p>You can see the bot in action at <a href="https://www.threads.net/@openbenches_org">https://www.threads.net/@openbenches_org</a></p>

<h2 id="get-the-code"><a href="https://shkspr.mobi/blog/2024/11/a-simple-and-free-way-to-post-rss-feeds-to-threads/#get-the-code">Get the code</a></h2>

<p>The <a href="https://codeberg.org/edent/RSS2Threads">code is available as Open Source</a>. It should be fairly self explanatory for a moderately competent programmer - but feel free to open an issue if you think it is confusing.</p>

<h2 id="get-it-working"><a href="https://shkspr.mobi/blog/2024/11/a-simple-and-free-way-to-post-rss-feeds-to-threads/#get-it-working">Get it working</a></h2>

<ol>
<li>Create an account on Threads (duh!) - this involves signing up to Instagram.</li>
<li>Create a Facebook Developer account.</li>
<li>Create <a href="https://developers.facebook.com/apps/">an app which requests the Threads posting API</a>.

<ul>
<li>You do not need to publish this app if you're only using it yourself.</li>
</ul></li>
<li>Create a User Token using the "User Token Generator"</li>
<li>Get your <a href="https://developers.facebook.com/docs/threads/threads-profiles/">Threads account's User ID</a> with:

<ul>
<li><code>curl -s -X GET "https://graph.threads.net/v1.0/me?ields=id,username,name,threads_profile_picture_url,threads_biography&amp;access_token=TOKEN"</code></li>
<li>(Yes, <code>ields</code>. If you use <code>fields</code> you get something else!)</li>
</ul></li>
<li>Clone the <a href="https://codeberg.org/edent/RSS2Threads">RSS2Threads repo</a> and stick it on a webserver somewhere.</li>
<li>Rename <code>config.sample.php</code> to <code>config.php</code> and add your feeds' details, along with your ID and Token.</li>
<li>Run <code>php rss2threads.php</code></li>
</ol>

<p>And that's it!</p>

<p>The service will download your RSS feed, check if it has posted the entries to Threads and, if not, post them.</p>

<h2 id="how-i-built-it"><a href="https://shkspr.mobi/blog/2024/11/a-simple-and-free-way-to-post-rss-feeds-to-threads/#how-i-built-it">How I built it</a></h2>

<p>Shoulders of giants, and all that! I have been using <a href="https://codeberg.org/nesges/rss2bsky">Thomas Nesges's RSS2BSky</a> for auto-posting to BlueSky. I also used <a href="https://github.com/0xjessel/threads-bart-bot">Jesse Chen's Python Threads example code</a>.</p>

<p>Posting is a two stage process.</p>

<ol>
<li>POST the URl encoded text to:

<ul>
<li><code>https://graph.threads.net/USER_ID/threads?text=My%20post&amp;access_token=TOKEN&amp;media_type=TEXT</code></li>
<li>If successful, the API will return a Creation ID.</li>
</ul></li>
<li>POST the Creation ID to:

<ul>
<li><code>https://graph.threads.net/USER_ID/threads_publish?creation_id=CREATION_ID&amp;access_token=TOKEN</code></li>
<li>If successful, the API will return a Post ID.</li>
</ul></li>
</ol>

<p>Successful RSS posts are stored in a simple SQLite database. If an RSS entry was posted successfully, it won't be reposted.</p>

<h2 id="caveats"><a href="https://shkspr.mobi/blog/2024/11/a-simple-and-free-way-to-post-rss-feeds-to-threads/#caveats">Caveats</a></h2>

<ul>
<li>There are no unit tests, fuzzing, or exception handling. It's assumed you're running this on well-formed RSS that you trust.</li>
<li>The Threads API is <strong>slow!</strong> It takes ages for a post to be sent to it.</li>
<li><a href="https://developers.facebook.com/docs/development/create-an-app/threads-use-case">Getting a Threads API token</a> is <strong>difficult</strong> and the margin is too small for me to explain it here.</li>
</ul>

<h2 id="feedback"><a href="https://shkspr.mobi/blog/2024/11/a-simple-and-free-way-to-post-rss-feeds-to-threads/#feedback">Feedback</a></h2>

<p>Please leave a comment here or <a href="https://codeberg.org/edent/RSS2Threads">on the code repository</a>.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=53776&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/11/a-simple-and-free-way-to-post-rss-feeds-to-threads/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[Using phpList for a blog's newsletter]]></title>
		<link>https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/</link>
					<comments>https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Thu, 31 Oct 2024 12:34:36 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[email]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[newsletter]]></category>
		<category><![CDATA[php]]></category>
		<category><![CDATA[rss]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=53583</guid>

					<description><![CDATA[Some people like to receive this blog via email. I previously used JetPack to send out subscriber messages - but it became increasingly clear that Automattic isn&#039;t a good steward of such things.  I couldn&#039;t find any services which would let me send a few thousand subscribers a few emails per week, at zero cost.  So, redecentralise!  I installed phpList which is an open source email campaign tool. …]]></description>
										<content:encoded><![CDATA[<p>Some people like to receive this blog via email. I previously used JetPack to send out subscriber messages - but it became increasingly clear that Automattic isn't a good steward of such things.  I couldn't find any services which would let me send a few thousand subscribers a few emails per week, at zero cost.</p>

<p>So, redecentralise!</p>

<p>I installed <a href="https://www.phplist.org/">phpList</a> which is an open source email campaign tool.  My webhost - <a href="https://krystal.io/">Krystal</a> - had a one-click install option. But, phpList isn't quite one-click for sending out a regular blog newsletter.  <a href="https://discuss.phplist.org/t/daily-rss-problems-there-are-no-feed-items-that-will-be-included-in-the-first-campaign/9835/">I found the set-up to be quite confusing</a>, so here are the steps I took to turn an RSS feed into an Email Newsletter for free.</p>

<h2 id="install-the-plugins"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#install-the-plugins">Install the plugins</a></h2>

<ol>
<li>Navigate to Config → Manage plugins</li>
<li>Enable "CommonPlugin"</li>
<li>Add the <a href="https://resources.phplist.com/plugin/rssfeed">RSS Feed Plugin</a> using the Plugin package URL <code>https://github.com/bramley/phplist-plugin-rssfeed/archive/master.zip</code></li>
</ol>

<h2 id="configure-the-rss-feed-plugin"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#configure-the-rss-feed-plugin">Configure the RSS Feed Plugin</a></h2>

<ol>
<li>Navigate to Config → Settings</li>
<li>Scroll down to the RSS Settings</li>
<li>Set both Minimum <em>and</em> Maximum number of items to 1<br><img src="https://shkspr.mobi/blog/wp-content/uploads/2024/10/rsssettings-fs8.png" alt="RSS Settings Screen." width="888" height="450" class="aligncenter size-full wp-image-53584"><br>That will ensure you only send the latest RSS item as your newsletter.</li>
<li>Set "Use the item summary content (the description or summary element) instead of the content element" to "No". This will allow the full text of the RSS item to be sent.</li>
</ol>

<h2 id="edit-config-php"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#edit-config-php">Edit <code>config.php</code></a></h2>

<p>For some reason, you need to manually edit this file in a text editor, rather than a GUI.</p>

<ol>
<li>Set <code>define('USE_REPETITION', 1);</code> - this allows the newsletter to be sent whenever there is a new RSS item.</li>
<li>Set <code>define('CLICKTRACK', 0);</code> - this removes tracking links from your emails. I don't care who opens my emails or what they click on.</li>
</ol>

<h2 id="add-the-campaign"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#add-the-campaign">Add The Campaign</a></h2>

<ol>
<li>Go to  Campaigns → Send a campaign.</li>
<li>Start a new campaign.</li>
</ol>

<h3 id="tab-1"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#tab-1">Tab 1</a></h3>

<ol>
<li>Campaign subject should be <code>[RSSITEM:TITLE]</code> - that will make the subject line the same as your <strong>post</strong>'s title</li>
<li>Compose message should be <code>[RSS]</code> - that will ensure the contents come from your RSS feed.</li>
</ol>

<h3 id="tab-2"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#tab-2">Tab 2</a></h3>

<ol>
<li>Add your RSS feed's URl</li>
<li>Order items "Newest" first - to get the most recent item.</li>
<li>Add a custom HTML template. I used one from <a href="https://emailframe.work/">https://emailframe.work/</a></li>
</ol>

<pre><code class="language-html">&lt;div style="margin:0; padding:0; background-color:#F2F2F2;"&gt;
  &lt;h1&gt;&lt;a href="[URL]"&gt;[TITLE]&lt;/a&gt;&lt;/h1&gt;
  &lt;table width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="#F2F2F2"&gt;
      &lt;tr&gt;
          &lt;td valign="top"&gt;
              [CONTENT]
          &lt;/td&gt;
      &lt;/tr&gt;
  &lt;/table&gt;
&lt;/div&gt;
</code></pre>

<h3 id="tab-3"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#tab-3">Tab 3</a></h3>

<ol>
<li>Send as HTML</li>
</ol>

<h3 id="tab-4"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#tab-4">Tab 4</a></h3>

<ol>
<li>"Stop sending after" - choose the furthest date in the future possible.</li>
<li>"Repeat campaign every" - I chose "hour". That should check the RSS feed each hour.</li>
</ol>

<h3 id="tab-5"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#tab-5">Tab 5</a></h3>

<ol>
<li>"Lists" - pick the email list you want to send from.</li>
</ol>

<h3 id="tab-6"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#tab-6">Tab 6</a></h3>

<ol>
<li>You should be finished! It will tell you if there are any errors.</li>
<li>Place the campaign in the queue for processing.</li>
</ol>

<h2 id="wordpress-sign-up-form"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#wordpress-sign-up-form">WordPress Sign Up Form</a></h2>

<p>You can either redirect users to your phpList subscription page, or put a form directly on your site.</p>

<pre><code class="language-html">&lt;form method="post" action="/YourSubscribePage/?p=subscribe&amp;id=1" name="subscribeform"&gt;
    &lt;label for="email"&gt;Email address:&lt;/label&gt;
    &lt;input type="email" name="email" required="required" placeholder="" size="40" id="email"&gt;
    &lt;input type="hidden" name="htmlemail" value="1"&gt;
    &lt;input type="hidden" name="list[2]" value="signup"&gt;
    &lt;input type="hidden" name="listname[2]" value="newsletter"&gt;
    &lt;div style="display:none"&gt;
        &lt;input type="text" name="VerificationCodeX" value="" size="20"&gt;
    &lt;/div&gt;
    &lt;input type="submit" name="subscribe" value="Subscribe"&gt;
&lt;/form&gt;
</code></pre>

<p>Adjust the hidden parameters based on your list.</p>

<p>If in doubt, go to Config →  Subscribe pages, and generate a new subscribe page. Then copy the form from that.</p>

<h2 id="cron-jobs"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#cron-jobs">Cron Jobs</a></h2>

<p>You need two cron jobs set up.</p>

<h3 id="update-the-rss-feed"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#update-the-rss-feed">Update the RSS feed</a></h3>

<p>I run this every hour:</p>

<p><code>/usr/bin/php /path/to/YourSubscribePage/admin/index.php -p get -m RssFeedPlugin -c /path/to/YourSubscribePage/config/config.php</code></p>

<h3 id="process-the-queue"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#process-the-queue">Process the Queue</a></h3>

<p>I run this a few minutes after the RSS feed is updated</p>

<p><code>/usr/bin/php -q /path/to/YourSubscribePage/admin/index.php -p processqueue -c /path/to/YourSubscribePage/config/config.php &gt;/dev/null</code></p>

<h2 id="and-then"><a href="https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/#and-then">And then...</a></h2>

<p>That <em>should</em> be it.  There are lots of options which you can fiddle around with. But the above should be enough to get your first newsletter out.</p>

<p>Huge thanks to <a href="https://dcameron.me.uk/">Duncan Cameron</a> for graciously answering my noddy questions and helping me out with the config.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=53583&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/10/using-phplist-for-a-blogs-newsletter/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[HDR on a Pioneer VSX-933]]></title>
		<link>https://shkspr.mobi/blog/2024/05/hdr-on-a-pioneer-vsx-933/</link>
					<comments>https://shkspr.mobi/blog/2024/05/hdr-on-a-pioneer-vsx-933/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Wed, 29 May 2024 11:34:49 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[av]]></category>
		<category><![CDATA[hdmi]]></category>
		<category><![CDATA[Home Cinema]]></category>
		<category><![CDATA[HowTo]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=50627</guid>

					<description><![CDATA[I bloody hate hardware manufacturers. I wanted to use HDR on my PlayStation 5. The console supports it, my TV supports it, my amp supports it, my cables support it. Yet it wasn&#039;t working.  I tried everything - updating firmware, replacing cables, and even reading the manual. Nothing.  And then I stumbled on the answer thanks to a random forum post.  Perform the following procedure when the unit…]]></description>
										<content:encoded><![CDATA[<p>I bloody hate hardware manufacturers. I wanted to use HDR on my PlayStation 5. The console supports it, my TV supports it, my amp supports it, my cables support it. Yet it wasn't working.  I tried everything - updating firmware, replacing cables, and even <em>reading the manual</em>. Nothing.</p>

<p>And then I stumbled on the answer thanks to a <a href="https://www.reddit.com/r/hometheater/comments/dx3wf6/pioneer_vsx933_not_able_to_display_4k_60hz/fci6e1g/">random forum post</a>.</p>

<blockquote><p>Perform the following procedure when the unit is on.</p>

<ol>
<li><p>While pressing DIMMER on the main unit, press AUTO/DIRECT to display the current setting on the display. While this is being displayed, while pressing DIMMER on the main unit, repeatedly press AUTO/DIRECT to switch the setting.</p></li>
<li><p>To exit the settings, release your finger. After a few seconds, the display goes out and the switching is complete.</p></li>
</ol></blockquote>

<p>Once the setting was changed to "Enhanced" HDR worked! But why isn't it in the manual? A bit of searching for the text finds a file called <a href="https://jp.pioneer-audiovisual.com/manual/sup/upd/hdmi_4k_pio.pdf">manual/<strong>sup/upd</strong>/hdmi_4k_pio.pdf</a> .</p>

<p>So I assume that this is a <em>supplement</em> meant to <em>update</em> the original manual - it is mentioned as new functionality <a href="https://assets.pioneerhomeusa.com/product-firmware/Firmware_Update_VSX-933_VSX-LX103_02-02-2022.pdf?v=1684719363">introduced after a firmware update</a>. But why isn't it in the <em>main</em> manual?</p>

<p>If you visit the <a href="https://emea.pioneer-av.com/vsx-933">VSX-933 product page</a> you can download the manual, but there's no mention of a supplement or an update.  The original manual was released in 2018, and the supplement in 2019.</p>

<p>I wonder what other features this amp is hiding that Pioneer simply haven't bothered to tell anyone about?</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=50627&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/05/hdr-on-a-pioneer-vsx-933/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		
			</item>
		<item>
		<title><![CDATA[link rel="alternate" type="text/plain"]]></title>
		<link>https://shkspr.mobi/blog/2024/05/link-relalternate-typetext-plain/</link>
					<comments>https://shkspr.mobi/blog/2024/05/link-relalternate-typetext-plain/#comments</comments>
				<dc:creator><![CDATA[@edent]]></dc:creator>
		<pubDate>Fri, 10 May 2024 11:34:57 +0000</pubDate>
				<category><![CDATA[/etc/]]></category>
		<category><![CDATA[HowTo]]></category>
		<category><![CDATA[php]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://shkspr.mobi/blog/?p=50490</guid>

					<description><![CDATA[Hot on the heels of yesterday&#039;s post, I&#039;ve now made all of this blog available in text-only mode.  Simply append .txt to the URl of any page and you&#039;ll get back the contents in plain UTF-8 text. No formatting, no images (although you can see the alt text), no nothing!   Front page https://shkspr.mobi/blog/.txt This blog post https://shkspr.mobi/blog/2024/05/link-relalternate-typetext-plain/.txt A …]]></description>
										<content:encoded><![CDATA[<p>Hot on the heels of <a href="https://shkspr.mobi/blog/2024/05/a-completely-plaintext-wordpress-theme/">yesterday's post</a>, I've now made all of this blog available in text-<em>only</em> mode.</p>

<p>Simply append <code>.txt</code> to the URl of <strong>any</strong> page and you'll get back the contents in plain UTF-8 text. No formatting, no images (although you can see the alt text), no nothing!</p>

<ul>
<li>Front page <a href="https://shkspr.mobi/blog/.txt"></a><a href="https://shkspr.mobi/blog/.txt">https://shkspr.mobi/blog/.txt</a></li>
<li>This blog post <a href="https://shkspr.mobi/blog/2024/05/link-relalternate-typetext-plain/.txt"></a><a href="https://shkspr.mobi/blog/2024/05/link-relalternate-typetext-plain/.txt">https://shkspr.mobi/blog/2024/05/link-relalternate-typetext-plain/.txt</a></li>
<li>A tag <a href="https://shkspr.mobi/blog/tag/solar.txt"></a><a href="https://shkspr.mobi/blog/tag/solar.txt">https://shkspr.mobi/blog/tag/solar.txt</a></li>
</ul>

<p>This was slightly tricky to get right!  While there might be an easier way to do it, here's how I got it to work.</p>

<p>Firstly, when someone requests <code>/whatever.txt</code>, WordPress is going to 404 - because that page doesn't exist. So, my theme's <code>functions.php</code>, detects any URls which end in <code>.txt</code> and redirects it to a different template.</p>

<pre><code class="language-php">//  Theme Switcher
add_filter( "template_include", "custom_theme_switch" );
function custom_theme_switch( $template ) {

    //  What was requested?
    $requested_url = $_SERVER["REQUEST_URI"];

    //  Check if the URL ends with .txt
    if ( substr( $requested_url, -4 ) === ".txt")  {    
        //  Get the path to the custom template
        $custom_template = get_template_directory() . "/templates/txt-template.php";
        //  Check if the custom template exists
        if ( file_exists( $custom_template ) ) {
            return $custom_template;
        }
    }

    //  Return the default template
    return $template;
}
</code></pre>

<p>The <code>txt-template.php</code> file is more complex.  It takes the requested URl, strips off the <code>.txt</code>, matches it against the WordPress rewrite rules, and then constructs the <code>WP_Query</code> which would have been run if the <code>.txt</code> wasn't there.</p>

<pre><code class="language-php">//  Run the query for the URl requested
$requested_url = $_SERVER['REQUEST_URI'];    // This will be /whatever
$blog_details = wp_parse_url( home_url() );  // Get the blog's domain to construct a full URl
$query = get_query_for_url( 
    $blog_details["scheme"] . "://" . $blog_details["host"] . substr( $requested_url, 0, -4 )
);

function get_query_for_url( $url ) {
    //  Get all the rewrite rules
    global $wp_rewrite;

    //  Get the WordPress site URL path
    $site_path = parse_url( get_site_url(), PHP_URL_PATH ) . "/";

    //  Parse the requested URL
    $url_parts = parse_url( $url );

    //  Remove the domain and site path from the URL
    //  For example, change `https://example.com/blog/2024/04/test` to just `2024/04/test`
    $url_path = isset( $url_parts['path'] ) ? str_replace( $site_path, '', $url_parts['path'] ) : '';

    //  Match the URL against WordPress rewrite rules
    $rewrite_rules = $wp_rewrite-&gt;wp_rewrite_rules();
    $matched_rule = false;

    foreach ( $rewrite_rules as $pattern =&gt; $query ) {
        if ( preg_match( "#^$pattern#", $url_path, $matches ) ) {
            $matched_rule = $query;
            break;
        }
    }

    //  Replace each occurrence of $matches[N] with the corresponding value
    foreach ( $matches as $key =&gt; $value ) {
        $matched_rule = str_replace( "\$matches[{$key}]", $value, $matched_rule );
    }

    //  Turn the query string into a WordPress query
    $query_params = array();
    parse_str(
        parse_url( $matched_rule, PHP_URL_QUERY), 
        $query_params
    );

    //  Construct a new WP_Query object using the extracted query parameters
    $query = new WP_Query($query_params);

    //  Return the result of the query
    return $query;
}
</code></pre>

<p>From there, it's a case of iterating over the posts returned by the query. You can <a href="https://gitlab.com/edent/blog-theme/-/blob/master/templates/txt-template.php">see the full code on my GitLab</a>.</p>
<img src="https://shkspr.mobi/blog/wp-content/themes/edent-wordpress-theme/info/okgo.php?ID=50490&HTTP_REFERER=RSS" alt="" width="1" height="1" loading="eager">]]></content:encoded>
					
					<wfw:commentRss>https://shkspr.mobi/blog/2024/05/link-relalternate-typetext-plain/feed/</wfw:commentRss>
			<slash:comments>4</slash:comments>
		
		
			</item>
	</channel>
</rss>
