⏳ This skill is pending AI review.

Scores will appear once the review pipeline completes.

version unknown

repo-explainer

@cyvid7-darus10⭐ 3 stars

Turn a codebase into a guided, interactive HTML tour: architecture map, step-through end-to-end flows with file:line citations, data model, and a "what changed and why" timeline of recent human and agent merges. Use when someone asks to explain, onboard onto, map, document, or catch up on a repository (backend, frontend, full-stack, mobile, library or CLI), or to show how a request flows end to end.

Choose how to use this skill

You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.

1. Native installer

This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.

Do not guess an installer command or replace an existing version without reviewing the diff.

2. Complete package recommended

Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.

No complete ProSkills package is published for this listing yet.

3. Prompt-only

Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.

Need only the instruction file?

Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.

No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.

—/10

// RATINGS

⭐GitHub Stars
⭐ 3 on GitHubGitHub ↗

New / niche

🟢ProSkills Score
—
📍

Not yet listed on ClawHub or SkillsMP

// README

Why

Understanding a repo used to mean reading it. A few people wrote the code, you knew who to ask, and the history told a story.

Picture a busy repo today. Twenty merge requests land in a day. Some come from engineers, many from coding agents working in parallel. The tests pass, but nobody holds the whole picture:

  • What is new since you last looked?
  • Why was it added? The reason sits in a PR description nobody will open again.
  • Who changed it: a person you can ask, or an agent you can't?
  • How does a request flow now? The wiki diagram is from last quarter.

Reading every diff does not scale. Repo Explainer has your agent do the reading, then teach the result like a good lesson: one idea at a time, diagrams you can click, and every claim linked to a real line of code.

What you get

One self-contained HTML page per repo:

  • The big picture. An architecture map. Click a part to see what it does, its files, and who it talks to.
  • End to end. Step through each request on a sequence diagram with a Continue button. Every hop cites file:line.
  • What changed, and why. Recent merges split by human and agent, each with its reason. Show on map lights up the parts that moved.
  • The data. An ER diagram with cardinality, then a clear field table per entity.
  • Check yourself. "Where would you change X?" questions with instant feedback.

Re-run it tomorrow and it only reads the new merges.

Start. Reading time and the main route at a glance.Map. Click a part to see its files, neighbours and flows.
Flows. One hop at a time, with the code that does it.Changes. Agent or human, the reason, and the risk.
Data. Relationships first, then fields.Quiz. Instant feedback, with the why.

Quick start

As an agent skill

git clone https://github.com/Cyvid7-Darus10/repo-explainer ~/.claude/skills/repo-explainer

Then ask your agent:

Explain this repo for a new engineer. Trace sign in and checkout end to end, and include what changed this week.

The agent follows SKILL.md. It scans the repo, picks a preset, traces flows through the real code, fills in explainer.json, and builds the page. Any agent that can read a skill file and run Node can use it.

From the command line

npx github:Cyvid7-Darus10/repo-explainer scan ./my-repo
npx github:Cyvid7-Darus10/repo-explainer init ./my-repo
npx github:Cyvid7-Darus10/repo-explainer changes ./my-repo --data explainer.json --write --merges
npx github:Cyvid7-Darus10/repo-explainer build explainer.json --repo ./my-repo --strict --open

Needs Node 18.3 or newer. Nothing to install. The output is one HTML file you can commit, attach to a PR, or host anywhere.

CommandWhat it does
scan <repo>Stack, entry points, route handlers with file:line, data files, env var names (never values), scripts, and a suggested preset
estimate [repo]Rough agent cost per run, per model
init <repo> [--preset id]A starter explainer.json with TODOs to fill
changes <repo> [--data f --write]Draft change entries from git: human or agent, type, PR link, parts touched
validate <file> [--repo dir] [--strict]Check references, lesson shape, writing style, and that every cited file exists
build <file> [--out f] [--repo dir]Validate, then render the page
presetsPresets for full stack, backend API, frontend, mobile, and library or CLI repos
patterns [id]Reusable flow shapes: auth, webhook, async job, cache, and more
galleryThe design kit page
assetsLogo and icon SVGs

What a run costs

The agent does the costly part: reading code and writing the explainer. These are estimated costs per run at Anthropic API prices, checked October 25, 2026.

RunFable 5.1Opus 5.5Sonnet 5.5Haiku 5.5
Update (new merges only)$0.95$0.44$0.22$0.014
Small repo (under 300 source files)$1.60$0.74$0.37$0.024
Medium repo (300 to 2,000 files)$3.65$1.82$0.91$0.32
Large repo (over 2,000 files)$9.50$5.00$2.50$0.93
  • Sonnet 5.5 is a good default.
  • Opus 5.5 pays off on large or unfamiliar systems.
  • Haiku 5.5 suits a daily "what changed" refresh.

These are estimates, not quotes. They assume most repeated context comes from the prompt cache. Real cost depends on how many flows you ask for and how much code the agent reads. Run repo-explainer estimate ./my-repo for your repo's size. On a Claude subscription, usage counts against your plan instead. The assumptions are in scripts/lib/estimate.mjs.

Built to stay consistent

Agents only write data. The design, components, icons and motion are fixed and tested, so every explainer looks the same and readers learn it once.

  • One data contract, checked on every build. See references/data-schema.md.
  • Presets and flow patterns so agents start from a proven shape.
  • Writing rules: gist first, short sentences, no filler. Enforced as warnings.
  • Design guardrail tests fail CI on shadows, gradients, off-palette colors or innerHTML.
  • Truthfulness: every part and step cites real files, and --repo checks they exist. Change reasons come from PRs and commits, never made up.

Security and privacy

  • The scanner reads env var names from example files only. It never opens .env.
  • All repo text renders as plain text, and embedded data is escaped. Links must be http or https.
  • The page makes no network calls except loading Google Fonts. It falls back to system fonts offline.

Report vulnerabilities through SECURITY.md.

Credits

  • Visual design follows Duolingo's design language: the palette and its color names, the type scale, and the pressable butto

// HOW IT'S BUILT

// FILES

94 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.

  • SKILL.md8.7 KB41416fa430feupstream ↗
    ---
    name: repo-explainer
    description: 'Turn a codebase into a guided, interactive HTML tour: architecture map, step-through end-to-end flows with file:line citations, data model, and a "what changed and why" timeline of recent human and agent merges. Use when someone asks to explain, onboard onto, map, document, or catch up on a repository (backend, frontend, full-stack, mobile, library or CLI), or to show how a request flows end to end.'
    ---
    
    # Repo Explainer
    
    You produce **one self-contained HTML file** that teaches a repo the way Brilliant teaches math: one idea at a time, with interactive diagrams and a big Continue button.
    
    **You only write data.** The design, components, icons, animation and layout are finished and tested. Your job is to investigate the repo and fill `explainer.json` with true, cited facts. The CLI turns that into the page.
    
    `SKILL_DIR` below means the folder this file is in. Run the CLI with `node "$SKILL_DIR/bin/repo-explainer.mjs" <command>` (Node 18.3+, no install step).
    
    ## Workflow
    
    Copy this checklist into your todo list and work through it in order.
    
    0. **(Optional) Check the cost.** `repo-explainer estimate <repo>` prints a rough per-model cost for this repo's size.
    1. **Scan.** Run `repo-explainer scan <repo>`. It gives you facts to verify, not conclusions: stack, entry points, route handlers, data files, env var *names*, scripts, and a suggested preset.
    2. **Pick a preset.** Run `repo-explainer presets` and take the suggestion unless it is clearly wrong. Each preset fixes the layers, the chapter order, the flows you must trace and the questions you must answer. See `presets/<id>.json`.
    3. **Start the data file.** Run `repo-explainer init <repo> --preset <id> --out <repo>/.explainer/explainer.json`. Every `TODO` in it is deliberate.
    4. **Investigate.** Follow `references/investigation-playbook.md`. Read the entry points, then trace each `mustTrace` flow through the real code, one call at a time. Write down `file:line` as you go.
    5. **Fill the data** using `references/data-schema.md`. Start each flow from a pattern: run `repo-explainer patterns`, then `repo-explainer patterns <id>`, copy its steps, and rename its roles to your node ids.
    6. **Draft the changes chapter.** Run `repo-explainer changes <repo> --data <file> --write --since "14 days ago"`. Add `--merges` when the repo merges PRs, and `--remote https://github.com/<o>/<r>` for links. Then replace each `why` TODO with the reason from the PR description, linked issue or commit body. If you cannot find a reason, say "No reason recorded". **Never invent one.**
    7. **Build with file checks.** Run `repo-explainer build <file> --repo <repo> --strict`. Fix every error and warning, since `--strict` fails on warnings too. `--repo` proves every cited path exists.
    8. **Look at it.** Open the HTML (add `--open`, or use a browser tool). Click a node, step through every flow to the trophy, answer the quiz, and narrow the window to phone width. Fix the data, not the template.
    9. **Hand off.** Report the output path, the flows you traced, and anything you could not verify.
    
    ## Brand and UX guardrails (non-negotiable)
    
    The look is a deliberate system: a Duolingo-style palette inside a Brilliant-style lesson flow. Consistency across every repo's explainer is the point, so treat these as hard rules:
    
    - **Do not edit** `template/`, `styles.css`, the icons, the tokens or the layout to make one explainer "fit". If something does not fit, change the data: shorter labels, fewer actors, a split flow. If the template is genuinely missing something, stop and say so. That is a template change for a maintainer and a separate PR.
    - **Do not add** CSS, `<style>`, inline styles, scripts, fonts, emoji icon sets, images, gradients, shadows, extra colors or extra border radii. No custom HTML in any string either: everything renders as plain text by design.
    - **Do not hand-write the HTML or draw your own diagrams.** Every visual comes from the data. Need an icon? Set `kind` to a name in `assets/icons/`. Need a color? Set the layer `color` to one of `green, blue, navy, midnight, yellow, pink`. Need the logo? Use `assets/logo/`. To see everything available, run `repo-explainer gallery`.
    - **Respect the shape of a lesson:**
      - **One idea per step.** A step `label` is a short action (≤ 6 words, e.g. "Checks the session"). A `detail` is at most 2 sentences.
      - **4–12 steps per flow.** More than that means splitting it into two flows.
      - **3–7 actors per flow, and 4–8 layers on the map.** More than that means grouping them.
      - **Labels:** node labels ≤ 3 words, layer labels ≤ 2 words, edge labels ≤ 3 words.
      - **Concepts:** 3–6, each with an `analogy` when one exists.
      - **Quiz:** 3–5 questions. Each asks "where would you change X" or "why does Y happen", never trivia. `explain` says why the answer is right.
    - **Accessibility is already handled.** It covers keyboard support, reduced motion, focus rings and contrast. Don't route around it: no text in place of icons, no meaning carried by color alone in your copy ("the green box" → "the Task service").
    
    ## How to write: gist first, no padding
    
    A reader should get the idea from the first five words. Write like you are explaining it to a teammate at a whiteboard.
    
    1. **Lead with the point.** Say what happens, then (if needed) one detail. Not "This step is responsible for handling the validation of…" but "Rejects bad input with a 422."
    2. **One idea per sentence, about 15 words or fewer.** Two sentences max per `detail`.
    3. **Use plain words.** Say "checks", not "performs verification of". Say "saves", not "persists the entity". Say "uses", not "leverages".
    4. **Name real things.** Say "`createTask()` in the task service", not "the relevant business logic".
    5. **No dashes as punctuation.** That means no em dash, no en dash, and no spaced hyphen. Split into two sentences, or use a colon or comma. (`validate` warns on these.)
    6. **No filler.** Cut "basically", "simply", "it's worth noting", "in order to", "various", and "robust/seamless/powerful".
    7. **Present tense, sentence case, and "you" for the reader.** No exclamation marks; the trophy screen already has one.
    8. **`why` answers "why this way?"** in one sentence, naming the cost it avoids. Example: "Email can take seconds; queuing keeps the click under 100 ms."
    
    | Instead of | Write |
    |---|---|
    | "The middleware layer is responsible for performing authentication checks — ensuring users are logged in." | "Checks the session. No session means a 401." |
    | "Leverages a queue-based architecture in order to asynchronously process emails." | "Puts the email on the queue. The worker sends it." |
    | "This is basically where the various business rules live." | "All business rules live here." |
    
    ## Truthfulness rules
    
    - **Cite everything.** Every node lists its `files`. Every flow step has a `file` (`path:line`). `--repo` must pass.
    - **Read before you claim.** The scan only tells you where to look. Never describe code you have not opened.
    - **The why is the point of a change entry.** Take it only from the PR, issue or commit. Keep `authorType` from the collector (`agent` / `human`) unless the history clearly shows otherwise.
    - **Never copy secrets.** Use env var names only, never values. Do not put credentials, tokens, internal hostnames or customer data in any field.
    - **When unsure, say so** in the step `detail` (e.g. "Retries appear to be 3. Set in queue.ts, not verified at runtime.").
    
    ## Reuse map
    
    | Need | Use | Never |
    |---|---|---|
    | A component box | `nodes[]` with `kind` | an image or custom SVG |
    | An arrow between parts | `edges[]` (`kind: async` for dashed) | prose like "A → B" |
    | A request flow | `flows[]`, started from `patterns/*.json` | a hand-made sequence diagram |
    | Why something is built that way | `step.why`, `concept.analogy` | a long `detail` |
    | Recent work | `repo-explainer changes` → `changes[]` | a pasted git log |
    | Folder guide | `tree[]` (`key: true` for hot spots) | a full file listing |
    | Logo, icons, palette | `assets/`, `repo-explainer gallery` | redrawing or recoloring |
    
    ## Updating an existing explainer
    
    For a repo that already has one (e.g. `.explainer/explainer.json`):
    
    1. Run `changes --write` to append new merges.
    2. Fill in each new `why`.
    3. Re-check any node or flow whose cited files appear in those changes.
    4. Bump `meta.generatedAt` and `meta.commit`.
    5. Rebuild with `--strict`.
    
    Keep the JSON in the repo so the next agent updates it instead of starting over.
    
    ## References
    
    - `references/data-schema.md`: every field, its limits and an example.
    - `references/investigation-playbook.md`: how to trace flows in each kind of stack.
    - `references/design-system.md`: tokens and the reasoning behind each rule (read-only context).
    - `references/ux-principles.md`: why the page is built as a guided lesson.
    - `examples/taskflow.explainer.json`: a complete, valid example to imitate.
    
  • README.md8.7 KBf1c44ab1d04fupstream ↗
    <p align="center">
      <img src="assets/logo/lockup.svg" alt="Repo Explainer" height="56">
    </p>
    
    <p align="center">
      <b>Turn any codebase into a guided, interactive tour.</b><br>
      An agent skill that maps the system, walks every request end to end, and shows what changed and why.
    </p>
    
    <p align="center">
      <a href="https://github.com/Cyvid7-Darus10/repo-explainer/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Cyvid7-Darus10/repo-explainer/actions/workflows/ci.yml/badge.svg"></a>
      <a href="LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/license-Apache%202.0-58cc02"></a>
      <img alt="Node 18.3 or newer" src="https://img.shields.io/badge/node-%E2%89%A518.3-1cb0f6">
      <img alt="Zero dependencies" src="https://img.shields.io/badge/dependencies-0-042c60">
    </p>
    
    <p align="center">
      <a href="https://cyvid7-darus10.github.io/repo-explainer/examples/taskflow.html"><b>Live demo</b></a>
      &nbsp;·&nbsp;
      <a href="https://cyvid7-darus10.github.io/repo-explainer/examples/gallery.html"><b>Design kit</b></a>
      &nbsp;·&nbsp;
      <a href="#quick-start"><b>Quick start</b></a>
      &nbsp;·&nbsp;
      <a href="#what-a-run-costs"><b>Cost</b></a>
    </p>
    
    <p align="center">
      <a href="https://cyvid7-darus10.github.io/repo-explainer/examples/taskflow.html"><img src="docs/media/demo.gif" alt="A Repo Explainer tour: the hero, the architecture map, stepping through a request, recent changes, the data model and a quiz" width="860"></a>
    </p>
    
    ## Why
    
    Understanding a repo used to mean reading it. A few people wrote the code, you knew who to ask, and the history told a story.
    
    Picture a busy repo today. Twenty merge requests land in a day. Some come from engineers, many from coding agents working in parallel. The tests pass, but nobody holds the whole picture:
    
    - **What is new** since you last looked?
    - **Why was it added?** The reason sits in a PR description nobody will open again.
    - **Who changed it:** a person you can ask, or an agent you can't?
    - **How does a request flow now?** The wiki diagram is from last quarter.
    
    Reading every diff does not scale. Repo Explainer has your agent do the reading, then **teach** the result like a good lesson: one idea at a time, diagrams you can click, and every claim linked to a real line of code.
    
    ## What you get
    
    One self-contained HTML page per repo:
    
    - **The big picture.** An architecture map. Click a part to see what it does, its files, and who it talks to.
    - **End to end.** Step through each request on a sequence diagram with a Continue button. Every hop cites `file:line`.
    - **What changed, and why.** Recent merges split by human and agent, each with its reason. **Show on map** lights up the parts that moved.
    - **The data.** An ER diagram with cardinality, then a clear field table per entity.
    - **Check yourself.** "Where would you change X?" questions with instant feedback.
    
    Re-run it tomorrow and it only reads the new merges.
    
    | | |
    |---|---|
    | <img src="docs/media/hero.png" alt="Hero with the main request route"> | <img src="docs/media/map.png" alt="Architecture map with a part selected"> |
    | **Start.** Reading time and the main route at a glance. | **Map.** Click a part to see its files, neighbours and flows. |
    | <img src="docs/media/flow.png" alt="Sequence diagram beside a step card with code"> | <img src="docs/media/changes.png" alt="Timeline of recent changes with reasons"> |
    | **Flows.** One hop at a time, with the code that does it. | **Changes.** Agent or human, the reason, and the risk. |
    | <img src="docs/media/data.png" alt="ER diagram with a field table"> | <img src="docs/media/quiz.png" alt="Quiz with instant feedback"> |
    | **Data.** Relationships first, then fields. | **Quiz.** Instant feedback, with the why. |
    
    ## Quick start
    
    ### As an agent skill
    
    ```sh
    git clone https://github.com/Cyvid7-Darus10/repo-explainer ~/.claude/skills/repo-explainer
    ```
    
    Then ask your agent:
    
    > Explain this repo for a new engineer. Trace sign in and checkout end to end, and include what changed this week.
    
    The agent follows [`SKILL.md`](SKILL.md). It scans the repo, picks a preset, traces flows through the real code, fills in `explainer.json`, and builds the page. Any agent that can read a skill file and run Node can use it.
    
    ### From the command line
    
    ```sh
    npx github:Cyvid7-Darus10/repo-explainer scan ./my-repo
    npx github:Cyvid7-Darus10/repo-explainer init ./my-repo
    npx github:Cyvid7-Darus10/repo-explainer changes ./my-repo --data explainer.json --write --merges
    npx github:Cyvid7-Darus10/repo-explainer build explainer.json --repo ./my-repo --strict --open
    ```
    
    Needs Node 18.3 or newer. Nothing to install. The output is one HTML file you can commit, attach to a PR, or host anywhere.
    
    <details>
    <summary><b>All commands</b></summary>
    
    | Command | What it does |
    |---|---|
    | `scan <repo>` | Stack, entry points, route handlers with `file:line`, data files, env var names (never values), scripts, and a suggested preset |
    | `estimate [repo]` | Rough agent cost per run, per model |
    | `init <repo> [--preset id]` | A starter `explainer.json` with `TODO`s to fill |
    | `changes <repo> [--data f --write]` | Draft change entries from git: human or agent, type, PR link, parts touched |
    | `validate <file> [--repo dir] [--strict]` | Check references, lesson shape, writing style, and that every cited file exists |
    | `build <file> [--out f] [--repo dir]` | Validate, then render the page |
    | `presets` | Presets for full stack, backend API, frontend, mobile, and library or CLI repos |
    | `patterns [id]` | Reusable flow shapes: auth, webhook, async job, cache, and more |
    | `gallery` | The design kit page |
    | `assets` | Logo and icon SVGs |
    
    </details>
    
    ## What a run costs
    
    The agent does the costly part: reading code and writing the explainer. These are estimated costs **per run** at [Anthropic API prices](https://claude.com/pricing), checked October 25, 2026.
    
    | Run | Fable 5.1 | Opus 5.5 | Sonnet 5.5 | Haiku 5.5 |
    |---|---|---|---|---|
    | Update (new merges only) | $0.95 | $0.44 | $0.22 | $0.014 |
    | Small repo (under 300 source files) | $1.60 | $0.74 | $0.37 | $0.024 |
    | Medium repo (300 to 2,000 files) | $3.65 | $1.82 | $0.91 | $0.32 |
    | Large repo (over 2,000 files) | $9.50 | $5.00 | $2.50 | $0.93 |
    
    - **Sonnet 5.5** is a good default.
    - **Opus 5.5** pays off on large or unfamiliar systems.
    - **Haiku 5.5** suits a daily "what changed" refresh.
    
    These are estimates, not quotes. They assume most repeated context comes from the prompt cache. Real cost depends on how many flows you ask for and how much code the agent reads. Run `repo-explainer estimate ./my-repo` for your repo's size. On a Claude subscription, usage counts against your plan instead. The assumptions are in [`scripts/lib/estimate.mjs`](scripts/lib/estimate.mjs).
    
    ## Built to stay consistent
    
    Agents only write data. The design, components, icons and motion are fixed and tested, so every explainer looks the same and readers learn it once.
    
    - **One data contract**, checked on every build. See [`references/data-schema.md`](references/data-schema.md).
    - **Presets and flow patterns** so agents start from a proven shape.
    - **Writing rules:** gist first, short sentences, no filler. Enforced as warnings.
    - **Design guardrail tests** fail CI on shadows, gradients, off-palette colors or `innerHTML`.
    - **Truthfulness:** every part and step cites real files, and `--repo` checks they exist. Change reasons come from PRs and commits, never made up.
    
    ## Security and privacy
    
    - The scanner reads env var **names** from example files only. It never opens `.env`.
    - All repo text renders as plain text, and embedded data is escaped. Links must be `http` or `https`.
    - The page makes no network calls except loading Google Fonts. It falls back to system fonts offline.
    
    Report vulnerabilities through [SECURITY.md](SECURITY.md).
    
    ## Credits
    
    - **Visual design** follows [Duolingo](https://www.duolingo.com)'s design language: the palette and its color names, the type scale, and the pressable buttons.
    - **Lesson format** is inspired by [Brilliant](https://brilliant.org): one idea per step, with a Continue button.
    - **Explanation design** draws on Bret Victor's [Explorable Explanations](https://worrydream.com/ExplorableExplanations/), Red Blob Games, and the C4 model.
    
    Repo Explainer is independent and not affiliated with Duolingo or Brilliant. It includes none of their logos, mascots, illustrations or fonts. Type is [Nunito](https://fonts.google.com/specimen/Nunito) and [JetBrains Mono](https://www.jetbrains.com/lp/mono/). The icons are original.
    
    ## Contributing
    
    Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) and run `npm run check` before opening a PR.
    
    ## License
    
    [Apache License 2.0](LICENSE). If you use or adapt this project, keep the [NOTICE](NOTICE) file, which credits Cyrus David Pastelero, as the license requires.
    
  • scripts/lib/assets.mjs3.1 KB61337f18d0d2upstream ↗
    import { readFileSync } from 'node:fs';
    import { join } from 'node:path';
    import vm from 'node:vm';
    import { TEMPLATE_DIR, escapeHtml } from './build.mjs';
    import { NODE_KINDS } from './schema.mjs';
    
    // Runs template/js/icons.js with a string-based `s()` so the exported SVG
    // files are byte-for-byte the shapes the explainer draws. One source of truth.
    
    const EXTRA_ICONS = ['robot', 'trophy', 'folder', 'file'];
    
    function svgString(tag, attrs, ...children) {
      const attrText = Object.entries(attrs || {})
        .filter(([, v]) => v !== null && v !== undefined && v !== false)
        .map(([k, v]) => ` ${k}="${escapeHtml(v)}"`)
        .join('');
      const inner = children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false).join('');
      return inner ? `<${tag}${attrText}>${inner}</${tag}>` : `<${tag}${attrText}/>`;
    }
    
    function loadIconModule() {
      const source = readFileSync(join(TEMPLATE_DIR, 'js', 'icons.js'), 'utf8');
      const context = vm.createContext({ s: svgString });
      vm.runInContext(`${source}\n;globalThis.__api = { icon, brandMark, PALETTE };`, context);
      return context.__api;
    }
    
    function standalone(svg, size) {
      return svg.replace('<svg ', `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" `);
    }
    
    /**
     * @returns {{ name: string, svg: string }[]} every node-kind icon plus UI extras, as standalone SVG
     */
    export function exportIcons(size = 64) {
      const { icon } = loadIconModule();
      return [...NODE_KINDS, ...EXTRA_ICONS].map((name) => ({ name, svg: `${standalone(icon(name), size)}\n` }));
    }
    
    /** @returns {{ name: string, svg: string }[]} logo mark, horizontal lockup, and social card */
    export function exportLogos() {
      const { brandMark } = loadIconModule();
      const mark = brandMark();
      const inner = mark.replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
      const font = "font-family=\"Nunito, 'Arial Rounded MT Bold', ui-rounded, system-ui, sans-serif\"";
      const lockup = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 64" width="300" height="64">
    <g transform="translate(0 4) scale(1.75)">${inner}</g>
    <text x="68" y="42" ${font} font-size="30" font-weight="900" fill="#58cc02" letter-spacing="-0.3">Repo Explainer</text>
    </svg>
    `;
      const social = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" width="1280" height="640">
    <rect width="1280" height="640" fill="#ffffff"/>
    <circle cx="1050" cy="320" r="260" fill="#d7ffb8"/>
    <circle cx="1180" cy="120" r="44" fill="#ffc800"/>
    <g transform="translate(96 150) scale(3)">${inner}</g>
    <text x="96" y="370" ${font} font-size="88" font-weight="900" fill="#58cc02" letter-spacing="-2">Repo Explainer</text>
    <text x="96" y="440" ${font} font-size="34" font-weight="700" fill="#4b4b4b">Understand any repo end to end, even when 20 PRs land a day.</text>
    </svg>
    `;
      return [
        { name: 'mark', svg: standalone(mark, 128) + '\n' },
        { name: 'lockup', svg: lockup },
        { name: 'social-card', svg: social },
      ];
    }
    
    /** @returns {Record<string, { ink: string, wash: string, text: string }>} layer palette used by the template */
    export function exportPalette() {
      return JSON.parse(JSON.stringify(loadIconModule().PALETTE));
    }
    
  • scripts/lib/build.mjs3.1 KB82be970a7cb2upstream ↗
    import { readFileSync } from 'node:fs';
    import { dirname, join } from 'node:path';
    import { fileURLToPath } from 'node:url';
    
    export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'template');
    
    // Order matters only for readability; all modules share one function scope.
    const SCRIPT_ORDER = ['dom.js', 'icons.js', 'map.js', 'models.js', 'flow.js', 'changes.js', 'lessons.js', 'sections.js', 'app.js'];
    
    const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
    
    export function escapeHtml(value) {
      return String(value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
    }
    
    /**
     * Serialise data so it can never close the surrounding <script> tag.
     * @param {unknown} data
     * @returns {string}
     */
    export function serializeForScript(data) {
      return JSON.stringify(data)
        .replace(/</g, '\<')
        .replace(/>/g, '\>')
        .replace(/&/g, '\&')
        .split(String.fromCharCode(0x2028)).join('\
')
        .split(String.fromCharCode(0x2029)).join('\
');
    }
    
    function readTemplate(templateDir, order = SCRIPT_ORDER) {
      const read = (file) => readFileSync(join(templateDir, file), 'utf8');
      const scripts = order.map((file) => `// ---- ${file} ----\n${read(join('js', file))}`).join('\n');
      return { html: read('explainer.html'), styles: read('styles.css'), scripts };
    }
    
    // One pass over the template, so text inserted for one placeholder (say, a
    // title containing "__EXPLAINER_DATA__") is never scanned for another.
    const PLACEHOLDER_PATTERN = /__TITLE__|__DESCRIPTION__|\/\*__STYLES__\*\/|\/\*__SCRIPTS__\*\/|__EXPLAINER_DATA__/g;
    
    function assemble({ html, styles, scripts }, title, data, bootCall = '', description = '') {
      const values = {
        __TITLE__: escapeHtml(title),
        __DESCRIPTION__: escapeHtml(description),
        '/*__STYLES__*/': styles,
        '/*__SCRIPTS__*/': `(() => {\n'use strict';\n${scripts}\n${bootCall}\n})();`,
        __EXPLAINER_DATA__: serializeForScript(data),
      };
      return html.replace(PLACEHOLDER_PATTERN, (match) => values[match]);
    }
    
    /**
     * Render explainer data into one self-contained HTML file.
     * @param {object} data validated explainer data
     * @param {{ templateDir?: string }} [options]
     * @returns {string}
     */
    export function buildExplainer(data, options = {}) {
      const parts = readTemplate(options.templateDir || TEMPLATE_DIR);
      const title = data && data.meta && data.meta.title ? `${data.meta.title} · Repo Explainer` : 'Repo Explainer';
      const description = data && data.meta && typeof data.meta.summary === 'string' ? data.meta.summary : '';
      return assemble(parts, title, data, 'boot();', description);
    }
    
    /**
     * Render the design-kit gallery (icons, palette, components, patterns).
     * @param {{ kinds: string[], patterns: object[], version?: string }} kit
     * @returns {string}
     */
    export function buildGallery(kit, options = {}) {
      const order = SCRIPT_ORDER.filter((f) => f !== 'app.js').concat('gallery.js');
      const parts = readTemplate(options.templateDir || TEMPLATE_DIR, order);
      return assemble(parts, 'Repo Explainer kit', kit, 'bootGallery();', 'Icons, colors, components and flow patterns for Repo Explainer.');
    }
    
  • scripts/lib/changes.mjs4.4 KB323ca57cfbe2upstream ↗
    import { execFileSync } from 'node:child_process';
    
    // Turns git history into draft "changes" entries. The agent must still fill
    // in `why` from PR descriptions / commit bodies, and map files to node ids.
    
    const FIELD = '';
    const RECORD = '';
    const DEFAULT_AGENT_PATTERNS = [/\[bot\]/i, /\bbot\b/i, /agent/i, /claude/i, /copilot/i, /codex/i, /devin/i, /cursor/i, /dependabot/i, /renovate/i];
    const AGENT_TRAILERS = /^(Co-Authored-By|Generated-By|Assisted-By):.*\b(claude|copilot|codex|devin|cursor|agent|bot)\b/im;
    const CONVENTIONAL = /^(feat|fix|refactor|perf|docs|test|chore|ci|security|build|style)(\(.+?\))?!?:\s*/i;
    const PR_REF = /(?:#|!)(\d+)/;
    
    function git(repo, args) {
      try {
        return execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
      } catch (error) {
        throw new Error(`git ${args[0]} failed in ${repo}: ${error.stderr || error.message}`);
      }
    }
    
    /**
     * @param {string} author
     * @param {string} body
     * @param {RegExp[]} agentPatterns
     * @returns {'agent' | 'human'}
     */
    export function classifyAuthor(author, body, agentPatterns = DEFAULT_AGENT_PATTERNS) {
      if (agentPatterns.some((p) => p.test(author))) return 'agent';
      return AGENT_TRAILERS.test(body) ? 'agent' : 'human';
    }
    
    /**
     * Parse one commit's subject into a title, kind and PR reference.
     * @param {string} subject
     */
    export function parseSubject(subject) {
      const kindMatch = subject.match(CONVENTIONAL);
      const kind = kindMatch ? kindMatch[1].toLowerCase() : undefined;
      const ref = (subject.match(PR_REF) || [])[0];
      const title = subject.replace(CONVENTIONAL, '').replace(/\s*\((?:#|!)\d+\)\s*$/, '').replace(/^Merge pull request #\d+ from \S+\s*/, '').trim();
      return { title: title || subject, kind: kind === 'build' || kind === 'style' ? 'chore' : kind, ref };
    }
    
    // Each record: sha F date F author F subject F body F <newline-separated files>
    function parseLog(raw) {
      return raw.split(RECORD).filter((chunk) => chunk.trim()).map((chunk) => {
        const [sha, date, author, subject, body = '', files = ''] = chunk.split(FIELD);
        return {
          sha: sha.trim(), date, author, subject, body: body.trim(),
          files: files.split('\n').map((f) => f.trim()).filter(Boolean),
        };
      });
    }
    
    /**
     * Map changed files onto node ids by the longest matching path prefix in node.files.
     * @param {string[]} files
     * @param {{ id: string, files?: string[] }[]} nodes
     * @returns {string[]}
     */
    export function mapFilesToNodes(files, nodes = []) {
      const prefixes = nodes.flatMap((node) => (node.files || []).map((f) => ({ id: node.id, prefix: f.replace(/[:#]L?\d+.*$/, '') })))
        .filter((p) => p.prefix)
        .sort((a, b) => b.prefix.length - a.prefix.length);
      const ids = files.map((file) => (prefixes.find((p) => file === p.prefix || file.startsWith(p.prefix.endsWith('/') ? p.prefix : `${p.prefix}/`)) || {}).id);
      return [...new Set(ids.filter(Boolean))];
    }
    
    /**
     * Read recent history and draft change entries.
     * @param {string} repo path to a git repo
     * @param {{ since?: string, limit?: number, mergesOnly?: boolean, nodes?: object[], remoteUrl?: string }} [options]
     */
    export function collectChanges(repo, options = {}) {
      const { since = '14 days ago', limit = 40, mergesOnly = false, nodes = [], remoteUrl } = options;
      const format = `${RECORD}%H${FIELD}%cI${FIELD}%an${FIELD}%s${FIELD}%b${FIELD}`;
      const args = ['log', `--since=${since}`, `-n${limit}`, `--pretty=format:${format}`, '--name-only'];
      if (mergesOnly) args.push('--merges', '--first-parent', '-m');
      if (remoteUrl && !/^https?:\/\//i.test(remoteUrl)) throw new Error(`invalid --remote "${remoteUrl}": must start with http:// or https://`);
      const commits = parseLog(git(repo, args));
      return commits.map((commit) => {
        const { title, kind, ref } = parseSubject(commit.subject);
        const prNumber = ref ? ref.slice(1) : undefined;
        return {
          id: commit.sha.slice(0, 10),
          title,
          date: commit.date,
          author: commit.author,
          authorType: classifyAuthor(commit.author, commit.body),
          ref: ref || commit.sha.slice(0, 7),
          url: remoteUrl && prNumber ? `${remoteUrl.replace(/\/$/, '')}/pull/${prNumber}` : undefined,
          kind,
          what: commit.body.split('\n').filter((l) => l.trim() && !/^[\w-]+-By:/i.test(l)).slice(0, 3).join(' ').slice(0, 400) || undefined,
          why: 'TODO: the reason for this change (read the PR description or linked issue)',
          nodes: mapFilesToNodes(commit.files, nodes),
          files: commit.files.slice(0, 20),
        };
      });
    }
    
  • scripts/lib/estimate.mjs2.8 KB874272101287upstream ↗
    // Rough cost of one agent run, by repo size and model. Usage profiles come
    // from what a run does: scan, read entry points and traced files, write the
    // JSON, rebuild a few times. Most re-sent context is served from the prompt
    // cache, which is why cache reads dominate the token count but not the bill.
    
    // USD per million tokens. Source: https://claude.com/pricing (checked 2026-10-25).
    export const PRICING = {
      checkedAt: '2026-10-25',
      source: 'https://claude.com/pricing',
      models: {
        'fable-5.1': { label: 'Fable 5.1', input: 10, output: 50, cacheRead: 0.25 },
        'opus-5.5': { label: 'Opus 5.5', input: 4, output: 20, cacheRead: 0.2 },
        'sonnet-5.5': { label: 'Sonnet 5.5', input: 2, output: 10, cacheRead: 0.1 },
        // Haiku 5.5 is cheaper for prompts ≤100K tokens; small runs stay under it.
        'haiku-5.5': {
          label: 'Haiku 5.5', input: 0.1, output: 0.5, cacheRead: 0.01,
          longContext: { above: 100000, input: 0.5, output: 2.5, cacheRead: 0.05 },
        },
      },
    };
    
    // Millions of tokens per run. "fresh" = uncached input (file reads, tool output).
    export const USAGE = {
      update: { label: 'Update (new merges only)', fresh: 0.04, cacheRead: 0.6, output: 0.008, peakContext: 60000 },
      small: { label: 'Small repo (< 300 source files)', fresh: 0.06, cacheRead: 1.0, output: 0.015, peakContext: 80000 },
      medium: { label: 'Medium repo (300 to 2,000 files)', fresh: 0.15, cacheRead: 3.6, output: 0.025, peakContext: 130000 },
      large: { label: 'Large repo (2,000+ files)', fresh: 0.4, cacheRead: 12, output: 0.05, peakContext: 250000 },
    };
    
    const SOURCE_EXT = /\.(m?[jt]sx?|py|go|rb|java|kt|swift|rs|php|cs|c|cc|cpp|h|scala|ex|exs|dart|vue|svelte|sql)$/;
    
    /**
     * @param {{ languages?: { ext: string, count: number }[], fileCount?: number }} scan
     * @returns {'small' | 'medium' | 'large'}
     */
    export function sizeOf(scan) {
      const sourceFiles = (scan.languages || []).filter((l) => SOURCE_EXT.test(`x${l.ext}`)).reduce((n, l) => n + l.count, 0);
      if (sourceFiles >= 2000) return 'large';
      if (sourceFiles >= 300) return 'medium';
      return 'small';
    }
    
    /**
     * @param {keyof USAGE} profile
     * @param {keyof PRICING['models']} model
     * @returns {number} estimated USD for one run
     */
    export function costOf(profile, model) {
      const use = USAGE[profile];
      const base = PRICING.models[model];
      const rate = base.longContext && use.peakContext > base.longContext.above ? base.longContext : base;
      return use.fresh * rate.input + use.cacheRead * rate.cacheRead + use.output * rate.output;
    }
    
    export function costTable(profiles = Object.keys(USAGE)) {
      return profiles.map((profile) => ({
        profile,
        label: USAGE[profile].label,
        costs: Object.fromEntries(Object.keys(PRICING.models).map((model) => [model, costOf(profile, model)])),
      }));
    }
    
    export function formatUsd(value) {
      return value < 0.1 ? `$${value.toFixed(3)}` : `$${value.toFixed(2)}`;
    }
    
  • scripts/lib/init.mjs2.4 KB01a62018dc2dupstream ↗
    import { readFileSync, readdirSync } from 'node:fs';
    import { dirname, join } from 'node:path';
    import { fileURLToPath } from 'node:url';
    import { SCHEMA_VERSION } from './schema.mjs';
    
    const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
    export const PRESETS_DIR = join(ROOT, 'presets');
    export const PATTERNS_DIR = join(ROOT, 'patterns');
    
    function readJsonDir(dir) {
      return readdirSync(dir).filter((f) => f.endsWith('.json')).sort()
        .map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')));
    }
    
    export function listPresets() {
      return readJsonDir(PRESETS_DIR);
    }
    
    export function listPatterns() {
      return readJsonDir(PATTERNS_DIR);
    }
    
    export function loadPreset(id) {
      const preset = listPresets().find((p) => p.id === id);
      if (!preset) throw new Error(`unknown preset "${id}" (available: ${listPresets().map((p) => p.id).join(', ')})`);
      return preset;
    }
    
    /**
     * Build a starter explainer from a preset and (optionally) scan facts.
     * Every TODO is intentional: validate warns until the agent replaces them.
     * @param {string} presetId
     * @param {object} [scan] output of scanRepo()
     */
    export function createStarter(presetId, scan = {}) {
      const preset = loadPreset(presetId);
      const today = new Date().toISOString().slice(0, 10);
      return {
        schemaVersion: SCHEMA_VERSION,
        meta: {
          title: `${scan.name || 'This repo'}, explained`,
          repo: scan.name || 'TODO: owner/repo',
          summary: 'TODO: two plain sentences. What this does, and for whom.',
          stack: (scan.frameworks || []).slice(0, 8),
          preset: preset.id,
          generatedAt: today,
          chapters: preset.chapters,
        },
        layers: preset.layers,
        nodes: preset.starterNodes.map((node) => ({ ...node, summary: 'TODO: one sentence on what this part does', files: [] })),
        edges: [],
        flows: preset.mustTrace.slice(0, 1).map((goal, i) => ({
          id: `flow-${i + 1}`, title: 'TODO: flow name', trigger: 'TODO: what starts it', summary: goal, steps: [],
        })),
        changes: [],
        tree: (scan.topLevel || []).filter((t) => t.path.endsWith('/')).slice(0, 12).map((t) => ({ path: t.path, note: 'TODO' })),
        dataModel: [],
        concepts: [],
        quiz: [],
        glossary: [],
        runbook: (scan.scripts || []).filter((sc) => /^(dev|start|test|build)$/.test(sc.name)).slice(0, 4)
          .map((sc) => ({ label: sc.name, command: `npm run ${sc.name}`, note: sc.command })),
      };
    }
    
  • scripts/lib/scan.mjs10 KB531a64bf9e22upstream ↗
    import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
    import { basename, extname, join, relative, sep } from 'node:path';
    
    // Facts, not conclusions: the scan tells an agent where to look. The agent
    // still has to read the code before claiming anything in the explainer.
    
    const IGNORED_DIRS = new Set([
      '.git', 'node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit', '.turbo', '.vercel',
      'coverage', 'vendor', 'target', '.venv', 'venv', '__pycache__', '.mypy_cache', '.pytest_cache',
      '.idea', '.vscode', 'Pods', 'DerivedData', '.gradle', '.dart_tool', 'tmp', '.cache',
    ]);
    
    const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']);
    const MAX_FILES = 20000;
    const MAX_GREP_BYTES = 200 * 1024;
    const MAX_ROUTE_HITS = 80;
    
    const MANIFESTS = ['package.json', 'pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'pyproject.toml', 'requirements.txt',
      'go.mod', 'Cargo.toml', 'Gemfile', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'composer.json', 'Package.swift',
      'pubspec.yaml', 'mix.exs', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yaml', 'wrangler.toml',
      'vercel.json', 'netlify.toml', 'serverless.yml', 'Procfile', 'fly.toml'];
    
    const JS_FRAMEWORKS = {
      next: 'Next.js', react: 'React', vue: 'Vue', nuxt: 'Nuxt', svelte: 'Svelte', '@sveltejs/kit': 'SvelteKit', astro: 'Astro',
      '@remix-run/react': 'Remix', 'solid-js': 'Solid', '@angular/core': 'Angular', 'react-native': 'React Native', expo: 'Expo',
      electron: 'Electron', vite: 'Vite', express: 'Express', fastify: 'Fastify', koa: 'Koa', hono: 'Hono', '@nestjs/core': 'NestJS',
      '@trpc/server': 'tRPC', graphql: 'GraphQL', 'socket.io': 'Socket.IO', prisma: 'Prisma', '@prisma/client': 'Prisma',
      'drizzle-orm': 'Drizzle', typeorm: 'TypeORM', mongoose: 'Mongoose', sequelize: 'Sequelize', knex: 'Knex',
      '@supabase/supabase-js': 'Supabase', firebase: 'Firebase', bullmq: 'BullMQ', ioredis: 'Redis', redis: 'Redis',
      stripe: 'Stripe', 'next-auth': 'NextAuth', '@clerk/nextjs': 'Clerk', zod: 'Zod', tailwindcss: 'Tailwind CSS',
      '@tanstack/react-query': 'TanStack Query', 'ai': 'Vercel AI SDK', '@anthropic-ai/sdk': 'Anthropic SDK', openai: 'OpenAI SDK',
      'commander': 'Commander CLI', yargs: 'yargs CLI', '@modelcontextprotocol/sdk': 'MCP SDK',
    };
    
    const TEXT_FRAMEWORKS = [
      { file: /^(pyproject\.toml|requirements.*\.txt)$/, pattern: /\b(fastapi|django|flask|sqlalchemy|celery|pydantic|alembic|typer|click|streamlit|langchain)\b/gi },
      { file: /^go\.mod$/, pattern: /\b(gin-gonic\/gin|labstack\/echo|gofiber\/fiber|go-chi\/chi|gorm\.io|sqlc|cobra)\b/gi },
      { file: /^Cargo\.toml$/, pattern: /^\s*(axum|actix-web|rocket|tokio|diesel|sqlx|clap|serde|tauri)\s*=/gim },
      { file: /^Gemfile$/, pattern: /\b(rails|sinatra|sidekiq|rspec)\b/gi },
      { file: /^(pom\.xml|build\.gradle(\.kts)?)$/, pattern: /\b(spring-boot|quarkus|micronaut|hibernate|ktor)\b/gi },
      { file: /^composer\.json$/, pattern: /\b(laravel\/framework|symfony)\b/gi },
    ];
    
    const ROUTE_PATTERNS = [
      /\b(app|router|server|api)\.(get|post|put|patch|delete|all|route)\s*\(\s*['"`]/,
      /@(app|router|bp|api)\.(get|post|put|patch|delete|route)\s*\(/,
      /@(Get|Post|Put|Patch|Delete|Controller|RequestMapping|GetMapping|PostMapping)\s*\(/,
      /\bexport\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/,
      /\b(r|e|g|mux|router)\.(GET|POST|PUT|PATCH|DELETE|HandleFunc|Handle)\s*\(/,
      /\bpath\s*\(\s*['"][^'"]*['"]\s*,/,
      /\.route\s*\(\s*['"]\//,
    ];
    
    const ROUTE_FILE_HINTS = [/\/api\//, /routes?\//, /controllers?\//, /handlers?\//, /\/pages\/api\//, /route\.(t|j)sx?$/, /urls\.py$/, /views\.py$/];
    
    const DATA_HINTS = [/schema\.prisma$/, /drizzle.*schema/i, /\/migrations?\//, /models?\.(py|rb|go|ts)$/, /\/models\//, /\/entities\//, /\.sql$/, /supabase\/migrations/];
    
    const ENTRY_CANDIDATES = ['src/index.ts', 'src/index.js', 'src/main.ts', 'src/main.tsx', 'src/main.js', 'src/app.ts', 'src/server.ts',
      'index.ts', 'index.js', 'server.js', 'server.ts', 'app.py', 'main.py', 'manage.py', 'main.go', 'cmd', 'src/main.rs', 'src/lib.rs',
      'app/layout.tsx', 'src/app/layout.tsx', 'pages/_app.tsx', 'src/pages/_app.tsx', 'App.tsx', 'src/App.tsx', 'lib/main.dart'];
    
    const FRONTEND = new Set(['Next.js', 'React', 'Vue', 'Nuxt', 'Svelte', 'SvelteKit', 'Astro', 'Remix', 'Solid', 'Angular', 'Vite']);
    const MOBILE = new Set(['React Native', 'Expo']);
    const BACKEND = new Set(['Express', 'Fastify', 'Koa', 'Hono', 'NestJS', 'tRPC', 'fastapi', 'django', 'flask', 'gin-gonic/gin',
      'labstack/echo', 'gofiber/fiber', 'go-chi/chi', 'axum', 'actix-web', 'rocket', 'rails', 'sinatra', 'spring-boot', 'laravel/framework']);
    
    function walk(root) {
      const files = [];
      const stack = [root];
      while (stack.length && files.length < MAX_FILES) {
        const dir = stack.pop();
        let entries = [];
        try {
          entries = readdirSync(dir, { withFileTypes: true });
        } catch {
          continue; // unreadable directory: skip, the scan is best-effort
        }
        entries.forEach((entry) => {
          if (entry.isSymbolicLink()) return;
          const full = join(dir, entry.name);
          if (entry.isDirectory()) {
            if (!IGNORED_DIRS.has(entry.name)) stack.push(full);
          } else if (entry.isFile() && !IGNORED_FILES.has(entry.name)) {
            files.push(relative(root, full).split(sep).join('/'));
          }
        });
      }
      return files.sort();
    }
    
    function readSafe(path, limit = MAX_GREP_BYTES) {
      try {
        if (statSync(path).size > limit) return '';
        return readFileSync(path, 'utf8');
      } catch {
        return '';
      }
    }
    
    function languages(files) {
      const counts = files.reduce((acc, file) => {
        const ext = extname(file).toLowerCase();
        return ext ? { ...acc, [ext]: (acc[ext] || 0) + 1 } : acc;
      }, {});
      return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([ext, count]) => ({ ext, count }));
    }
    
    function topLevel(root, files) {
      const counts = files.reduce((acc, file) => {
        const [head, ...rest] = file.split('/');
        const key = rest.length ? `${head}/` : head;
        return { ...acc, [key]: (acc[key] || 0) + 1 };
      }, {});
      return Object.entries(counts).map(([path, fileCount]) => ({ path, files: fileCount }));
    }
    
    function packageFacts(root, files) {
      const pkgFiles = files.filter((f) => basename(f) === 'package.json' && f.split('/').length <= 4);
      const frameworks = new Set();
      const scripts = [];
      pkgFiles.forEach((file) => {
        let pkg;
        try {
          pkg = JSON.parse(readSafe(join(root, file)));
        } catch {
          return; // malformed package.json: report nothing rather than guess
        }
        const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
        Object.keys(deps).forEach((dep) => { if (JS_FRAMEWORKS[dep]) frameworks.add(JS_FRAMEWORKS[dep]); });
        Object.entries(pkg.scripts || {}).slice(0, 15).forEach(([name, command]) => scripts.push({ package: file, name, command }));
      });
      return { frameworks, scripts };
    }
    
    function textFrameworks(root, files, frameworks) {
      files.filter((f) => f.split('/').length <= 3).forEach((file) => {
        const rule = TEXT_FRAMEWORKS.find((r) => r.file.test(basename(file)));
        if (!rule) return;
        const content = readSafe(join(root, file));
        [...content.matchAll(rule.pattern)].forEach((m) => frameworks.add(m[1].toLowerCase()));
      });
      return frameworks;
    }
    
    function routes(root, files) {
      const hits = [];
      const candidates = files.filter((f) => /\.(m?[jt]sx?|py|go|rb|java|kt|php|rs|cs)$/.test(f));
      const prioritised = [...candidates.filter((f) => ROUTE_FILE_HINTS.some((r) => r.test(`/${f}`))),
        ...candidates.filter((f) => !ROUTE_FILE_HINTS.some((r) => r.test(`/${f}`)))];
      for (const file of prioritised) {
        if (hits.length >= MAX_ROUTE_HITS) break;
        const lines = readSafe(join(root, file)).split('\n');
        lines.forEach((line, i) => {
          if (hits.length < MAX_ROUTE_HITS && ROUTE_PATTERNS.some((p) => p.test(line))) {
            hits.push({ file: `${file}:${i + 1}`, line: line.trim().slice(0, 140) });
          }
        });
      }
      const fileRoutes = files.filter((f) => /(^|\/)app\/.*\/route\.(t|j)sx?$|(^|\/)pages\/api\//.test(f));
      return { handlers: hits, fileRoutes: fileRoutes.slice(0, 60) };
    }
    
    function envKeys(root, files) {
      const examples = files.filter((f) => /(^|\/)\.env\.(example|sample|template)$|(^|\/)\.env\.local\.example$/.test(f));
      const keys = new Set();
      examples.forEach((file) => readSafe(join(root, file)).split('\n').forEach((line) => {
        const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]+)\s*=/);
        if (match) keys.add(match[1]); // names only — values are never read into the scan
      }));
      return { files: examples, keys: [...keys].sort() };
    }
    
    function suggestPreset(frameworks, routeFacts, dataFiles) {
      const has = (set) => [...frameworks].some((f) => set.has(f));
      const hasApi = has(BACKEND) || routeFacts.handlers.length > 0 || routeFacts.fileRoutes.length > 0;
      if (has(MOBILE)) return 'mobile-app';
      if (has(FRONTEND) && (hasApi || dataFiles.length > 0)) return 'fullstack-web';
      if (has(FRONTEND)) return 'frontend-spa';
      if (hasApi) return 'backend-api';
      return 'library-cli';
    }
    
    /**
     * Collect navigation facts about a repository.
     * @param {string} root absolute or relative path to the repo
     * @returns {object} facts for an agent to verify, never to copy blindly
     */
    export function scanRepo(root) {
      if (!existsSync(root) || !statSync(root).isDirectory()) {
        throw new Error(`scan failed: "${root}" is not a directory`);
      }
      const files = walk(root);
      const { frameworks, scripts } = packageFacts(root, files);
      textFrameworks(root, files, frameworks);
      const routeFacts = routes(root, files);
      const dataFiles = files.filter((f) => DATA_HINTS.some((r) => r.test(`/${f}`))).slice(0, 60);
      return {
        name: basename(root.replace(/\/$/, '')) || 'repo',
        fileCount: files.length,
        truncated: files.length >= MAX_FILES,
        topLevel: topLevel(root, files),
        languages: languages(files),
        manifests: files.filter((f) => MANIFESTS.includes(basename(f)) && f.split('/').length <= 4),
        frameworks: [...frameworks].sort(),
        entrypoints: ENTRY_CANDIDATES.filter((c) => existsSync(join(root, c))),
        routes: routeFacts,
        dataFiles,
        env: envKeys(root, files),
        scripts,
        docs: files.filter((f) => /(^|\/)(README|ARCHITECTURE|CONTRIBUTING|ADR|docs\/).*/i.test(f) && f.split('/').length <= 3).slice(0, 30),
        suggestedPreset: suggestPreset(frameworks, routeFacts, dataFiles),
      };
    }
    
  • scripts/lib/schema.mjs1.7 KBb0f6544b3014upstream ↗
    // Single source of truth for the explainer data contract.
    // Documented for humans in references/data-schema.md — keep them in sync.
    
    export const SCHEMA_VERSION = 1;
    
    export const CHAPTER_KEYS = ['map', 'flows', 'changes', 'tree', 'data', 'concepts', 'quiz', 'glossary', 'run'];
    
    export const NODE_KINDS = [
      'user', 'ui', 'mobile', 'api', 'service', 'db', 'queue', 'cache',
      'external', 'job', 'cli', 'auth', 'storage', 'library',
    ];
    
    export const LAYER_COLORS = ['green', 'blue', 'navy', 'midnight', 'yellow', 'pink'];
    
    export const STEP_KINDS = ['request', 'response', 'async', 'data', 'internal'];
    
    export const EDGE_KINDS = ['sync', 'async', 'data'];
    
    export const AUTHOR_TYPES = ['human', 'agent'];
    
    export const CHANGE_KINDS = ['feat', 'fix', 'refactor', 'perf', 'docs', 'test', 'chore', 'ci', 'security'];
    
    export const PLACEHOLDER = /\bTODO\b/;
    
    // Lesson-shape limits from SKILL.md. Exceeding them is a warning (an error
    // under --strict) because it makes the page harder to read, not broken.
    export const LIMITS = {
      stepsPerFlow: { min: 3, max: 12 },
      actorsPerFlow: 7,
      layers: 8,
      nodeLabelWords: 4,
      stepLabelWords: 7,
      edgeLabelWords: 4,
      detailSentences: 3,
      concepts: 6,
      quiz: 6,
    };
    
    export const MARKUP = /<\/?[a-z][a-z0-9-]*(\s[^>]*)?>/i;
    
    // Prose fields are read by people; code-ish fields are exempt from style checks.
    export const NON_PROSE_KEYS = new Set(['code', 'file', 'files', 'path', 'command', 'url', 'ref', 'id', 'type', 'name', 'lang', 'from', 'to', 'layer', 'kind', 'color', 'date', 'generatedAt', 'commit', 'repo', 'author', 'authorType', 'chapters', 'preset', 'stack', '$schema']);
    
    // Em dash, en dash, or a spaced hyphen used as punctuation.
    export const DASH = /\s[—–-]\s|—|–/;
    
  • scripts/lib/validate.mjs11 KB922536157eddupstream ↗
    import { existsSync } from 'node:fs';
    import { resolve } from 'node:path';
    import {
      AUTHOR_TYPES, CHANGE_KINDS, CHAPTER_KEYS, DASH, EDGE_KINDS, LAYER_COLORS, LIMITS, MARKUP, NODE_KINDS, NON_PROSE_KEYS, PLACEHOLDER, STEP_KINDS,
    } from './schema.mjs';
    
    /**
     * @typedef {{ errors: string[], warnings: string[] }} ValidationResult
     */
    
    const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
    const isText = (value) => typeof value === 'string' && value.trim().length > 0;
    const arr = (value) => (Array.isArray(value) ? value : []);
    
    function collector() {
      const errors = [];
      const warnings = [];
      return {
        error: (path, message) => errors.push(`${path}: ${message}`),
        warn: (path, message) => warnings.push(`${path}: ${message}`),
        result: () => ({ errors, warnings }),
      };
    }
    
    function requireText(report, path, value) {
      if (!isText(value)) report.error(path, 'must be a non-empty string');
    }
    
    function checkEnum(report, path, value, allowed) {
      if (value !== undefined && !allowed.includes(value)) {
        report.warn(path, `"${value}" is not one of ${allowed.join(", ")}. It will render with a default style`);
      }
    }
    
    function checkUniqueIds(report, path, items) {
      const seen = new Set();
      items.forEach((item, i) => {
        requireText(report, `${path}[${i}].id`, item && item.id);
        if (item && seen.has(item.id)) report.error(`${path}[${i}].id`, `duplicate id "${item.id}"`);
        if (item) seen.add(item.id);
      });
      return seen;
    }
    
    function checkMeta(report, meta) {
      if (!isObject(meta)) return report.error('meta', 'is required');
      requireText(report, 'meta.title', meta.title);
      requireText(report, 'meta.summary', meta.summary);
      arr(meta.chapters).forEach((key, i) => {
        if (!CHAPTER_KEYS.includes(key)) report.error(`meta.chapters[${i}]`, `unknown chapter "${key}" (use ${CHAPTER_KEYS.join(', ')})`);
      });
      return undefined;
    }
    
    function checkGraph(report, data) {
      const layers = arr(data.layers);
      const nodes = arr(data.nodes);
      if (!layers.length) report.error('layers', 'needs at least one layer');
      if (!nodes.length) report.error('nodes', 'needs at least one node');
      const layerIds = checkUniqueIds(report, 'layers', layers);
      layers.forEach((layer, i) => {
        requireText(report, `layers[${i}].label`, layer.label);
        checkEnum(report, `layers[${i}].color`, layer.color, LAYER_COLORS);
      });
      const nodeIds = checkUniqueIds(report, 'nodes', nodes);
      nodes.forEach((node, i) => {
        requireText(report, `nodes[${i}].label`, node.label);
        if (!layerIds.has(node.layer)) report.error(`nodes[${i}].layer`, `"${node.layer}" is not a layer id`);
        checkEnum(report, `nodes[${i}].kind`, node.kind, NODE_KINDS);
        if (!arr(node.files).length && node.kind !== 'user') report.warn(`nodes[${i}].files`, `"${node.id}" cites no files, so readers cannot find it in the code`);
      });
      layers.forEach((layer, i) => {
        if (!nodes.some((node) => node.layer === layer.id)) report.warn(`layers[${i}]`, `layer "${layer.id}" has no nodes`);
      });
      arr(data.edges).forEach((edge, i) => {
        if (!nodeIds.has(edge.from)) report.error(`edges[${i}].from`, `"${edge.from}" is not a node id`);
        if (!nodeIds.has(edge.to)) report.error(`edges[${i}].to`, `"${edge.to}" is not a node id`);
        checkEnum(report, `edges[${i}].kind`, edge.kind, EDGE_KINDS);
      });
      return nodeIds;
    }
    
    function checkFlows(report, flows, nodeIds) {
      if (!flows.length) report.warn('flows', 'no end-to-end flow traced. This is the most useful chapter');
      checkUniqueIds(report, 'flows', flows);
      flows.forEach((flow, i) => {
        requireText(report, `flows[${i}].title`, flow.title);
        const steps = arr(flow.steps);
        if (!steps.length) report.error(`flows[${i}].steps`, 'needs at least one step');
        steps.forEach((step, j) => {
          const path = `flows[${i}].steps[${j}]`;
          requireText(report, `${path}.label`, step.label);
          if (!nodeIds.has(step.from)) report.error(`${path}.from`, `"${step.from}" is not a node id`);
          if (!nodeIds.has(step.to)) report.error(`${path}.to`, `"${step.to}" is not a node id`);
          checkEnum(report, `${path}.kind`, step.kind, STEP_KINDS);
          if (!isText(step.file)) report.warn(`${path}.file`, 'no file cited for this step');
        });
      });
    }
    
    function checkChanges(report, changes, nodeIds) {
      changes.forEach((change, i) => {
        const path = `changes[${i}]`;
        requireText(report, `${path}.title`, change.title);
        if (Number.isNaN(Date.parse(change.date))) report.error(`${path}.date`, 'must be an ISO date');
        checkEnum(report, `${path}.authorType`, change.authorType, AUTHOR_TYPES);
        checkEnum(report, `${path}.kind`, change.kind, CHANGE_KINDS);
        if (change.url !== undefined && !/^https?:\/\//i.test(String(change.url))) report.error(`${path}.url`, 'must start with http:// or https://');
        if (!isText(change.why)) report.warn(`${path}.why`, 'missing the reason. The "why" is the point of this chapter');
        arr(change.nodes).forEach((id, j) => {
          if (!nodeIds.has(id)) report.error(`${path}.nodes[${j}]`, `"${id}" is not a node id`);
        });
      });
    }
    
    function checkLessons(report, data) {
      arr(data.quiz).forEach((q, i) => {
        requireText(report, `quiz[${i}].question`, q.question);
        const options = arr(q.options);
        if (options.length < 2) report.error(`quiz[${i}].options`, 'needs at least two options');
        if (!Number.isInteger(q.answer) || q.answer < 0 || q.answer >= options.length) {
          report.error(`quiz[${i}].answer`, `must be an index between 0 and ${Math.max(options.length - 1, 0)}`);
        }
      });
      arr(data.concepts).forEach((c, i) => {
        requireText(report, `concepts[${i}].title`, c.title);
        requireText(report, `concepts[${i}].body`, c.body);
      });
      const models = new Set(arr(data.dataModel).map((m) => m.name));
      arr(data.dataModel).forEach((m, i) => arr(m.relations).forEach((rel, j) => {
        if (!models.has(rel.to)) report.warn(`dataModel[${i}].relations[${j}].to`, `"${rel.to}" is not a model in dataModel`);
      }));
    }
    
    function citedFiles(data) {
      return [
        ...arr(data.nodes).flatMap((n) => arr(n.files)),
        ...arr(data.flows).flatMap((f) => arr(f.steps).map((st) => st.file)),
        ...arr(data.concepts).map((c) => c.file),
        ...arr(data.tree).map((t) => t.path),
      ].filter(isText);
    }
    
    function checkFilesExist(report, data, repoRoot) {
      const missing = [...new Set(citedFiles(data))]
        .map((file) => file.replace(/[:#]L?\d+(-L?\d+)?$/, '').replace(/\/$/, ''))
        .filter((file) => !existsSync(resolve(repoRoot, file)));
      missing.forEach((file) => report.error('files', `cited path "${file}" does not exist in ${repoRoot}`));
    }
    
    const words = (value) => (isText(value) ? value.trim().split(/\s+/).length : 0);
    const sentences = (value) => (isText(value) ? (value.match(/[.!?](\s|$)/g) || []).length : 0);
    
    function checkLessonShape(report, data) {
      if (arr(data.layers).length > LIMITS.layers) report.warn('layers', `${arr(data.layers).length} layers. Group into ${LIMITS.layers} or fewer`);
      arr(data.nodes).forEach((n, i) => {
        if (words(n.label) > LIMITS.nodeLabelWords) report.warn(`nodes[${i}].label`, `"${n.label}" is long. Keep node labels to ${LIMITS.nodeLabelWords} words`);
      });
      arr(data.edges).forEach((e, i) => {
        if (words(e.label) > LIMITS.edgeLabelWords) report.warn(`edges[${i}].label`, `keep edge labels to ${LIMITS.edgeLabelWords} words`);
      });
      arr(data.flows).forEach((flow, i) => {
        const steps = arr(flow.steps);
        const { min, max } = LIMITS.stepsPerFlow;
        if (steps.length && (steps.length < min || steps.length > max)) report.warn(`flows[${i}].steps`, `${steps.length} steps. Aim for ${min}–${max}; split long flows`);
        const actors = new Set(steps.flatMap((st) => [st.from, st.to])).size;
        if (actors > LIMITS.actorsPerFlow) report.warn(`flows[${i}]`, `${actors} actors. Group parts so a flow has at most ${LIMITS.actorsPerFlow}`);
        steps.forEach((st, j) => {
          if (words(st.label) > LIMITS.stepLabelWords) report.warn(`flows[${i}].steps[${j}].label`, `keep step labels to ${LIMITS.stepLabelWords} words. Put the rest in detail`);
          if (sentences(st.detail) > LIMITS.detailSentences) report.warn(`flows[${i}].steps[${j}].detail`, `one idea per step. Keep detail to ${LIMITS.detailSentences} sentences`);
        });
      });
      if (arr(data.concepts).length > LIMITS.concepts) report.warn('concepts', `keep to ${LIMITS.concepts} key ideas`);
      if (arr(data.quiz).length > LIMITS.quiz) report.warn('quiz', `keep to ${LIMITS.quiz} questions`);
    }
    
    // Writing rule from SKILL.md: say it plainly, no dashes as punctuation.
    function checkProseStyle(report, data) {
      const walk = (value, path, key) => {
        if (NON_PROSE_KEYS.has(key)) return;
        if (typeof value === 'string' && DASH.test(value)) report.warn(path, 'uses a dash as punctuation. Split it into two sentences, or use a colon or comma');
        else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`, key));
        else if (value !== null && typeof value === 'object') Object.entries(value).forEach(([k, v]) => walk(v, path ? `${path}.${k}` : k, k));
      };
      walk(data, '', '');
    }
    
    // Strings render as text, so markup would show up literally — and signals an
    // attempt to restyle the page. Code fields are exempt.
    function checkNoMarkup(report, data) {
      const walk = (value, path, key) => {
        if (typeof value === 'string' && key !== 'code' && MARKUP.test(value)) report.error(path, 'contains HTML markup. Strings are plain text; styling comes from the template');
        else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`, key));
        else if (isObject(value)) Object.entries(value).forEach(([k, v]) => walk(v, path ? `${path}.${k}` : k, k));
      };
      walk(data, '', '');
    }
    
    function checkPlaceholders(report, data) {
      const hits = [];
      const walk = (value, path) => {
        if (typeof value === 'string' && PLACEHOLDER.test(value)) hits.push(path);
        else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`));
        else if (isObject(value)) Object.entries(value).forEach(([k, v]) => walk(v, path ? `${path}.${k}` : k));
      };
      walk(data, '');
      hits.forEach((path) => report.warn(path, 'still contains a TODO placeholder'));
    }
    
    /**
     * Validate an explainer data object.
     * @param {unknown} data
     * @param {{ repoRoot?: string }} [options] when repoRoot is given, every cited file must exist there
     * @returns {ValidationResult}
     */
    export function validateExplainer(data, options = {}) {
      const report = collector();
      if (!isObject(data)) {
        report.error('root', 'explainer data must be a JSON object');
        return report.result();
      }
      checkMeta(report, data.meta);
      const nodeIds = checkGraph(report, data);
      checkFlows(report, arr(data.flows), nodeIds);
      checkChanges(report, arr(data.changes), nodeIds);
      checkLessons(report, data);
      checkLessonShape(report, data);
      checkNoMarkup(report, data);
      checkProseStyle(report, data);
      checkPlaceholders(report, data);
      if (options.repoRoot) checkFilesExist(report, data, options.repoRoot);
      return report.result();
    }
    
  • references/data-schema.md4.3 KB8c970a6dc6e2upstream ↗
    # Data schema (`explainer.json`)
    
    One JSON file drives the whole page. The enforced contract is `scripts/lib/schema.mjs` + `scripts/lib/validate.mjs`; this page explains it. A complete example lives at `examples/taskflow.explainer.json`.
    
    Every string is rendered as **plain text**: markup is rejected, and newlines only matter inside `code`.
    
    ## `meta` (required)
    
    | Field | Type | Notes |
    |---|---|---|
    | `title` | string, **required** | `"<Repo>, explained"` reads well. |
    | `summary` | string, **required** | Two plain sentences: what it does and for whom. |
    | `repo` | string | `owner/name`. |
    | `stack` | string[] | ≤ 8 chips, e.g. `"Next.js 15"`. |
    | `preset` | string | The preset id you used. |
    | `chapters` | string[] | The order of chapters, from `map, flows, changes, tree, data, concepts, quiz, glossary, run`. Chapters with no data are skipped automatically. |
    | `generatedAt` | ISO date | Shown in the footer. |
    | `commit` | string | The SHA the explainer describes. |
    
    `intros` (optional) overrides a chapter's heading or lead: `{ "flows": { "title": "…", "lead": "…" } }`.
    
    ## `layers` (required, 1–8)
    
    `{ id, label, color }`. Layers become the map's columns, from left to right in array order, so order them by distance from the user. `color` is one of `green, blue, navy, midnight, yellow, pink`.
    
    ## `nodes` (required)
    
    `{ id, label, layer, kind, tech?, summary, files[] }`
    
    - `kind` is one of `user, ui, mobile, api, service, db, queue, cache, external, job, cli, auth, storage, library`, and picks the icon (see the gallery).
    - `files` holds paths or folders that exist (`--repo` checks this). Folders end in `/`.
    - Keep `label` to 3 words or fewer and `summary` to one sentence.
    
    ## `edges`
    
    `{ from, to, label?, kind? }`. `kind`: `sync` (default, solid), `async` (dashed) or `data`. Selecting a node animates only its own edges, so a dense graph stays readable.
    
    ## `flows`: the core chapter
    
    `{ id, title, trigger, summary, steps[] }`, with 4–12 steps and at most 7 distinct actors.
    
    Each step is `{ from, to, label, kind, detail?, why?, file, code?, lang? }`:
    
    | Field | Notes |
    |---|---|
    | `from`, `to` | Node ids. When they are the same, the step is drawn as a self-loop (internal work). |
    | `kind` | `request`, `response` (dashed), `async` (dashed), `data` or `internal`. |
    | `label` | ≤ 6 words, an action: "Checks the session". |
    | `detail` | ≤ 2 sentences: what happens. |
    | `why` | Optional. The design reason, shown as a green callout. Use it sparingly, where a reader would ask "why like this?" |
    | `file` | `path:line`. Required in practice: it is a warning without it, and an error under `--strict`. |
    | `code` | Optional. ≤ 8 lines of the real code at that line, trimmed. |
    
    ## `changes`: what changed and why
    
    `{ id, title, date, author, authorType, ref, url?, kind, what, why, risk?, nodes[], files[] }`
    
    - Draft these with `repo-explainer changes`. It fills `authorType` (`agent` / `human`, detected from author names and `Co-Authored-By` trailers), `kind` (from conventional-commit prefixes), `files`, and `nodes` (mapped through each node's `files`).
    - **You** fill in `why` from the PR description, linked issue or commit body. If there is none, write "No reason recorded".
    - `risk` is optional: what could break, or what to watch.
    
    ## `tree`
    
    `{ path, note, key? }`. Indentation comes from path depth. Mark the 2–4 folders where most changes happen with `key: true`.
    
    ## `dataModel`
    
    `{ name, summary?, fields: [{ name, type, note?, ref? }], relations: [{ to, kind }] }`, where `to` is another model's `name`.
    
    - The chapter draws an ER diagram from `relations`. Use `kind` values `has many`, `has one`, `belongs to` or `many to many`, so the lines get 1 / N cardinality. List each pair once; the mirror relation is merged.
    - `id` is marked PK. A field is marked FK when it is named `<model>Id` or `<model>_id`, or when you set `ref` to the model name (e.g. `assigneeId` with `ref: "User"`). Ids of outside systems, like `stripeCustomerId`, are not FKs.
    
    ## `concepts` (3–6)
    
    `{ title, body, analogy?, file?, code?, lang? }`. One idea each.
    
    ## `quiz` (3–5)
    
    `{ question, options[], answer, explain }`, where `answer` is the zero-based index. Ask "where would you change X?" or "why does Y happen?".
    
    ## `glossary`, `runbook`
    
    - `glossary`: `{ term, definition }`.
    - `runbook`: `{ label, command, note? }`. Commands get a Copy button.
    
  • references/design-system.md3.1 KB2fdf2306acc0upstream ↗
    # Design system
    
    > **Credit.** The visual language (palette, type scale, 12px radius, and the thick-bottom-border "pressable" buttons) follows **Duolingo**'s design language, including its color names (Ecto Green, Macaw Blue, Eel, Lingot). The one-idea-per-step lesson flow with a Continue button is inspired by **Brilliant**. Repo Explainer is an independent open-source project, not affiliated with or endorsed by Duolingo or Brilliant. It ships no Duolingo or Brilliant logos, mascots, illustrations or fonts: the type is Nunito, and the icons are original.
    
    This page is **read-only context for agents**. The tokens live in `template/styles.css`, and explainers never override them.
    
    ## Tokens
    
    | Token | Value | Use |
    |---|---|---|
    | `--color-ecto-green` | `#58cc02` | Primary buttons, display headings, the current step |
    | `--color-ecto-green-dark` | `#58a700` | Bottom border of green buttons (the press effect) |
    | `--color-eel-light` | `#d7ffb8` | Correct-answer wash, callouts, done-step badges |
    | `--color-lingot-lime` | `#a5ed6e` | **Outline only.** Active pills, callout borders. Never a fill. |
    | `--color-macaw-blue` | `#1cb0f6` | Secondary actions, links, focus ring, active edges |
    | `--color-eel-dark-blue` | `#042c60` | Headings and labels |
    | `--color-midnight` | `#000437` | Code blocks |
    | `--color-charcoal` / `--color-ash` | `#4b4b4b` / `#777` | Body text / secondary text |
    | `--color-swan` / `--color-polar` | `#e5e5e5` / `#f7f7f7` | Borders and inactive states / soft washes |
    | `--color-bee`, `--color-pink` | `#ffc800`, `#ff86d0` | Illustration accents and layer colors only |
    | `--color-cardinal` | `#ff4b4b` | Wrong quiz answers, **only** |
    
    **Type.** Nunito 500/700/800/900 (with JetBrains Mono for code) and letter-spacing `0.053em`. Display headings are Nunito 900 with `-0.02em` tracking, always 28px or larger.
    
    **Shape.** One radius, 12px (pills use 999px only for progress bars and dots). Borders are 2px, and pressable elements get a 4px bottom border that collapses to 2px when pressed.
    
    ## Rules and their reasons
    
    | Rule | Why |
    |---|---|
    | No box-shadows | Depth comes from borders. Shadows read as a different product and blur at small sizes. |
    | No gradients | Flat color keeps diagrams legible and the palette recognizable. |
    | One accent per meaning | Green = progress/correct, blue = navigate/select, cardinal = wrong. Adding colors dilutes the signal. |
    | Layer colors come from a fixed set of 6 | Keeps every repo's map consistent, so readers can learn the visual vocabulary once. |
    | Icons by `kind` only | One family of flat illustrations. Mixed icon styles look broken. |
    | Motion is short (200–500ms), eases out, plays once | It explains change (a step advancing, a reveal) instead of decorating. Everything respects `prefers-reduced-motion`. |
    | Text is plain | It prevents XSS from repo content and stops one-off restyling. |
    
    ## Guardrail tests
    
    `test/brand.test.mjs` fails the build if the stylesheet gains a `box-shadow`, a gradient, a color outside the token list, or a border radius other than 12px, 8px (tags), 6px (kbd) or 999px (dots/bars). Change the tests only together with this document, in a dedicated PR.
    
  • references/investigation-playbook.md3.6 KBdd46b02000d1upstream ↗
    # Investigation playbook
    
    Goal: a reader who finishes the explainer can answer "where would I change X?" for the repo's core behaviour. Get there by tracing real calls, not by summarising folder names.
    
    ## 1. Orient (10 minutes)
    
    1. Run `repo-explainer scan <repo>` and read the output top to bottom.
    2. Read the README, any `ARCHITECTURE.md` or ADRs, and `CONTRIBUTING.md`. Note what they *claim*, then verify it.
    3. Open every manifest in `scan.manifests`. The dependencies tell you the moving parts.
    4. List the deployable units: apps, services, workers, and cron. Each one usually becomes one or more nodes.
    
    ## 2. Draw the map first
    
    - **Nodes are runtime responsibilities, not folders.** "Auth middleware", not `src/middleware/`.
    - **Aim for 6–14 nodes.** If you have more, group them; if fewer, you are probably hiding the interesting part.
    - **Edges are calls, reads/writes, or messages,** and each gets a label naming the call ("createTask()", "enqueue", "webhook").
    - **Put external services in their own layer** so readers see where the system boundary is.
    
    ## 3. Trace each flow call by call
    
    Start at the trigger (a click, an HTTP route, a cron, a webhook, a CLI command). Then repeat:
    
    1. Open the file and find the exact line that hands off to the next part.
    2. Record `{from, to, label, file:line}`.
    3. Follow the call into the next file.
    
    Stop when the response returns or the side effect lands.
    
    Where to find the next hop:
    
    | Stack | Entry | Next hop is usually |
    |---|---|---|
    | Next.js / Remix / SvelteKit | `app/**/page.tsx`, `route.ts`, server actions, `middleware.ts` | an imported service, ORM call, or `fetch` |
    | Express / Fastify / Hono / Nest | `app.get(…)`, `@Get()`, router files | middleware chain → controller → service |
    | FastAPI / Django / Flask | `@router.get`, `urls.py` → `views.py` | dependency-injected service, ORM model |
    | Go (net/http, gin, chi, echo) | `main.go` route registration | handler → service interface → repository |
    | Rails / Laravel | `routes.rb` / `routes/web.php` | controller action → model / job |
    | SPA (React/Vue) | router config → page component | data hook (`useQuery`, store action) → API client |
    | Mobile | app entry → navigator → screen | view model → repository → network/local DB |
    | Queue workers | worker bootstrap, `process()`/`perform` | job handler → service → external API |
    | CLI / library | `bin` in package.json, `main()`, exported index | arg parser → command → core |
    
    Include the **error path** once per flow (validation failure, auth failure, retry), as a step or in a `detail`.
    
    ## 4. Find the "why"
    
    Look in commit messages, PR descriptions, ADRs, and comments near unusual code, especially comments containing `because`, `workaround` or `NOTE`. Put a reason in `step.why` only when you found it or it follows directly from the code (e.g. "verifies the signature so events can't be forged"). Don't speculate.
    
    ## 5. Changes chapter
    
    1. Run `repo-explainer changes <repo> --data <file> --write`. Narrow the window with `--since` if the repo is busy; 20 merges a day means 2–3 days is plenty.
    2. For each entry, open the PR or commit and write `why` in one or two sentences. Add `risk` when the change touches money, auth, data deletion, or concurrency.
    3. Drop noise: lockfile bumps, formatting, bot version bumps (unless they matter).
    
    ## 6. Check yourself before building
    
    - [ ] Every node has `files`, and every step has `file:line`.
    - [ ] Each flow reads correctly as a story when you only read the step labels.
    - [ ] Quiz answers are findable from the explainer itself.
    - [ ] No env values, tokens, or customer data anywhere.
    - [ ] `build --repo <repo> --strict` passes.
    
  • references/ux-principles.md2.2 KB665f0229e200upstream ↗
    # UX principles
    
    Why the page is shaped like a lesson rather than a wiki.
    
    1. **Guide, then let them explore.** A sandbox of boxes and arrows is not an explanation, because readers need to be led to the interesting part. Every chapter opens with one sentence telling the reader what to do. The flow player walks the request one hop at a time; the map is there to explore afterwards. *(After Bret Victor's "Explorable Explanations" and Red Blob Games' critique of sandbox widgets.)*
    2. **Readable without interacting.** All content is visible as normal text. Interaction adds focus but never hides the only copy of a fact.
    3. **Start broad, zoom on demand.** The map is the C4 "container" level. Selecting a node zooms to its files, neighbours and the flows it appears in. Flows go down to `file:line`. *(After the C4 model's context → container → component levels.)*
    4. **One idea per screen.** Each step shows one hop, one sentence of what happens, and an optional "why". Concepts are a carousel, one card at a time. *(After Brilliant's lesson pacing.)*
    5. **Immediate feedback.** Quiz answers grade instantly, with the reason. Finishing a flow shows a trophy and offers the next flow.
    6. **Never trap the scroll.** Diagrams scroll sideways only, so the page always moves with the wheel. Sticky headers and the sticky step card keep context without inner vertical scroll areas.
    7. **Change is a first-class chapter.** When humans and agents land dozens of merges a day, "what is this system?" is only half the question. The other half is "what moved since I last looked, and why?". Each change is tied to the map ("Show on map") so readers update their mental model rather than reading a log.
    8. **Calm motion.** Motion only shows change: an arrow draws in, a dot travels once, a card slides in from the direction you moved. No loops except the selected-edge flow, which shows direction. Reduced-motion users get none of it.
    
    ## Sources
    
    - Bret Victor, *Explorable Explanations*: https://worrydream.com/ExplorableExplanations/
    - Red Blob Games, notes on interactive explanations: https://redblobgames.com/x/2306-educ432
    - C4 model overview: https://codesee.io/learning-center/c4-diagram
    - Explorable explanation (overview): https://en.wikipedia.org/wiki/Explorable_explanation
    
  • assets/icons/api.svg515 Bdd3b74efe03cupstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="4" y="9" width="24" height="14" rx="7" fill="#58cc02"/><rect x="9" y="4" width="3" height="6" rx="1.5" fill="#58a700"/><rect x="20" y="4" width="3" height="6" rx="1.5" fill="#58a700"/><path d="M11 16h10M18 13l3 3-3 3" stroke="#fff" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><rect x="14.5" y="23" width="3" height="6" rx="1.5" fill="#58a700"/></svg>
    
  • assets/icons/auth.svg386 B3bf919c5f3f5upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M10 14v-3a6 6 0 0112 0v3" stroke="#042c60" stroke-width="3" fill="none"/><rect x="6" y="13" width="20" height="16" rx="4" fill="#ffc800"/><circle cx="16" cy="20" r="2.5" fill="#042c60"/><rect x="15" y="21" width="2" height="4" rx="1" fill="#042c60"/></svg>
    
  • assets/icons/cache.svg289 Bf9c0585d080dupstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle cx="16" cy="16" r="13" fill="#fff5d3"/><path d="M18 4L8 18h7l-2 10 11-15h-7z" fill="#ffc800" stroke="#a56d00" stroke-width="1.2" stroke-linejoin="round"/></svg>
    
  • assets/icons/cli.svg387 Bdcb754f26894upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="3" y="5" width="26" height="22" rx="4" fill="#000437"/><path d="M8 12l4 4-4 4" stroke="#a5ed6e" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 21h8" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>
    
  • assets/icons/db.svg371 Bccf94f712843upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M5 8v16c0 2.8 4.9 5 11 5s11-2.2 11-5V8z" fill="#1cb0f6"/><ellipse cx="16" cy="8" rx="11" ry="5" fill="#ddf4ff" stroke="#1cb0f6" stroke-width="2"/><path d="M5 16c0 2.8 4.9 5 11 5s11-2.2 11-5" stroke="#fff" stroke-width="2" fill="none"/></svg>
    
  • assets/icons/external.svg287 B4cc74b5bbdd6upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M9 25a6 6 0 01-.8-11.9A8 8 0 0123.6 11 6.5 6.5 0 0124 25z" fill="#1cb0f6"/><path d="M12 19h8" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>
    
  • assets/icons/file.svg247 B6ea6bd1becfaupstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M8 3h11l6 6v19a1 1 0 01-1 1H8a1 1 0 01-1-1V4a1 1 0 011-1z" fill="#e5e5e5"/><path d="M19 3v6h6" fill="#cfcfcf"/></svg>
    
  • assets/icons/folder.svg223 B8bf0cfed2be4upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M3 9a3 3 0 013-3h7l3 3h10a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3z" fill="#1cb0f6"/></svg>
    
  • assets/icons/job.svg318 B6d422f59f1aaupstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle cx="16" cy="16" r="13" fill="#58cc02"/><circle cx="16" cy="16" r="9.5" fill="#fff"/><path d="M16 10v6.5l4 2.5" stroke="#042c60" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
    
  • assets/icons/library.svg350 Ba3a16faef3e8upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="4" y="5" width="7" height="23" rx="2" fill="#58cc02"/><rect x="12.5" y="8" width="7" height="20" rx="2" fill="#1cb0f6"/><rect x="21" y="4" width="7" height="24" rx="2" fill="#ff86d0" transform="rotate(8 24.5 16)"/></svg>
    
  • assets/icons/mobile.svg367 Bdb4b0856c38fupstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="8" y="2" width="16" height="28" rx="4" fill="#042c60"/><rect x="10.5" y="6" width="11" height="18" rx="2" fill="#fff"/><rect x="12.5" y="9" width="7" height="3" rx="1.5" fill="#58cc02"/><circle cx="16" cy="27" r="1.3" fill="#fff"/></svg>
    
  • assets/icons/queue.svg350 Bdb018cfcd1f9upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="3" y="9" width="7" height="14" rx="3" fill="#ff86d0"/><rect x="12.5" y="9" width="7" height="14" rx="3" fill="#ff86d0" opacity="0.75"/><rect x="22" y="9" width="7" height="14" rx="3" fill="#ff86d0" opacity="0.45"/></svg>
    
  • assets/icons/robot.svg382 B010973a48ee9upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="6" y="9" width="20" height="17" rx="5" fill="#1cb0f6"/><circle cx="12" cy="17" r="2.5" fill="#fff"/><circle cx="20" cy="17" r="2.5" fill="#fff"/><rect x="15" y="3" width="2" height="6" fill="#042c60"/><circle cx="16" cy="3" r="2" fill="#ffc800"/></svg>
    
  • assets/icons/service.svg274 B8a504ee6b1bdupstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M16 2l12 7v14l-12 7-12-7V9z" fill="#042c60"/><circle cx="16" cy="16" r="5.5" fill="#fff"/><circle cx="16" cy="16" r="2.5" fill="#58cc02"/></svg>
    
  • assets/icons/storage.svg288 B11f993300c3bupstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M3 9a3 3 0 013-3h7l3 3h10a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3z" fill="#ffc800"/><rect x="3" y="12" width="26" height="15" rx="3" fill="#ffd84d"/></svg>
    
  • assets/icons/trophy.svg397 Bb776aae360f4upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M9 4h14v7a7 7 0 01-14 0z" fill="#ffc800"/><path d="M9 6H5a4 4 0 004 6M23 6h4a4 4 0 01-4 6" stroke="#ffc800" stroke-width="2.5" fill="none"/><rect x="14" y="17" width="4" height="6" fill="#a56d00"/><rect x="9" y="23" width="14" height="5" rx="2" fill="#58cc02"/></svg>
    
  • assets/icons/ui.svg473 B2959ea143e8aupstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><rect x="3" y="5" width="26" height="22" rx="4" fill="#1cb0f6"/><rect x="6" y="11" width="20" height="13" rx="2" fill="#fff"/><circle cx="7.5" cy="8" r="1.3" fill="#fff"/><circle cx="11.5" cy="8" r="1.3" fill="#fff"/><rect x="9" y="14" width="9" height="3" rx="1.5" fill="#58cc02"/><rect x="9" y="19" width="14" height="2" rx="1" fill="#e5e5e5"/></svg>
    
  • assets/icons/user.svg238 B98d1463b3e77upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle cx="16" cy="11" r="6" fill="#ffc800"/><path d="M5 29c0-6.5 4.9-11 11-11s11 4.5 11 11z" fill="#1cb0f6"/></svg>
    
  • assets/logo/lockup.svg569 B400550b10f54upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 64" width="300" height="64">
    <g transform="translate(0 4) scale(1.75)"><rect x="2" y="2" width="28" height="28" rx="9" fill="#58cc02"/><path d="M9 21l5-10 4 7 2-3 3 6" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="23" cy="11" r="2.5" fill="#ffc800"/></g>
    <text x="68" y="42" font-family="Nunito, 'Arial Rounded MT Bold', ui-rounded, system-ui, sans-serif" font-size="30" font-weight="900" fill="#58cc02" letter-spacing="-0.3">Repo Explainer</text>
    </svg>
    
  • assets/logo/mark.svg347 B536d02d17959upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 32 32" aria-hidden="true"><rect x="2" y="2" width="28" height="28" rx="9" fill="#58cc02"/><path d="M9 21l5-10 4 7 2-3 3 6" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="23" cy="11" r="2.5" fill="#ffc800"/></svg>
    
  • assets/logo/social-card.svg942 B83d2f13bf355upstream ↗
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" width="1280" height="640">
    <rect width="1280" height="640" fill="#ffffff"/>
    <circle cx="1050" cy="320" r="260" fill="#d7ffb8"/>
    <circle cx="1180" cy="120" r="44" fill="#ffc800"/>
    <g transform="translate(96 150) scale(3)"><rect x="2" y="2" width="28" height="28" rx="9" fill="#58cc02"/><path d="M9 21l5-10 4 7 2-3 3 6" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="23" cy="11" r="2.5" fill="#ffc800"/></g>
    <text x="96" y="370" font-family="Nunito, 'Arial Rounded MT Bold', ui-rounded, system-ui, sans-serif" font-size="88" font-weight="900" fill="#58cc02" letter-spacing="-2">Repo Explainer</text>
    <text x="96" y="440" font-family="Nunito, 'Arial Rounded MT Bold', ui-rounded, system-ui, sans-serif" font-size="34" font-weight="700" fill="#4b4b4b">Understand any repo end to end, even when 20 PRs land a day.</text>
    </svg>
    
  • .github/ISSUE_TEMPLATE/bug.md262 B745bf5e9a1a4upstream ↗
    ---
    name: Bug report
    about: Something renders wrong or a command fails
    labels: bug
    ---
    
    **What happened**
    
    **What you expected**
    
    **Steps to reproduce** (attach the `explainer.json` if you can; strip anything private)
    
    **Environment:** Node version, OS, browser
    
  • .github/pull_request_template.md320 B5f44fd21d0c6upstream ↗
    ## Why
    
    <!-- The problem this solves. The diff already shows what changed. -->
    
    ## Checklist
    
    - [ ] `npm run check` passes
    - [ ] Visual change? Before/after screenshot attached, checked at 375px and with reduced motion
    - [ ] Design-system change? `references/design-system.md` and `test/brand.test.mjs` updated together
    
  • .github/workflows/ci.yml715 B615d2ed17969upstream ↗
    name: CI
    
    on:
      push:
        branches: [main]
      pull_request:
    
    permissions:
      contents: read
    
    jobs:
      test:
        runs-on: ubuntu-latest
        strategy:
          matrix:
            node: [18, 20, 22]
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
          - uses: actions/setup-node@v4
            with:
              node-version: ${{ matrix.node }}
          - run: git config --global user.email [email protected] && git config --global user.name CI
          - run: npm run check
          - name: Example stays valid under --strict
            run: node bin/repo-explainer.mjs validate examples/taskflow.explainer.json --strict
          - name: Built files are up to date
            run: git diff --exit-code -- examples/
    
  • .gitignore167 B15d50ea02faeupstream ↗
    node_modules/
    .DS_Store
    *.log
    /explainer.json
    /gallery.html
    /repo-explainer-assets/
    /.playwright-mcp/
    coverage/
    !test/fixtures/**/node_modules/
    /examples/.stress.html
    
  • bin/repo-explainer.mjs8.4 KB37a80ba4a371upstream ↗
    #!/usr/bin/env node
    import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
    import { basename, dirname, join, resolve } from 'node:path';
    import { parseArgs } from 'node:util';
    import { execFileSync } from 'node:child_process';
    import { buildExplainer, buildGallery } from '../scripts/lib/build.mjs';
    import { NODE_KINDS } from '../scripts/lib/schema.mjs';
    import { validateExplainer } from '../scripts/lib/validate.mjs';
    import { scanRepo } from '../scripts/lib/scan.mjs';
    import { collectChanges } from '../scripts/lib/changes.mjs';
    import { createStarter, listPatterns, listPresets } from '../scripts/lib/init.mjs';
    import { exportIcons, exportLogos } from '../scripts/lib/assets.mjs';
    import { PRICING, costTable, formatUsd, sizeOf } from '../scripts/lib/estimate.mjs';
    
    const HELP = `repo-explainer: turn a repo into a guided, interactive HTML tour
    
    Usage
      repo-explainer scan <repo>                       Facts to start from (stack, routes, data files, env keys)
      repo-explainer estimate [repo]                   Rough agent cost per run, per model (all sizes if no repo)
      repo-explainer init <repo> [--preset id]         Write a starter explainer.json (preset auto-picked from scan)
      repo-explainer changes <repo> [--data file]      Draft "what changed & why" entries from git history
      repo-explainer validate <file> [--repo dir]      Check the data; --repo also checks every cited file exists
      repo-explainer build <file> [--out f] [--repo d] Validate, then render one self-contained HTML file
      repo-explainer presets                           List presets
      repo-explainer patterns [id]                     List reusable flow patterns, or print one
      repo-explainer assets [--out dir]                Export logo + icon SVGs
      repo-explainer gallery [--out file]              Render the design kit (icons, palette, components, patterns)
    
    Options
      --out, -o      Output path                      --since     git window (default "14 days ago")
      --preset, -p   Preset id                        --limit     max commits (default 40)
      --repo, -r     Repo root for file checks        --merges    only merge commits (PR merges)
      --strict       Treat warnings as errors         --write     merge drafted changes into --data file
      --open         Open the built file              --remote    repo URL for PR links (e.g. https://github.com/o/r)
    `;
    
    const OPTIONS = {
      out: { type: 'string', short: 'o' },
      preset: { type: 'string', short: 'p' },
      repo: { type: 'string', short: 'r' },
      data: { type: 'string' },
      since: { type: 'string' },
      limit: { type: 'string' },
      remote: { type: 'string' },
      merges: { type: 'boolean' },
      write: { type: 'boolean' },
      strict: { type: 'boolean' },
      open: { type: 'boolean' },
      help: { type: 'boolean', short: 'h' },
    };
    
    class UsageError extends Error {}
    
    function readJson(path) {
      if (!existsSync(path)) throw new UsageError(`file not found: ${path}`);
      try {
        return JSON.parse(readFileSync(path, 'utf8'));
      } catch (error) {
        throw new UsageError(`could not parse ${path}: ${error.message}`);
      }
    }
    
    function writeFile(path, content) {
      mkdirSync(dirname(resolve(path)), { recursive: true });
      writeFileSync(path, content);
      process.stderr.write(`✔ wrote ${path}\n`);
    }
    
    function printJson(value) {
      process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
    }
    
    function report({ errors, warnings }, strict) {
      warnings.forEach((w) => process.stderr.write(`  ⚠ ${w}\n`));
      errors.forEach((e) => process.stderr.write(`  ✖ ${e}\n`));
      const failed = errors.length > 0 || (strict && warnings.length > 0);
      process.stderr.write(`${failed ? '✖' : '✔'} ${errors.length} error(s), ${warnings.length} warning(s)\n`);
      return !failed;
    }
    
    function requirePositional(positionals, label) {
      if (!positionals[0]) throw new UsageError(`missing ${label}\n\n${HELP}`);
      return positionals[0];
    }
    
    const COMMANDS = {
      scan(positionals) {
        printJson(scanRepo(resolve(requirePositional(positionals, '<repo>'))));
      },
    
      init(positionals, opts) {
        const repo = resolve(requirePositional(positionals, '<repo>'));
        const scan = scanRepo(repo);
        const preset = opts.preset || scan.suggestedPreset;
        const out = opts.out || 'explainer.json';
        if (existsSync(out)) throw new UsageError(`${out} already exists. Pass --out to choose another path`);
        writeFile(out, `${JSON.stringify(createStarter(preset, scan), null, 2)}\n`);
        process.stderr.write(`  preset: ${preset}${opts.preset ? '' : ' (suggested by scan)'}\n  next: fill the TODOs, then \`repo-explainer build ${out} --repo ${repo}\`\n`);
      },
    
      changes(positionals, opts) {
        const repo = resolve(requirePositional(positionals, '<repo>'));
        const data = opts.data ? readJson(opts.data) : null;
        const drafted = collectChanges(repo, {
          since: opts.since, limit: opts.limit ? Number(opts.limit) : undefined, mergesOnly: opts.merges,
          nodes: data ? data.nodes : [], remoteUrl: opts.remote,
        });
        if (!opts.write) return printJson(drafted);
        if (!data) throw new UsageError('--write needs --data <explainer.json>');
        const known = new Set((data.changes || []).map((c) => c.id));
        const merged = { ...data, changes: [...(data.changes || []), ...drafted.filter((c) => !known.has(c.id))] };
        writeFile(opts.data, `${JSON.stringify(merged, null, 2)}\n`);
        process.stderr.write(`  added ${merged.changes.length - (data.changes || []).length} change(s); fill each "why"\n`);
        return undefined;
      },
    
      validate(positionals, opts) {
        const file = requirePositional(positionals, '<file>');
        const ok = report(validateExplainer(readJson(file), { repoRoot: opts.repo && resolve(opts.repo) }), opts.strict);
        if (!ok) process.exitCode = 1;
      },
    
      build(positionals, opts) {
        const file = requirePositional(positionals, '<file>');
        const data = readJson(file);
        if (!report(validateExplainer(data, { repoRoot: opts.repo && resolve(opts.repo) }), opts.strict)) {
          process.exitCode = 1;
          return;
        }
        const out = opts.out || join(dirname(file), `${basename(file).replace(/(\.explainer)?\.json$/, '')}.html`);
        writeFile(out, buildExplainer(data));
        if (opts.open) execFileSync(process.platform === 'darwin' ? 'open' : 'xdg-open', [out]);
      },
    
      estimate(positionals) {
        const size = positionals[0] ? sizeOf(scanRepo(resolve(positionals[0]))) : null;
        const rows = costTable(size ? ['update', size] : undefined);
        const models = Object.keys(PRICING.models);
        const pad = (v, n) => String(v).padEnd(n);
        process.stdout.write(`${pad('Run', 36)}${models.map((m) => pad(PRICING.models[m].label, 12)).join('')}\n`);
        rows.forEach((row) => process.stdout.write(`${pad(row.label, 36)}${models.map((m) => pad(formatUsd(row.costs[m]), 12)).join('')}\n`));
        process.stdout.write(`\nEstimates, not quotes. Prices from ${PRICING.source} (checked ${PRICING.checkedAt}).\n`);
        if (size) process.stdout.write(`This repo looks ${size}.\n`);
      },
    
      presets() {
        listPresets().forEach((p) => process.stdout.write(`${p.id.padEnd(16)} ${p.name}\n${' '.repeat(17)}${p.whenToUse}\n`));
      },
    
      patterns(positionals) {
        const all = listPatterns();
        if (!positionals[0]) {
          all.forEach((p) => process.stdout.write(`${p.id.padEnd(18)} ${p.use}\n`));
          return;
        }
        const pattern = all.find((p) => p.id === positionals[0]);
        if (!pattern) throw new UsageError(`unknown pattern "${positionals[0]}"`);
        printJson(pattern);
      },
    
      gallery(_positionals, opts) {
        const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
        writeFile(opts.out || 'gallery.html', buildGallery({ kinds: NODE_KINDS, patterns: listPatterns(), version: pkg.version }));
      },
    
      assets(_positionals, opts) {
        const out = opts.out || 'repo-explainer-assets';
        exportIcons().forEach(({ name, svg }) => writeFile(join(out, 'icons', `${name}.svg`), svg));
        exportLogos().forEach(({ name, svg }) => writeFile(join(out, 'logo', `${name}.svg`), svg));
      },
    };
    
    function main(argv) {
      const { values, positionals } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
      const [command, ...rest] = positionals;
      if (!command || values.help) {
        process.stdout.write(HELP);
        return;
      }
      const run = COMMANDS[command];
      if (!run) throw new UsageError(`unknown command "${command}"\n\n${HELP}`);
      run(rest, values);
    }
    
    try {
      main(process.argv.slice(2));
    } catch (error) {
      process.stderr.write(`✖ ${error instanceof UsageError ? error.message : `repo-explainer failed: ${error.message}`}\n`);
      process.exitCode = 1;
    }
    
  • CHANGELOG.md535 B8bc420b24eebupstream ↗
    # Changelog
    
    All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org).
    
    ## 0.1.0 (2026-10-25)
    
    First public release.
    
    - Chapters: architecture map, end-to-end flow player, what changed and why, folder guide, ER diagram, key ideas, quiz, glossary, runbook.
    - CLI commands: `scan`, `estimate`, `init`, `changes`, `validate`, `build`, `presets`, `patterns`, `gallery`, `assets`.
    - 5 presets and 11 flow patterns.
    - Brand, writing-style, lesson-shape and truthfulness checks.
    
  • CODE_OF_CONDUCT.md441 B6396b48b0cfdupstream ↗
    # Code of conduct
    
    This project follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
    
    In short: be kind, assume good intent, and keep feedback about the work.
    
    Report unacceptable behaviour privately to the maintainer through a [GitHub security advisory](https://github.com/Cyvid7-Darus10/repo-explainer/security/advisories/new) or the email on the maintainer's GitHub profile.
    
  • CONTRIBUTING.md1.7 KBf1049038200eupstream ↗
    # Contributing
    
    Thanks for helping. This project has two audiences, people and agents, and one rule that keeps them happy: **explainers change data, maintainers change the template.**
    
    ## Setup
    
    ```sh
    git clone https://github.com/Cyvid7-Darus10/repo-explainer
    cd repo-explainer
    npm run check        # tests + rebuild the example and gallery
    ```
    
    No dependencies to install. Node 18.3 or newer.
    
    ## Where things live
    
    | Path | What |
    |---|---|
    | `SKILL.md` | Instructions agents follow. Keep it short and imperative. |
    | `bin/repo-explainer.mjs` | The CLI. |
    | `scripts/lib/` | Scan, validate, build, changes, estimate. Plain ES modules. |
    | `template/` | The page: `explainer.html`, `styles.css`, and `js/` modules (concatenated at build time). |
    | `presets/`, `patterns/` | Repo-type presets and reusable flow shapes (JSON). |
    | `references/` | Schema, playbook, design system, UX principles. |
    | `test/` | `node:test` suites, including brand guardrails. |
    
    ## Pull requests
    
    - Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
    - One logical change per PR. In the description, explain **why**.
    - Add or update tests for behaviour changes.
    - **Visual changes:** include a before/after screenshot, and check that it still feels right at 375px wide with reduced motion on.
    - **Design-system changes** (tokens, radii, colors, motion) must update `references/design-system.md` and `test/brand.test.mjs` in the same PR.
    - **New presets or patterns** must pass `test/scan.test.mjs`, which checks they are internally consistent.
    
    ## Writing style
    
    Docs and UI copy follow the same rules as explainers: gist first, short sentences, plain words, no dashes as punctuation, no filler.
    
  • docs/media/changes.png105 KBa7d2360faf55upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/media/data.png51 KBf2e5179b9da7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/media/demo.gif731 KB53aeb3456a75upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/media/flow.png103 KB0c0ca68dc909upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/media/hero.png98 KB6f0027e04a10upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/media/map.png112 KBb17a8d732afeupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/media/quiz.png73 KB7da3333a2885upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • docs/media/social-card.png47 KB2a52afb13b60upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/gallery.html106 KBb7f8ae740dd9upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/taskflow.explainer.json18 KB61d5fd81cd5cupstream ↗
    {
      "$schema": "../references/data-schema.md",
      "meta": {
        "title": "Taskflow, explained",
        "repo": "acme/taskflow",
        "summary": "A team to-do app. A Next.js web app talks to a small API, which stores tasks in Postgres and hands slow work (emails, webhooks) to a background worker.",
        "stack": [
          "Next.js 15",
          "TypeScript",
          "Postgres",
          "Prisma",
          "BullMQ",
          "Redis",
          "Stripe"
        ],
        "preset": "fullstack-web",
        "generatedAt": "2026-10-25",
        "commit": "9f3c2ab41e"
      },
      "layers": [
        {
          "id": "client",
          "label": "Client",
          "color": "blue"
        },
        {
          "id": "edge",
          "label": "Edge & API",
          "color": "green"
        },
        {
          "id": "domain",
          "label": "Domain",
          "color": "navy"
        },
        {
          "id": "infra",
          "label": "Data & infra",
          "color": "yellow"
        },
        {
          "id": "external",
          "label": "Outside world",
          "color": "pink"
        }
      ],
      "nodes": [
        {
          "id": "user",
          "label": "Team member",
          "layer": "client",
          "kind": "user",
          "summary": "Someone using Taskflow in their browser.",
          "files": []
        },
        {
          "id": "web",
          "label": "Web app",
          "layer": "client",
          "kind": "ui",
          "tech": "Next.js App Router",
          "summary": "Pages and React components. Server components fetch data; client components handle forms and optimistic updates.",
          "files": [
            "apps/web/app/",
            "apps/web/components/"
          ]
        },
        {
          "id": "middleware",
          "label": "Auth middleware",
          "layer": "edge",
          "kind": "auth",
          "tech": "Next middleware + JWT",
          "summary": "Runs before every request. Reads the session cookie, rejects anonymous users, and puts the user id on the request.",
          "files": [
            "apps/web/middleware.ts"
          ]
        },
        {
          "id": "api",
          "label": "Tasks API",
          "layer": "edge",
          "kind": "api",
          "tech": "Route handlers",
          "summary": "Thin HTTP layer. Parses and validates input with Zod, then calls the task service. Never touches the database directly.",
          "files": [
            "apps/web/app/api/tasks/route.ts"
          ]
        },
        {
          "id": "service",
          "label": "Task service",
          "layer": "domain",
          "kind": "service",
          "tech": "Plain TypeScript",
          "summary": "All business rules live here: who can edit a task, what happens when it is completed, which events fire.",
          "files": [
            "packages/core/src/tasks/service.ts"
          ]
        },
        {
          "id": "billing",
          "label": "Billing service",
          "layer": "domain",
          "kind": "service",
          "tech": "Stripe SDK",
          "summary": "Checks plan limits (free teams get 50 open tasks) and syncs subscription state from Stripe webhooks.",
          "files": [
            "packages/core/src/billing/"
          ]
        },
        {
          "id": "db",
          "label": "Postgres",
          "layer": "infra",
          "kind": "db",
          "tech": "Prisma ORM",
          "summary": "The source of truth for teams, users and tasks.",
          "files": [
            "packages/db/prisma/schema.prisma"
          ]
        },
        {
          "id": "queue",
          "label": "Job queue",
          "layer": "infra",
          "kind": "queue",
          "tech": "BullMQ on Redis",
          "summary": "Holds slow work so requests stay fast. Each job is retried 5 times with backoff.",
          "files": [
            "packages/core/src/queue.ts"
          ]
        },
        {
          "id": "worker",
          "label": "Worker",
          "layer": "infra",
          "kind": "job",
          "tech": "Node process",
          "summary": "Pulls jobs off the queue: sends emails, fires customer webhooks, rolls up daily stats.",
          "files": [
            "apps/worker/src/index.ts"
          ]
        },
        {
          "id": "email",
          "label": "Email provider",
          "layer": "external",
          "kind": "external",
          "tech": "Resend",
          "summary": "Delivers notification emails.",
          "files": [
            "apps/worker/src/jobs/send-email.ts"
          ]
        },
        {
          "id": "stripe",
          "label": "Stripe",
          "layer": "external",
          "kind": "external",
          "summary": "Payments and subscriptions.",
          "files": [
            "apps/web/app/api/stripe/webhook/route.ts"
          ]
        }
      ],
      "edges": [
        {
          "from": "user",
          "to": "web",
          "label": "clicks"
        },
        {
          "from": "web",
          "to": "middleware",
          "label": "fetch /api/*"
        },
        {
          "from": "middleware",
          "to": "api",
          "label": "user id"
        },
        {
          "from": "api",
          "to": "service",
          "label": "createTask()"
        },
        {
          "from": "service",
          "to": "billing",
          "label": "checkLimit()"
        },
        {
          "from": "service",
          "to": "db",
          "label": "insert",
          "kind": "data"
        },
        {
          "from": "service",
          "to": "queue",
          "label": "enqueue",
          "kind": "async"
        },
        {
          "from": "queue",
          "to": "worker",
          "label": "job",
          "kind": "async"
        },
        {
          "from": "worker",
          "to": "email",
          "label": "send"
        },
        {
          "from": "stripe",
          "to": "billing",
          "label": "webhook",
          "kind": "async"
        }
      ],
      "flows": [
        {
          "id": "create-task",
          "title": "Create a task",
          "trigger": "User presses “Add task”",
          "summary": "The most common write in the app. Follow it from the click to the email the assignee receives.",
          "steps": [
            {
              "from": "user",
              "to": "web",
              "label": "Types a title, presses Add",
              "kind": "request",
              "detail": "The form is a client component. It shows the task instantly (optimistic update) before the server answers.",
              "file": "apps/web/components/new-task-form.tsx:24"
            },
            {
              "from": "web",
              "to": "api",
              "label": "POST /api/tasks",
              "kind": "request",
              "detail": "A normal fetch with the session cookie attached. The auth middleware runs first: no valid session means a 401 and the request stops.",
              "file": "apps/web/components/new-task-form.tsx:41",
              "code": "await fetch('/api/tasks', {\n  method: 'POST',\n  body: JSON.stringify({ title, assigneeId }),\n})",
              "lang": "ts",
              "why": "Doing auth once in middleware means no route can forget to check it."
            },
            {
              "from": "api",
              "to": "service",
              "label": "Validates, calls createTask()",
              "kind": "request",
              "detail": "Zod rejects bad input with a 422 before any business logic runs.",
              "file": "apps/web/app/api/tasks/route.ts:18",
              "code": "const input = CreateTask.parse(await req.json())\nconst task = await tasks.createTask(userId, input)\nreturn Response.json(task, { status: 201 })",
              "lang": "ts"
            },
            {
              "from": "service",
              "to": "service",
              "label": "Checks the plan limit",
              "kind": "internal",
              "detail": "Free teams can have 50 open tasks. Over the limit, billing.checkLimit() throws and the API answers 402.",
              "file": "packages/core/src/billing/limits.ts:9"
            },
            {
              "from": "service",
              "to": "db",
              "label": "INSERT the task",
              "kind": "data",
              "detail": "Inside a transaction together with an activity-log row, so the history can never miss a task.",
              "file": "packages/core/src/tasks/service.ts:57"
            },
            {
              "from": "service",
              "to": "queue",
              "label": "Enqueue “task.assigned”",
              "kind": "async",
              "detail": "Only if someone else was assigned. The request does not wait for the email.",
              "why": "Email providers can take seconds. Queuing keeps the click under 100 ms.",
              "file": "packages/core/src/tasks/service.ts:71"
            },
            {
              "from": "api",
              "to": "web",
              "label": "201 Created",
              "kind": "response",
              "detail": "The real task replaces the optimistic one. If anything failed, the form rolls back and shows the error.",
              "file": "apps/web/app/api/tasks/route.ts:20"
            },
            {
              "from": "queue",
              "to": "worker",
              "label": "Worker sends the email",
              "kind": "async",
              "detail": "Usually within a second. Failed jobs retry with backoff, then the assignee gets the email.",
              "file": "apps/worker/src/index.ts:15"
            }
          ]
        },
        {
          "id": "upgrade",
          "title": "Upgrade to Pro",
          "trigger": "Stripe sends a webhook",
          "summary": "Payment happens on Stripe’s page. We only learn about it through a webhook.",
          "steps": [
            {
              "from": "stripe",
              "to": "billing",
              "label": "checkout.session.completed",
              "kind": "async",
              "detail": "The webhook route checks the Stripe signature first, then hands the event to the billing service.",
              "why": "Without the signature check, anyone could POST a fake ‘paid’ event.",
              "file": "apps/web/app/api/stripe/webhook/route.ts:10"
            },
            {
              "from": "billing",
              "to": "db",
              "label": "Set team.plan = 'pro'",
              "kind": "data",
              "detail": "Idempotent: replaying the same event changes nothing.",
              "file": "packages/core/src/billing/sync.ts:22"
            },
            {
              "from": "billing",
              "to": "queue",
              "label": "Enqueue welcome email",
              "kind": "async",
              "file": "packages/core/src/billing/sync.ts:30"
            },
            {
              "from": "queue",
              "to": "worker",
              "label": "Job runs",
              "kind": "async",
              "file": "apps/worker/src/index.ts:15"
            },
            {
              "from": "worker",
              "to": "email",
              "label": "“Welcome to Pro”",
              "kind": "request",
              "file": "apps/worker/src/jobs/send-email.ts:8"
            }
          ]
        }
      ],
      "changes": [
        {
          "id": "pr-482",
          "title": "Move task emails onto the queue",
          "date": "2026-10-24",
          "author": "claude-agent",
          "authorType": "agent",
          "ref": "#482",
          "kind": "perf",
          "what": "createTask() used to call Resend inline. Now it enqueues a job and the worker sends it.",
          "why": "p95 for POST /api/tasks was 1.8 s because of email latency. It is now 90 ms.",
          "risk": "Emails can arrive a few seconds late; retries may duplicate an email if the provider times out after sending.",
          "nodes": [
            "service",
            "queue",
            "worker"
          ],
          "files": [
            "packages/core/src/tasks/service.ts",
            "apps/worker/src/jobs/send-email.ts"
          ]
        },
        {
          "id": "pr-479",
          "title": "Enforce the 50-task limit on the free plan",
          "date": "2026-10-24",
          "author": "maria",
          "authorType": "human",
          "ref": "#479",
          "kind": "feat",
          "what": "New billing.checkLimit() call before every insert, plus a 402 response and an upgrade banner.",
          "why": "Pricing change agreed for Q4: free stays useful for small teams, heavy users upgrade.",
          "nodes": [
            "billing",
            "service",
            "api",
            "web"
          ],
          "files": [
            "packages/core/src/billing/limits.ts"
          ]
        },
        {
          "id": "pr-475",
          "title": "Verify Stripe webhook signatures",
          "date": "2026-10-23",
          "author": "security-agent",
          "authorType": "agent",
          "ref": "#475",
          "kind": "security",
          "what": "The webhook route now rejects any request whose Stripe-Signature header does not verify.",
          "why": "An audit found the endpoint trusted any POST. Anyone could have upgraded a team for free.",
          "nodes": [
            "stripe",
            "billing"
          ],
          "files": [
            "apps/web/app/api/stripe/webhook/route.ts"
          ]
        },
        {
          "id": "pr-471",
          "title": "Split auth checks out of route handlers",
          "date": "2026-10-22",
          "author": "dev-agent-2",
          "authorType": "agent",
          "ref": "#471",
          "kind": "refactor",
          "what": "Twelve copies of the session check were replaced by one middleware.",
          "why": "Two new routes had shipped without auth. One place to check means none can forget.",
          "nodes": [
            "middleware",
            "api"
          ],
          "files": [
            "apps/web/middleware.ts"
          ]
        }
      ],
      "tree": [
        {
          "path": "apps/",
          "note": "Things you can run"
        },
        {
          "path": "apps/web/",
          "note": "The Next.js app: pages, components and API routes",
          "key": true
        },
        {
          "path": "apps/web/app/api/",
          "note": "HTTP endpoints. Thin: validate, then call packages/core"
        },
        {
          "path": "apps/web/middleware.ts",
          "note": "Auth for every request"
        },
        {
          "path": "apps/worker/",
          "note": "Background job runner (emails, webhooks, stats)"
        },
        {
          "path": "packages/",
          "note": "Shared code, no HTTP in here"
        },
        {
          "path": "packages/core/",
          "note": "Business rules. Start here to change behaviour",
          "key": true
        },
        {
          "path": "packages/db/",
          "note": "Prisma schema and migrations"
        }
      ],
      "dataModel": [
        {
          "name": "Team",
          "summary": "A workspace. Billing lives here.",
          "fields": [
            {
              "name": "id",
              "type": "uuid"
            },
            {
              "name": "name",
              "type": "text"
            },
            {
              "name": "plan",
              "type": "'free' | 'pro'",
              "note": "Synced from Stripe"
            },
            {
              "name": "stripeCustomerId",
              "type": "text?"
            }
          ],
          "relations": [
            {
              "to": "User",
              "kind": "has many"
            },
            {
              "to": "Task",
              "kind": "has many"
            }
          ]
        },
        {
          "name": "User",
          "fields": [
            {
              "name": "id",
              "type": "uuid"
            },
            {
              "name": "email",
              "type": "text",
              "note": "Unique"
            },
            {
              "name": "teamId",
              "type": "uuid"
            }
          ],
          "relations": [
            {
              "to": "Team",
              "kind": "belongs to"
            }
          ]
        },
        {
          "name": "Task",
          "summary": "The thing people actually care about.",
          "fields": [
            {
              "name": "id",
              "type": "uuid"
            },
            {
              "name": "title",
              "type": "text"
            },
            {
              "name": "status",
              "type": "'open' | 'done'"
            },
            {
              "name": "assigneeId",
              "type": "uuid?",
              "ref": "User"
            },
            {
              "name": "completedAt",
              "type": "timestamptz?",
              "note": "Set by the service, never by the client"
            }
          ],
          "relations": [
            {
              "to": "Team",
              "kind": "belongs to"
            },
            {
              "to": "User",
              "kind": "assigned to"
            }
          ]
        }
      ],
      "concepts": [
        {
          "title": "Routes are thin, the service is thick",
          "body": "API routes only parse input and shape the response. Every rule lives in packages/core: limits, permissions, side effects.",
          "analogy": "Routes are the waiter: they take the order and bring the plate. The service is the kitchen.",
          "file": "packages/core/src/tasks/service.ts"
        },
        {
          "title": "Slow things go on the queue",
          "body": "If something talks to the outside world and the user does not need the result right now, it becomes a job.",
          "analogy": "Like dropping a letter in a postbox instead of walking it to the recipient.",
          "code": "await queue.add('task.assigned', { taskId: task.id })",
          "lang": "ts"
        },
        {
          "title": "Optimistic UI",
          "body": "The web app shows your change before the server confirms it, then quietly fixes things up if the server disagrees.",
          "file": "apps/web/components/new-task-form.tsx"
        }
      ],
      "quiz": [
        {
          "question": "You need to add a rule: “archived teams can’t create tasks”. Where does it go?",
          "options": [
            "apps/web/app/api/tasks/route.ts",
            "packages/core/src/tasks/service.ts",
            "apps/web/middleware.ts",
            "The Prisma schema"
          ],
          "answer": 1,
          "explain": "Business rules belong in the service, so every caller gets them: API, worker and scripts."
        },
        {
          "question": "Why doesn’t creating a task wait for the email to send?",
          "options": [
            "Emails are sent by the browser",
            "The email is queued and the worker sends it later",
            "Emails are only sent on Pro plans"
          ],
          "answer": 1,
          "explain": "PR #482 moved emails onto the queue, which cut p95 from 1.8 s to 90 ms."
        },
        {
          "question": "What stops someone faking a Stripe ‘payment succeeded’ event?",
          "options": [
            "The auth middleware",
            "Rate limiting",
            "Webhook signature verification"
          ],
          "answer": 2,
          "explain": "Added in #475 after an audit. The middleware doesn’t run for Stripe, because Stripe has no session cookie."
        }
      ],
      "glossary": [
        {
          "term": "Optimistic update",
          "definition": "Showing a change in the UI before the server confirms it."
        },
        {
          "term": "Job",
          "definition": "A unit of background work stored in Redis and run by the worker."
        },
        {
          "term": "Plan limit",
          "definition": "The 50-open-task cap on free teams, enforced in billing/limits.ts."
        },
        {
          "term": "Idempotent",
          "definition": "Safe to run twice: the second run changes nothing."
        }
      ],
      "runbook": [
        {
          "label": "Install",
          "command": "pnpm install"
        },
        {
          "label": "Start Postgres and Redis",
          "command": "docker compose up -d db redis",
          "note": "Needs Docker Desktop running."
        },
        {
          "label": "Run the app and the worker",
          "command": "pnpm dev",
          "note": "Web on :3000, worker logs in the same terminal."
        }
      ]
    }
    
  • examples/taskflow.html112 KB889a4d8a5aeaupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • index.html1.1 KB4a611dc3a0caupstream ↗
    <!doctype html>
    <html lang="en">
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Repo Explainer</title>
    <meta name="description" content="Turn any codebase into a guided, interactive tour: architecture map, end-to-end flows, and what changed and why.">
    <meta property="og:title" content="Repo Explainer">
    <meta property="og:description" content="Turn any codebase into a guided, interactive tour: architecture map, end-to-end flows, and what changed and why.">
    <meta property="og:image" content="https://cyvid7-darus10.github.io/repo-explainer/docs/media/social-card.png">
    <meta name="twitter:card" content="summary_large_image">
    <meta http-equiv="refresh" content="0; url=examples/taskflow.html">
    <link rel="canonical" href="https://cyvid7-darus10.github.io/repo-explainer/examples/taskflow.html">
    <style>body{margin:0;font-family:system-ui,sans-serif;background:#fff;color:#4b4b4b;display:grid;place-items:center;min-height:100vh}a{color:#1cb0f6;font-weight:700}</style>
    </head>
    <body>
    <p>Opening the <a href="examples/taskflow.html">live demo</a>…</p>
    </body>
    </html>
    
  • LICENSE11 KB57bc88a15a0eupstream ↗
                                     Apache License
                               Version 2.0, January 2004
                            http://www.apache.org/licenses/
    
       TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    
       1. Definitions.
    
          "License" shall mean the terms and conditions for use, reproduction,
          and distribution as defined by Sections 1 through 9 of this document.
    
          "Licensor" shall mean the copyright owner or entity authorized by
          the copyright owner that is granting the License.
    
          "Legal Entity" shall mean the union of the acting entity and all
          other entities that control, are controlled by, or are under common
          control with that entity. For the purposes of this definition,
          "control" means (i) the power, direct or indirect, to cause the
          direction or management of such entity, whether by contract or
          otherwise, or (ii) ownership of fifty percent (50%) or more of the
          outstanding shares, or (iii) beneficial ownership of such entity.
    
          "You" (or "Your") shall mean an individual or Legal Entity
          exercising permissions granted by this License.
    
          "Source" form shall mean the preferred form for making modifications,
          including but not limited to software source code, documentation
          source, and configuration files.
    
          "Object" form shall mean any form resulting from mechanical
          transformation or translation of a Source form, including but
          not limited to compiled object code, generated documentation,
          and conversions to other media types.
    
          "Work" shall mean the work of authorship, whether in Source or
          Object form, made available under the License, as indicated by a
          copyright notice that is included in or attached to the work
          (an example is provided in the Appendix below).
    
          "Derivative Works" shall mean any work, whether in Source or Object
          form, that is based on (or derived from) the Work and for which the
          editorial revisions, annotations, elaborations, or other modifications
          represent, as a whole, an original work of authorship. For the purposes
          of this License, Derivative Works shall not include works that remain
          separable from, or merely link (or bind by name) to the interfaces of,
          the Work and Derivative Works thereof.
    
          "Contribution" shall mean any work of authorship, including
          the original version of the Work and any modifications or additions
          to that Work or Derivative Works thereof, that is intentionally
          submitted to Licensor for inclusion in the Work by the copyright owner
          or by an individual or Legal Entity authorized to submit on behalf of
          the copyright owner. For the purposes of this definition, "submitted"
          means any form of electronic, verbal, or written communication sent
          to the Licensor or its representatives, including but not limited to
          communication on electronic mailing lists, source code control systems,
          and issue tracking systems that are managed by, or on behalf of, the
          Licensor for the purpose of discussing and improving the Work, but
          excluding communication that is conspicuously marked or otherwise
          designated in writing by the copyright owner as "Not a Contribution."
    
          "Contributor" shall mean Licensor and any individual or Legal Entity
          on behalf of whom a Contribution has been received by Licensor and
          subsequently incorporated within the Work.
    
       2. Grant of Copyright License. Subject to the terms and conditions of
          this License, each Contributor hereby grants to You a perpetual,
          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
          copyright license to reproduce, prepare Derivative Works of,
          publicly display, publicly perform, sublicense, and distribute the
          Work and such Derivative Works in Source or Object form.
    
       3. Grant of Patent License. Subject to the terms and conditions of
          this License, each Contributor hereby grants to You a perpetual,
          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
          (except as stated in this section) patent license to make, have made,
          use, offer to sell, sell, import, and otherwise transfer the Work,
          where such license applies only to those patent claims licensable
          by such Contributor that are necessarily infringed by their
          Contribution(s) alone or by combination of their Contribution(s)
          with the Work to which such Contribution(s) was submitted. If You
          institute patent litigation against any entity (including a
          cross-claim or counterclaim in a lawsuit) alleging that the Work
          or a Contribution incorporated within the Work constitutes direct
          or contributory patent infringement, then any patent licenses
          granted to You under this License for that Work shall terminate
          as of the date such litigation is filed.
    
       4. Redistribution. You may reproduce and distribute copies of the
          Work or Derivative Works thereof in any medium, with or without
          modifications, and in Source or Object form, provided that You
          meet the following conditions:
    
          (a) You must give any other recipients of the Work or
              Derivative Works a copy of this License; and
    
          (b) You must cause any modified files to carry prominent notices
              stating that You changed the files; and
    
          (c) You must retain, in the Source form of any Derivative Works
              that You distribute, all copyright, patent, trademark, and
              attribution notices from the Source form of the Work,
              excluding those notices that do not pertain to any part of
              the Derivative Works; and
    
          (d) If the Work includes a "NOTICE" text file as part of its
              distribution, then any Derivative Works that You distribute must
              include a readable copy of the attribution notices contained
              within such NOTICE file, excluding those notices that do not
              pertain to any part of the Derivative Works, in at least one
              of the following places: within a NOTICE text file distributed
              as part of the Derivative Works; within the Source form or
              documentation, if provided along with the Derivative Works; or,
              within a display generated by the Derivative Works, if and
              wherever such third-party notices normally appear. The contents
              of the NOTICE file are for informational purposes only and
              do not modify the License. You may add Your own attribution
              notices within Derivative Works that You distribute, alongside
              or as an addendum to the NOTICE text from the Work, provided
              that such additional attribution notices cannot be construed
              as modifying the License.
    
          You may add Your own copyright statement to Your modifications and
          may provide additional or different license terms and conditions
          for use, reproduction, or distribution of Your modifications, or
          for any such Derivative Works as a whole, provided Your use,
          reproduction, and distribution of the Work otherwise complies with
          the conditions stated in this License.
    
       5. Submission of Contributions. Unless You explicitly state otherwise,
          any Contribution intentionally submitted for inclusion in the Work
          by You to the Licensor shall be under the terms and conditions of
          this License, without any additional terms or conditions.
          Notwithstanding the above, nothing herein shall supersede or modify
          the terms of any separate license agreement you may have executed
          with Licensor regarding such Contributions.
    
       6. Trademarks. This License does not grant permission to use the trade
          names, trademarks, service marks, or product names of the Licensor,
          except as required for reasonable and customary use in describing the
          origin of the Work and reproducing the content of the NOTICE file.
    
       7. Disclaimer of Warranty. Unless required by applicable law or
          agreed to in writing, Licensor provides the Work (and each
          Contributor provides its Contributions) on an "AS IS" BASIS,
          WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
          implied, including, without limitation, any warranties or conditions
          of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
          PARTICULAR PURPOSE. You are solely responsible for determining the
          appropriateness of using or redistributing the Work and assume any
          risks associated with Your exercise of permissions under this License.
    
       8. Limitation of Liability. In no event and under no legal theory,
          whether in tort (including negligence), contract, or otherwise,
          unless required by applicable law (such as deliberate and grossly
          negligent acts) or agreed to in writing, shall any Contributor be
          liable to You for damages, including any direct, indirect, special,
          incidental, or consequential damages of any character arising as a
          result of this License or out of the use or inability to use the
          Work (including but not limited to damages for loss of goodwill,
          work stoppage, computer failure or malfunction, or any and all
          other commercial damages or losses), even if such Contributor
          has been advised of the possibility of such damages.
    
       9. Accepting Warranty or Additional Liability. While redistributing
          the Work or Derivative Works thereof, You may choose to offer,
          and charge a fee for, acceptance of support, warranty, indemnity,
          or other liability obligations and/or rights consistent with this
          License. However, in accepting such obligations, You may act only
          on Your own behalf and on Your sole responsibility, not on behalf
          of any other Contributor, and only if You agree to indemnify,
          defend, and hold each Contributor harmless for any liability
          incurred by, or claims asserted against, such Contributor by reason
          of your accepting any such warranty or additional liability.
    
       END OF TERMS AND CONDITIONS
    
       APPENDIX: How to apply the Apache License to your work.
    
          To apply the Apache License to your work, attach the following
          boilerplate notice, with the fields enclosed by brackets "[]"
          replaced with your own identifying information. (Don't include
          the brackets!)  The text should be enclosed in the appropriate
          comment syntax for the file format. We also recommend that a
          file or class name and description of purpose be included on the
          same "printed page" as the copyright notice for easier
          identification within third-party archives.
    
       Copyright [yyyy] [name of copyright owner]
    
       Licensed under the Apache License, Version 2.0 (the "License");
       you may not use this file except in compliance with the License.
       You may obtain a copy of the License at
    
           http://www.apache.org/licenses/LICENSE-2.0
    
       Unless required by applicable law or agreed to in writing, software
       distributed under the License is distributed on an "AS IS" BASIS,
       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
       See the License for the specific language governing permissions and
       limitations under the License.
    
    
  • NOTICE302 B1494190e251cupstream ↗
    Repo Explainer
    Copyright 2026 Cyrus David Pastelero
    
    This product includes software developed by Cyrus David Pastelero
    (https://github.com/Cyvid7-Darus10/repo-explainer).
    
    Under the Apache License 2.0, if you redistribute this software or a work based on it,
    you must include this NOTICE file with it.
    
  • package.json1.4 KB45bf0ac4292fupstream ↗
    {
      "name": "repo-explainer",
      "version": "0.1.0",
      "description": "An agent skill that turns any codebase into a guided, interactive HTML tour: architecture map, step-through end-to-end flows, and a timeline of what changed and why.",
      "type": "module",
      "bin": {
        "repo-explainer": "bin/repo-explainer.mjs"
      },
      "files": [
        "bin/",
        "scripts/lib/",
        "template/",
        "presets/",
        "patterns/",
        "assets/",
        "references/",
        "SKILL.md",
        "NOTICE"
      ],
      "scripts": {
        "test": "node --test test/*.test.mjs",
        "build:example": "node bin/repo-explainer.mjs build examples/taskflow.explainer.json",
        "build:gallery": "node bin/repo-explainer.mjs gallery --out examples/gallery.html",
        "build:assets": "node bin/repo-explainer.mjs assets --out assets",
        "check": "npm test && npm run build:example && npm run build:gallery"
      },
      "engines": {
        "node": ">=18.3"
      },
      "keywords": [
        "agent-skill",
        "claude-code",
        "codebase",
        "architecture",
        "onboarding",
        "documentation",
        "sequence-diagram",
        "developer-experience"
      ],
      "license": "Apache-2.0",
      "author": "Cyrus David Pastelero",
      "repository": {
        "type": "git",
        "url": "git+https://github.com/Cyvid7-Darus10/repo-explainer.git"
      },
      "bugs": {
        "url": "https://github.com/Cyvid7-Darus10/repo-explainer/issues"
      },
      "homepage": "https://github.com/Cyvid7-Darus10/repo-explainer#readme"
    }
    
  • patterns/async-job.json1.0 KB2c37a47c0777upstream ↗
    {
      "id": "async-job",
      "name": "Hand off to a background job",
      "use": "Slow or unreliable work moved out of the request.",
      "roles": [
        "service",
        "queue",
        "worker",
        "external"
      ],
      "steps": [
        {
          "from": "service",
          "to": "queue",
          "label": "Enqueue job",
          "kind": "async",
          "detail": "The request returns without waiting.",
          "why": "Keeps user-facing latency low."
        },
        {
          "from": "queue",
          "to": "worker",
          "label": "Deliver job",
          "kind": "async",
          "detail": "Note retry count and backoff."
        },
        {
          "from": "worker",
          "to": "external",
          "label": "Do the slow work",
          "kind": "request"
        },
        {
          "from": "worker",
          "to": "queue",
          "label": "Ack or retry",
          "kind": "async",
          "detail": "What happens after the last retry fails?"
        }
      ],
      "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
    }
    
  • patterns/auth-session.json1.3 KB290fe80687a3upstream ↗
    {
      "id": "auth-session",
      "name": "Sign in, then authenticated request",
      "use": "Login flows: credentials → session/token → later requests checked.",
      "roles": [
        "user",
        "web",
        "auth",
        "db"
      ],
      "steps": [
        {
          "from": "user",
          "to": "web",
          "label": "Submits credentials",
          "kind": "request"
        },
        {
          "from": "web",
          "to": "auth",
          "label": "POST /login",
          "kind": "request"
        },
        {
          "from": "auth",
          "to": "db",
          "label": "Look up user, verify hash",
          "kind": "data"
        },
        {
          "from": "auth",
          "to": "web",
          "label": "Set session cookie / token",
          "kind": "response",
          "detail": "Note cookie flags (HttpOnly, Secure, SameSite) or token storage.",
          "why": "Explain why this storage was chosen."
        },
        {
          "from": "web",
          "to": "auth",
          "label": "Later request carries session",
          "kind": "request"
        },
        {
          "from": "auth",
          "to": "auth",
          "label": "Verify and attach user",
          "kind": "internal",
          "detail": "Where this check lives decides whether a route can forget it."
        }
      ],
      "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
    }
    
  • patterns/cache-aside.json863 Be8f442c95722upstream ↗
    {
      "id": "cache-aside",
      "name": "Cache-aside read",
      "use": "Reads that check a cache before the database.",
      "roles": [
        "service",
        "cache",
        "db"
      ],
      "steps": [
        {
          "from": "service",
          "to": "cache",
          "label": "GET key",
          "kind": "request"
        },
        {
          "from": "cache",
          "to": "service",
          "label": "Miss",
          "kind": "response"
        },
        {
          "from": "service",
          "to": "db",
          "label": "Query source of truth",
          "kind": "data"
        },
        {
          "from": "service",
          "to": "cache",
          "label": "SET key with TTL",
          "kind": "request",
          "detail": "Note TTL and how the key is invalidated on writes."
        }
      ],
      "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
    }
    
  • patterns/cli-command.json1.1 KB5bfb8d9bdbdeupstream ↗
    {
      "id": "cli-command",
      "name": "CLI command",
      "use": "From argv to output and exit code.",
      "roles": [
        "caller",
        "entry",
        "core",
        "io"
      ],
      "steps": [
        {
          "from": "caller",
          "to": "entry",
          "label": "Runs the command",
          "kind": "request"
        },
        {
          "from": "entry",
          "to": "entry",
          "label": "Parse flags",
          "kind": "internal",
          "detail": "Name the arg parser; note defaults."
        },
        {
          "from": "entry",
          "to": "core",
          "label": "Call core function",
          "kind": "request"
        },
        {
          "from": "core",
          "to": "io",
          "label": "Read / write files or network",
          "kind": "data"
        },
        {
          "from": "core",
          "to": "entry",
          "label": "Result or error",
          "kind": "response"
        },
        {
          "from": "entry",
          "to": "caller",
          "label": "Print + exit code",
          "kind": "response",
          "detail": "Which exit codes mean what?"
        }
      ],
      "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
    }
    
  • patterns/fan-out-events.json839 Be4529bd0ba9aupstream ↗
    {
      "id": "fan-out-events",
      "name": "Publish an event, many consumers",
      "use": "Event bus / pub-sub / outbox.",
      "roles": [
        "service",
        "queue",
        "worker"
      ],
      "steps": [
        {
          "from": "service",
          "to": "queue",
          "label": "Publish event",
          "kind": "async",
          "detail": "Is it written in the same transaction (outbox)?"
        },
        {
          "from": "queue",
          "to": "worker",
          "label": "Consumer A handles it",
          "kind": "async"
        },
        {
          "from": "queue",
          "to": "worker",
          "label": "Consumer B handles it",
          "kind": "async",
          "detail": "Consumers must tolerate duplicates and reordering."
        }
      ],
      "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
    }
    
  • patterns/offline-sync.json902 Befbbfa359945upstream ↗
    {
      "id": "offline-sync",
      "name": "Offline write, later sync",
      "use": "Mobile/local-first apps.",
      "roles": [
        "user",
        "screens",
        "storage",
        "api"
      ],
      "steps": [
        {
          "from": "user",
          "to": "screens",
          "label": "Edits while offline",
          "kind": "request"
        },
        {
          "from": "screens",
          "to": "storage",
          "label": "Save locally + mark dirty",
          "kind": "data"
        },
        {
          "from": "storage",
          "to": "api",
          "label": "Sync when online",
          "kind": "async"
        },
        {
          "from": "api",
          "to": "storage",
          "label": "Merge result",
          "kind": "response",
          "detail": "Explain the conflict rule (last-write-wins, CRDT, server wins)."
        }
      ],
      "howToUse": "Copy steps into flows[].steps, rename each role to a real node id, replace labels with this repo's names, and add file:line to every step."
    }
    
  • patterns/optimistic-ui.json911 Be31ebfb266bfupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • patterns/page-load.json1.1 KB072271c30a25upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • patterns/request-response.json1.2 KB05cb19a9aa03upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • patterns/validation-error.json824 B0d89bcf985c1upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • patterns/webhook-in.json1.1 KBe2fd16a4260bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • presets/backend-api.json1.9 KB284ad76fb958upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • presets/frontend-spa.json1.9 KBb049ca171842upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • presets/fullstack-web.json2.0 KBdb00fb00235cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • presets/library-cli.json1.5 KBb729561fde03upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • presets/mobile-app.json1.7 KBf063428e9241upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • SECURITY.md844 B9ec4dd4172dfupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/explainer.html1.2 KB821a6fa84744upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/app.js7.2 KB0f020607d758upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/changes.js4.5 KB23d683cdd509upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/dom.js5.2 KB5de171325256upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/flow.js12 KBfc4e356c46f9upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/gallery.js5.7 KB206e48e85bc7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/icons.js6.4 KB94e11c952aa4upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/lessons.js4.0 KBc50d682b0f78upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/map.js8.1 KB7059edf692ecupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/models.js8.3 KB9ebf097a261bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/js/sections.js6.5 KB4a5173e98471upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • template/styles.css35 KB595269818f97upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • test/brand.test.mjs3.5 KB8c4d8599ec02upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • test/build.test.mjs3.1 KBc3115a61ac35upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • test/changes.test.mjs3.4 KBd1f098f495abupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • test/estimate.test.mjs1.9 KBfb2c0868606cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • test/scan.test.mjs3.0 KB76252e8f04daupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • test/skill.test.mjs1.2 KBdbcbca7548b3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • test/validate.test.mjs5.0 KB9a9a10c6e5c3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

Plain-text copy, no login: /skill-bundles/cyvid7-darus10-repo-explainer.md

// REPO STATS

3 stars