1<!DOCTYPE HTML> 2<html lang="en-US"> 3<head> 4 <meta charset="UTF-8"> 5 <title>Welcome! :: Mark Hamstra | MODX Development & Services</title> 6 <base href="https://www.markhamstra.com/"> 7 <link rel="canonical" href="https://www.markhamstra.com/" /> 8 9 10 11 <!-- View port, content type and compatible mode --> 12 <meta name="viewport" content="width=device-width, initial-scale=1" /> 13 <meta name="Content-Type" content="text/html; charset=UTF-8" /> 14 <!-- Some meta tags.. I like simplicity, so only the ones that I think add value go here --> 15 <meta name="description" content="Hi there! Nice to meet you - my name is Mark Hamstra and I am a MODX Revolution Developer working freelance for other companies and agencies. I also write stuff about MODX. Can I help you?" /> 16 <meta http-equiv="last-modified" content="Tue 31 Mar 18:06 +0200" /> 17 <meta name="author" content="Mark Hamstra | MODX Development & Services" /> 18 <meta name="copyright" content="Copyright (c) Mark Hamstra | MODX Development & Services" /> 19 <meta name="robots" content="index, follow" /> 20 <link rel="shortcut icon" type="image/x-icon" href="//www.markhamstra.com/favicon.png" /> 21 <link rel="alternate" type="application/rss+xml" href="http://feeds.feedburner.com/modx-blog?format=xml" title="Mark Hamstra | MODX Development & Services RSS Feed" /> 22 <!-- Facebook OpenGraph --> 23 <meta property="og:title" content="Welcome!" /> 24 <meta property="og:url" content="https://www.markhamstra.com/" /> 25 <meta property="og:type" content="article" /> 26 <meta property="og:site_name" content="Mark Hamstra | MODX Development & Services" /> 27 28 29 30 31 <link rel="stylesheet" href="https://www.markhamstra.com/next/assets/styles/main.css?v=20200719" media="all" /> 32 <link rel="stylesheet" href="https://www.markhamstra.com/next/assets/styles/prism.css?v=20181129" media="all" /> 33 34 <link rel="stylesheet" href="https://www.markhamstra.com/next/assets/styles/print.css" media="print" /> 35 36 37</head> 38<body> 39 <header style="margin-bottom: 0; border-bottom: 0;"> 40 <div class="inner"> 41 <div id="logo"> 42 <a href="https://www.markhamstra.com/" title="Mark Hamstra | MODX Development & Services"><img width="290" height="116" src="https://www.markhamstra.com/next/assets/images/site_logo_trans.png" alt="Mark Hamstra | MODX Development & Services" /></a> 43 </div> 44 45 <nav> 46 <ul> 47 <li class="first"><a href="https://www.markhamstra.com/about/" title="About Mark" >About</a></li> 48<li><a href="https://www.markhamstra.com/thoughts/" title="Thoughts" >Thoughts</a></li> 49<li><a href="https://www.markhamstra.com/modx/" title="MODX" >MODX</a></li> 50<li><a href="https://www.markhamstra.com/xpdo/" title="xPDO" >xPDO</a></li> 51<li><a href="https://www.markhamstra.com/extjs/" title="ExtJS" >ExtJS</a></li> 52<li class="last"><a href="https://www.markhamstra.com/food/" title="Food" >Food</a></li> 53 54 <li id="search"> 55 <form method="POST" action="https://www.markhamstra.com/search/"> 56 <input type="text" name="query" value="" id="search-query" placeholder="Search..." /> 57 <input type="submit" value="»" id="search-submit" /> 58</form> 59 </li> 60 </ul> 61 </nav> 62 </div> 63 </header> 64 <!-- Adapted from https://codepen.io/followingsea/pres/podOWod?editors=1000 - feel free to reuse --> 65<a href="https://secure.avaaz.org/campaign/en/stop_the_war_loc/" target="_blank" rel="noopener" style="width:100%;height:10vh;background-color:black;background:linear-gradient(to bottom, #0057B7 50%,#FFDD00 50%);display:flex;align-items:center;justify-content:center;margin-top:0; margin-bottom: 0;"> 66 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1239 423" height="8vh"><title>Stand With Ukraine. Stop Putin. Stop War.</title><path fill="#FFDD00" d="M0 139.6 26.5 121c7.3 11.7 17.8 17 32 17 15.5 0 26-6.3 26-15.1 0-12.1-19.7-21.6-37-30.2C28.9 83.5 8.6 73.4 8.6 47.1 8.6 18.5 32.7-.3 63-.3 85.5-.3 104.1 9 111.6 29L87.2 46c-4.9-9.8-14.4-13.8-23.6-13.8-12.4 0-21.6 7.2-21.6 15.7 0 9.8 12.5 13.2 31.4 22.1 27.3 12.9 44.5 26.8 44.5 52.2 0 30.1-24.6 49.2-59.8 49.2C31 171.3 11 160.1 0 139.6zM242.2 36h-40.5v132.1h-33.2V36h-40.6V3.2h114.4V36zM245.5 85.8c0-47.4 38.6-86 85.7-86s85.8 38.7 85.8 86c0 47-38.7 85.4-85.9 85.4-47 0-85.6-38.4-85.6-85.4zm138-.2c0-28.8-23.5-52.4-52.4-52.4-28.7 0-52.2 23.5-52.2 52.4 0 28.6 23.4 52.1 52.2 52.1 28.9 0 52.4-23.4 52.4-52.1zM438.5 3.2h57.9c31.9 0 58 25.1 58 55.8 0 30.6-26.1 55.5-58 55.5h-24.8v53.6h-33.1V3.2zm82.8 55.4c0-12.5-11.1-22.6-24.9-22.6h-24.8v45.6l24.8.1c13.7.1 24.9-10.3 24.9-23.1zM634.4 3.2h57.9c31.9 0 58 25.1 58 55.8 0 30.6-26.1 55.5-58 55.5h-24.8v53.6h-33.1V3.2zm82.8 55.4c0-12.5-11.2-22.6-24.9-22.6h-24.8v45.6l24.8.1c13.7.1 24.9-10.3 24.9-23.1zM767.5 107.1V3.2h33.3V107c0 18 14.2 31.1 30.2 31.1s30.4-13.2 30.4-31.1V3.2h33.3v103.9c0 37.3-28.5 64.5-63.6 64.5-35.1.1-63.6-27.2-63.6-64.5zM1026.3 36h-40.5v132.1h-33.2V36H912V3.2h114.4V36zM1044.9 3.2h33.1v164.9h-33.1V3.2zM1239.3 2.8v168.4h-1.5L1139.9 76v92.4h-33.1V.1h1.6l97.7 95V2.8h33.2z"/><path fill="#0057B7" d="m80.8 391 26.5-18.6c7.3 11.7 17.8 17 32 17 15.5 0 26-6.3 26-15.1 0-12.1-19.7-21.6-37-30.2-18.5-9.2-38.9-19.3-38.9-45.6 0-28.5 24.1-47.4 54.4-47.4 22.5 0 41.1 9.3 48.6 29.3L168 297.5c-4.9-9.8-14.4-13.8-23.6-13.8-12.4 0-21.6 7.2-21.6 15.7 0 9.8 12.5 13.2 31.4 22.1 27.3 12.9 44.5 26.8 44.5 52.2 0 30.1-24.6 49.2-59.8 49.2-27.1-.2-47.1-11.4-58.1-31.9zM323 287.4h-40.5v132.1h-33.2V287.4h-40.6v-32.8H323v32.8zM326.3 337.2c0-47.4 38.6-86 85.7-86s85.8 38.7 85.8 86c0 47-38.7 85.4-85.9 85.4-47.1 0-85.6-38.4-85.6-85.4zm138-.2c0-28.8-23.5-52.4-52.4-52.4-28.7 0-52.2 23.5-52.2 52.4 0 28.6 23.4 52.1 52.2 52.1 28.8 0 52.4-23.4 52.4-52.1zM519.3 254.6h57.9c31.9 0 58 25.1 58 55.8 0 30.6-26.1 55.5-58 55.5h-24.8v53.6h-33.1V254.6zM602 310c0-12.5-11.1-22.6-24.9-22.6h-24.8V333l24.8.1c13.8.1 24.9-10.3 24.9-23.1zM703 254.6h35.7l25.4 83.2 35.4-82.6h.6l35.3 82.4 25.3-83.1h35.8l-53.7 166.9h-1.9l-41.4-89.6-40.8 89.6h-1.8L703 254.6zM982.4 396.5h-66l-10.5 23h-35.8l78.8-169h1l78.9 169h-35.9l-10.5-23zm-12.9-28.3-20-44.1-20.2 44.1h40.2zM1130.9 419.5l-52.5-77.9v77.9h-33.1l-.1-164.9h53.8c30.8 0 56.1 23.7 56.1 53.4 0 21.2-12.8 39.7-30.5 45.9l46.9 65.6h-40.6zm-52.6-90.7 21.3.1c11.3.1 21.3-9 21.3-20.8s-10.1-20.8-21.3-20.8h-21.3v41.5z"/></svg> 67</a> 68 <div id="outer"> 69 <div id="wrapper"> 70<div class="content"> 71 <p><strong>Hello! </strong>Welcome to my humble web presence. I'm Mark Hamstra, 35 years young, wearer of many hats at <a href="https://www.modmore.com/?utm_source=mhwd&utm_medium=intro&utm_campaign=modmore">mod<strong>more</strong></a>, among other projects. Most of my time is spent building and maintaining awesome extras and tools at modmore, but I also love gaming, cooking, and my dogs. <a href="https://www.markhamstra.com/about/">More about me</a>.<br></p> 72 73<p id="ext-gen1110">This site is where I share <a href="https://www.markhamstra.com/thoughts/">thoughts</a>, cool projects and other oddities related to <a href="https://www.markhamstra.com/modx/">MODX</a>
73, <a href="https://www.markhamstra.com/xpdo/">xPDO</a> and <a href="https://www.markhamstra.com/extjs/">ExtJS</a>. I also write about MODX regularly <a href="https://modx.today/posts/author/markhamstra" target="_blank">over at MODX.today</a>. I don't really post much here anymore, but maybe I will again some day. <a href="https://www.markhamstra.com/extjs/2013/extjs-dream-nightmare-jquery/">Read a random article.</a> 74</p> 75<p id="ext-gen1110"><br></p> 76 77 78<article class="articleSummary" itemscope itemtype="http://schema.org/Article"> 79 <div class="articleMeta"> 80 <p class="published" itemprop="datePublished">2021-01-05</p> 81 <h2 class="title" itemprop="name"> 82 <a href="https://www.markhamstra.com/php/fixing-redirects-error-pages-cloudflare-workers/" title="Fixing redirects & error pages in HelpScout Docs with Cloudflare Workers"> 83 Fixing redirects & error pages in HelpScout Docs with Cloudflare Workers 84 </a> 85 </h2> 86 <div class="clear"></div> 87 </div> 88 <div class="articleDetails"> 89 <div class="articleContent" itemprop="description"> 90 91 <p>After countless hours of copying and updating old content, I switched over the <a href="https://support.modmore.com/" target="_blank">modmore support site</a> to a new system last weekend. While the old one was running in MODX (FaqMan plus some custom routing), the new support site is powered by <a href="https://www.helpscout.com/knowledge-base/" target="_blank">HelpScout Docs</a>. <br></p> 92<p>Docs is integrated with the <a href="https://www.helpscout.com/email-management-software/" target="_blank">HelpScout helpdesk</a> we've provided support with for many years, so using that to host the FAQs makes sense. It allows the help widget ("Beacon") on the site to search through the FAQs, and also gives us quick access to all FAQs when responding to customer emails to point them in the right direction. <br></p> 93<p>Unfortunately shortly after making the switch, I ran into 2 compounding issues.</p> 94<p><strong>Problem 1: the built-in 404 page just plain sucks.</strong> On top of lacking branding, it does not offer the user any way to continue to find what they're looking for with a search or basic navigation. No search, no navigation, not even a link to the homepage.</p> 95<figure><img src="assets/uploads/2021/php/page_not_found.jpg" alt="HelpScout Docs' very plain page-not-found error" ></figure> 96<p><strong>Problem 2: it would not accept a bunch of redirects</strong>. While Docs<strong></strong> supports adding redirects through the back-end interface, it would not accept original URLs with a "+" or "(" or ")" in the path. While these are valid characters in URLs (whether plainly or percent encoded), Docs did not agree. <br></p> 97<figure><img src="assets/uploads/2021/php/invalid_url.jpg" ></figure> 98<p>Unfortunately about half or the URLs on the original support site used some special character. </p> 99<p><strong>Which means problem 1 + problem 2 = half of the users following existing links to the FAQs, end up on a 404 page with no clue where to look next. Ouch.</strong></p> 100<p>While the HelpScout team confirmed the problems, they also indicated they would not be fixed until a future big revamp, so I've had to figure out a way to deal with this. </p> 101<ul> 102 <li>There's a good API for Docs which I could use to build a custom support site again, while feeding off the data stored with HelpScout, but the point of moving to Docs was so I didn't have to maintain or host the support site.</li> 103 <li>I could flip back the DNS to the old support site, but that would actually make the situation worse: I'd just gone through all the content and rewritten parts of it, which was only in Docs. I'd need to spend even more time copying that back into MODX if I were to go this route. <br></li> 104</ul> 105<p>Finally I had a random thought while walking the dog: the entire modmore site is behind CloudFlare. CloudFlare has this thing called CloudFlare Workers, which interacts with a request "on the edge" and can be used to rewrite responses on the fly with JavaScript. <strong><br></strong></p> 106<p><strong>With CloudFlare Workers, I could fix both problems: apply the missing redirects, and present a different 404 page.</strong></p> 107<p>I had never used Workers before, but the gist is pretty easy: register a JavaScript function that takes in the fetch request, do stuff with it before and/or after it hits the origin server, and return a response. The function then gets applied to a route (in this case, the entire support subdomain) to make the magic happen.<br></p> 108<p>In less than an hour I adapted <a href="https://developers.cloudflare.com/workers/examples/bulk-redirects" target="_blank">the official example for applying bulk redirects</a>, and <a href="https://www.mickaelvieira.com/blog/2020/01/27/custom-404-page-with-cloudflare-workers.html" target="_blank">this tutorial by Mickael Vieira for custom 404 pages with Workers</a>, to fix both problems. Plus the free Workers tier is more than enough for our traffic, so I'm more than pleased with this quick solution.</p> 109<p>The final worker code (with some minor tweaks) is included below if anyone has similar needs and is looking for inspiration:</p> 110 111<pre><code class="language-javascript">// Redirects we can't configure for HelpScout docs from old path > new URL 112const redirectMap = new Map([ 113 ["/faq/14-development+licenses", "https://support.modmore.com/category/119-free-development-licenses"], 114 // ... add more redirects here ... 115]); 116 117// This makes us interact with the request 118addEventListener("fetch", event => { 119 event.respondWith(handleRequest(event.request)); 120}); 121 122// The magic function that handles the request to apply our redirects and 404 handling. 123async function handleRequest(request) { 124 const url = new URL(request.url); 125 const path = url.pathname; 126 127 // Check if we have a redirect on-file we need to process before handing off the request to the origin 128 const redirect = redirectMap.get(path); 129 if (redirect) { 130 return Response.redirect(redirect, 301); 131 } 132 133 // Send the request to the origin (helpscout) server 134 const response = await fetch(url.toString(), request); 135 136 // If we reach a html 404 page, replace it with our own 137 if (response.status === 404 && isHTMLContentTypeAccepted(request)) { 138 return fetchAndStreamNotFoundPage(response); 139 } 140 141 // Return the origin response 142 return response; 143} 144 145// Function that streams a 404 page 146// See https://www.mickaelvieira.com/blog/2020/01/27/custom-404-page-with-cloudflare-workers.html 147async function fetchAndStreamNotFoundPage(resp) { 148 const { status, statusText } = resp; 149 const { readable, writable } = new TransformStream(); 150 151 // This could in the future point to a dedicated 404 page, but for now I'm okay with showing the homepage 152 const response = await fetch("https://support.modmore.com/?err=404"); 153 const { headers } = response; 154 155 response.body.pipeTo(writable); 156 157 return new Response(readable, { 158 status, 159 statusText, 160 headers 161 }); 162} 163
164// Makes sure only text/html requests are handled. 165function isHTMLContentTypeAccepted(request) { 166 const acceptHeader = request.headers.get("Accept"); 167 return ( 168 typeof acceptHeader === "string" && acceptHeader.indexOf("text/html") >= 0 169 ); 170}</code></pre> 171 172<p>I'm still a big fan of HelpScout's helpdesk solution. Over the years I've gotten to know them as very detail- and customer-oriented in their services, so hopefully the big Docs revamp they're planning brings that attitude to the hosted knowledge base as well.<br></p> 173<p>Until that happens, I'm very happy that CloudFlare Workers saved the day and helped me avoid having to do a lot of extra work! </p> 174 </div> 175 176 <div class="articleSide"> 177 <div class="articleSideTop"> 178 179 </div> 180 <div class="articleSideBottom"> 181 <h2>Similar Articles</h2> 182<ul class="sidebarRelated"> 183 <li><a href="https://www.markhamstra.com/modx/2012/01/community-challenge-200-documentation-bugs/" class="r83-10" title="Community Challenge: file 200 documentation bugs, I'll spend 2 days fixing them">Community Challenge: file 200 documentation bugs, I'll spend 2 days fixing them</a></li> 184<li><a href="https://modx.today/posts/2016/03/fixing-uploads-with-filesluggy" class="r264-6" title="Fixing uploads with FileSluggy">Fixing uploads with FileSluggy</a></li> 185<li><a href="https://www.markhamstra.com/modx/2011/08/fixing-manager-menu-overflowing-in-certain-languag/" class="r24-6" title="Fixing Manager menu overflowing in certain languages">Fixing Manager menu overflowing in certain languages</a></li> 186</ul> 187 188 <h2>Share! :) 189 <span class="shareLinks"> 190 <a href="https://twitter.com/share?url=http://mrkh.co/cfworkers&via=mark_hamstra&hashtags=MODX&text=Fixing redirects & error pages in HelpScout Docs with Cloudflare Workers" title="Tweet this Article!" target="_blank"> 191 <img src="https://www.markhamstra.com/next/assets/icons/Twitter.png" alt="Tweet!" /> 192 </a> 193 <a href="http://www.facebook.com/sharer.php?u=http%3A%2F%2Fmrkh.co%2Fcfworkers" title="Share on Facebook" target="_blank"> 194 <img src="https://www.markhamstra.com/next/assets/icons/Facebook.png" alt="Facebook" /> 195 </a> 196</span> 197 198 </h2> 199 <label><input type="text" value="http://mrkh.co/cfworkers" onclick="this.select()" class="shorturl" /></label> 200 </div> 201 </div> 202 </div> 203 <div class="articleComments"> 204 <p><a href="https://www.markhamstra.com/php/fixing-redirects-error-pages-cloudflare-workers/">Read 0 comments and share your own thoughts!</a></p> 205 </div> 206</article> 207<article class="articleSummary" itemscope itemtype="http://schema.org/Article"> 208 <div class="articleMeta"> 209 <p class="published" itemprop="datePublished">2020-07-19</p> 210 <h2 class="title" itemprop="name"> 211 <a href="https://www.markhamstra.com/xpdo/using-ulid-primary-keys-in-xpdo/" title="Using ULID primary keys in xPDO"> 212 Using ULID primary keys in xPDO 213 </a> 214 </h2> 215 <div class="clear"></div> 216 </div> 217 <div class="articleDetails"> 218 <div class="articleContent" itemprop="description"> 219 220 <p>You've probably heard of UUIDs as a way to have unique identifiers for objects. <a href="https://github.com/ulid/spec" target="_blank">But have you heard of ULIDs</a>?</p> 221<p>I sure didn't <a href="https://twitter.com/philsturgeon/status/1283055418512748549">until @PhilSturgeon tweeted about it</a>, but since then I've taken a closer look and just finished implementing them into an xPDO project that was using UUIDs until now.</p> 222<p>The basic premise is that, just like UUIDs, you can create the ULID without needing to know what the last one was (which is different from the standard primary auto-increment ID mostly used in xPDO projects). They're guaranteed to be unique, at least up to massive number of generations per <em>milli</em>second. If the project needs to scale across multiple servers, or if you use it for request logging, that's a big plus. </p> 223<p>Comparing ULID vs UUID is more subjective, but I like that you get simpler and shorter identifiers. While a UUID would look something like <code>05c337c3-d2b3-4a50-a8b1-90e4fae23cfc</code>, a ULID looks like <code>01edksqtx9cfzzt1y9sm57h3yq</code>. It's still random gibberish, but it's cleaner and ready to be used in URLs.</p><p>If I understand it correctly, the first part of the ULID is based on the timestamp and will actually sort (roughly) by the time the ULID was generated, which is also a useful feat and may help with insert performance in databases.</p><p>To incorporate this into an xPDO project, you'll need to replace your <code>xPDOSimpleObject</code> usage with a custom base object and implement some logic related to the primary key.</p><p>
223For this example, I'm using <a href="https://github.com/robinvdvleuten/php-ulid" target="_blank">robinvdvleuten/php-ulid</a>. If you're following along, install that into your project with composer and make sure your autoloader has been loaded.</p><p>In your xPDO XML Schema, define a base object that all your other objects will extend from. Make note that this should extend <u>xPDOObject</u> (and not xPDO<u>Simple</u>Object), and define the field to hold the primary key.</p><p> <strong>The examples in this article are based on xPDO 3</strong>, for use with xPDO 2 remove the namespaces (and perhaps add a custom prefix for your project to avoid conflicts) and it ought to work just the same.</p> 224 225 226<pre><code class="language-html"><?xml version="1.0" encoding="UTF-8"?> 227<model package="YourNamespace\Model\" baseClass="YourNamespace\Model\BaseObject" platform="mysql" defaultEngine="InnoDB" version="1.1"> 228 <object class="YourNamespace\Model\BaseObject" extends="xPDO\Om\xPDOObject" inherit="single"> 229 <field key="ulid" dbtype="varchar" precision="52" phptype="string" null="false" /> 230 231 <index alias="ulid" name="ulid" primary="true" unique="true" type="BTREE"> 232 <column key="ulid" length="" collation="A" null="false" /> 233 </index> 234 </object> 235 236 237 <object class="SomeObject" table="some_object"> 238 ... 239 </object> 240</model></code></pre> 241 242<p>As a ULID is encoded as a 26 character string, you can get away with lowering the <code>precision</code> to 26 but I like to have a little bit of padding in case things change down the road. </p><p>Because I'm defining the <code>baseClass</code> in the model, I've skipped providing the <code>extends</code> attribute on the object. </p><p>Also note that we're defining the index to be primary and unique.</p><p>Now build the model classes using a build script or for xPDO 3 the parse-schema command:</p><p></p> 243 244<pre><code class="language-html">vendor/bin/xpdo parse-schema mysql path/to/your/project.mysql.schema.xml src/ -v --update=1 --psr4=YourNamespace</code></pre> 245 246<p>With the model files generated, edit your <code>BaseObject</code> class which should be in <code>src/Model/BaseObject.php</code> or <code>model/yourproject/baseobject.class.php</code> for xPDO 2.</p> 247<p>What we're going to do is overriding the save() method to set the primary key for new objects with a freshly generated ULID.</p> 248<p>(Again, note this is an xPDO 3 example. If using xPDO 2, remove namespaces and it should work the same.)</p> 249 250<pre><code class="language-php"><?php 251namespace YourNamespace\Model; 252 253use Ulid\Ulid; 254use xPDO\Om\xPDOObject; 255use xPDO\xPDO; 256 257class BaseObject extends xPDOObject 258{ 259 public function save($cacheFlag = null) 260 { 261 if ($this->isNew()) { 262 $this->set($this->getPK(), Ulid::generate(true)); 263 } 264 return parent::save($cacheFlag); 265 } 266}</code></pre> 267 268<p>Pretty simple, huh? In this example I'm passing <code>true</code> to the <code>generate</code> method because I prefer the key to be lowercase. </p> 269<p>Now when you use the xPDO APIs to create new objects, it'll automatically add the ULID. And this will also work with your foreign keys and relations - just make sure to use a <code>varchar</code> field that can hold the ULID rather than an <code>int</code> like you may be used to with autoincrementing keys.</p> 270<p>When retrieving objects note that you should use the array syntax on <code>$xpdo->getObject</code> to specify the key. xPDO might just be smart enough to handle it as we've defined the primary key, but for security reasons you should never pass arbitrary user data as a string into the second parameter of <code>getObject</code>. </p> 271 272<pre><code class="language-php"><?php 273 274$ulid = (string)$_GET['ulid']; // or whereever you're getting this from, like a routing component 275$object = $xpdo->getObject(\YourNamespace\Model\SomeObject::class, ['ulid' => $ulid]); 276if ($object) { 277 var_dump($object->toArray()); 278} 279else { 280 echo 'Doesn\'t exist'; 281}</code></pre> 282 283<p>Enjoy.</p> 284 </div> 285 286 <div class="articleSide"> 287 <div class="articleSideTop"> 288 289 </div> 290 <div class="articleSideBottom"> 291 <h2>Similar Articles</h2> 292<ul class="sidebarRelated"> 293 <li>
293<a href="https://www.markhamstra.com/modx/2016/flushing-permissions-in-modx/" class="r238-28" title="Flushing permissions in MODX">Flushing permissions in MODX</a></li> 294<li><a href="https://www.markhamstra.com/modx/2012/01/book-review-responsive-web-design/" class="r85-16" title="Book Review: Responsive Web Design by Ethan Marcotte">Book Review: Responsive Web Design by Ethan Marcotte</a></li> 295<li><a href="https://modx.today/posts/2018/11/what-happened-to-modx-3" class="r258-12" title="What happened to MODX 3?">What happened to MODX 3?</a></li> 296</ul> 297 298 <h2>Share! :) 299 <span class="shareLinks"> 300 <a href="https://twitter.com/share?url=http://mrkh.co/ulid&via=mark_hamstra&hashtags=MODX&text=Using ULID primary keys in xPDO" title="Tweet this Article!" target="_blank"> 301 <img src="https://www.markhamstra.com/next/assets/icons/Twitter.png" alt="Tweet!" /> 302 </a> 303 <a href="http://www.facebook.com/sharer.php?u=http%3A%2F%2Fmrkh.co%2Fulid" title="Share on Facebook" target="_blank"> 304 <img src="https://www.markhamstra.com/next/assets/icons/Facebook.png" alt="Facebook" /> 305 </a> 306</span> 307 308 </h2> 309 <label><input type="text" value="http://mrkh.co/ulid" onclick="this.select()" class="shorturl" /></label> 310 </div> 311 </div> 312 </div> 313 <div class="articleComments"> 314 <p><a href="https://www.markhamstra.com/xpdo/using-ulid-primary-keys-in-xpdo/">Read 5 comments and share your own thoughts!</a></p> 315 </div> 316</article> 317<article class="articleSummary" itemscope itemtype="http://schema.org/Article"> 318 <div class="articleMeta"> 319 <p class="published" itemprop="datePublished">2020-07-18</p> 320 <h2 class="title" itemprop="name"> 321 <a href="https://www.markhamstra.com/php/autorotating-images-with-imagine/" title="Autorotating images with Imagine"> 322 Autorotating images with Imagine 323 </a> 324 </h2> 325 <div class="clear"></div> 326 </div> 327 <div class="articleDetails"> 328 <div class="articleContent" itemprop="description"> 329 330 <p>The Imagine PHP Library is a very useful tool for image manipulations. I'm using it on a variety of projects, including projects where users upload images directly from mobile devices, which are often rotated.</p><p>The actual rotation is stored in the EXIF data, but that's not automatically handled.</p><p>It turns out that implementing that with Imagine is really easy though, thanks to the Autorotate filter. </p><p>Here's a functional example of taking an image, rotating it if needed, making sure it's at most 1920x1080px in size while keeping the aspect ratio, and finally stripping out the metadata.</p> 331 332<pre><code class="language-php"><?php 333 334// Create an Imagine instance - using Imagick if available otherwise falling down to GD 335try { 336 $imagine = new \Imagine\Imagick\Imagine(); 337} catch (\Imagine\Exception\RuntimeException $e) { 338 $imagine = new \Imagine\Gd\Imagine(); 339} 340 341// Use the Exif metadata reader to be able of accessing the orientation 342$imagine->setMetadataReader(new \Imagine\Image\Metadata\ExifMetadataReader()); 343 344// Load the image into memory - this could also use $imagine->read or $imagine->open 345$img = $imagine->load('.. raw image data ..'); 346 347// Use the autorotate filter to rotate the image if needed 348$filter = new \Imagine\Filter\Basic\Autorotate(); 349$filter->apply($img); 350 351// Resize down to max 1920x1080px while keeping aspect ratio 352$img = $img->thumbnail( 353 new \Imagine\Image\Box(1920, 1920), 354 \Imagine\Image\ManipulatorInterface::THUMBNAIL_INSET 355); 356 357// Strip off any metadata embedded in the image to save space and privacy 358$img->strip(); 359 360// Render the binary data to store (or use $img->save() or $img->show()) 361$binary = $img->get('jpg');</code></pre> 362 </div> 363 364 <div class="articleSide"> 365 <div class="articleSideTop"> 366 367 </div> 368 <div class="articleSideBottom"> 369 <h2>Similar Articles</h2> 370<ul class="sidebarRelated"> 371 <li><a href="https://modx.today/posts/2016/03/fixing-uploads-with-filesluggy" class="r264-30" title="Fixing uploads with FileSluggy">Fixing uploads with FileSluggy</a></li> 372<li><a href="https://modx.today/posts/2016/12/a-look-at-php-usage-with-modx-revolution" class="r263-30" title="A look at PHP Usage with MODX Revolution">A look at PHP Usage with MODX Revolution</a></li> 373<li>
373<a href="https://www.markhamstra.com/modx/2011/12/versionx-2-0-versioning-modx-revolution/" class="r65-30" title="VersionX 2.0 - Bringing improved versioning to Revolution">VersionX 2.0 - Bringing improved versioning to Revolution</a></li> 374</ul> 375 376 <h2>Share! :) 377 <span class="shareLinks"> 378 <a href="https://twitter.com/share?url=http://mrkh.co/imagine-rotate&via=mark_hamstra&hashtags=MODX&text=Autorotating images with Imagine" title="Tweet this Article!" target="_blank"> 379 <img src="https://www.markhamstra.com/next/assets/icons/Twitter.png" alt="Tweet!" /> 380 </a> 381 <a href="http://www.facebook.com/sharer.php?u=http%3A%2F%2Fmrkh.co%2Fimagine-rotate" title="Share on Facebook" target="_blank"> 382 <img src="https://www.markhamstra.com/next/assets/icons/Facebook.png" alt="Facebook" /> 383 </a> 384</span> 385 386 </h2> 387 <label><input type="text" value="http://mrkh.co/imagine-rotate" onclick="this.select()" class="shorturl" /></label> 388 </div> 389 </div> 390 </div> 391 <div class="articleComments"> 392 <p><a href="https://www.markhamstra.com/php/autorotating-images-with-imagine/">Read 0 comments and share your own thoughts!</a></p> 393 </div> 394</article> 395<article class="articleSummary" itemscope itemtype="http://schema.org/Article"> 396 <div class="articleMeta"> 397 <p class="published" itemprop="datePublished">2020-03-02</p> 398 <h2 class="title" itemprop="name"> 399 <a href="https://www.markhamstra.com/modx/2020/modx-php-and-session-garbage-collection/" title="MODX, PHP & session garbage collection"> 400 MODX, PHP & session garbage collection 401 </a> 402 </h2> 403 <div class="clear"></div> 404 </div> 405 <div class="articleDetails"> 406 <div class="articleContent" itemprop="description"> 407 408 <p>When did you last check the size of your <code>modx_session</code> database table? Was it huge, like <em>gigabytes </em>huge? If so, you're not alone. </p><p>To understand the problem, you need a little bit of background.</p><h3>How do sessions work in PHP?</h3><p>Typically, the standard PHP session handler will create a session when requested, and store it as a simple file somewhere on the system. The path it writes session to is configured with the <code>session.save_path</code> configuration in the php.ini, and can point anywhere. When that option is empty, it writes to the temp directory on the server. </p><p>Creating and loading sessions is simple enough, but the next thing the session handler does is clean up sessions. This is called garbage control, or gc. This removes sessions beyond their expiration time, to make sure it doesn't keep growing indefinitely and takes up your vital disk space. </p><p>Garbage control doesn't have to run on every request. If your session/cookie life time is configured to a week and you're not too picky about the exact timing they are removed, then sessions only really need to be checked once a day. Cleaning up sessions can take a little time and resources, so PHP is by default configured to only do that once every 100 requests. </p><h3>How do sessions work in MODX?</h3> 409<p>MODX registers a custom session handler that takes care of storing, retrieving, and cleaning up sessions. It writes this to one of its own database tables (<code>modx_session</code>), rather than files. This allows MODX a little more control over the flow of sessions. </p><p>It is also possible to instruct MODX to use standard PHP sessions, and there's also an extra available to <a href="https://modx.com/extras/package/redissession" target="_blank">write sessions to Redis</a>. But the vast majority of sites will simply be using the default, writing to the database.</p><h3>So, why does MODX not clean up its session table?</h3><p>MODX awaits the signal from PHP that it's time to clean up sessions. This relies on 2 configuration options in PHP:</p><ul><li><code>session.gc_probability</code></li><li><code>session.gc_divisor</code></li></ul><p><a href="https://www.php.net/manual/en/session.configuration.php#ini.session.gc-probability">You can find the official documentation for those options here</a>.</p><p>Usually the probability is set to 1, and divisor to a value like 100 or 1000. That means that in approximately 1/100 requests, the garbage control will run. </p><p>When MODX does not seem to be cleaning up its table, it's usually because of an attempt to improve the performance of the garbage collection, by bypassing PHP and off-loading it to a cron job that runs outside of the request/response cycle. </p><p>Those environments assume PHP writes its sessions to a standard location in the filesystem, and clean up that directory based on the timestamp on the file. The <code>session.gc_probability</code> option is then set to 0, to tell PHP to never run its own session garbage collection.</p><p>That works great - <em>if your sessions are written to the standard location. </em>Which MODX doesn't. </p><h3>How common is this?</h3><p>Based on data from <a href="https://sitedash.app/" target="_blank">SiteDash</a>, which checks the size and status of your session table automatically, it's pretty common indeed. Out of a sample of 1727 sites, 27% seem to be affected by this.<br></p><h3>How can I fix this?</h3><p>Re-enable <code>session.gc_probability</code>. Set it to 1, and make sure <code>session.gc_divisor</code> is also set properly for your traffic.</p><p>Depending on your host and if you have access to a server control panel, you may be able of changing it yourself. In other cases, contact your host and ask them how it should be changed. <br></p> 410 </div> 411 412 <div class="articleSide"> 413 <div class="articleSideTop"> 414 415 </div> 416 <div class="articleSideBottom"> 417 <h2>Similar Articles</h2> 418<ul class="sidebarRelated"> 419 <li>
419<a href="https://www.markhamstra.com/modx/2011/08/fixing-manager-menu-overflowing-in-certain-languag/" class="r24-48" title="Fixing Manager menu overflowing in certain languages">Fixing Manager menu overflowing in certain languages</a></li> 420<li><a href="https://modx.today/posts/2016/12/a-look-at-php-usage-with-modx-revolution" class="r263-40" title="A look at PHP Usage with MODX Revolution">A look at PHP Usage with MODX Revolution</a></li> 421<li><a href="https://modx.today/posts/2016/03/fixing-uploads-with-filesluggy" class="r264-32" title="Fixing uploads with FileSluggy">Fixing uploads with FileSluggy</a></li> 422</ul> 423 424 <h2>Share! :) 425 <span class="shareLinks"> 426 <a href="https://twitter.com/share?url=http://mrkh.co/gc&via=mark_hamstra&hashtags=MODX&text=MODX, PHP & session garbage collection" title="Tweet this Article!" target="_blank"> 427 <img src="https://www.markhamstra.com/next/assets/icons/Twitter.png" alt="Tweet!" /> 428 </a> 429 <a href="http://www.facebook.com/sharer.php?u=http%3A%2F%2Fmrkh.co%2Fgc" title="Share on Facebook" target="_blank"> 430 <img src="https://www.markhamstra.com/next/assets/icons/Facebook.png" alt="Facebook" /> 431 </a> 432</span> 433 434 </h2> 435 <label><input type="text" value="http://mrkh.co/gc" onclick="this.select()" class="shorturl" /></label> 436 </div> 437 </div> 438 </div> 439 <div class="articleComments"> 440 <p><a href="https://www.markhamstra.com/modx/2020/modx-php-and-session-garbage-collection/">Read 4 comments and share your own thoughts!</a></p> 441 </div> 442</article> 443<article class="articleSummary" itemscope itemtype="http://schema.org/Article"> 444 <div class="articleMeta"> 445 <p class="published" itemprop="datePublished">2019-08-26</p> 446 <h2 class="title" itemprop="name"> 447 <a href="https://www.markhamstra.com/xpdo/2019/package-data/" title="Making data available for setup options and resolvers in xPDO Packages"> 448 Making data available for setup options and resolvers in xPDO Packages 449 </a> 450 </h2> 451 <div class="clear"></div> 452 </div> 453 <div class="articleDetails"> 454 <div class="articleContent" itemprop="description"> 455 456 <p>For a recent extra, I needed to get some arbitrary data into a package, in such a way that it's available for both the setup.options.php and the resolver - without duplicating that data. Specifically, it's a big array containing definitions for a theme with assets and elements that needed to be manually created/updated <em>only </em>if the user choose to do so. </p><p>After some time, I found a way to do that using the package attributes. And in this article I'll show you how to add that to a standard build. </p><h3>Define the data</h3><p>First, define the data. I created the file _build/data/theme.inc.php to return an array, but you can place it where it makes most sense. The file will only be accessed when building the package, so does not have to be in the core or assets folder (although it could be, if that makes sense for your use case).</p> 457 458<pre><code class="language-php"><?php 459 460$def = [ 461 // a whole bunch of data and elements 462]; 463 464return $def;</code></pre> 465 466<h3>Add the data to the package attributes</h3><p>The package attributes is a special part of the package, which gets stored in the package manifest rather than a vehicle. It's used to hold some standard information: package name, changelog, readme, and license, among others.</p><p>In a standard build script the code to set the package attributes looks something like this:</p> 467 468<pre><code class="language-php"><?php 469// ... lots of other code ... 470$builder->setPackageAttributes([ 471 'license' => file_get_contents($sources['docs'] . 'license.txt'), 472 'readme' => file_get_contents($sources['docs'] . 'readme.txt'), 473 'changelog' => file_get_contents($sources['docs'] . 'changelog.txt'), 474 'setup-options' => [ 475 'source' => $sources['build'] . 'setup.options.php', 476 ], 477]);</code></pre> 478 479<p>It turns out though - package attributes are not limited to those standard items. Any attribute will be stored into the package manifest.</p><p>Let's take advantage of that by adding our own attribute containing (in this case) a theme-definition from our file:</p> 480 481<pre><code class="language-php"><?php 482$builder->setPackageAttributes([ 483 'license' => file_get_contents($sources['docs'] . 'license.txt'), 484 'readme' => file_get_contents($sources['docs'] . 'readme.txt'), 485 'changelog' => file_get_contents($sources['docs'] . 'changelog.txt'), 486 'setup-options' => [ 487 'source' => $sources['build'] . 'setup.options.php', 488 ], 489 'theme-definition' => json_encode(include __DIR__ . '/data/theme.inc.php'), 490]);</code></pre> 491 492<p>As the theme definition returns an array, we're simply include-ing it. I decided to encode it as JSON, but I don't think you have to do that - the package manifest is serialised so should also support arbitrary arrays.</p><p>If you were to build a package at this point, that would include the theme-definition, but it's not being used yet. </p><h3>Accessing package attributes in setup.options.php</h3><p>In the _build/setup.options.php file, which is used to build the interface for the setup options shown when installing a package, the package attributes are available in $options['attributes']. </p><p>For example, to retrieve the theme-definition, the code would look like this:</p> 493 494<pre><code class="language-php"><?php 495$def = array_key_exists('theme-definition', $options['attributes']) 496 ? json_decode($options['attributes']['theme-definition'], true) 497 : []; 498 499if (empty($def) || !is_array($def)) { 500 return 'Failed to load theme definition: ' . json_encode($options, JSON_PRETTY_PRINT); 501} 502 503foreach ($def as $definition) { 504 // ... render the option ... 505}</code></pre> 506 507<p>Now you can build a dynamic interface based on your data definition. We return an error to the setup options panel if we can't find the attribute. </p><h3>Access data in resolvers</h3><p>Building the interface is step one - accessing the same information in a resolver is step two. </p><p>In resolvers, the package attributes are in $options. </p> 508 509<pre><code class="language-php"><?php 510 511$def = array_key_exists('theme-definition', $options) 512 ? json_decode($options['theme-definition'], true) 513 : []; 514 515if (empty($def) || !is_array($def)) { 516 $modx->log(modX::LOG_LEVEL_ERROR, 'Failed to load theme definition'); 517 return false;
518}</code></pre> 519 520<p>The selected values in the setup options window are also available in $options. So if you created a setup option named "create_template", you can check that like so: </p> 521 522<pre><code class="language-php"><?php 523 524$def = array_key_exists('theme-definition', $options) 525 ? json_decode($options['theme-definition'], true) 526 : []; 527 528if (empty($def) || !is_array($def)) { 529 $modx->log(modX::LOG_LEVEL_ERROR, 'Failed to load theme definition'); 530 return false; 531} 532 533if (array_key_exists('create_template', $options) && $options['create_template']) { 534 foreach ($def['templates'] as $template) { 535 // ... create the template or something ... 536 } 537}</code></pre> 538 539<p>Especially for use cases like themes, or where you have some dynamic data you want to manually create/update in a resolver instead of as a vehicle, this can be a useful technique to have under your belt.</p> 540 </div> 541 542 <div class="articleSide"> 543 <div class="articleSideTop"> 544 545 </div> 546 <div class="articleSideBottom"> 547 <h2>Similar Articles</h2> 548<ul class="sidebarRelated"> 549 <li><a href="https://www.markhamstra.com/xpdo/2011/store-extra-packages-in-your-project-directory/" class="r183-90" title="Extra packages in project dir">Extra packages in project dir</a></li> 550<li><a href="https://www.markhamstra.com/modx/2016/flushing-permissions-in-modx/" class="r238-70" title="Flushing permissions in MODX">Flushing permissions in MODX</a></li> 551<li><a href="https://www.markhamstra.com/modx/2013/meet-modmore/" class="r196-70" title="Meet modmore!">Meet modmore!</a></li> 552</ul> 553 554 <h2>Share! :) 555 <span class="shareLinks"> 556 <a href="https://twitter.com/share?url=http://mrkh.co/package-attributes&via=mark_hamstra&hashtags=MODX&text=Making data available for setup options and resolvers in xPDO Packages" title="Tweet this Article!" target="_blank"> 557 <img src="https://www.markhamstra.com/next/assets/icons/Twitter.png" alt="Tweet!" /> 558 </a> 559 <a href="http://www.facebook.com/sharer.php?u=http%3A%2F%2Fmrkh.co%2Fpackage-attributes" title="Share on Facebook" target="_blank"> 560 <img src="https://www.markhamstra.com/next/assets/icons/Facebook.png" alt="Facebook" /> 561 </a> 562</span> 563 564 </h2> 565 <label><input type="text" value="http://mrkh.co/package-attributes" onclick="this.select()" class="shorturl" /></label> 566 </div> 567 </div> 568 </div> 569 <div class="articleComments"> 570 <p><a href="https://www.markhamstra.com/xpdo/2019/package-data/">Read 0 comments and share your own thoughts!</a></p> 571 </div> 572</article> 573<article class="articleSummary" itemscope itemtype="http://schema.org/Article"> 574 <div class="articleMeta"> 575 <p class="published" itemprop="datePublished">2019-03-06</p> 576 <h2 class="title" itemprop="name"> 577 <a href="https://www.markhamstra.com/thoughts/supporting-open-source-with-patreon/" title="Supporting Open Source with Patreon"> 578 Supporting Open Source with Patreon 579 </a> 580 </h2> 581 <div class="clear"></div> 582 </div> 583 <div class="articleDetails"> 584 <div class="articleContent" itemprop="description"> 585 586 <p>Patreon is a community membership service that lets you pledge monthly donations, at a price you set yourself, to creators. </p><p>
586Back in December 2017 I first created a Patreon account to support Vasily "bezumkin" Naumkin with his work on the MODX core. Shortly after that I added pledges to the creators of <a href="https://www.patreon.com/s_bergmann" target="_blank">PHPunit</a>, <a href="https://www.patreon.com/frankdejonge" target="_blank">FlySystem</a>, and <em></em><a href="https://www.patreon.com/waitbutwhy" target="_blank">Wait But Why</a> (which has nothing to do with programming, but is just <a href="https://waitbutwhy.com/" target="_blank">one of my favourite blogs on the internet</a>). At a later point in 2018 I also pledged to <a href="https://www.patreon.com/homebrew">Homebrew</a>.</p><p>They're all small amounts, more a token of my support and gratitude than anything else. The largest part was for Vasily ($25, until July), and the others were $3-5 each for a total of $38. </p><p>There are no tangible benefits, other than making sure that the software doesn't just go away by rewarding the creator. I routinely spend more money on stupid things I don't really need (<a href="https://www.amazon.com/Pillow-Button-Office-Desktop-Peripheral/dp/B01I0XNF14" target="_blank">like a huge foam enter button</a>), while what these creators share with the world is much more valuable, so that's a really good deal.<span></span></p><p>They are also recurring, automatically charged monthly, which starts to add up over time. </p><p>In 2018 my personal Patreon donations totalled at $348. Still not enough to pay anyone's bills or full time employment, but probably more than I would've donated to these projects if they only accepted one-off donations, and the numbers do get more meaningful for the creator when you consider the effect of more people contributing. Previously these creators would fund their work through client work, sacrifice free time after a day job, rely on one-time donations, or juggle things in another way, while with Patreon they're offered a predictable (extra) income directly attributable to the work they share.</p><p><span></span></p><hr><p>After a chat about the work that goes into my open source projects and in particular the work that goes into the MODX core, I decided to set up <a href="https://www.patreon.com/markhamstra" target="_blank">my own Patreon</a> yesterday.</p><p>I am fortunate enough that I have a business that's running well and pays the bills, but I still constantly have to prioritise my time and energy. When the to do list explodes, or energy gets low (yay, burnout), I have to choose what things to work on. The reality of running a business is that things that make money are more important than things that don't, and as a result it's usually the open source work that gets snoozed first even though I strongly believe that the work I do on the MODX project should be an important part of my day-to-day work. </p><p>So that's where Patreon comes in.</p><p><a href="https://www.patreon.com/markhamstra" target="_blank">By supporting me on Patreon</a>, you're supporting my work for the MODX core and open source extras. Every contribution shows that these hours upon hours of work are worth something to you, and that will motivate me to keep up and make more time available to keep making the CMS you use better, one PR at a time. </p><hr><p>If you're interested in supporting others in the MODX community, check out <a href="https://www.patreon.com/JoshuaLuckers/overview" target="_blank">Joshua Lückers' Patreon</a>, who is the most recent person to become a MODX integrator and has been putting in lots of hours as well. </p><p>I'm not aware of any other MODXers with a Patreon page at the moment (Vasily shut his down in July), but if you find any, or have any other Patreons you support, leave a comment below :)</p><p></p> 587 </div> 588 589 <div class="articleSide"> 590 <div class="articleSideTop"> 591 592 </div> 593 <div class="articleSideBottom"> 594 <h2>Similar Articles</h2> 595<ul class="sidebarRelated"> 596 <li><a href="https://www.markhamstra.com/modx/2012/10/documentation-friday-scott-borys/" class="r136-120" title="Introducing Documentation Friday">Introducing Documentation Friday</a></li> 597<li>
597<a href="https://www.markhamstra.com/modx/2012/01/community-challenge-200-documentation-bugs/" class="r83-120" title="Community Challenge: file 200 documentation bugs, I'll spend 2 days fixing them">Community Challenge: file 200 documentation bugs, I'll spend 2 days fixing them</a></li> 598<li><a href="https://www.markhamstra.com/modx/2011/11/nested-caching-in-modx-revolution/" class="r38-84" title="How nested (recursive) Caching works in MODX">How nested (recursive) Caching works in MODX</a></li> 599</ul> 600 601 <h2>Share! :) 602 <span class="shareLinks"> 603 <a href="https://twitter.com/share?url=http://mrkh.co/patreon&via=mark_hamstra&hashtags=MODX&text=Supporting Open Source with Patreon" title="Tweet this Article!" target="_blank"> 604 <img src="https://www.markhamstra.com/next/assets/icons/Twitter.png" alt="Tweet!" /> 605 </a> 606 <a href="http://www.facebook.com/sharer.php?u=http%3A%2F%2Fmrkh.co%2Fpatreon" title="Share on Facebook" target="_blank"> 607 <img src="https://www.markhamstra.com/next/assets/icons/Facebook.png" alt="Facebook" /> 608 </a> 609</span> 610 611 </h2> 612 <label><input type="text" value="http://mrkh.co/patreon" onclick="this.select()" class="shorturl" /></label> 613 </div> 614 </div> 615 </div> 616 <div class="articleComments"> 617 <p><a href="https://www.markhamstra.com/thoughts/supporting-open-source-with-patreon/">Read 0 comments and share your own thoughts!</a></p> 618 </div> 619</article> 620 621 <!--<h3>But wait! There's more!</h3>--> 622 <br /><br /> 623 <div class="pages"> 624 <ul> 625 <li class="current"><a href="https://www.markhamstra.com/">1</a></li> 626<li><a href="https://www.markhamstra.com/?page=2">2</a></li> 627<li><a href="https://www.markhamstra.com/?page=3">3</a></li> 628<li><a href="https://www.markhamstra.com/?page=4">4</a></li> 629<li><a href="https://www.markhamstra.com/?page=5">5</a></li> 630<li><a href="https://www.markhamstra.com/?page=6">6</a></li><li class="control"><a href="https://www.markhamstra.com/?page=2">>></a></li><li class="control"><a href="https://www.markhamstra.com/?page=15">Last</a></li> 631 </ul> 632 </div> 633 634</div> 635 </div> <!--/wrapper--> 636</div> <!--/outer--> 637 638<div class="clear"></div> 639 640<footer> 641 <div class="boxes"> 642 <div class="footerBox"> 643 <p>Find me around the web 644 <span class="socialIcons"> 645 <a href="https://twitter.com/mark_hamstra"><img src="https://www.markhamstra.com/next/assets/icons/Twitter.png" alt="Twitter" /></a> 646 <a href="https://www.facebook.com/moreformodx"><img src="https://www.markhamstra.com/next/assets/icons/Facebook.png" alt="Facebook" /></a> 647 <a href="http://github.com/Mark-H"><img class="githubMark" src="https://www.markhamstra.com/next/assets/icons/GitHub-Mark-32px.png" alt="Github" /></a> 648 <a href="http://www.linkedin.com/in/hamstramark"><img src="https://www.markhamstra.com/next/assets/icons/LinkedIn.png" alt="LinkedIn" /></a> 649 <a href="https://www.markhamstra.com/rss-feed.rss"><img src="https://www.markhamstra.com/next/assets/icons/RSS.png" alt="RSS" /></a> 650 </p> 651 <p class="copyright">© 2010-2026 <a href="https://www.markhamstra.com/" title="Mark Hamstra Web Development">Mark Hamstra</a> | <a href="https://www.markhamstra.com/sitemap/">Sitemap</a> | <a href="https://www.markhamstra.com/privacy/">Privacy</a> | <a href="https://www.markhamstra.com/modx/2012/08/this-site-uses-cookies-and-they-taste-great/">Cookie Policy</a></p> 652 </div><div class="sponsorBox"> 653 <p><a href="https://www.modmore.com/?utm_source=mhwd&utm_medium=footer&utm_campaign=logo"><img src="https://assets.modmore.com/img/logo_blue.png" alt="modmore - More for MODX"/></a> 654 <a href="https://my.skytoaster.com/aff.php?aff=5 655"><img src="https://assets.modmore.com/img/skytoaster-modmore.png" alt="SkyToaster" /></a></p> 656 </div> 657 </div> 658 659</footer> 660 661 662 663
664<script type="text/javascript"> 665 var _gaq = _gaq || []; 666 _gaq.push(['_setAccount', 'UA-24742831-1']); 667 _gaq.push(['_trackPageview']); 668 _gaq.push(['_trackPageLoadTime']); 669 (function() { 670 var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; 671 ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; 672 var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); 673 })(); 674 675</script>
675 676
677<script src="//assets.markhamstra.com/scripts/scripts.min.js" type="text/javascript"></script>
vendor: 1 bytes, line 677
677
678<script src="//www.markhamstra.com/next/assets/scripts/prism.js" type="text/javascript"></script>
678 679 680 681 682 683 684
685<script type="text/javascript"> 686 $(document).ready(function(){$("a[rel^='prettyPhoto'], a[rel^='lightbox']").prettyPhoto({ 687 allowResize: false,social_tools: '',show_title: false 688 });}); 689 690 /*function path(){ 691 var args = arguments, result = []; 692 for(var i = 0; i < args.length; i++) 693 result.push(args[i].replace('@', '//www.markhamstra.com/assets/scripts/sh/')); 694 return result; 695 }; 696 SyntaxHighlighter.autoloader.apply(null, path( 697 'css @shBrushCss.js', 698 'js jscript javascript @shBrushJScript.js', 699 'php @shBrushPhp.js', 700 'text plain @shBrushPlain.js', 701 'xml xhtml xslt html @shBrushXml.js' 702 )); 703 SyntaxHighlighter.all();*/ 704</script>
704 705 706 707</body> 708</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.