Skip to content

Conventions

House rules for writing a page here. Readers browsing lessons do not need this file; it is for whoever is about to add one.

The rule that comes before the others

A page never claims something a program has not printed — and CI prints it twice, on Ubuntu and on macOS, under the same perl. An answer key is only what both machines agree on.

The shape of a lesson

01_One_Liners/
  n_and_p_are_a_loop/
    README.md                   the lesson
    demo/                       input files a reader can run the page's one-liners on
      orders.txt
    examples/
      n_and_p_loop_sh.sh        a script that runs the one-liners over demo/
      n_and_p_loop_sh.out       its recorded output (generated — do not hand-edit)

One idea per folder. The folder name is the idea, in lower_snake_case, and it becomes a permanent URL — so name it for what it teaches, not for where it sits in the reading order. A Perl program is examples/<stem>_pl.pl and a script is examples/<stem>_sh.sh: a page names an example by its bare stem, so stems must be unique across languages, and the suffix is how.

The page

Open with the title, a **Level:** line (101 / 201 / 301, then ·, then who it is for) and a **One line:** that states the claim rather than the topic. Do not hard-wrap paragraphs — one paragraph, one line.

Output is generated, never typed

Mark the spot and let the tool fill it:

<!-- output:split_pl -->
<!-- /output -->

tools/run_examples.py runs the example and pastes what it actually printed, with a provenance line above the fence. Inside the markers is generated; outside is yours. A second kind, <!-- source:stem -->, pastes the program itself — use it when the code is the lesson.

python3 tools/run_examples.py                      # verify + refill
python3 tools/run_examples.py --update --only X    # record X's output as its answer key
python3 tools/run_examples.py --check              # write nothing, fail on drift (CI)
python3 tools/check_all.py                         # every gate CI runs, honest exit status
python3 tools/check_all.py --staged                # the same gates on the tree your next commit makes

Always pass --only with --update, and read what it recorded before committing: --update accepts whatever the program printed, so it will happily enshrine a bug. Two did on the first day — a helper that copied its argument and so reported pos() as undef, and a yn($s =~ /re/) that died because a failed match in an argument list passes no argument at all.

The programs

Perl: use v5.36 first, core modules only. One line turns on strict, warnings, say, signatures and the unicode_strings feature. Core means what perl ships — Encode, Unicode::Normalize, Unicode::Collate, Text::ParseWords, Scalar::Util, File::Temp. A lesson that needs CPAN (Text::CSV) names the module in prose and does not run it.

use utf8 whenever the source holds a non-ASCII character, and binmode STDOUT, ':encoding(UTF-8)' before printing text — except in the lessons whose subject is leaving them out, which say so in their opening comment.

A warning the lesson is about goes to stdout, in order. The runner records stdout only. A program installs a $SIG{__WARN__} handler that prints the message with its at FILE line N. removed, so editing the program does not change the key; a script writes 2>warnings.txt and then cats it. Anything else on stderr is reported as a note, and a non-zero exit fails the run — so an example that shows die catches it with eval.

Shell: bash, from the example's folder, under LC_ALL=C. Print each command before running it — the say helper in the existing scripts does that — so a verified block reads like a terminal. A script that edits files (-i) copies demo/ to a mktemp -d and works there.

Deterministic, on both machines. No clocks, timings or random numbers. No hash order — sort the keys — and no references printed as HASH(0x…). No tool whose formatting differs between BSD and GNU: od -An -tx1 pads its columns on macOS and not on Debian, and wc -l < file right-aligns its number on macOS. Dump bytes with perl instead — perl -0777 -ne 'print join(" ", unpack "(H2)*", $_), "\n"', the hex helper in the Wide character lesson's script.

The fixed environment

The runner deletes these before every run, because each changes what perl does before the program's first line, and a reader who has one set would otherwise see a different answer from the page:

Variable What it would change
PERL_UNICODE the -C switch from outside — UTF-8 layers on STDIN, STDOUT and files, which hides the "Wide character" and double-encoding lessons
PERL5OPT extra switches, -M included, added to every perl
PERL5LIB, PERLLIB module directories searched before the core ones
PERLIO the default I/O layers
PERL_HASH_SEED, PERL_PERTURB_KEYS hash order

It also sets LC_ALL=C and LANG=C. Perl ignores the locale unless a program says use locale, so this is for the shell tools around it.

Two machines, one perl

CI runs every example on ubuntu-latest and macos-latest, with perl 5.42 installed by shogo82148/actions-setup-perl on both — the macOS image's own /usr/bin/perl is 5.34, too old for use v5.36. Measured differences so far — add a row when you find one, and say where you measured it:

Linux macOS
perl measured locally 5.40.1, Debian's gcc:14 image 5.42.0, Homebrew, macOS 26
Unicode tables in that perl 15.0 16.0
every example's output identical identical
B::Deparse of -n, -p, -l, -a, -F, -i, -0 identical identical
od -An -tx1 single spaces columns padded
wc -l < file the bare number right-aligned in 8 columns

Measured 2026-09-13. The Linux column is the gcc:14 Docker image because it carries Debian's full perl package; ubuntu:24.04, debian:bookworm-slim and python:3.13-slim ship only perl-base, which has no Encode.

Bridges

Every lesson ends with If you are coming from another language: the page in the Python ↗, Java text ↗, C ↗ or Rust ↗ library that teaches the same idea, and the encodings library ↗ page for what the bytes mean. Link a sibling page when one exists; do not repeat it. A sibling page links to <site>/<folder>/index.html — every library in the family builds with use_directory_urls: false.

  • Link a folder by naming its README.md[label](some_folder/README.md), never [label](some_folder/).
  • A link that leaves the library ends its label with ; an internal link never does. python3 tools/check_link_style.py --fix adds and removes them; CI runs it without --fix.

A new lesson folder gets a row in NAV_ORDER in mkdocs_hooks.py. Its sidebar label is its README's # H1 with the backticks dropped. tools/check_nav_chain.py fails on a row naming a folder that does not exist, so commit the folder and its row together.

Committing in a shared checkout

Several sessions may work in one checkout. Stage only your own paths, then run python3 tools/check_all.py --staged — it gates the tree your commit will make, not your working directory, so someone else's half-built lesson can neither fail your commit nor ride along in it.