diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..c623a69
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,24 @@
+name: Test
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ - name: Install node dependencies
+ run: npm ci
+ - uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+ - name: Run test suite
+ run: bundle exec rake test
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..a610fdd
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,44 @@
+# dotcom
+
+Personal Jekyll site (`www.nateberkopec.com`).
+
+## Test Setup
+
+The test suite mirrors the Speedshop structure:
+
+- `test/ruby/` — Minitest unit tests for Jekyll plugins
+- `test/integration/site_test.rb` — HTTP smoke tests against the built site
+- `test/integration/link_integrity_test.rb` — full built-site link/resource/anchor validation
+
+### Commands
+
+```bash
+bundle exec rake test:ruby
+bundle exec rake test:integration
+bundle exec rake test
+```
+
+Or with mise:
+
+```bash
+mise run test:ruby
+mise run test:integration
+mise run test
+```
+
+### Link Integrity Rules
+
+`test/integration/link_integrity_test.rb` checks the built `_site` output and verifies:
+
+- all internal and external links/resources (anchors, images, scripts, stylesheets, forms, media)
+- fragment anchors (`#id`) resolve to existing elements
+- redirects are allowed
+- `mailto:` and `tel:` are syntax-validated only
+- external checks retry 3 times, then hard-fail
+- requests are parallelized with `Concurrent::ThreadPoolExecutor` (pool size: 25)
+
+### Local Test Server
+
+Integration tests build `_site` and start a local static server automatically on `127.0.0.1` using port `0` (ephemeral).
+The assigned port is discovered at runtime and used by the test helpers.
+Set `BASE_URL` to run against a specific target instead.
diff --git a/Gemfile b/Gemfile
index 81932b5..f5c8ef4 100644
--- a/Gemfile
+++ b/Gemfile
@@ -6,6 +6,10 @@ gem "csv"
gem "logger"
gem "base64"
gem "bigdecimal"
+gem "concurrent-ruby", ">= 1.3"
+gem "nokogiri", ">= 1.16"
+gem "minitest", ">= 5.22"
+gem "cgi", ">= 0.4"
group :jekyll_plugins do
gem "jekyll-postcss-v2", ">= 1.0.2"
diff --git a/Gemfile.lock b/Gemfile.lock
index df06719..02fa8b5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -5,6 +5,7 @@ GEM
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bigdecimal (4.0.1)
+ cgi (0.5.1)
colorator (1.1.0)
concurrent-ruby (1.3.6)
csv (3.3.5)
@@ -59,9 +60,21 @@ GEM
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
mercenary (0.4.0)
+ mini_portile2 (2.8.9)
+ minitest (6.0.1)
+ prism (~> 1.5)
+ nokogiri (1.19.0)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ nokogiri (1.19.0-arm-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.0-arm-linux-musl)
+ racc (~> 1.4)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
+ prism (1.9.0)
public_suffix (7.0.2)
+ racc (1.8.1)
rake (13.3.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
@@ -110,11 +123,15 @@ PLATFORMS
DEPENDENCIES
base64
bigdecimal
+ cgi (>= 0.4)
+ concurrent-ruby (>= 1.3)
csv
foreman (>= 0.90.0)
jekyll (>= 4.4.0)
jekyll-postcss-v2 (>= 1.0.2)
logger
+ minitest (>= 5.22)
+ nokogiri (>= 1.16)
BUNDLED WITH
4.0.3
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..27c3980
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,16 @@
+require "rake/testtask"
+
+Rake::TestTask.new("test:ruby") do |t|
+ t.libs << "test/ruby"
+ t.test_files = FileList["test/ruby/**/*_test.rb"]
+end
+
+Rake::TestTask.new("test:integration") do |t|
+ t.libs << "test/integration"
+ t.test_files = FileList["test/integration/**/*_test.rb"]
+end
+
+desc "Run all tests"
+task test: ["test:ruby", "test:integration"]
+
+task default: :test
diff --git a/_data/social.yml b/_data/social.yml
index 49478be..22e92c1 100755
--- a/_data/social.yml
+++ b/_data/social.yml
@@ -1,4 +1,4 @@
-- link: //www.twitter.com/nateberkopec
+- link: https://www.twitter.com/nateberkopec
name: Twitter
- link: //github.com/nateberkopec
name: Github
diff --git a/_layouts/post.html b/_layouts/post.html
index 576e0eb..6955068 100755
--- a/_layouts/post.html
+++ b/_layouts/post.html
@@ -11,7 +11,7 @@
{% endif %}
{{ page.title | titleize }}
{% if page.summary %}
diff --git a/_posts/2017-03-19-how-i-made-self-publishing-about-ruby-on-rails.md b/_posts/2017-03-19-how-i-made-self-publishing-about-ruby-on-rails.md
index 78da979..dca6685 100644
--- a/_posts/2017-03-19-how-i-made-self-publishing-about-ruby-on-rails.md
+++ b/_posts/2017-03-19-how-i-made-self-publishing-about-ruby-on-rails.md
@@ -21,7 +21,7 @@ It would be immensely helpful to me if other programming authors (self-published
I think "product money" is the eventual goal for nearly all freelancers or solopreneurs. There's probably a lot of developers sitting on the sidelines too wishing they could tell their boss to take this scrum and shove it. And, now that I've made some product money, I can tell you it is pretty awesome. Waking up with more money than you went to bed with is a very, very good feeling. I've been trying to "start a business" since 2008 (you may know me from my appearance on ABC's reality show Shark Tank in 2009). It's always been a struggle for me to find the magical intersection in the theoretical Venn diagram of my interests/skills and what people will pay for. For some reason, I had always avoided information products, because all I knew about was programming, and specifically Ruby on Rails. I think I wanted to get *out* of programming, and make a product for more "normal people", like the infamous Bingo Card Creator.
{% marginfigure /assets/img/posts/37sigproduct.jpg "The copy bragged that it was 'featured on InternetRetailer.com!'" %}
-For some reason, as software developer solopreneurs, a lot of us are caught up on SaaS products. Yes, subscription revenue is great. But SaaS is not easy, and the effort of creating one frequently means you're sinking tons of your own development time into something that doesn't end up making any money (or doesn't make enough to compare with your freelancing rate). Worse, as developers, we *love* coding. In fact, a lot of us love it so much that we can code for *years* without actually *shipping* anything! It took me a long time to get past this. In the end, [something from Amy Hoy actually pushed me over the edge](https://unicornfree.com/2013/why-you-should-do-a-tiny-product-first) - she pointed out the 37signal's first product wasn't a piece of software, it was an information product. It was a ~$79 45-page whitepaper about SEO. [You can still download it here.](https://37signals.com/report_search_0103.php) But, of course, it was dripping with the viewpoint, style, and contrarianism that we've come to expect from Basecamp-nee-37signals. All that was important, all that *made* 37signals what it is, could still be delivered in a simple information product rather than a SaaS.
+For some reason, as software developer solopreneurs, a lot of us are caught up on SaaS products. Yes, subscription revenue is great. But SaaS is not easy, and the effort of creating one frequently means you're sinking tons of your own development time into something that doesn't end up making any money (or doesn't make enough to compare with your freelancing rate). Worse, as developers, we *love* coding. In fact, a lot of us love it so much that we can code for *years* without actually *shipping* anything! It took me a long time to get past this. In the end, [something from Amy Hoy actually pushed me over the edge](https://unicornfree.com/2013/why-you-should-do-a-tiny-product-first) - she pointed out the 37signal's first product wasn't a piece of software, it was an information product. It was a ~$79 45-page whitepaper about SEO. [You can still download it here.](https://archive.org/search?query=https://37signals.com/report_search_0103.php) But, of course, it was dripping with the viewpoint, style, and contrarianism that we've come to expect from Basecamp-nee-37signals. All that was important, all that *made* 37signals what it is, could still be delivered in a simple information product rather than a SaaS.
That was the last push I needed - I was firmly thinking about creating an information product for programmers. This was in May of 2015.
@@ -111,7 +111,7 @@ Most writing about "scaling" turns into an ego-measurement contest where "real s
Those are just *my* positions. If you're considering writing about programming yourself, here are some positions that others have taken or that you should consider:
* **Learning should be hard.** This is the entire point of the ["Learn X the Hard Way" series by Zed Shaw](https://learncodethehardway.org/). This is great positioning because it spits directly in the face of decades of "Learn X in 24 Hours" books that dominated bookshelves in the late 90's and throughout the 00's. Zed is writing for a different audience than those books - he wants people who are searching for a deep understanding, not the shortcut method.
-* **Learning should be weird.** This is \_why's legacy. The Poignant Guide was an absolutely insane piece of programming how-to. It wasn't just cutesy, oh-so-friendly "MAKE PROGRAMMING LE FUN" material, like, say, [Rails Zombies](http://railsforzombies.org/). It was weird, it was genuinely unique, it was unlike anything that had come before or since.
+* **Learning should be weird.** This is \_why's legacy. The Poignant Guide was an absolutely insane piece of programming how-to. It wasn't just cutesy, oh-so-friendly "MAKE PROGRAMMING LE FUN" material, like, say, [Rails Zombies](https://archive.org/search?query=http://railsforzombies.org/). It was weird, it was genuinely unique, it was unlike anything that had come before or since.
* **Look at the dominant groupthink, and head the opposite way.** {% marginfigure /assets/img/posts/fuckyou.jpg "Unpopular opinion: everyone who hated this does not understand why Rails succeeded."%} There seems to be a lot of opportunities here in JavaScript, for example, which is a community with extremely strong groupthink. Who's advocating for *simplicity* in JavaScript? As far as I can tell right now, no one. The bigger and badder the framework, the better. What about *stability* in JavaScript? Right now, it's a matter of learning the hot new framework of the month. Someone that wrote about using vanilla, no-framework JavaScript in a practical setting or wrote about simpler, smaller libraries would probably do really well in this climate, I think. Maybe this already exists, I'm not a big follower of the JavaScript blogosphere. Or with programming languages in general, the meme right now is in favor of strongly typed languages - well where's the classic OO, dynamic-typing advocate? Who's reviving Alan Kay or some of the genius of the original Smalltalk? Look at the well-travelled road, and head the opposite way. No subject area in programming can support only a single, correct position. There's always an alternative, and those alternatives need advocates.
* If positioning interests you, you may want to check out [The 22 Immutable Laws of Marketing](https://www.amazon.com/22-Immutable-Laws-Marketing-Violate/dp/0887306667). It's a classic tome.
@@ -165,7 +165,7 @@ OK, you're writing a great post every couple of weeks or so, now how do let ever
{% marginfigure /assets/img/posts/sleazyguy.jpg "The easiest way to not be this guy is to actually have a great product." %}
First, let's remember that a good product is the best marketing you can have. If after ~3 months of posting you're still not getting the numbers you want, it means that your writing just isn't hitting the right buttons. Either your subject area is too narrow, your positioning is uninteresting, or you haven't cranked your voice to 11 yet.
-{% marginfigure /assets/img/posts/seth_godin_3.jpg "The king of permission marketing, [Seth Godin](http://sethgodin.typepad.com/seths_blog/2008/01/permission-mark.html)." %}
+{% marginfigure /assets/img/posts/seth_godin_3.jpg "The king of permission marketing, [Seth Godin](https://seths.blog/2008/01/permission-mark/)." %}
The entire strategy I followed is based around *building permission*. We are giving away awesome things for free (our blog posts, which are like miniature products) in return for the *permission* to market to someone in the future. I give you an awesome, informative blog post, and you give me your email address and the permission to email you in the future (your newsletter). So, with that in mind, our most important distribution channel is gonna be our newsletter.
There's a lot of ways to embed a newsletter in your posts. I've waffled back and forth a lot on this, and ended up with a popup in the lower-right-hand corner that appears after you've scrolled halfway through a post, and an additional signup box at the end of the post. {% marginfigure /assets/img/posts/popup.png "My current newsletter side-popup. It never appears again if you click the X." %} A lot of people choose to go for the big, obtrusive popover for a newsletter signup. You know the kind - it completely blocks out the page content and says "sign up to get my 5 free tips about..." I don't think this works for a technical audience that spends most of their day on the Internet. Maybe it even does work to juice the numbers on newsletter signups, but remember: if they put in their email address just to get your stupid popup to go away, they didn't give you permission to market your product to you later. Newsletter signups are about *capturing* the permission generated by your great content, so you want them to be *useful*, not obtrusive. How can a reader possibly give you permission if they haven't even read your content yet?
@@ -205,11 +205,11 @@ Again, I recommend getting yourself in the mindset of a $100-tier (a $100-500 pr
In addition, I don't recommend starting with a subscription. These seem far harder to sell nowadays because the competition is so steep - every freelancer *thinks* they want a subscription revenue stream, so there's an endless sea of $10/month screencast services that you'll have to compete against. The secret is that with a high-enough priced product (i.e. not a $10 product), you can make just as much money and experience similar stability as you can with a subscription.
-As for actually deciding what my $100-tier product was going to be about and how I was going to sell it, I followed [Brian Harris'](http://videofruit.com/blog/online-course-sell/) process almost to the letter. If you're seriously following this post as a guide, I suggest just heading to him directly but the gist of it is:
+As for actually deciding what my $100-tier product was going to be about and how I was going to sell it, I followed [Brian Harris'](https://archive.org/search?query=http://videofruit.com/blog/online-course-sell/) process almost to the letter. If you're seriously following this post as a guide, I suggest just heading to him directly but the gist of it is:
{% marginfigure /assets/img/posts/originalspeed.jpg "Original splash image I used to test the CGRP" %}
-* **Write a long-copy style marketing page** - [Here's the actual Google Doc I used when developing the Complete Guide to Rails Performance](https://docs.google.com/document/d/1wOxDoPyroW7hapOYF0g869IEemegmfZF_aezecDO4ng/edit?usp=sharing). Write about 2,000+ words about your product, describing it from all angles. For more about how to write long-copy marketing, [see here](http://videofruit.com/blog/online-course-sell/). You're basically trying to address every possible objection to why someone *wouldn't* buy your product. If you think long-copy marketing pages don't work, you are 100% wrong and clearly have not tried to sell a product like this before. There's a reason why I do it, there's a reason why every successful infoproduct seller does it. They work.
+* **Write a long-copy style marketing page** - [Here's the actual Google Doc I used when developing the Complete Guide to Rails Performance](https://docs.google.com/document/d/1wOxDoPyroW7hapOYF0g869IEemegmfZF_aezecDO4ng/edit?usp=sharing). Write about 2,000+ words about your product, describing it from all angles. For more about how to write long-copy marketing, [see here](https://archive.org/search?query=http://videofruit.com/blog/online-course-sell/). You're basically trying to address every possible objection to why someone *wouldn't* buy your product. If you think long-copy marketing pages don't work, you are 100% wrong and clearly have not tried to sell a product like this before. There's a reason why I do it, there's a reason why every successful infoproduct seller does it. They work.
* **Take 50 newsletter subscribers as a cohort and ask them to pre-order** - Time for our first build-measure-learn loop! Take 50 newsletter subscribers at random, and send them your marketing page. Tell them you're trying out this product idea and ask them for some feedback. Videofruit has a specific list of questions to ask, which I also used. When they reply with an answer to your questions, reply back with a thank you and ask them to pre-order at the price point that you're testing. I used Gumroad to take preorders (more on them in a bit).
* **Revise the marketing page based on what you learned.** The survey responses you received will be gold. Go back and revise the marketing page with this feedback. If 5 or more (that is, 10%) of your 50 subscribers actually put in their credit card and hit "pre-order", test a *higher* price point this time. If 5 people ordered at $200, test $300.
* **Take another 50 newsletter subscribers, and try again.** Repeat the process with another set of 50 newsletter subscribers. Again, your goal is for 10% of them to put in their credit cards and hit "preorder".
diff --git a/_posts/2017-08-10-how-to-get-a-computer-science-degree-in-a-warzone.md b/_posts/2017-08-10-how-to-get-a-computer-science-degree-in-a-warzone.md
index cdb8ea2..c4d7e4d 100644
--- a/_posts/2017-08-10-how-to-get-a-computer-science-degree-in-a-warzone.md
+++ b/_posts/2017-08-10-how-to-get-a-computer-science-degree-in-a-warzone.md
@@ -18,7 +18,7 @@ Actually, he wasn't *just* in Syria. He was at the University of Aleppo. His nam
At this point, the [Syrian Civil War](https://en.wikipedia.org/wiki/Syrian_Civil_War) had been raging in Aleppo for almost four years. One tenth of the total deaths of the war so far have happened in Aleppo. When Mohammed wrote this email in 2016, the tide was just starting to turn in favor of Assad's forces. You would be forgiven if, like me, you thought that life did not go on inside of a warzone, especially in a city where entire city blocks had been leveled to dust. But, it does - although life is hard, life goes on.
-{% marginfigure /assets/img/posts/dorm-bombing.jpg "Aftermath of the 2013 Aleppo University bombing. Mohammed: 'When I saw the photo of the University bombing in the post I saw everything: I saw who did it, how he did it, the bodies, legs, arms, blood, glass, melted metal, I was there. My heart beat is getting faster and the inhales and exhales is disturbing my body better to close this thing.' [NYTimes](http://www.nytimes.com/2013/01/16/world/middleeast/syria-violence.html)" %}
+{% marginfigure /assets/img/posts/dorm-bombing.jpg "Aftermath of the 2013 Aleppo University bombing. Mohammed: 'When I saw the photo of the University bombing in the post I saw everything: I saw who did it, how he did it, the bodies, legs, arms, blood, glass, melted metal, I was there. My heart beat is getting faster and the inhales and exhales is disturbing my body better to close this thing.' [NYTimes](https://archive.nytimes.com/www.nytimes.com/2013/01/16/world/middleeast/syria-violence.html)" %}
The [veil of ignorance](https://en.wikipedia.org/wiki/Veil_of_ignorance) had been stripped away. Here was someone my age, on the other side of the world, with the same interests as I do, but living in an actual warzone. He played video games with his friends, like I do, enjoyed programming in Ruby on Rails, like I do, but [his university was being bombed](https://en.wikipedia.org/wiki/Aleppo_University_bombings).
@@ -26,7 +26,7 @@ Mohammed and I continued corresponding. He never asked for anything except advic
In January of this year, Mohammed fled Syria and made it to Turkey, where he is officially a Syrian refugee.
-{% marginfigure /assets/img/posts/avatar.jpg "Mohammed with his public key fingerprint. We both signed this post, see the end. [Keybase](https://keybase.pub/mohammedelias/me/avatar.jpg)" %}
+{% marginfigure /assets/img/posts/avatar.jpg "Mohammed with his public key fingerprint. We both signed this post, see the end. [Keybase](https://keybase.io/mohammedelias)" %}
I'll turn it over to Mohammed to let him tell you his story. Mohammed's English is not bad, but I did heavily edit this section for readability and clarity.
@@ -50,11 +50,11 @@ Most of the students had very old hardware. Only a handful had brand new laptops
In the spring of 2011, the Arab Spring spread to Syria. That summer, the civil war began.
-{% marginnote "[Washington Post, April 2013](https://www.washingtonpost.com/world/middle_east/in-syria-kidnappings-on-the-rise-as-lawlessness-spreads/2013/04/21/b0bb2f2e-a854-11e2-8302-3c7e0ea97057_story.html): 'Kidnappings of ordinary Syrians are rising at an alarming rate, a stark sign of the spreading lawlessness in their country after two years of war.'" %}
+{% marginnote "[Washington Post, April 2013](https://archive.org/search?query=https://www.washingtonpost.com/world/middle_east/in-syria-kidnappings-on-the-rise-as-lawlessness-spreads/2013/04/21/b0bb2f2e-a854-11e2-8302-3c7e0ea97057_story.html): 'Kidnappings of ordinary Syrians are rising at an alarming rate, a stark sign of the spreading lawlessness in their country after two years of war.'" %}
On January 15 2013, Aleppo University was bombed. 82 people were killed and 160 were injured. Shortly after, while travelling back to Al-Qamishli to my family, I was kidnapped by thugs. I was in captivity for 32 days, 7 hours and about 21 minutes. I know because I counted, second by second. The gang that kidnapped me released me after forcing my family to pay a ransom of about $12,000 USD. Well, this was my savings to travel to Japan, and since the Syrian Lira had inflated so much since the war started it was even worse.
-{% marginfigure /assets/img/posts/scud-attack.jpg "[NY Times, February 23 2013](http://www.nytimes.com/2013/02/23/world/middleeast/scud-missile-aleppo.html): 'Antigovernment activists in Syria said the military fired Scud missiles into at least three rebel-held districts of Aleppo on Friday, flattening dozens of houses, killing at least 12 civilians and burying perhaps dozens of others under piles of rubble.' [Youtube video of the attack](https://www.youtube.com/watch?v=aOhPWCQSZSc&feature=youtu.be)." %}
+{% marginfigure /assets/img/posts/scud-attack.jpg "[NY Times, February 23 2013](https://archive.nytimes.com/www.nytimes.com/2013/02/23/world/middleeast/scud-missile-aleppo.html): 'Antigovernment activists in Syria said the military fired Scud missiles into at least three rebel-held districts of Aleppo on Friday, flattening dozens of houses, killing at least 12 civilians and burying perhaps dozens of others under piles of rubble.' [Youtube video of the attack](https://www.youtube.com/watch?v=aOhPWCQSZSc&feature=youtu.be)." %}
Aleppo was getting very bad at this time. There was no safe road to exit or enter Aleppo. In addition, if the faculty left or the university shut down, I would immediately be drafted into military service for the Assad regime. I started looking for ways to leave Syria. I emailed every foreign embassy on Earth to complete my undergraduate study. They all refused me because I did not have refugee status with the UNHCR.
@@ -80,7 +80,7 @@ By the end of 2016, I finished 5 levels of Japanese, took a preparation course f
{% marginfigure /assets/img/posts/friendshouse.jpg "This was taken 5 hours before Mohammed left Aleppo." %}
-In Syria, [military service is compulsory for males over the age of 18 who are not in school](http://mpc-journal.org/blog/2017/04/23/compulsory-military-conscription-in-syria-drives-many-males-into-exile/). My exemption from service would expire after my graduation in March of 2017. I decided to leave Syria before that happened, before I could get caught by Assad's regime and turned into a cold-blood killer or to be killed by his mercenaries.
+In Syria, [military service is compulsory for males over the age of 18 who are not in school](https://archive.org/search?query=http://mpc-journal.org/blog/2017/04/23/compulsory-military-conscription-in-syria-drives-many-males-into-exile/). My exemption from service would expire after my graduation in March of 2017. I decided to leave Syria before that happened, before I could get caught by Assad's regime and turned into a cold-blood killer or to be killed by his mercenaries.
In December of 2016, I crossed the border to Turkey after a very tough journey. I spent a night on the Syrian-Turkish border, surrounded by rocket fire and bombs falling. The next day, after I had left, 22 died from rocket fire near where I slept. 3 weeks later, I got a job as a junior iOS developer in a small office in Istanbul. I work 50 hours per week for about $430 per month. After 5 months I moved to Bursa, a smaller and cheaper city than Istanbul, where I got another job in a more reliable office than the previous one for $500 per month.
@@ -100,24 +100,24 @@ Hi, it's Nate again.
Mohammed is out of the frying pan, but he's not out of the fire yet. If you've been following the news, you know that Turkey's future is highly unstable. After some discussion, Mohammed thinks his best chance at a safe and steady life and career is to **immigrate to Canada**. Canada has an extensive refugee immigration program, and I know there are a lot of companies there that might want to help.
-For a refugee who cannot return to their country due to civil war, [there are a few conditions](http://www.cic.gc.ca/english/refugees/outside/index.asp) you have to meet before you can apply to immigrate to Canada:
+For a refugee who cannot return to their country due to civil war, [there are a few conditions](https://archive.org/search?query=http://www.cic.gc.ca/english/refugees/outside/index.asp) you have to meet before you can apply to immigrate to Canada:
-{% marginnote "In 2016, a temporary policy allowed private sponsors in Canada to [refer refugees who did not have official refugee status with the UNCHR or a foreign state.](http://www.cic.gc.ca/english/department/laws-policy/syria-iraq-new.asp) Although this policy has expired, Mohammed still qualifies because he has official refugee status in Turkey." %}
+{% marginnote "In 2016, a temporary policy allowed private sponsors in Canada to [refer refugees who did not have official refugee status with the UNCHR or a foreign state.](https://archive.org/search?query=http://www.cic.gc.ca/english/department/laws-policy/syria-iraq-new.asp) Although this policy has expired, Mohammed still qualifies because he has official refugee status in Turkey." %}
* **"You must be outside your home country."** Mohammed is in Turkey.
* **"You have been seriously affected by civil war or armed conflict"** Hopefully obvious from the above.
-* **"You will still need the UNHCR, a referral organization, or a private sponsorship group to refer you."** The UNHCR protects refugees who are fleeing persecution based on their race, ethnicity or other status, which Mohammed is not (he's in the [Member of the Country of Asylum class](http://www.cic.gc.ca/english/resources/publications/ref-sponsor/section-2.asp#a2.1)). He will need a private sponsor.
+* **"You will still need the UNHCR, a referral organization, or a private sponsorship group to refer you."** The UNHCR protects refugees who are fleeing persecution based on their race, ethnicity or other status, which Mohammed is not (he's in the [Member of the Country of Asylum class](https://archive.org/search?query=http://www.cic.gc.ca/english/resources/publications/ref-sponsor/section-2.asp)). He will need a private sponsor.
-The reason why I've written this post is to help Mohammed find a private sponsor. In Canada, you can do that through two ways: by forming a [Group of Five](http://www.cic.gc.ca/english/refugees/sponsor/groups.asp), private citizens and permanent residents of Canada who all live in the same city and pledge to help the refugee they sponsor to resettle in Canada. The second way is a [Community Sponsor](http://www.cic.gc.ca/english/refugees/sponsor/community.asp), which can be a corporation or community organization.
+The reason why I've written this post is to help Mohammed find a private sponsor. In Canada, you can do that through two ways: by forming a [Group of Five](https://archive.org/search?query=http://www.cic.gc.ca/english/refugees/sponsor/groups.asp), private citizens and permanent residents of Canada who all live in the same city and pledge to help the refugee they sponsor to resettle in Canada. The second way is a [Community Sponsor](https://archive.org/search?query=http://www.cic.gc.ca/english/refugees/sponsor/community.asp), which can be a corporation or community organization.
-All private sponsorships entail financial and non-financial support, for the period of about a year or until Mohammed becomes self-sufficient. Since Mohammed is a junior computer programmer, hopefully that won't take very long. For more about what a sponsor has to do, [check out Canada's official explanation of the role of a sponsor.](http://www.cic.gc.ca/english/resources/publications/ref-sponsor/section-2.asp#a2.6)
+All private sponsorships entail financial and non-financial support, for the period of about a year or until Mohammed becomes self-sufficient. Since Mohammed is a junior computer programmer, hopefully that won't take very long. For more about what a sponsor has to do, [check out Canada's official explanation of the role of a sponsor.](https://archive.org/search?query=http://www.cic.gc.ca/english/resources/publications/ref-sponsor/section-2.asp)
-**If you are a permanent resident of Canada** (and, uh, [haven't murdered anybody](http://www.cic.gc.ca/english/resources/publications/ref-sponsor/section-2.asp#a2.4)) and are interested in helping Mohammed resettle in Canada, [please let Mohammed know by contacting him through this form](https://goo.gl/forms/6wvh3G5HCVVdEX7f1).
+**If you are a permanent resident of Canada** (and, uh, [haven't murdered anybody](https://archive.org/search?query=http://www.cic.gc.ca/english/resources/publications/ref-sponsor/section-2.asp)) and are interested in helping Mohammed resettle in Canada, [please let Mohammed know by contacting him through this form](https://goo.gl/forms/6wvh3G5HCVVdEX7f1).
**If you are a Canadian corporation and want to help**, you can [contact Mohammed through this form.](https://goo.gl/forms/6wvh3G5HCVVdEX7f1) Mohammed has experience as a Ruby and iOS developer, and is currently working both at an iOS shop and with me on some Ruby projects. [Here's his resume.](https://gist.github.com/nateberkopec/c4cabcf32aebe0841205f065b5e83b4e) I think he would be a great asset to any Canadian employer. **However, your organization does not have to hire Mohammed to be his Community Sponsor**.
In addition, if you live in *any* country whose immigration laws might allow someone like Mohammed immigrate, and you think you could help - please do [get in contact](https://goo.gl/forms/6wvh3G5HCVVdEX7f1).
-Mohammed is active on [Twitter](https://twitter.com/MohammedEliass) and [Github](https://github.com/Mohammed-Eliass).
+Mohammed is active on [Twitter](https://twitter.com/MohammedEliass) and [Github](https://archive.org/search?query=https://github.com/Mohammed-Eliass).
-This blog post has been cryptographically signed by both [myself](https://gist.github.com/nateberkopec/c3a4b97892e0ddcbf4f1b5053140c645) and [Mohammed](https://gist.github.com/Mohammed-Eliass/9d9067068b73049f2142ceb86af1a54f). You may verify our signatures on [Keybase](https://keybase.io/verify). I also signed the commit on Github ([repository here](https://github.com/nateberkopec/dotcom)) for this blog post.
+This blog post has been cryptographically signed by both [myself](https://gist.github.com/nateberkopec/c3a4b97892e0ddcbf4f1b5053140c645) and [Mohammed](https://archive.org/search?query=https://gist.github.com/Mohammed-Eliass/9d9067068b73049f2142ceb86af1a54f). You may verify our signatures on [Keybase](https://keybase.io/verify). I also signed the commit on Github ([repository here](https://github.com/nateberkopec/dotcom)) for this blog post.
diff --git a/_posts/2019-10-04-failing-on-shark-tank-changed-my-life.md b/_posts/2019-10-04-failing-on-shark-tank-changed-my-life.md
index 8b43458..44bc89e 100644
--- a/_posts/2019-10-04-failing-on-shark-tank-changed-my-life.md
+++ b/_posts/2019-10-04-failing-on-shark-tank-changed-my-life.md
@@ -74,7 +74,7 @@ For the next 10 years I tried to both keep and reject my entrepreneurial identit
During this self-imposed exile, a very good friend of mine, Hursh Agrawal, started a startup called Branch with some people he met at [a startup bootcamp](https://www.leanstartupmachine.com/). A few years later, they exited to Facebook. I didn't realize it at the time, but I really resented my friend and his success, because underneath I was thinking: "Why didn't I do this? He was just as prepared as you are - why aren't you doing this, like you said you would?" I buried those feelings and kept telling myself that I had to "learn more", despite the fact that my peer was succeeding with the same amount of experience that I had.
-But I couldn't deny my entrepreneurial desires. Eventually I quit full-time computer programming work and started freelancing. This was a bit better for me, as I had to deal with sales and marketing. I found [a niche in Ruby on Rails performance](https://speedshop.co), and wrote [a book](https://railsspeed.com) about it that has [done pretty well](https://www.nateberkopec.com/blog/2017/03/10/how-i-made-self-publishing-about-ruby-on-rails.html). In the last 3 years, I've cleared over $500,000 from a combination of my writings, workshops and consulting.
+But I couldn't deny my entrepreneurial desires. Eventually I quit full-time computer programming work and started freelancing. This was a bit better for me, as I had to deal with sales and marketing. I found [a niche in Ruby on Rails performance](https://speedshop.co), and wrote [a book](https://railsspeed.com) about it that has [done pretty well](https://www.nateberkopec.com/blog/how-i-made-self-publishing-about-ruby-on-rails/). In the last 3 years, I've cleared over $500,000 from a combination of my writings, workshops and consulting.
I still felt like a failure. {% marginfigure /assets/img/posts/gumroadCGRPsales.jpg "If you've ever wondered if you can feel like a failure while selling $1k/week in a product, yes: you can." %}
diff --git a/index.html b/index.html
index 7057f5e..6762b57 100755
--- a/index.html
+++ b/index.html
@@ -27,9 +27,9 @@
-
+
-
+
I'm the proprietor of Speedshop,
@@ -75,7 +75,7 @@
Other Stuff
diff --git a/mise.toml b/mise.toml
index 75a6bd7..ac5ee91 100644
--- a/mise.toml
+++ b/mise.toml
@@ -3,3 +3,15 @@ terraform = "latest"
[env]
_.file = ".env"
+
+[tasks."test:ruby"]
+description = "Run Ruby unit tests"
+run = "bundle exec rake test:ruby"
+
+[tasks."test:integration"]
+description = "Run integration tests"
+run = "bundle exec rake test:integration"
+
+[tasks.test]
+description = "Run full test suite"
+depends = ["test:ruby", "test:integration"]
diff --git a/test/integration/form_action_test.rb b/test/integration/form_action_test.rb
new file mode 100644
index 0000000..6091090
--- /dev/null
+++ b/test/integration/form_action_test.rb
@@ -0,0 +1,80 @@
+require "minitest/autorun"
+require "nokogiri"
+require "pathname"
+require "uri"
+require_relative "../test_helper"
+
+class FormActionTest < Minitest::Test
+ EXPECTED_HOST = "nateberkopec.us11.list-manage.com"
+ EXPECTED_PATH = "/subscribe/post"
+ REQUIRED_QUERY_KEYS = %w[u id].freeze
+
+ def setup
+ TestHelper.ensure_site_built!
+ end
+
+ def test_form_actions_are_valid_mailchimp_subscribe_endpoints
+ actions_with_locations = collect_form_actions
+
+ refute actions_with_locations.empty?, "Expected at least one form[action] in built site"
+
+ failures = actions_with_locations.filter_map do |action, locations|
+ validate_action(action, locations)
+ end
+
+ assert failures.empty?, failures.join("\n\n")
+ end
+
+ private
+
+ def collect_form_actions
+ actions = Hash.new { |hash, key| hash[key] = [] }
+
+ Dir.glob(File.join(TestHelper::SITE_DIR, "**", "*.html")).sort.each do |file_path|
+ doc = Nokogiri::HTML(File.read(file_path))
+ relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(TestHelper::SITE_DIR)).to_s
+
+ doc.css("form[action]").each do |form|
+ action = form["action"].to_s.strip
+ next if action.empty?
+
+ actions[action] << relative_path
+ end
+ end
+
+ actions
+ end
+
+ def validate_action(raw_action, locations)
+ uri = parse_action_uri(raw_action)
+ return format_failure(raw_action, locations, "Could not parse URI") unless uri
+
+ issues = []
+ issues << "scheme #{uri.scheme.inspect} (expected \"https\")" unless uri.scheme == "https"
+ issues << "host #{uri.host.inspect} (expected #{EXPECTED_HOST.inspect})" unless uri.host == EXPECTED_HOST
+ issues << "path #{uri.path.inspect} (expected #{EXPECTED_PATH.inspect})" unless uri.path == EXPECTED_PATH
+
+ params = URI.decode_www_form(uri.query.to_s).to_h
+ missing_keys = REQUIRED_QUERY_KEYS.reject { |key| params[key].to_s.length.positive? }
+ issues << "missing query keys: #{missing_keys.join(", ")}" if missing_keys.any?
+
+ return if issues.empty?
+
+ format_failure(raw_action, locations, issues.join("; "))
+ end
+
+ def parse_action_uri(raw_action)
+ normalized = raw_action.start_with?("//") ? "https:#{raw_action}" : raw_action
+ URI.parse(normalized)
+ rescue URI::InvalidURIError
+ nil
+ end
+
+ def format_failure(action, locations, details)
+ <<~MSG.strip
+ Invalid form action: #{action}
+ #{details}
+ from: #{locations.first}
+ MSG
+ end
+end
diff --git a/test/integration/link_integrity_test.rb b/test/integration/link_integrity_test.rb
new file mode 100644
index 0000000..23f7676
--- /dev/null
+++ b/test/integration/link_integrity_test.rb
@@ -0,0 +1,402 @@
+require "cgi/escape"
+require "concurrent-ruby"
+require "minitest/autorun"
+require "net/http"
+require "nokogiri"
+require "set"
+require "pathname"
+require "uri"
+require_relative "../test_helper"
+
+class LinkIntegrityTest < Minitest::Test
+ REQUEST_TIMEOUT_SECONDS = 10
+ REDIRECT_LIMIT = 5
+ MAX_RETRIES = 3
+ POOL_SIZE = 25
+
+ SELECTORS = [
+ ["a[href]", "href"],
+ ["link[href]", "href"],
+ ["script[src]", "src"],
+ ["img[src]", "src"],
+ ["img[srcset]", "srcset"],
+ ["source[src]", "src"],
+ ["source[srcset]", "srcset"],
+ ["video[src]", "src"],
+ ["video[poster]", "poster"],
+ ["audio[src]", "src"],
+ ["iframe[src]", "src"],
+ ["embed[src]", "src"],
+ ["object[data]", "data"]
+ ].freeze
+
+ def setup
+ TestHelper.ensure_site_built!
+ TestHelper.start_site_server!
+
+ @base_url = TestHelper.base_url
+ @internal_hosts = Set.new([
+ URI.parse(@base_url).host,
+ "localhost",
+ "127.0.0.1",
+ "www.nateberkopec.com",
+ "nateberkopec.com"
+ ].compact)
+ end
+
+ def test_all_built_links_assets_and_fragments_are_valid
+ references = collect_references
+
+ failures = []
+ failures.concat(validate_non_http_schemes(references))
+
+ url_targets, fragment_targets, resolution_failures = build_targets(references)
+ failures.concat(resolution_failures)
+
+ response_cache = Concurrent::Map.new
+
+ failures.concat(check_targets(url_targets, response_cache))
+ failures.concat(check_fragments(fragment_targets, response_cache))
+
+ assert failures.empty?, format_failures(failures)
+ end
+
+ private
+
+ def collect_references
+ refs = []
+
+ Dir.glob(File.join(TestHelper::SITE_DIR, "**", "*.html")).sort.each do |path|
+ html = File.read(path)
+ doc = Nokogiri::HTML(html)
+ source_url = served_path_for(path)
+
+ SELECTORS.each do |selector, attribute|
+ doc.css(selector).each do |node|
+ next if selector == "link[href]" && skip_link_check?(node)
+
+ raw_value = node[attribute]
+ next if raw_value.nil? || raw_value.strip.empty?
+
+ values = attribute == "srcset" ? parse_srcset(raw_value) : [raw_value]
+ values.each do |value|
+ refs << {
+ source_file: relative_site_path(path),
+ source_url: source_url,
+ raw: value.strip,
+ selector: selector
+ }
+ end
+ end
+ end
+ end
+
+ refs
+ end
+
+ def parse_srcset(value)
+ value.split(",").map do |entry|
+ entry.strip.split(/\s+/).first
+ end.compact.reject(&:empty?)
+ end
+
+ def validate_non_http_schemes(references)
+ failures = []
+
+ references.each do |reference|
+ raw = reference[:raw]
+
+ if raw.start_with?("mailto:")
+ failures << failure(:invalid_mailto, reference, "Invalid mailto syntax") unless valid_mailto?(raw)
+ elsif raw.start_with?("tel:")
+ failures << failure(:invalid_tel, reference, "Invalid tel syntax") unless valid_tel?(raw)
+ end
+ end
+
+ failures
+ end
+
+ def build_targets(references)
+ url_targets = Hash.new { |h, k| h[k] = Set.new }
+ fragment_targets = Hash.new { |h, k| h[k] = Set.new }
+ failures = []
+
+ references.each do |reference|
+ raw = reference[:raw]
+ next if ignorable_scheme?(raw)
+ next if raw.start_with?("mailto:", "tel:")
+ next if raw == "#"
+
+ resolved = resolve_url(reference[:source_url], raw)
+ unless resolved
+ failures << failure(:invalid_url, reference, "Could not resolve URL")
+ next
+ end
+
+ normalized = normalize_internal_url(resolved)
+ normalized_key = normalized.dup
+ fragment = normalized_key.fragment
+ normalized_key.fragment = nil
+
+ target_key = normalized_key.to_s
+ url_targets[target_key] << reference_location(reference)
+
+ next unless fragment && !fragment.empty?
+
+ fragment_targets[[target_key, CGI.unescape(fragment)]] << reference_location(reference)
+ end
+
+ [url_targets, fragment_targets, failures]
+ end
+
+ def check_targets(targets, response_cache)
+ run_parallel_checks(targets) do |url, locations|
+ check_target(url, locations.to_a, response_cache)
+ end
+ end
+
+ def check_target(url, locations, response_cache)
+ uri = URI.parse(url)
+
+ last_error = nil
+ last_response = nil
+
+ MAX_RETRIES.times do
+ begin
+ result = request_with_redirects(uri)
+ response = result[:response]
+ code = response.code.to_i
+
+ if successful_status?(uri, code)
+ response_cache[url] = result
+ return nil
+ end
+
+ last_response = response
+ last_error = "HTTP #{code}"
+ rescue StandardError => e
+ last_error = "#{e.class}: #{e.message}"
+ end
+ end
+
+ details = if last_response
+ "HTTP #{last_response.code} after #{MAX_RETRIES} attempts"
+ else
+ "#{last_error} after #{MAX_RETRIES} attempts"
+ end
+
+ {
+ kind: :dead_link,
+ url: url,
+ locations: locations,
+ details: details
+ }
+ end
+
+ def check_fragments(fragment_targets, response_cache)
+ run_parallel_checks(fragment_targets) do |(url, fragment), locations|
+ check_fragment(url, fragment, locations.to_a, response_cache)
+ end
+ end
+
+ def check_fragment(url, fragment, locations, response_cache)
+ result = response_cache[url]
+
+ unless result
+ uri = URI.parse(url)
+ fetched = request_with_redirects(uri)
+ result = response_cache.put_if_absent(url, fetched) || fetched
+ end
+
+ response = result[:response]
+ content_type = response["content-type"].to_s
+
+ unless content_type.include?("text/html") || content_type.include?("application/xhtml+xml")
+ return {
+ kind: :invalid_fragment,
+ url: "#{url}##{fragment}",
+ locations: locations,
+ details: "Fragment target is not HTML (#{content_type.empty? ? 'unknown content-type' : content_type})"
+ }
+ end
+
+ doc = Nokogiri::HTML(response.body)
+ exists = doc.css("[id], [name]").any? do |node|
+ node["id"] == fragment || node["name"] == fragment
+ end
+
+ return nil if exists
+
+ {
+ kind: :missing_fragment,
+ url: "#{url}##{fragment}",
+ locations: locations,
+ details: "No matching id/name in target document"
+ }
+ rescue StandardError => e
+ {
+ kind: :invalid_fragment,
+ url: "#{url}##{fragment}",
+ locations: locations,
+ details: "#{e.class}: #{e.message}"
+ }
+ end
+
+ def run_parallel_checks(targets)
+ executor = Concurrent::ThreadPoolExecutor.new(
+ min_threads: POOL_SIZE,
+ max_threads: POOL_SIZE,
+ max_queue: 10_000,
+ fallback_policy: :caller_runs
+ )
+
+ futures = targets.map do |target, locations|
+ Concurrent::Promises.future_on(executor) do
+ yield(target, locations)
+ end
+ end
+
+ futures.map(&:value!).compact
+ ensure
+ executor&.shutdown
+ executor&.wait_for_termination(30)
+ end
+
+ def successful_status?(uri, code)
+ return true if code.between?(200, 299)
+ return true if code == 403 && blocked_but_existing_host?(uri.host)
+
+ false
+ end
+
+ def blocked_but_existing_host?(host)
+ ["www.reddit.com", "reddit.com"].include?(host)
+ end
+
+ def request_with_redirects(uri)
+ current = uri
+
+ REDIRECT_LIMIT.times do
+ response = perform_request(current)
+ code = response.code.to_i
+
+ if code.between?(300, 399)
+ location = response["location"]
+ raise "Redirect without Location header" if location.nil? || location.empty?
+
+ current = URI.join(current.to_s, location)
+ current = normalize_internal_url(current)
+ next
+ end
+
+ return {response: response, final_uri: current}
+ end
+
+ raise "Too many redirects"
+ end
+
+ def perform_request(uri)
+ Net::HTTP.start(
+ uri.host,
+ uri.port,
+ use_ssl: uri.scheme == "https",
+ open_timeout: REQUEST_TIMEOUT_SECONDS,
+ read_timeout: REQUEST_TIMEOUT_SECONDS
+ ) do |http|
+ request = Net::HTTP::Get.new(uri.request_uri.empty? ? "/" : uri.request_uri)
+ request["User-Agent"] = "dotcom-link-checker/1.0"
+ http.request(request)
+ end
+ end
+
+ def resolve_url(source_url, raw)
+ base = "#{@base_url}#{source_url}"
+ candidate = raw.start_with?("//") ? "#{URI.parse(@base_url).scheme}:#{raw}" : raw
+
+ URI.join(base, candidate)
+ rescue URI::InvalidURIError
+ nil
+ end
+
+ def normalize_internal_url(uri)
+ return uri unless @internal_hosts.include?(uri.host)
+
+ base = URI.parse(@base_url)
+
+ normalized = uri.dup
+ normalized.scheme = base.scheme
+ normalized.host = base.host
+ normalized.port = base.port
+ normalized
+ end
+
+ def ignorable_scheme?(raw)
+ raw.start_with?("javascript:", "data:", "about:", "blob:")
+ end
+
+ def skip_link_check?(node)
+ rel = node["rel"].to_s.downcase
+ rel.split.include?("preconnect") || rel.split.include?("dns-prefetch")
+ end
+
+ def valid_mailto?(value)
+ address_part = value.delete_prefix("mailto:").split("?").first.to_s
+ addresses = address_part.split(",").map(&:strip).reject(&:empty?)
+ return false if addresses.empty?
+
+ addresses.all? { |address| URI::MailTo::EMAIL_REGEXP.match?(address) }
+ rescue StandardError
+ false
+ end
+
+ def valid_tel?(value)
+ number = value.delete_prefix("tel:").strip
+ number.match?(/\A\+?[0-9()\-\.\s]+\z/) && number.match?(/[0-9]/)
+ end
+
+ def served_path_for(file_path)
+ relative = relative_site_path(file_path)
+
+ return "/" if relative == "index.html"
+ return "/#{relative.delete_suffix('index.html')}" if relative.end_with?("/index.html")
+
+ "/#{relative}"
+ end
+
+ def relative_site_path(path)
+ Pathname.new(path).relative_path_from(Pathname.new(TestHelper::SITE_DIR)).to_s
+ end
+
+ def reference_location(reference)
+ "#{reference[:source_file]} (#{reference[:selector]} => #{reference[:raw]})"
+ end
+
+ def failure(kind, reference, details)
+ {
+ kind: kind,
+ url: reference[:raw],
+ locations: [reference_location(reference)],
+ details: details
+ }
+ end
+
+ def format_failures(failures)
+ grouped = failures.group_by { |failure| failure[:kind] }
+
+ summary = grouped.map { |kind, entries| "#{kind}: #{entries.count}" }.join(", ")
+
+ details = failures.first(40).map do |failure|
+ <<~TEXT
+ - [#{failure[:kind]}] #{failure[:url]}
+ #{failure[:details]}
+ from: #{failure[:locations].first}
+ TEXT
+ end.join("\n")
+
+ <<~MSG
+ Link integrity check failed (#{failures.count} total failures; #{summary})
+
+ #{details}
+ MSG
+ end
+end
diff --git a/test/integration/server.rb b/test/integration/server.rb
new file mode 100644
index 0000000..1737fe7
--- /dev/null
+++ b/test/integration/server.rb
@@ -0,0 +1,110 @@
+require "pathname"
+require "webrick"
+
+host = ENV.fetch("TEST_SERVER_HOST", "127.0.0.1")
+port = ENV.fetch("TEST_SERVER_PORT", "0").to_i
+port_file = ENV["TEST_SERVER_PORT_FILE"]
+site_dir = ENV.fetch("TEST_SITE_DIR")
+
+class SiteServlet < WEBrick::HTTPServlet::AbstractServlet
+ def initialize(server, site_dir)
+ super(server)
+ @site_dir = site_dir
+ end
+
+ def do_GET(req, res)
+ serve(req, res, include_body: true)
+ end
+
+ def do_HEAD(req, res)
+ serve(req, res, include_body: false)
+ end
+
+ private
+
+ def serve(req, res, include_body:)
+ resolved = resolve_path(req.path)
+
+ unless resolved
+ res.status = 404
+ res["Content-Type"] = "text/plain"
+ res.body = "Not Found" if include_body
+ return
+ end
+
+ if resolved[:redirect]
+ res.status = 301
+ res["Location"] = resolved[:redirect]
+ return
+ end
+
+ file_path = resolved[:file]
+
+ unless File.file?(file_path)
+ res.status = 404
+ res["Content-Type"] = "text/plain"
+ res.body = "Not Found" if include_body
+ return
+ end
+
+ res.status = 200
+ res["Content-Type"] = WEBrick::HTTPUtils.mime_type(File.extname(file_path), WEBrick::HTTPUtils::DefaultMimeTypes)
+ res["Cache-Control"] = "max-age=86400"
+ res["Vary"] = "Accept-Encoding"
+ res.body = File.binread(file_path) if include_body
+ end
+
+ def resolve_path(path)
+ normalized = path.to_s.split("?").first
+ normalized = "/" if normalized.empty?
+
+ candidate = File.expand_path(".#{normalized}", @site_dir)
+ return nil unless within_site?(candidate)
+
+ if normalized.end_with?("/")
+ index_file = File.join(candidate, "index.html")
+ return {file: index_file} if File.file?(index_file)
+ end
+
+ return {file: candidate} if File.file?(candidate)
+
+ html_file = "#{candidate}.html"
+ return {file: html_file} if File.file?(html_file)
+
+ index_file = File.join(candidate, "index.html")
+ return {redirect: "#{normalized}/"} if File.file?(index_file)
+
+ nil
+ end
+
+ def within_site?(candidate)
+ root = Pathname.new(@site_dir).realpath
+ path = Pathname.new(candidate).cleanpath
+
+ root_str = root.to_s
+ path_str = path.to_s
+
+ path_str == root_str || path_str.start_with?("#{root_str}/")
+ rescue Errno::ENOENT
+ false
+ end
+end
+
+server = WEBrick::HTTPServer.new(
+ Port: port,
+ BindAddress: host,
+ AccessLog: [],
+ Logger: WEBrick::Log.new(File::NULL)
+)
+
+if port_file
+ assigned_port = server.listeners.first.addr[1]
+ File.write(port_file, assigned_port.to_s)
+end
+
+server.mount("/", SiteServlet, site_dir)
+
+trap("INT") { server.shutdown }
+trap("TERM") { server.shutdown }
+
+server.start
diff --git a/test/integration/site_test.rb b/test/integration/site_test.rb
new file mode 100644
index 0000000..1b20883
--- /dev/null
+++ b/test/integration/site_test.rb
@@ -0,0 +1,52 @@
+require "minitest/autorun"
+require "net/http"
+require "uri"
+require_relative "../test_helper"
+
+class SiteTest < Minitest::Test
+ def setup
+ TestHelper.ensure_site_built!
+ TestHelper.start_site_server!
+ end
+
+ def test_homepage_loads
+ response = get("/")
+
+ assert_equal "200", response.code
+ assert_includes response["content-type"], "text/html"
+ end
+
+ def test_blog_index_loads
+ response = get("/blog/")
+
+ assert_equal "200", response.code
+ end
+
+ def test_feed_exists
+ response = get("/feed.xml")
+
+ assert_equal "200", response.code
+ end
+
+ def test_robots_exists
+ response = get("/robots.txt")
+
+ assert_equal "200", response.code
+ end
+
+ def test_missing_page_is_404
+ response = get("/this-page-does-not-exist-12345")
+
+ assert_equal "404", response.code
+ end
+
+ private
+
+ def get(path)
+ uri = URI.parse("#{TestHelper.base_url}#{path}")
+
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 10) do |http|
+ http.request(Net::HTTP::Get.new(uri.request_uri))
+ end
+ end
+end
diff --git a/test/ruby/array_intersection_test.rb b/test/ruby/array_intersection_test.rb
new file mode 100644
index 0000000..a4348a0
--- /dev/null
+++ b/test/ruby/array_intersection_test.rb
@@ -0,0 +1,39 @@
+require "liquid"
+require_relative "test_helper"
+require_relative "../../_plugins/array_intersection"
+
+class ArrayIntersectionFilterTest < Minitest::Test
+ def test_intersect_handles_csv_strings
+ filter = build_filter
+
+ result = filter.intersect("a, b, c", "b, d")
+
+ assert_equal ["b"], result
+ end
+
+ def test_intersect_handles_arrays
+ filter = build_filter
+
+ result = filter.intersect(["x", "y"], ["y", "z"])
+
+ assert_equal ["y"], result
+ end
+
+ def test_intersection_returns_true_when_any_match
+ filter = build_filter
+
+ assert_equal true, filter.intersection("a,b", "b,c")
+ end
+
+ def test_intersection_returns_false_when_no_match
+ filter = build_filter
+
+ assert_equal false, filter.intersection("a,b", "c,d")
+ end
+
+ private
+
+ def build_filter
+ Object.new.extend(Jekyll::ArrayIntersectionFilter)
+ end
+end
diff --git a/test/ruby/fullwidth_test.rb b/test/ruby/fullwidth_test.rb
new file mode 100644
index 0000000..e3ea076
--- /dev/null
+++ b/test/ruby/fullwidth_test.rb
@@ -0,0 +1,13 @@
+require "liquid"
+require_relative "test_helper"
+require_relative "../../_plugins/fullwidth"
+
+class FullwidthTagTest < Minitest::Test
+ def test_render_includes_image_and_caption
+ result = Liquid::Template.parse('{% fullwidth /img/x.png "Caption" %}').render
+
+ assert_includes result, "class='fullwidth'"
+ assert_includes result, "/img/x.png"
+ assert_includes result, "Caption"
+ end
+end
diff --git a/test/ruby/main_column_img_test.rb b/test/ruby/main_column_img_test.rb
new file mode 100644
index 0000000..e9c32cb
--- /dev/null
+++ b/test/ruby/main_column_img_test.rb
@@ -0,0 +1,13 @@
+require "liquid"
+require_relative "test_helper"
+require_relative "../../_plugins/main_column_img"
+
+class MainColumnTagTest < Minitest::Test
+ def test_render_includes_image_and_caption
+ result = Liquid::Template.parse('{% maincolumn /img/z.png "Cap" %}').render
+
+ assert_includes result, "class='fullwidth'"
+ assert_includes result, "/img/z.png"
+ assert_includes result, "Cap"
+ end
+end
diff --git a/test/ruby/margin_figure_test.rb b/test/ruby/margin_figure_test.rb
new file mode 100644
index 0000000..dfd74ec
--- /dev/null
+++ b/test/ruby/margin_figure_test.rb
@@ -0,0 +1,13 @@
+require "liquid"
+require_relative "test_helper"
+require_relative "../../_plugins/margin_figure"
+
+class MarginFigureTagTest < Minitest::Test
+ def test_render_includes_image_and_caption
+ result = Liquid::Template.parse('{% marginfigure /img/y.png "Cap" %}').render
+
+ assert_includes result, "class='marginnote'"
+ assert_includes result, "/img/y.png"
+ assert_includes result, "Cap"
+ end
+end
diff --git a/test/ruby/marginnote_test.rb b/test/ruby/marginnote_test.rb
new file mode 100644
index 0000000..9f6bb78
--- /dev/null
+++ b/test/ruby/marginnote_test.rb
@@ -0,0 +1,11 @@
+require "liquid"
+require_relative "test_helper"
+require_relative "../../_plugins/marginnote"
+
+class MarginnoteTagTest < Minitest::Test
+ def test_render_wraps_content_in_marginnote_span
+ result = Liquid::Template.parse('{% marginnote "hello" %}').render
+
+ assert_includes result, "hello"
+ end
+end
diff --git a/test/ruby/mathjaxtag_test.rb b/test/ruby/mathjaxtag_test.rb
new file mode 100644
index 0000000..bb8912f
--- /dev/null
+++ b/test/ruby/mathjaxtag_test.rb
@@ -0,0 +1,17 @@
+require "liquid"
+require_relative "test_helper"
+require_relative "../../_plugins/mathjaxtag"
+
+class MathjaxTagTest < Minitest::Test
+ def test_math_tag_renders_display_script_open
+ result = Liquid::Template.parse("{% math %}").render
+
+ assert_equal '", result
+ end
+end
diff --git a/test/ruby/newthought_test.rb b/test/ruby/newthought_test.rb
new file mode 100644
index 0000000..4bcc58d
--- /dev/null
+++ b/test/ruby/newthought_test.rb
@@ -0,0 +1,11 @@
+require "liquid"
+require_relative "test_helper"
+require_relative "../../_plugins/newthought"
+
+class NewthoughtTagTest < Minitest::Test
+ def test_render_wraps_content_in_newthought_span
+ result = Liquid::Template.parse('{% newthought "Important" %}').render
+
+ assert_includes result, "Important"
+ end
+end
diff --git a/test/ruby/sidenote_test.rb b/test/ruby/sidenote_test.rb
new file mode 100644
index 0000000..5b76427
--- /dev/null
+++ b/test/ruby/sidenote_test.rb
@@ -0,0 +1,13 @@
+require "liquid"
+require_relative "test_helper"
+require_relative "../../_plugins/sidenote"
+
+class SidenoteTagTest < Minitest::Test
+ def test_render_includes_number_and_note_text
+ result = Liquid::Template.parse('{% sidenote 4 "A note" %}').render
+
+ assert_includes result, "sidenote-number'>4"
+ assert_includes result, "A note"
+ assert_includes result, "class='sidenote'"
+ end
+end
diff --git a/test/ruby/test_helper.rb b/test/ruby/test_helper.rb
new file mode 100644
index 0000000..0b7a438
--- /dev/null
+++ b/test/ruby/test_helper.rb
@@ -0,0 +1,2 @@
+require "minitest/autorun"
+require_relative "../test_helper"
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..23da8ab
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,106 @@
+require "bundler"
+require "fileutils"
+require "net/http"
+require "rbconfig"
+require "securerandom"
+require "tmpdir"
+require "uri"
+
+module TestHelper
+ ROOT_DIR = File.expand_path("..", __dir__)
+ SITE_DIR = File.expand_path("../_site", __dir__)
+ SERVER_SCRIPT = File.join(ROOT_DIR, "test", "integration", "server.rb")
+ CONFIGURED_BASE_URL = ENV["BASE_URL"]
+ DEFAULT_LOCAL_HOST = "127.0.0.1"
+
+ def self.ensure_site_built!
+ return if @site_built
+
+ FileUtils.rm_rf(SITE_DIR)
+
+ Dir.chdir(ROOT_DIR) do
+ Bundler.with_unbundled_env do
+ system("bundle", "exec", "jekyll", "build", "--quiet", exception: true)
+ end
+ end
+
+ @site_built = true
+ end
+
+ def self.base_url
+ return CONFIGURED_BASE_URL if CONFIGURED_BASE_URL
+
+ start_site_server!
+ @local_base_url
+ end
+
+ def self.start_site_server!
+ return if CONFIGURED_BASE_URL
+ return if @server_pid
+
+ @port_file = File.join(Dir.tmpdir, "dotcom-test-server-#{Process.pid}-#{SecureRandom.hex(6)}.port")
+
+ env = {
+ "TEST_SERVER_HOST" => DEFAULT_LOCAL_HOST,
+ "TEST_SERVER_PORT" => "0",
+ "TEST_SERVER_PORT_FILE" => @port_file,
+ "TEST_SITE_DIR" => SITE_DIR
+ }
+
+ @server_pid = Process.spawn(env, RbConfig.ruby, SERVER_SCRIPT, chdir: ROOT_DIR, out: File::NULL, err: File::NULL)
+
+ assigned_port = wait_for_assigned_port!
+ @local_base_url = "http://#{DEFAULT_LOCAL_HOST}:#{assigned_port}"
+
+ wait_for_server!(URI.parse(@local_base_url))
+ at_exit { stop_site_server! }
+ end
+
+ def self.stop_site_server!
+ return unless @server_pid
+
+ Process.kill("TERM", @server_pid)
+ Process.wait(@server_pid)
+ rescue Errno::ESRCH, Errno::ECHILD
+ nil
+ ensure
+ @server_pid = nil
+ @local_base_url = nil
+ FileUtils.rm_f(@port_file) if @port_file
+ @port_file = nil
+ end
+
+ def self.wait_for_assigned_port!
+ 60.times do
+ if File.exist?(@port_file)
+ raw = File.read(@port_file).strip
+ return Integer(raw) if raw.match?(/\A\d+\z/) && raw.to_i.positive?
+ end
+
+ if @server_pid && Process.waitpid(@server_pid, Process::WNOHANG)
+ raise "Test server exited before reporting assigned port"
+ end
+
+ sleep 0.1
+ end
+
+ stop_site_server!
+ raise "Timed out waiting for assigned test server port"
+ end
+
+ def self.wait_for_server!(uri)
+ 60.times do
+ begin
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
+ http.request(Net::HTTP::Get.new("/"))
+ end
+ return if response
+ rescue StandardError
+ sleep 0.1
+ end
+ end
+
+ stop_site_server!
+ raise "Test server did not start at #{uri}"
+ end
+end