From 39a55d15a09056c811ae6e65a1182fc38a17ef03 Mon Sep 17 00:00:00 2001 From: bobbyonmagic <237182227+bobbyonmagic@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:33:39 +0300 Subject: [PATCH 1/2] Add a YAML parsing simulator --- app/games/yaml-parsing-simulator/page.tsx | 81 ++++ components/games-list.tsx | 2 + components/games/game-component-registry.ts | 2 + components/games/yaml-parsing-simulator.tsx | 265 +++++++++++++ lib/games.ts | 26 ++ lib/games/yaml-sim-engine.test.mjs | 84 +++++ lib/games/yaml-sim-engine.ts | 350 ++++++++++++++++++ .../games/yaml-parsing-simulator-og.png | Bin 0 -> 38721 bytes 8 files changed, 810 insertions(+) create mode 100644 app/games/yaml-parsing-simulator/page.tsx create mode 100644 components/games/yaml-parsing-simulator.tsx create mode 100644 lib/games/yaml-sim-engine.test.mjs create mode 100644 lib/games/yaml-sim-engine.ts create mode 100644 public/images/games/yaml-parsing-simulator-og.png diff --git a/app/games/yaml-parsing-simulator/page.tsx b/app/games/yaml-parsing-simulator/page.tsx new file mode 100644 index 000000000..b3532ebde --- /dev/null +++ b/app/games/yaml-parsing-simulator/page.tsx @@ -0,0 +1,81 @@ +import type { Metadata } from 'next'; +import YamlParsingSimulator from '@/components/games/yaml-parsing-simulator'; +import { SimulatorShell } from '@/components/games/simulator-shell'; +import { generateGameMetadata } from '@/lib/game-metadata'; + +export async function generateMetadata(): Promise { + return generateGameMetadata('yaml-parsing-simulator'); +} + +const seoLearningPoints = [ + 'Why `country: NO` becomes the boolean false, and which parsers do it', + 'Why `version: 1.10` is a lower number than `version: 1.9`', + 'How YAML 1.1 and YAML 1.2 disagree about the same file, and why that matters', + 'The difference between an empty value, `~`, `null` and an empty string', + 'When a leading zero means octal and when it means decimal', + 'What `|` and `>` do to the newlines in a CI script', + 'Why a tab in indentation is rejected and so hard to spot', + 'Why quoting is the fix for nearly all of it', +]; + +function YamlEducational() { + return ( + <> +

About this YAML parsing simulator

+
+
+

What you'll learn

+
    +
  • That YAML resolves your untagged values into types, and which ones surprise people
  • +
  • That YAML 1.1 and YAML 1.2 disagree, so the same file means different things in different tools
  • +
  • Why the failures are quiet: nothing errors, a value just stops being what you meant
  • +
  • The two-character fix, and when you actually need it
  • +
+
+
+

How it works

+
    +
  • The parser is real and runs in your browser. Nothing here is a canned answer.
  • +
  • Every document is read twice, once under each spec, and the differences are listed.
  • +
  • Edit any example. Your own YAML is resolved by the same rules.
  • +
+
+
+

+ The behaviour shown here follows the{' '} + + YAML 1.2.2 specification + {' '} + and the older{' '} + + YAML 1.1 boolean type + + , which is the one that turns Norway into false. Most tools still ship a 1.1-era resolver: + that is not a bug in your file, it is a disagreement between versions of the format. +

+

+ Related: the{' '} + + Kubernetes terminal simulator + {' '} + for the manifests this bites hardest, and the{' '} + + Git concepts simulator + {' '} + for the other thing everyone learns by breaking it. +

+ + ); +} + +export default function Page() { + return ( + } + seoLearningPoints={seoLearningPoints} + > + + + ); +} diff --git a/components/games-list.tsx b/components/games-list.tsx index cb1d69b64..af3b0a0aa 100644 --- a/components/games-list.tsx +++ b/components/games-list.tsx @@ -37,12 +37,14 @@ import { Gamepad2, FlaskConical, LucideIcon, + FileCode, } from 'lucide-react'; import Link from 'next/link'; // Icon mapping for serializable icon names const iconMap: Record = { Bug, + FileCode, MailWarning, Network, Trophy, diff --git a/components/games/game-component-registry.ts b/components/games/game-component-registry.ts index bc9c40c32..9251be70b 100644 --- a/components/games/game-component-registry.ts +++ b/components/games/game-component-registry.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react'; import AgenticLoopSimulator from '@/components/games/agentic-loop-simulator'; +import YamlParsingSimulator from '@/components/games/yaml-parsing-simulator'; import HerokuStyleNameGenerator from '@/components/games/heroku-style-name-generator'; import BinaryByteSimulator from '@/components/games/binary-byte-simulator'; import BounceTriageSimulator from '@/components/games/bounce-triage-simulator'; @@ -57,6 +58,7 @@ import WebhookDeliverySimulator from '@/components/games/webhook-delivery-simula export const GAME_COMPONENTS: Record = { 'agentic-loop-simulator': AgenticLoopSimulator, + 'yaml-parsing-simulator': YamlParsingSimulator, 'binary-byte-simulator': BinaryByteSimulator, 'bounce-triage-simulator': BounceTriageSimulator, 'docker-under-the-hood-simulator': DockerUnderTheHoodSimulator, diff --git a/components/games/yaml-parsing-simulator.tsx b/components/games/yaml-parsing-simulator.tsx new file mode 100644 index 000000000..f2a6ab5d9 --- /dev/null +++ b/components/games/yaml-parsing-simulator.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { parseYaml, render, specDisagreements, type ParseResult } from '@/lib/games/yaml-sim-engine'; + +/** + * YAML Parsing Simulator. + * + * You edit YAML on the left and see what the parser decided on the right, under + * both YAML 1.1 and YAML 1.2, because the two specs disagree and that + * disagreement is where almost every YAML surprise comes from. + * + * The parser is real and runs in the browser: see lib/games/yaml-sim-engine.ts. + * Nothing here is canned, so a value you type is resolved by the same rules the + * lessons describe. + * + * Styling is scoped under `.yamlsim` (classes prefixed `ys-`) so it does not + * collide with the site's global Tailwind layer. + */ + +interface Lesson { + title: string; + blurb: string; + yaml: string; + /** What to notice, shown under the output. */ + point: string; +} + +const LESSONS: Lesson[] = [ + { + title: 'The Norway problem', + blurb: 'A country list, written the obvious way.', + yaml: `countries: + - name: Norway + code: NO + - name: Sweden + code: SE`, + point: + 'Under YAML 1.1, NO is the boolean false. Norway loses its country code. SE is untouched, so the bug only appears for one row and only in some parsers.', + }, + { + title: 'Versions are not strings', + blurb: 'A version number in a deployment file.', + yaml: `image: myapp +version: 1.10 +replicas: 3 +minVersion: 1.9`, + point: + 'version: 1.10 is the float 1.1, which is a lower number than 1.9. The trailing zero is gone before anything compares them. Quote it.', + }, + { + title: 'File modes and leading zeros', + blurb: 'A permission, written the way chmod takes it.', + yaml: `defaultMode: 0755 +fallbackMode: 0644 +odd: 08`, + point: + 'YAML 1.1 reads a leading zero as octal, so 0755 is 493. YAML 1.2 reads it as plain decimal 755. Same file, two different permissions, and 08 is not valid octal so it is a string in one and 8 in the other.', + }, + { + title: 'Quoting is the fix', + blurb: 'The same values, quoted.', + yaml: `code: "NO" +version: "1.10" +mode: "0755" +enabled: "yes"`, + point: + 'A quoted scalar is always a string, in both specs, with no resolution applied. This is the whole fix and it costs two characters.', + }, + { + title: 'Empty is not empty string', + blurb: 'A key with nothing after it.', + yaml: `name: app +replicas: +labels: ~ +annotations: null +env: ""`, + point: + 'Three of these are null and only one is an empty string. A missing value is null, ~ is null, null is null. If your code checks for "" it will not catch any of them.', + }, + { + title: 'Block scalars keep your newlines', + blurb: 'A script in a CI file.', + yaml: `literal: | + echo one + echo two +folded: > + this becomes + a single line`, + point: + 'The pipe keeps line breaks, which is what you want for a script. The angle bracket folds them into spaces, which is what you want for prose and what silently breaks a shell script.', + }, + { + title: 'Tabs are not indentation', + blurb: 'Indented with a tab, which looks identical in most editors.', + yaml: `service:\n\tport: 8080`, + point: + 'YAML forbids tabs in indentation. Your editor renders it the same width as spaces, which is why this error is so frustrating to find by eye.', + }, + { + title: 'The missing space', + blurb: 'A colon with no space after it.', + yaml: `key:value +other: fine`, + point: + 'A mapping needs a colon followed by a space. Without it the whole thing is one plain string, so the parser does not fail where you think it did.', + }, +]; + +const KIND_COLOR: Record = { + string: '#34d399', + int: '#60a5fa', + float: '#a78bfa', + bool: '#f59e0b', + null: '#f87171', +}; + +function Output({ result }: { result: ParseResult }) { + if (!result.ok) { + return ( +
+
line {result.line}: {result.message}
+ {result.hint &&
{result.hint}
} +
+ ); + } + return
{render(result.root)}
; +} + +export default function YamlParsingSimulator() { + const [lesson, setLesson] = useState(0); + const [text, setText] = useState(LESSONS[0].yaml); + + const as11 = useMemo(() => parseYaml(text, '1.1'), [text]); + const as12 = useMemo(() => parseYaml(text, '1.2'), [text]); + const disagreements = useMemo(() => specDisagreements(text), [text]); + + const pick = (i: number) => { + setLesson(i); + setText(LESSONS[i].yaml); + }; + + return ( +
+ + +
+ {LESSONS.map((l, i) => ( + + ))} +
+ +

{LESSONS[lesson].blurb} Edit it and watch what changes.

+ +
+
+
YAML you wrote
+