This morning, my RSS feed told me that Alex Schroeder started a podcast.
I have long admired Alex’s contributions.
I did some ad-hoc editing for his Halberds and Helments RPG. The layout of Halberds and Helmets inspired me to go and learn about the Tufte style, which I have since adopted for my blog.
Below are excerpts from Episode 1It’s about 14 minutes long
in which Alex speaks to his goals and values for RPGs. I find myself in close alignment with Alex (though I do like the occassional system that requires mastery…looking at you Burning Wheel).
RPGS are about using your imagination to run the game; using rules to enforce consequences. It’s not just telling any sort of story, we are enforcing consequences.
Agreed. The best RPGs have a good risk/reward tension.
What sort of rules do we want? What sort of stories do we want to tell? What sort of risks do we want to take?
I’ve been circling around on this, and will be looking to have this conversation when my group wraps up Tomb of Annihilation.
Changing the rules is part of the game. We drop stuff we don’t like. We drop stuff we don’t remember. We add stuff we read online. We add stuff we decide at the table. And so the text evolves. It changes.
Most everyone that runs and plays RPGs hacks on their system.
You can use the rules as written. You can start keeping notes. If your rules are short and your notes are long, then at some point you might as well write your own rules. It’s going to be a small book. But it’s going to be your book.
I like the fear. I like my characters. I love them. But I also like to fear for them. This is what adventure is for me. When I as a player fear for my characters. That’s risk. That’s when I feel good. When I accomplish something. When I risked it. It didn’t just involve some talking. It involved facing the consequences.
The most memorable moments of games are always when I know that I’ve risked my character, and that character through my ingenuity and luck of the dice roll, pulls off something memorable.
Keep record of how you want to run things.
Yup.
Having few rules, means there is a lot of freedom. All the things that don’t require rules, we can handle it by just talking about it.
I’m eyeing Whitehack or 5th Edition as my default base game. Both have a small rule set. Whitehack encourages more conversations.
Another thing I really like, is when the world doesn’t really move too fast. When it’s basically static…I don’t want to pressure the players to do this or that on a timer. If the world is going to end in 2 weeks. Then we really have to do just the thing. And who picked the thing? The referee picked the thing…forcing the plot on everybody else at the table. Therefore I say to my players, it’s going to be an open world…the game is going to grow where your in game actions go.
Amen. The Tomb of Annihilation put a clock into play, forcing action. In doing so, much of what was written in the adventure falls out of the critical path.
I’ll try to provide information to players so they can change if these situations will be appropriate for them; because of the theme, danger, risk they are willing to take. That is where agency comes in. Where you can feel powerful about the decisions you can make. Because there was information. You acted upon it. And it changed the things that happened.
Yes. I want to do more of that in my GM-ing.
Why simple mechanics? They have the benefit that they are quick to read…they benefit from remembering. Players know about 6 abilities, HP, AC, Damage, and Saving throws. This jump starts everything. It’s really great if you have Lady Blackbird in front of you, with all of the rules on your single character sheet. But if the rules are in a big book, that’s 200 pages long or more, then that really tires me and is harder to explain.
To paraphrase, simple mechanics are about accessibility and minimal barrier to entry. They provide a minimum barrier for engaging with the fiction.
Epitath
Alex’s diary post pointed me to Judd Karlman’s Dreaming about Dragons podcast, another contributor to games that I’ve enjoyed following. Looks like I’ve got some listening to do.
Welcome back, from a bit of a hiatus. For this section we’ll dive into Starships. As you might expect, there is quite a bit going on.
The table of contents for this chapter is:
Building a Starship
Example Starships
Modifying and Tuning Starships
Space Travel
Sensors and Detection
Maintenance and Repair
Space Combat
Building a Starship
Some rather straight forward rules. Ships have a base hull Cost, Speed, Armor, HP, Crew (Min/Max), AC, Power, Mass, Hardpoints, and Hull Class.
Cost - this is the baseline cost for a ship with a basic drive.
Speed - the bonus to add to Piloting rolls for combat or maneuvering. Small ships higher speed.
Armor - the damage soaked by the hull
HP - the amount of damage the ship can take before it becomes inoperable (or explodes)
Crew - the minimum and maximum allowed crew
AC - like a character’s AC, denotes its difficulty to hit
Power - the amount of free power available for ship enhancements
Mass - the amount of free mass available for ship enhancements
Hardpoints - roughly the amount of weapons you can add to the ship
Hull Class - the category and relative size of the ship, limits what you may be able to add to the ship
Table 1: Base Starships
Hull Type
Cost
Speed
Armor
HP
Crew
AC
Power
Mass
Hard
Class
Strike Fighter
200K
5
5
8
1|1
16
5
2
2
Fighter
Free Merchant
500K
3
2
20
1|6
14
10
15
2
Frigate
Patrol Boat
2.5m
4
5
25
5|20
14
15
10
4
Frigate
Fleet Cruiser
10m
1
15
60
50|200
14
50
30
10
Frigate
From the base, you add fittings:
Table 2: Starship Fittings
Fitting
Cost
Power
Mass
Class
Effect
Atmosphiric configuration
5k*
0
1#
Fighter
Can land: frigates and fighters only
Cargo space
No cost
0
1
Fighter
Pressurized cargo space
Drive-2 upgrade
10k*
1#
1#
Fighter
Upgrade a spike drive to drive-2 rating
Fuel scoops
5k*
2
1#
Frigate
Ship can scoop fuel from a gas giant or star
# - Costs x2 for Frigate, x3 for cruisers, x4 for capital ship
* - Costs x10 for Frigate, x25 for cruisers, x100 for capital ship
And some defenses.
Table 3: Ships Defense
Ship Defense
Cost
Power
Mass
Class
Effect
Augmented Plating
25k*
0
1#
Fighter
+2 AC, -1 speed
Point Defense Lasers
10k*
3
2#
Frigate
+2 AC versus weapons that use ammo
# - Costs x2 for Frigate, x3 for cruisers, x4 for capital ship
* - Costs x10 for Frigate, x25 for cruisers, x100 for capital ship
And some weapons.
Table 4: Ships Weaponry
Ship Weapon
Cost
Dmg
Power
Mass
Hard
Class
TL
Qualities
Multifocal Laser
100k
1d4
5
1
1
Fighter
4
Armor Piercing 20
Flak Emitter Battery
500k
2d6
5
3
1
Frigate
4
Armor Piercing 10, Flak
Plasma Beam
700k
3d6
5
2
2
Frigate
4
Armor Piercing 10
By design, you will not have enough space to get what you want. As is, the resulting ship is a vanilla ship.
Example Starships
Stars without Numbers provides some example ships. Listing their stats, along with base cost and annual maintenance cost.
Table 5: Free Merchant
HP:
20
Power:
10|0 free
AC:
14
Mass:
15|0 free
Armor:
2
Crew:
1|6
Speed:
3
Hull Class:
Frigate
Crew Skill:
+1
NPC CP:
4*
Weapons:
Multifocal Laser (+3 1d4, AP 20) Sandthrower (+3 2d4, Flak)
Defenses:
None
Defenses:
Spike Drive-1, 160 tons of cargo space Atmospheric Configuration
Fuel Scoops, Fuel Bunker
Cost:
775K base price, 38,750 maintenance, 131,400 yearly crew cost for 3 crew
* - Command Points for the NPC crew to use in starship combat
Modifying and Tuning Starships
Tracking to the same system used in Equipment mods, engineers can add modifications to their ship. Key is the following: “[While the ship is in active use], if a ship mod is neglected for one week of active use, it breaks down.”
Think of the Millenium Falcon. “I made a lot of special modifications…” And those modifications require continuous attention from Chewbacca and Han Solo.
There are rules for Installing these special modifications. Or even redesigning a base hull (an expensive proposition). Each modification requires a base Fix skill, and a pre-tech component cost (e.g. the relics of an advanced time period long past).
Examples
Drill Velocity Upgrade (Fix-2): The ship’s spike drive is counted as one level better when determining spike drill transit speed and maximum drill range, up to a maximum of drive-6. Cost: 10% of hull, 1 component/hull class
Space Travel
For FTL, you drill through space. Your spike drive rating indicates how many hexes you can traverse in one drill (after which you’ll need to refuel). To prepare for a drill out, you need time and space. Yes you can rush it.
You use rutters (course records) to help plot. Commonly traveled paths are freely available, and after only a few days old.
Normally, the transit time takes 6 days per hex travelled, divided by the spike drill rating. You can trim the course, and treat the spike drill rating as one rank hirer for the purposes of calculating the transit time.
Navigators make a check.
Table 6: Spike Drills
Base difficulty for a spike drill
7
The course is totally uncharted
+6
The rutter’s more than 5 years old
+2
The rutter’s from 1 to 5 years old
+1
The rutter’s less than a year old
+0
The rutter’s less than a month old
-2
The drill’s distance, per 2 full hexes
+2
Trimming the course *
+2
The drill activation was rushed
+2
And what about failing the Spike Drill check?
If the roll fails, something has gone wrong on the drill, and a roll on the failed navigation results are necessary. These mishaps don’t normally result in the summary death of the crew, but they often force the navigator to make emergency course changes or drill exits that might leave the ship in a dangerously un- friendly region of space.
You roll on a table, to see what complications ensue.
There are also considerations for Intra-System Travel, higher spike drive ratings means faster intra-system travel.
Sensors and Detection
Sometimes you may want to be discreet about your travels, these rules are in play to provide a bit of cat and mouse. Also, if pirates are looking to ambush you, they’re going to use a comparable system.
Maintenance and Repair
Yup. Ships cost money. You’re going to always be scrounging for money to keep your ship space-worthy. Every six months [of mostly active use] you need to pay 5% of its base cost. The rules cover much of the antics you would expect based on Battlestar Galactica, Star Wars, and Firefly.
Space Combat
No miniatures on a battlemat, instead space combat uses a Ship’s Station-based system. Players fill the roll of Bridge, Captain, Comms, Engineering, and Gunnery; along with each being able to take General Actions.
The ship’s captain decides the order of departments and they each act acccordingly. As the mini-game presses on Ship Crises may emerge that require immediate attention.
Below is one example. A PC will need to take a Deal with a Crisis action and describe how they’re solving the problem; They’ll need to make a difficult check to resolve the issu.
Fuel Bleed: The ship’s fuel tanks have been holed or emergency vents have been force-triggered by battle damage. If not resolved by the end of the next round, the ship will jettison all fuel except the minimal amount needed for in-system operation
There is almost always something to do on the ship during a time of combat.
Conclusion
Packed into this chapter are the aspirations of every spacer. To get a ship, modify it, and engage in glorious snub fighter combat. And right alongside of it is the costs and challenges of owning said space ship.
I keep coming back to the Embarkation system. I suppose it’s because it provides a subsystem that aims to emulate journeys without grinding through each hex. Also, within the Journey system, are clear transitions from “adventuring state” to “encounter state” as well as procedures for that transition.
Several years ago, I wrote about transitions in RPGs. In essence, how does the game transition from one sub-system to another sub-system? Perhaps this is something to revisit.
A Bit More In-Depth
In the RAW system, Embarkation results drift towards a 12. Below is the 12 result:
12 (or more) From Auspicious Beginnings
The company sets out upon a path that will likely show them wonders long since forgotten or into dangers that most would quail at the thought of. But the auspices are good, and should the company be true, they will surely prevail.
As a result, add 2 to the rolls on the Journey Events Table. Additionally rolls made to determine the initial outcome of these encounters should be made with Advantage.
In the RAW system, as skill goes up, you begin skipping events on the Journey table; namely chance encounters with travellers and foraging opportunities. And push towards more encounters with powerful entities; 25% of your journey rolls will now result in 12s “Many Meetings? Fly You Fools!”; Below is an excerpt.
If the Embarkation roll was a 1, the encounter will automatically be with a servant of the Enemy. Conversely, if it was a 12 the company has encountered one of the great powers for Good. The outcome of such a meeting however, will depend on how the company approaches matters.
Without quoting the whole “Many Meetings? Fly You Fools!” the gist is as follows. As your Embarkation rolls trend upward, your chance encounters on the road drift from the following:
Powerful agents of the Enemy
Low-level strangers
Hunting and foraging opportunities
Powerful agents of Good; to
To:
Powerful agents of Good
Which may work from a campaign stand-point; As you gain in levels and the Shadow encroaches, you are likely to meet agents of Good moving throughout the land. But 25% of each travel encounter with agents of Good and advantage in that encounter seems a bit much.
In addition, the trend of your Embarkation rolls will be towards applying advantage to Journey rolls. Which raises some questions; Should your journey’s be getting ever easier? And if so, should you even roll for them? Should the implied “darkening” of the campaign world impact the Embarkation rolls? As time progresses and the Shadow strengthens (at least according to canon), might we consider modifications to the Embarkation roll?
All of this is speculation; I haven’t used the rules as written, but wanted to explore that sub-systems boundaries. And in a level-based system, such as 5E, with bound accuracy, we do need to examine the distribution of modifiers within that boundary.
I want the Journey subsystem to work. I’d love for the Journey process to help build out the world.
Postscript
I even wrote a script to generate results. It’s a lot of aggregate data that would require further complication and pivot tables to glean further insights. Please let me know if you do anything interesting with this.
Post-Postscript
The Embarkation and Journey tables makes a critical assumption that may not be in play for most sandbox games; There is a common map from which the players can plan their journeys. Not everything need be filled in on the map, but underneath that player facing layer is a layer that the Loremaster uses to adjudicate difficulty.
Loremaster determines Peril Rating of the journey.
The Guide makes an Embarkation Roll: 1d12 modified by the Guide’s Survival proficiency bonus plus half their Wisdom bonus minus the Peril Rating.
The Loremaster either relays the result, or optionally hints at it.
Determine the number of Journey Events.
Events are played through, noting down the result for reference.
The Arrival roll (d8) is made, and results are applied.
Before I get started in breaking down the sub-system, I definitely want to say that I like the goal of this system: Make Journey’s meaningful relative to other aspects of play.
Digging In
I want to dig into “The Guide makes an Embarkation Roll”, and its odd formula:
1d12 plus
Guide’s Survival Proficiency bonus (without Wisdom modifier) plus
1⁄2 Wisdom bonus minus
Peril Rating (between 1 and 5)
With the Embarkation roll, you consult a table with a range of 1 or below upto 12 and above. The lower half is detremental to the party’s Journey rolls, the upper half is positive.
The most naive 1st level Player-hero (-1 Wis modifier) with no Survival proficiency travelling the most perilous route will have a -6 to their roll. Everything will go bad. If they take an easy journey, they’ll have a -2 to their roll: a 33% chance of a positive Embarkation.
The most skilled 1st level Player-hero (+3 Wis modifier) with Survival proficiency and Expertise (as an Open Cultural Trait) travelling the most perilous route will have a +0 to their roll (+3⁄2 +4 - 5). If they make an easy journey, they’ll have a +4 to their roll: an 84% chance of a positive Embarkation.
Once that skilled 1st level Player-hero bumps proficiency bonus (and Wisdom perhaps), they’ll have at least a +6 to their roll on the easiest terrain and at least a +2 for the most perilous journey.
Sifting Through
I’m not quite certain where to go from here. I suspect the design intention of the Embarkation, Journey, and Arrival mini-game is that journeys might grind you down. You’re not rolling for random encounters, nor really tracking rations, but instead playing the mini-game to see how travel goes.
Below is an initial pass at capturing what appears to be the intention of the Embarkation rules.
This procedure breaks the Embarkation roll into two separate rolls. First roll on the Guide’s DC for Starting off on a Journey table.
Table 1: Guide’s DC for Starting off on a Journey
Terrain
Survival DC
Easy
7
Moderate
12
Hard
17
Severe
22
Daunting
27
Looking at the page 77 of the 5.1 SRD, I chose to split the difference between task difficulty.
On success the GM will roll 1d6+6 and consult the Adventures of Middle-earth Embarkation Table to determine the result. On a failure, the GM will roll 1d6 and consult the same table.
I wonder if there should be a modifier for failing/succeeding by 5 or more; Perhaps a ±1 to the d6 roll? Or roll the d6 with disadvantage/advantage?
The end result is the Embarkation table is preserved, but the roll cleaves closer to the rules of the basic game.
Function for loading a structural representation of a page/post. This is a common task for the website, so I’ve extracted a method and a structure to encode that behavior.
# Exposes a common data structure for interacting with a page/post
FileWithFrontmatterAndContent = Struct.new(:filename, :frontmatter, :body) do
def content
[Psych.dump(sorted_frontmatter).strip, '---', body].join("\n")
end
def tags
frontmatter.fetch("tags", [])
end
def sorted_frontmatter
returning_value = {}
sorted_frontmatter = frontmatter.sort { |a,b| a[0] <=> b[0] }
sorted_frontmatter.each do |key, value|
if value.is_a?(Array)
returning_value[key] = value.sort
else
returning_value[key] = value
end
end
returning_value
end
end
# Responsible for loading the given filename, separating frontmatter from content
# and returning a FileWithFrontmatterAndContent object
def load_file_with_frontmatter_and_content_from(filename)
frontmatter_text = ''
content = ''
frontmatter = nil
File.readlines(filename).each do |line|
if line.strip == '---'
if frontmatter.nil?
frontmatter = true
next
elsif frontmatter == true
frontmatter = false
end
elsif frontmatter
frontmatter_text += line
else
content += line
end
end
frontmatter = Psych.load(frontmatter_text)
FileWithFrontmatterAndContent.new(filename, frontmatter, content)
end
Template and Method for Generating Redirects
Below is the HTML template and the code used to generate those redirect files.
# The template used when generating an HTML-page redirect.
REDIRECT_TEMPLATE = %(
<!DOCTYPE html>
<html>
<head>
<title>%{to}</title>
<link rel="canonical" href="%{to}"/>
<meta name="robots" content="noindex">
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta http-equiv="refresh" content="0; url=%{to}"/>
</head>
<body>
<h1>Redirecting to %{to}</h1>
<a href="%{to}">Click here if you are not redirected.</a>
</body>
</html>
).strip
# Responsible for creating a redirect page based on the given paramters.
# The page will redirect to the given :to_slug, from the given :from_slug
def create_redirect_page_for(from_slug:, to_slug:, skip_existing_file: true)
from_file_directory = File.join(PUBLIC_PATH, from_slug)
from_filename = File.join(from_file_directory, 'index.html')
if skip_existing_file && File.exist?(from_filename)
$stdout.puts "\tSkipping #{from_slug}; Redirect already exists"
else
content = REDIRECT_TEMPLATE % { to: File.join(SITE_CONFIG.fetch("baseURL"), to_slug, '/') }
FileUtils.mkdir_p(from_file_directory)
$stdout.puts %(\tCreating redirect at "#{from_slug}")
File.open(from_filename, 'w+') do |file|
file.puts(content)
end
end
end
The Build Process
Below are the tasks that comprises the build process.
desc "Build the hugo site for production, target to ./public"
task build: [
"build:blogroll",
"build:sort_frontmatter",
"build:hugo",
"build:redirects",
"build:amplify",
"build:beautify",
"build:minify",
"build:duplicate_feed"
]
And the individual details of each of those build tasks.
namespace :build do
desc 'Remove all, except .git, files in ./public'
task :cleanDestinationDir do
require 'fileutils'
if !system("cd #{PUBLIC_PATH} && git checkout gh-pages && git reset --hard && git clean -df && git pull --rebase")
$stderr.puts "Error cleaning destination directory, see above messages"
exit!(1)
end
Dir.glob(File.join(PUBLIC_PATH, '*')).each do |filename|
next if filename =~ /\.git$/
FileUtils.rm_rf(filename)
end
end
desc "Use hugo to build the ./public dir"
task hugo: ["build:cleanDestinationDir"] do
$stdout.puts "Buidling hugo site to ./public"
if !system("cd #{PROJECT_PATH}; hugo")
$stderr.puts "\tError building website"
exit!(2)
end
end
desc 'Using the ./data/redirects.yml, build redirects in ./public'
task redirects: ["build:hugo"] do
$stdout.puts "Creating Redirects…"
require 'fileutils'
require 'psych'
redirects_file = File.join(PROJECT_PATH, 'data/redirects.yml')
Psych.load_file(redirects_file).each do |redirect|
create_redirect_page_for(
from_slug: File.join('/', redirect.fetch('from'), '/'),
to_slug: redirect.fetch('to'),
skip_existing_file: redirect.fetch('skip_existing_file')
)
end
$stdout.puts "\tDone Creating Redirects"
end
desc 'Working with the existing files, build AMP friendly versions in ./public'
task amplify: ["build:hugo"] do
require 'rest_client'
require 'nokogiri'
$stdout.puts "Amplifying the content…"
# Need to clean-up stylesheet as it includes elements that are not AMP compatable
stylesheet_content = ''
skipping = false
# Because there are style declarations that should not be included as they violate
# AMP requirements
tufte_filename = Dir.glob(File.join(PUBLIC_PATH, "css/tufte.*.css")).first
File.readlines(tufte_filename).each do |line|
next if line =~ /@charset/
if line =~ /\A *\// # Remove comments
if line.strip == "/* BEGIN SKIP-AMP */"
skipping = true
elsif line.strip == "/* END SKIP-AMP */"
skipping = false
end
next
else
next if skipping
stylesheet_content += line.strip + "\n"
end
end
stylesheet_content = RestClient.post "https://cssminifier.com/raw", input: stylesheet_content
# These scripts need to be injected into every page
base_amp_scripts = []
base_amp_scripts << %(<style amp-custom>#{stylesheet_content}</style>)
base_amp_scripts << %(<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>)
base_amp_scripts << %(<script async src="https://cdn.ampproject.org/v0.js"></script>)
base_amp_scripts << %(<script async custom-element="amp-form" src="https://cdn.ampproject.org/v0/amp-form-0.1.js"></script>)
base_amp_scripts << %(<script async custom-element="amp-analytics" src="https://cdn.ampproject.org/v0/amp-analytics-0.1.js"></script>)
Dir.glob(File.join(PUBLIC_PATH, "**/*.html")).each do |filename|
next if filename.start_with?(File.join(PUBLIC_PATH, 'assets'))
next if filename.start_with?(File.join(PUBLIC_PATH, 'css'))
next if filename.start_with?(File.join(PUBLIC_PATH, 'fonts'))
next if filename.start_with?(File.join(PUBLIC_PATH, 'amp'))
# Skipping tag as those are now in tags/
next if filename.start_with?(File.join(PUBLIC_PATH, 'tag/'))
amp_scripts = base_amp_scripts.clone
# Checking blog posts
if filename =~ /\/\d{4}\//
amp_filename = filename.sub(/\/(\d{4})\//, '/amp/\1/')
else
# Checking pages
amp_filename = filename.sub(PUBLIC_PATH, File.join(PUBLIC_PATH, 'amp'))
end
FileUtils.mkdir_p(File.dirname(amp_filename))
content = File.read(filename)
# Ensure that HTML is marked as AMP ready
content.sub!(/^ *\<html /, '<html amp ')
content.sub!(%(manifest="/cache.appcache"), '')
content.sub!("hide-when-javascript-disabled", '')
# Because details-tag is not valid in amp
content.gsub!("<details open", "<span")
# Likewise because details-tag and summary-tag are not valid in amp
content.gsub!(/\<(\/?)(summary|details)/, '<\1span')
doc = Nokogiri::HTML(content)
# Convert img tag into an AMP compliant amp-img tag
doc.css('img').each do |node|
amp_img = doc.create_element('amp-img')
src = node.get_attribute('src')
width = node.get_attribute('data-width')
height = node.get_attribute('data-height')
amp_img.set_attribute('src', src)
amp_img.set_attribute('width', width)
amp_img.set_attribute('height', height)
amp_img.set_attribute('layout', 'responsive')
node.replace amp_img
end
added_iframe_script = false
# Convert iframe tag into an AMP compliant amp-iframe tag
doc.css('iframe').each do |node|
amp_iframe = doc.create_element('amp-iframe')
node.attributes.each do |key, value|
next if key == 'marginheight'
next if key == 'marginwidth'
amp_iframe.set_attribute(key, value.to_s)
end
amp_iframe.set_attribute('sandbox', "allow-scripts allow-same-origin")
amp_iframe.set_attribute('layout', "responsive")
noscript = doc.create_element('noscript')
noscript << node.clone
node.parent << noscript
node.replace amp_iframe
next if added_iframe_script
added_iframe_script = true
amp_scripts << %(<script async custom-element="amp-iframe" src="https://cdn.ampproject.org/v0/amp-iframe-0.1.js"></script>)
end
doc.css('script').each do |node|
# LD+JSON is valid for amp; All others are not
next if node['type'] == 'application/ld+json'
node.remove
end
doc.css('link[media]').each do |node|
node.remove
end
# Because the license contains several problematic amp attributes,
# I'm removing that license
doc.css('.credits .license').each do |node|
node.remove
end
content = doc.to_html
content.sub!("</head>", amp_scripts.join("\n") + "\n</head>")
amp_analytics = %(<amp-analytics type="gtag" data-credentials="include">\n<script type="application/json">\n{ "vars" : { "gtag_id": "#{SITE_CONFIG.fetch('googleAnalytics')}", "config" : { "#{SITE_CONFIG.fetch('googleAnalytics')}": { "groups": "default" } } } }\n</script>\n</amp-analytics>)
content.sub!(/\<body([^\>]*)\>/, '<body\1>' + "\n#{amp_analytics}")
File.open(amp_filename, 'w+') { |f| f.puts content }
end
$stdout.puts "\tDone Amplifying"
end
desc 'Beautify the HTML of the sites'
task beautify: ["build:hugo", "build:redirects", "build:amplify"] do
$stdout.puts "Beautfying the HTML…"
# Redirects and resulting amp pages should be beautiful too
require 'htmlbeautifier'
require 'nokogiri'
require 'json'
Dir.glob(File.join(PROJECT_PATH, 'public', "**/*.html")).each do |filename|
messy = File.read(filename)
doc = Nokogiri::HTML(messy)
doc.css('script').each do |node|
next unless node['type'] == 'application/ld+json'
begin
json = JSON.dump(JSON.load(node.content))
node.content = json
rescue JSON::ParserError => e
$stderr.puts "JSON parse error encountered in #{filename}"
raise e
end
end
messy = doc.to_html
beautiful = HtmlBeautifier.beautify(messy, indent: "\t")
File.open(filename, 'w+') do |f|
f.puts beautiful
end
end
$stdout.puts "\tDone Beautifying"
end
task duplicate_feed: ["build:hugo"] do
# Because some sources have https://takeonrules.com/feed/ I need to resolve that behavior
require 'fileutils'
$stdout.puts "Duplicating and building externally published feeds"
feed = File.join(PUBLIC_PATH, 'feed.xml')
alternate_feed = File.join(PUBLIC_PATH, 'feed/index.xml')
FileUtils.mkdir_p(File.join(PUBLIC_PATH, "feed"))
FileUtils.cp(feed, alternate_feed)
end
task minify: ["build:hugo", "build:amplify"] do
# Minify-ing CSS will remove some comments that are build switches for the amplify process
# So amplify first
require 'rest_client'
$stdout.puts "Minifying CSS"
# TODO as part of the amplify, I'd like to send along a minified CSS; For now, that just won't happen
Dir.glob(File.join(PROJECT_PATH, 'public/css/**/*.css')).each do |filename|
response = RestClient.post "https://cssminifier.com/raw", input: File.read(filename)
File.open(filename, "w+") do |f|
f.puts response
end
end
end
namespace :blogroll do
desc "Fetch blogroll entries"
task :fetch do
if ENV["NO_BLOGROLL"]
$stdout.puts "Skipping blog roll"
next
end
$stdout.puts "Fetching blog roll entries"
require 'rest_client'
require 'nokogiri'
require 'time'
require 'psych'
require 'feedjira'
require 'uri'
class BlogRollEntry
attr_reader :site_url, :site_title, :item_pubDate, :item_title, :item_url, :author
def initialize(xml:)
feed = Feedjira::Feed.parse(xml)
item = feed.entries.first
uri = URI.parse(feed.url)
@site_url = "#{uri.scheme}://#{uri.host}"
@site_title = feed.title
@item_pubDate = item.published.strftime('%Y-%m-%d %H:%M:%S %z')
@item_url = item.url
if item.title
@item_title = item.title
else
@item_title = item.url.split("/").last.sub(/\.\w+$/, '').gsub(/\W+/, ' ')
end
end
include Comparable
def <=>(other)
date_comparison = item_pubDate <=> other.item_pubDate
return date_comparison unless date_comparison == 0
site_title <=> other.site_title
end
def to_hash
{
"site_url" => site_url,
"site_title" => site_title,
"item_pubDate" => item_pubDate,
"item_title" => item_title,
"item_url" => item_url
}
end
end
entries = []
blogroll = Psych.load_file(File.join(PROJECT_PATH, 'data/blogroll.yml'))
blogroll.each do |feed_url|
begin
$stdout.puts "\tFetching #{feed_url}"
response = RestClient.get(feed_url)
entries << BlogRollEntry.new(xml: response.body)
rescue RestClient::Exceptions::OpenTimeout
$stdout.puts "Timeout for #{feed_url}, moving on"
end
end
output = entries.sort.reverse.map(&:to_hash)
File.open(File.join(PROJECT_PATH, 'data/blogroll_entries.yml'), 'w+') do |f|
f.puts Psych.dump(output)
end
end
desc "Commit blogroll entries"
task :commit do
if ENV["NO_BLOGROLL"]
$stdout.puts "Skipping blog roll"
next
end
message = "Updating blogroll entries\n\n```console\n$ bundle exec rake publish:blogroll\n```"
$stdout.puts "Committing ./data/blogroll_entries.yml"
system(%{cd #{PROJECT_PATH}; git add #{File.join(PROJECT_PATH, 'data/blogroll_entries.yml')}; git commit -m "#{message}"})
end
end
desc "Sort frontmatter alphabetically"
task :sort_frontmatter do
require 'psych'
$stdout.puts "Sorting front matter"
Dir.glob(File.join(PROJECT_PATH, 'content/**', '*.md')).each do |filename|
file_with_frontmatter_and_content = load_file_with_frontmatter_and_content_from(filename)
File.open(filename, 'w+') do |f|
f.puts file_with_frontmatter_and_content.content
end
end
end
task blogroll: ["build:blogroll:fetch", "build:blogroll:commit"]
desc "Fetch blog roll entries"
end
The Publish Process
The task that is used to push the pages up to the server. Note that prior to
running publish, I run the build process