$sloprun.dev

Regex Tester

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A regular expression is a tiny search language, and most people learn it by guessing. This one opens mid-flight: a working email pattern chewing through a messy pile of sign-up notes, matches lit up in place, every captured piece named and listed, and the pattern itself translated back into plain English — so [a-z0-9._%+-]+ reads as "one or more of a–z, 0–9, or . _ % + -" instead of keyboard noise. Hover or tap a highlight to see what its groups caught, flip the i flag off to watch the shouty address drop out, or try the presets for dates, prices, repeated words and quoted text. Break the pattern on purpose too — it names what the engine choked on, counts your brackets, and keeps explaining every piece it can still read.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/regex-tester" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Regex Tester — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/regex-tester

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1571 lines 64.7 KB index.html
demos/regex-tester/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Regex Tester — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* ---- syntax chip palette (deliberately no green: green is earned) ---- */
:root {
  --c-group: #DCE7FA; --c-class: #EDE1FB; --c-quant: #FFE7CC;
  --c-anchor: #FBDFE4; --c-esc: #D8EDF6; --c-lit: #ECEBE6;
  --hl-a: rgba(192, 86, 0, .20); --hl-b: rgba(43, 92, 168, .18);
  --hl-a-edge: rgba(192, 86, 0, .55); --hl-b-edge: rgba(43, 92, 168, .5);
  --hl-on: rgba(192, 86, 0, .32);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --c-group: #1E3251; --c-class: #32254B; --c-quant: #4A3318;
    --c-anchor: #4A2530; --c-esc: #17384A; --c-lit: #2E333B;
    --hl-a: rgba(255, 180, 84, .26); --hl-b: rgba(122, 165, 255, .26);
    --hl-a-edge: rgba(255, 180, 84, .7); --hl-b-edge: rgba(122, 165, 255, .7);
    --hl-on: rgba(255, 180, 84, .40);
  }
}
:root[data-theme="dark"] {
  --c-group: #1E3251; --c-class: #32254B; --c-quant: #4A3318;
  --c-anchor: #4A2530; --c-esc: #17384A; --c-lit: #2E333B;
  --hl-a: rgba(255, 180, 84, .26); --hl-b: rgba(122, 165, 255, .26);
  --hl-a-edge: rgba(255, 180, 84, .7); --hl-b-edge: rgba(122, 165, 255, .7);
  --hl-on: rgba(255, 180, 84, .40);
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; max-width: 100%; overflow-x: hidden; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 22px 16px 40px; }

/* ---- header ---- */
header { margin-bottom: 16px; }
h1 { font-size: 1.35rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.lede { margin: 0 0 10px; color: var(--muted); max-width: 66ch; font-size: .95rem; }
.spec { display: flex; flex-wrap: wrap; gap: 6px; }
.spec b {
  font-family: var(--font-mono); font-size: .7rem; font-weight: 500; letter-spacing: .02em;
  padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px;
  color: var(--muted); background: var(--surface); white-space: nowrap;
}

/* ---- layout ---- */
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; align-items: start; }
@media (min-width: 940px) { .grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); } }
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 14px;
}
.card + .card { margin-top: 14px; }
.card h2 {
  font-family: var(--font-mono); font-size: .72rem; font-weight: 600; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 10px;
}
.card h2 .hint { text-transform: none; letter-spacing: 0; font-weight: 500; opacity: .8; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.spread { justify-content: space-between; }
.sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* ---- controls ---- */
button, input, textarea { font: inherit; color: inherit; }
.btn {
  font-family: var(--font-mono); font-size: .74rem; padding: 5px 9px;
  background: var(--surface); color: var(--muted);
  border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
}
.btn:hover { border-color: var(--accent); color: var(--ink); }
.btn[aria-pressed="true"] {
  background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600;
}
.btn.mini { font-size: .68rem; padding: 3px 7px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.label { font-family: var(--font-mono); font-size: .68rem; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted); }

/* ---- pattern field ---- */
.patrow {
  display: flex; align-items: stretch; gap: 0; background: var(--bg);
  border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
}
.patrow:focus-within { border-color: var(--accent); }
.patrow.bad { border-color: var(--danger); }
.patrow .slash {
  display: flex; align-items: center; padding: 0 6px; color: var(--muted);
  font-family: var(--font-mono); font-size: 1rem; user-select: none;
}
.patrow input {
  border: 0; background: transparent; padding: 9px 2px; min-width: 0;
  font-family: var(--font-mono); font-size: .88rem;
}
.patrow input:focus { outline: none; }
#pattern { flex: 1 1 auto; }
#flags { flex: 0 0 7ch; width: 7ch; color: var(--accent); }
.flagbar { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; align-items: center; }
.flagset { display: flex; flex-wrap: wrap; gap: 5px; flex: 1 1 auto; }
.flagbar .btn { min-width: 2.1rem; text-align: center; }
.flagbar .btn i { font-style: normal; opacity: .7; font-size: .66rem; margin-left: 3px; }

/* ---- ribbon (pattern rendered as chips) ---- */
.ribbon {
  display: flex; flex-wrap: wrap; align-items: center; gap: 3px;
  margin-top: 10px; padding: 8px; border: 1px dashed var(--line);
  border-radius: 8px; background: var(--bg); min-height: 40px;
}
.chip {
  font-family: var(--font-mono); font-size: .78rem; line-height: 1.3;
  padding: 3px 5px; border-radius: 5px; border: 1px solid transparent;
  background: var(--c-lit); color: var(--ink); cursor: pointer;
  white-space: pre-wrap; word-break: break-word; max-width: 100%;
}
.chip.k-group { background: var(--c-group); }
.chip.k-class { background: var(--c-class); }
.chip.k-quant { background: var(--c-quant); }
.chip.k-anchor { background: var(--c-anchor); }
.chip.k-esc { background: var(--c-esc); }
.chip.k-lit { background: var(--c-lit); }
.chip { text-align: left; -webkit-appearance: none; appearance: none; }
.chip.on, .chip:hover { border-color: var(--accent); }
.ribbon .delim { font-family: var(--font-mono); color: var(--muted); font-size: .82rem; padding: 0 2px; }
.ribbon .empty { font-family: var(--font-mono); font-size: .76rem; color: var(--muted); }

/* ---- error box ---- */
.err {
  margin-top: 10px; padding: 9px 10px; border-radius: 8px;
  border: 1px solid var(--danger); background: var(--surface);
}
.err .t { font-family: var(--font-mono); font-size: .74rem; color: var(--danger); font-weight: 600; }
.err .m { font-family: var(--font-mono); font-size: .78rem; margin-top: 3px; word-break: break-word; }
.err .h { font-size: .84rem; color: var(--muted); margin-top: 5px; }

/* ---- editor with highlight layer ---- */
.editor { position: relative; }
.editor textarea, .editor .layer {
  font-family: var(--font-mono); font-size: .84rem; line-height: 1.62;
  padding: 11px 12px; border: 1px solid var(--line); border-radius: 8px;
  white-space: pre-wrap; overflow-wrap: break-word; word-break: normal;
  letter-spacing: 0; tab-size: 2;
}
.editor textarea {
  display: block; width: 100%; height: 268px; resize: vertical;
  background: var(--bg); color: var(--ink); position: relative; z-index: 1;
  caret-color: var(--accent);
}
.editor textarea:focus { outline: none; border-color: var(--accent); }
.editor.focused .layer { border-color: transparent; }
.layer {
  position: absolute; inset: 0; z-index: 2; pointer-events: none;
  overflow: hidden; color: transparent; background: transparent;
  border-color: transparent; user-select: none;
}
.layer .inner { display: block; }
.layer mark {
  color: transparent; background: var(--hl-a); border-radius: 3px;
  box-shadow: inset 0 -1.5px 0 var(--hl-a-edge); pointer-events: auto; cursor: pointer;
}
.layer mark.alt { background: var(--hl-b); box-shadow: inset 0 -1.5px 0 var(--hl-b-edge); }
.layer mark.on { background: var(--hl-on); box-shadow: inset 0 0 0 1.5px var(--accent); }
.layer mark.zero {
  position: relative; display: inline-block; width: 0; background: none; box-shadow: none;
}
.layer mark.zero::after {
  content: ""; position: absolute; left: -1px; top: .08em; width: 2px; height: 1.05em;
  background: var(--accent); border-radius: 1px; opacity: .8;
}
.layer mark.zero.on::after { width: 3px; opacity: 1; }
/* the status strip is a sibling of .editor, not a child — keep this selector flat */
.meta {
  display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
  margin-top: 8px; font-family: var(--font-mono); font-size: .72rem; color: var(--muted);
}
.meta #hintline { flex: 1 1 12ch; min-width: 0; }
.pill {
  font-family: var(--font-mono); font-size: .72rem; padding: 3px 8px; border-radius: 999px;
  border: 1px solid var(--line); background: var(--bg); color: var(--muted); white-space: nowrap;
}
.pill.ok { color: var(--run); border-color: var(--run); background: var(--run-soft); font-weight: 600; }
.pill.bad { color: var(--danger); border-color: var(--danger); font-weight: 600; }
.pill.warn { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }

/* ---- tooltip ---- */
.tip {
  position: absolute; z-index: 5; max-width: min(300px, 88%); pointer-events: none;
  background: var(--surface); border: 1px solid var(--accent); border-radius: 8px;
  padding: 8px 9px; box-shadow: 0 6px 20px rgba(0,0,0,.14); font-size: .76rem;
}
.tip[hidden] { display: none; }
.tip .th { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); letter-spacing: .04em; }
.tip .tv { font-family: var(--font-mono); font-size: .78rem; word-break: break-word; margin-top: 2px; }
.tip .tg { margin-top: 6px; display: grid; gap: 3px; }
.tip .tg div { font-family: var(--font-mono); font-size: .72rem; word-break: break-word; }
.tip .tg b { color: var(--accent); font-weight: 600; }
.tip .tg span.none { color: var(--muted); font-style: italic; }

/* ---- match list ---- */
.mlist { list-style: none; margin: 0; padding: 0; max-height: 380px; overflow-y: auto; }
.mlist li { margin: 0 0 5px; }
.mrow {
  display: block; width: 100%; text-align: left; cursor: pointer;
  background: var(--bg); border: 1px solid var(--line); border-left: 3px solid var(--line);
  border-radius: 7px; padding: 7px 9px;
}
.mrow:hover { border-color: var(--accent); }
.mrow.on { background: var(--accent-soft); border-color: var(--accent); border-left-color: var(--accent); }
.mrow .top { display: flex; gap: 8px; align-items: baseline; justify-content: space-between; }
.mrow .idx { font-family: var(--font-mono); font-size: .7rem; color: var(--accent); font-weight: 600; }
.mrow .pos { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); white-space: nowrap; }
.mrow .txt {
  font-family: var(--font-mono); font-size: .8rem; margin-top: 2px;
  word-break: break-word; white-space: pre-wrap;
}
.mrow .gs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.mrow .g {
  font-family: var(--font-mono); font-size: .68rem; padding: 2px 5px; border-radius: 5px;
  background: var(--c-group); color: var(--ink); word-break: break-word; max-width: 100%;
}
.mrow .g b { font-weight: 600; opacity: .72; margin-right: 4px; }
.mrow .g.none { background: transparent; border: 1px dashed var(--line); color: var(--muted); }
.empty-note { font-size: .86rem; color: var(--muted); margin: 4px 0 0; }
.empty-note code { font-family: var(--font-mono); font-size: .8rem; }
.more { font-family: var(--font-mono); font-size: .7rem; color: var(--muted); padding: 4px 2px; }

/* ---- breakdown ---- */
.bk { list-style: none; margin: 0; padding: 0; max-height: 360px; overflow-y: auto; }
.bk li {
  display: flex; gap: 8px; align-items: flex-start; padding: 4px 6px;
  border-radius: 6px; margin-left: calc(var(--d, 0) * 13px);
  border-left: 2px solid transparent;
}
.bk li.nested { border-left-color: var(--line); }
.bk li.on { background: var(--accent-soft); }
.bk .tk {
  font-family: var(--font-mono); font-size: .78rem; padding: 2px 5px; border-radius: 5px;
  background: var(--c-lit); white-space: pre-wrap; word-break: break-word; flex: 0 0 auto;
  max-width: 45%;
}
.bk .tk.k-group { background: var(--c-group); }
.bk .tk.k-class { background: var(--c-class); }
.bk .tk.k-quant { background: var(--c-quant); }
.bk .tk.k-anchor { background: var(--c-anchor); }
.bk .tk.k-esc { background: var(--c-esc); }
.bk .bd { font-size: .84rem; color: var(--ink); }
.bk .bd em { color: var(--muted); font-style: normal; }

/* ---- replace ---- */
details.rep { margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); padding: 10px 14px; }
details.rep > summary {
  cursor: pointer; font-family: var(--font-mono); font-size: .72rem; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted); list-style: none;
}
details.rep > summary::-webkit-details-marker { display: none; }
details.rep > summary::before { content: "▸ "; color: var(--accent); }
details.rep[open] > summary::before { content: "▾ "; }
.repin {
  width: 100%; margin-top: 9px; background: var(--bg); border: 1px solid var(--line);
  border-radius: 8px; padding: 8px 10px; font-family: var(--font-mono); font-size: .82rem;
}
.repin:focus { outline: none; border-color: var(--accent); }
.repout {
  margin: 9px 0 4px; padding: 10px 12px; background: var(--bg); border: 1px solid var(--line);
  border-radius: 8px; font-family: var(--font-mono); font-size: .8rem; line-height: 1.6;
  white-space: pre-wrap; word-break: break-word; max-height: 190px; overflow: auto;
}
.repnote { font-size: .8rem; color: var(--muted); margin: 0; }
.repnote code { font-family: var(--font-mono); }

footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }

@media (prefers-reduced-motion: no-preference) {
  .btn, .chip, .mrow, .layer mark, .pill { transition: background-color .12s ease, border-color .12s ease, color .12s ease; }
  @keyframes flash { 0%, 100% { box-shadow: inset 0 0 0 1.5px var(--accent); } 50% { box-shadow: inset 0 0 0 3px var(--accent); } }
  .layer mark.flash { animation: flash .5s ease 2; }
}
@media (min-width: 940px) { .editor textarea { height: 330px; } }
@media (max-width: 420px) {
  .editor textarea { height: 230px; }
  .bk .tk { max-width: 100%; }
  .bk li { flex-wrap: wrap; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
  <h1>Regex Tester</h1>
  <p class="lede">A regular expression is a tiny search language. Type one below and watch it light up
    every place it matches — with the captured pieces pulled out and the whole pattern
    translated back into plain English, symbol by symbol.</p>
  <div class="spec">
    <b>live as you type</b>
    <b>your browser's own RegExp engine</b>
    <b>no network</b>
    <b id="specCount">7 matches</b>
  </div>
</header>

<div class="grid">
  <!-- ============ left column ============ -->
  <div>
    <section class="card">
      <div class="row spread" style="margin-bottom:10px">
        <h2 style="margin:0">Pattern</h2>
        <div class="row" style="gap:5px">
          <span class="label">try</span>
          <button class="btn mini" type="button" data-preset="email">emails</button>
          <button class="btn mini" type="button" data-preset="date">dates</button>
          <button class="btn mini" type="button" data-preset="price">prices</button>
          <button class="btn mini" type="button" data-preset="dupe">repeats</button>
          <button class="btn mini" type="button" data-preset="quote">quotes</button>
        </div>
      </div>

      <div class="patrow" id="patrow">
        <span class="slash" aria-hidden="true">/</span>
        <label class="sr" for="pattern">Regular expression pattern</label>
        <input id="pattern" type="text" spellcheck="false" autocapitalize="off" autocorrect="off"
               autocomplete="off" aria-describedby="errbox">
        <span class="slash" aria-hidden="true">/</span>
        <label class="sr" for="flags">Flags</label>
        <input id="flags" type="text" spellcheck="false" autocapitalize="off" autocorrect="off"
               autocomplete="off" maxlength="8" aria-label="Flags">
      </div>

      <div class="flagbar" id="flagbar">
        <div class="flagset" role="group" aria-label="Flags">
          <button class="btn" type="button" data-flag="g" aria-pressed="false" title="global — find every match, not just the first">g<i>all</i></button>
          <button class="btn" type="button" data-flag="i" aria-pressed="false" title="ignore case — treat A and a as the same">i<i>case</i></button>
          <button class="btn" type="button" data-flag="m" aria-pressed="false" title="multiline — ^ and $ match at every line, not just the whole text">m<i>lines</i></button>
          <button class="btn" type="button" data-flag="s" aria-pressed="false" title="dot-all — let . match line breaks too">s<i>dot</i></button>
          <button class="btn" type="button" data-flag="u" aria-pressed="false" title="unicode — handle emoji and other characters as single units">u<i>uni</i></button>
          <button class="btn" type="button" data-flag="y" aria-pressed="false" title="sticky — each match must start exactly where the last one ended">y<i>stick</i></button>
        </div>
        <button class="btn mini" type="button" id="btnCopy" style="margin-left:auto"
                aria-label="Copy the pattern and flags">copy</button>
      </div>

      <div class="ribbon" id="ribbon" role="group" aria-label="The pattern, piece by piece"></div>

      <div class="err" id="errbox" hidden>
        <div class="t">PATTERN WON'T COMPILE</div>
        <div class="m" id="errmsg"></div>
        <div class="h" id="errhint"></div>
      </div>
    </section>

    <section class="card">
      <div class="row spread" style="margin-bottom:10px">
        <h2 style="margin:0">Test text</h2>
        <button class="btn mini" type="button" id="btnReset">reset text</button>
      </div>
      <div class="editor" id="editor">
        <label class="sr" for="text">Text to search</label>
        <textarea id="text" spellcheck="false" autocapitalize="off" autocorrect="off"></textarea>
        <div class="layer" id="layer" aria-hidden="true"><span class="inner" id="inner"></span></div>
        <div class="tip" id="tip" hidden></div>
      </div>
      <div class="meta">
        <span class="pill" id="statusPill">ready</span>
        <span class="pill warn" id="riskPill" hidden>nested repeat</span>
        <span id="timing">0.0 ms</span>
        <span id="hintline">hover or tap a highlight to see its groups</span>
      </div>
      <p class="sr" id="live" aria-live="polite"></p>
    </section>

    <details class="rep" id="repwrap">
      <summary>Replace preview</summary>
      <label class="sr" for="replace">Replacement text</label>
      <input class="repin" id="replace" type="text" spellcheck="false" autocapitalize="off"
             autocorrect="off" autocomplete="off">
      <div class="repout" id="repout"></div>
      <p class="repnote">Use <code>$1</code>, <code>$2</code> for numbered groups,
        <code>$&lt;name&gt;</code> for named ones, and <code>$&amp;</code> for the whole match.</p>
    </details>
  </div>

  <!-- ============ right column ============ -->
  <div>
    <section class="card">
      <h2>Matches <span class="hint" id="mcount"></span></h2>
      <ul class="mlist" id="mlist"></ul>
      <p class="empty-note" id="mempty" hidden></p>
    </section>

    <section class="card">
      <h2>What this pattern says <span class="hint">— piece by piece</span></h2>
      <ul class="bk" id="bk"></ul>
    </section>
  </div>
</div>

<footer>demo · sloprun.dev</footer>
</div>

<script>
(function () {
  "use strict";

  var $ = function (id) { return document.getElementById(id); };
  function attr(s) {
    return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
      .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  }
  function esc(s) {
    return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  }
  function humanList(a) {
    if (a.length === 0) return "nothing";
    if (a.length === 1) return a[0];
    return a.slice(0, -1).join(", ") + " or " + a[a.length - 1];
  }

  /* =====================================================================
     samples — everyday text, deliberately messy
     ===================================================================== */
  var SAMPLES = {
    email: "Book club sign-ups — please add anyone I missed.\n\nMaya Ortiz <[email protected]> is bringing the chairs.\nForwarded from: [email protected] (he checked the room twice).\[email protected], [email protected]; [email protected]\n\nNot addresses: \"@nobody\", plain-text-at-example, or 100 @ 3pm.\nOne old note says  jules AT mapletree DOT example  — fix that by hand.\nThe reply-to came back as [email protected] (shouting, sorry).\nEveryone else reads the shared inbox: [email protected].\n\nPhone list lives somewhere else: 555-0134, 555-0182.",
    date: "Delivery notes\n--------------\nOrder 4471 went out 3/2/2026, promised by 14/03/2026 (they write day first).\nThe depot is shut 12/25/25, so nothing moves that week.\nBackup slots: 1/1/26 and 7/4/2026.\nNot dates: 3 / 2 / 2026 with spaces, the fraction 22/7, or version 2/3.",
    price: "Bake sale — end of day tally\n  12 loaves ..................... $54.00\n  jam jars, small ............... $7.50\n  jam jars, large ............... $1,250 (bulk order, paid later)\n  napkins and cups .............. $12\n  refund to Priya ............... -$7.50\nTotal banked: $1,316.00 — the cash box started with $40.\nA lone $ sign, or $ 20 written with a space, should not count.",
    dupe: "Proofreading pass, round two\n\nThe the recipe wants two cups of flour, then then a pinch of salt.\nShe said she she would bring the the big pot on Sunday.\nCareful: \"had had\" is fine in \"he had had enough\", and \"that that\"\ncan be correct too — a machine cannot tell, you have to read it.\nNothing repeats on on this line? It does.",
    quote: "Notes from the tasting\n\nShe called it \"a bit too sweet\" and then \"actually, perfect\".\nThe label says \"small batch\" but the box says \"made in a big shed\".\nTip: change .*? to .* in the pattern and watch one match swallow\nthe whole line — that is the difference between lazy and greedy."
  };

  var PRESETS = {
    email: {
      pattern: "(?<user>[a-z0-9._%+-]+)@(?<domain>[a-z0-9.-]+\\.[a-z]{2,})",
      flags: "gi", replace: "$<user> at $<domain>", text: SAMPLES.email
    },
    date: {
      pattern: "\\b(?<d>\\d{1,2})/(?<m>\\d{1,2})/(?<y>\\d{2,4})\\b",
      flags: "g", replace: "[$<y>-$<m>-$<d>]", text: SAMPLES.date
    },
    price: {
      pattern: "\\$(?<amount>\\d{1,3}(?:,\\d{3})*(?:\\.\\d{2})?)",
      flags: "g", replace: "$<amount> pounds", text: SAMPLES.price
    },
    dupe: {
      pattern: "\\b(?<word>\\w+)\\s+\\k<word>\\b",
      flags: "gi", replace: "$<word>", text: SAMPLES.dupe
    },
    quote: {
      pattern: "\"(?<said>.*?)\"",
      flags: "g", replace: "“$<said>”", text: SAMPLES.quote
    }
  };

  /* =====================================================================
     pattern parser — for the plain-English breakdown only.
     Deliberately forgiving: it still explains a pattern that will not compile.
     ===================================================================== */
  var ESC_WORDS = {
    d: ["k-esc", "Any digit, 0 through 9."],
    D: ["k-esc", "Any character that is not a digit."],
    w: ["k-esc", "One “word” character: a letter, a digit, or an underscore."],
    W: ["k-esc", "Anything that is not a letter, digit or underscore."],
    s: ["k-esc", "One space, tab, or line break."],
    S: ["k-esc", "Any character that is not a space, tab or line break."],
    b: ["k-anchor", "A word boundary — the seam between a word character and anything else. It matches a position, not a character."],
    B: ["k-anchor", "A spot that is NOT a word boundary — somewhere inside a word."],
    n: ["k-esc", "A line break (new line)."],
    r: ["k-esc", "A carriage return."],
    t: ["k-esc", "A tab character."],
    f: ["k-esc", "A form feed."],
    v: ["k-esc", "A vertical tab."],
    "0": ["k-esc", "A null character."]
  };

  function classPieces(body) {
    var named = [], singles = [], i = 0;
    while (i < body.length) {
      var ch = body.charAt(i);
      if (ch === "\\") {
        var nx = body.charAt(i + 1);
        if (nx === "") { i += 1; continue; }
        if (nx === "d") named.push("digits 0–9");
        else if (nx === "w") named.push("letters, digits or _");
        else if (nx === "s") named.push("whitespace");
        else if (nx === "D") named.push("anything but a digit");
        else if (nx === "W") named.push("anything but a letter, digit or _");
        else if (nx === "S") named.push("anything but whitespace");
        else if (nx === "n") named.push("a line break");
        else if (nx === "t") named.push("a tab");
        else if (nx === "r") named.push("a carriage return");
        else singles.push(nx);
        i += 2;
        continue;
      }
      if (body.charAt(i + 1) === "-" && i + 2 < body.length) {
        named.push(ch + "–" + body.charAt(i + 2));
        i += 3;
        continue;
      }
      singles.push(ch);
      i += 1;
    }
    var parts = named.slice();
    if (singles.length) {
      parts.push("the character" + (singles.length > 1 ? "s" : "") + " " + singles.join(" "));
    }
    return parts;
  }

  function describeClass(raw) {
    /* an unterminated class ("[abc") has no closing bracket to trim */
    var body = raw.charAt(raw.length - 1) === "]" && raw.length > 1 ? raw.slice(1, -1) : raw.slice(1);
    var neg = body.charAt(0) === "^";
    if (neg) body = body.slice(1);
    var parts = classPieces(body);
    if (!parts.length) {
      return neg
        ? "An empty “not this” set — it rules nothing out, so it matches any single character."
        : "An empty set — there is no character in it, so this can never match.";
    }
    if (neg) return "One character that is NOT " + humanList(parts) + ".";
    return "One character from this set: " + humanList(parts) + ".";
  }

  function quantWords(text) {
    var lazy = /\?$/.test(text) && text.length > 1;
    var core = lazy ? text.slice(0, -1) : text;
    var base;
    if (core === "*") base = "Zero or more of the piece just before it";
    else if (core === "+") base = "One or more of the piece just before it";
    else if (core === "?") base = "The piece just before it is optional — zero times or once";
    else {
      var m = /^\{(\d*)(,?)(\d*)\}$/.exec(core);
      if (!m) return "A repeat count.";
      if (!m[2]) base = "Exactly " + m[1] + " of the piece just before it";
      else if (!m[3]) base = "At least " + m[1] + " of the piece just before it, no upper limit";
      else base = "Between " + m[1] + " and " + m[3] + " of the piece just before it";
    }
    if (core === "?" && !lazy) return base + ".";
    if (lazy) return base + ", taking as few as it can get away with (lazy).";
    return base + ", grabbing as many as it can, then giving some back only if the rest of the pattern needs it (greedy).";
  }

  function isQuantStart(src, i) {
    var c = src.charAt(i);
    if (c === "*" || c === "+" || c === "?") return true;
    if (c === "{") return /^\{\d+(,\d*)?\}/.test(src.slice(i));
    return false;
  }

  function readQuant(src, i) {
    var c = src.charAt(i), end;
    if (c === "{") {
      var m = /^\{\d+(,\d*)?\}/.exec(src.slice(i));
      end = i + m[0].length;
    } else {
      end = i + 1;
    }
    if (src.charAt(end) === "?") end += 1;
    return end;
  }

  function parsePattern(src, flags) {
    var toks = [], i = 0, depth = 0, capN = 0, names = [];
    var multi = flags.indexOf("m") > -1, dotall = flags.indexOf("s") > -1;
    var n = src.length, guard = 0;

    function push(start, end, kind, desc, d) {
      toks.push({
        start: start, end: end, text: src.slice(start, end),
        kind: kind, desc: desc, depth: d === undefined ? depth : d
      });
    }

    while (i < n && guard++ < 4000) {
      var c = src.charAt(i), start = i, end;

      if (isQuantStart(src, i) && toks.length) {
        end = readQuant(src, i);
        push(start, end, "k-quant", quantWords(src.slice(start, end)));
        i = end;
        continue;
      }

      if (c === "\\") {
        var nx = src.charAt(i + 1);
        end = i + 2;
        if (nx === "u" && src.charAt(i + 2) === "{") {
          var close = src.indexOf("}", i + 3);
          end = close === -1 ? n : close + 1;
          push(start, end, "k-esc", "The character with this unicode number.");
        } else if (nx === "u") {
          end = Math.min(i + 6, n);
          push(start, end, "k-esc", "The character with this unicode number.");
        } else if (nx === "x") {
          end = Math.min(i + 4, n);
          push(start, end, "k-esc", "The character with this hex code.");
        } else if ((nx === "p" || nx === "P") && src.charAt(i + 2) === "{") {
          var pc = src.indexOf("}", i + 3);
          end = pc === -1 ? n : pc + 1;
          push(start, end, "k-esc", (nx === "p" ? "One character with this unicode property." : "One character WITHOUT this unicode property.") + " (Needs the u flag.)");
        } else if (nx === "k" && src.charAt(i + 2) === "<") {
          var kc = src.indexOf(">", i + 3);
          end = kc === -1 ? n : kc + 1;
          push(start, end, "k-group", "Back-reference: the exact text that the group named “" + src.slice(i + 3, end - 1) + "” already matched, repeated here.");
        } else if (/[1-9]/.test(nx)) {
          while (/[0-9]/.test(src.charAt(end))) end += 1;
          push(start, end, "k-group", "Back-reference: the exact text that capture group " + src.slice(i + 1, end) + " already matched, repeated here.");
        } else if (ESC_WORDS[nx]) {
          push(start, end, ESC_WORDS[nx][0], ESC_WORDS[nx][1]);
        } else if (nx === "") {
          end = i + 1;
          push(start, end, "k-lit", "A backslash with nothing after it — there is no character here for it to escape.");
        } else {
          push(start, end, "k-lit", "A literal " + nx + " character. The backslash strips its special meaning.");
        }
        i = end;
        continue;
      }

      if (c === "[") {
        /* JavaScript closes a class on the first "]" — even the very first one,
           which is why [] is an empty set and []] is that set plus a literal ]. */
        end = i + 1;
        if (src.charAt(end) === "^") end += 1;
        while (end < n && src.charAt(end) !== "]") {
          end += src.charAt(end) === "\\" ? 2 : 1;
        }
        end = Math.min(end + 1, n);
        push(start, end, "k-class", describeClass(src.slice(start, end)));
        i = end;
        continue;
      }

      if (c === "(") {
        var head = src.slice(i, i + 4);
        if (head.slice(0, 3) === "(?:") {
          push(i, i + 3, "k-group", "Start of a group that does not capture — it only bundles what is inside so a repeat or a choice can cover all of it.");
          i += 3;
        } else if (head.slice(0, 3) === "(?=") {
          push(i, i + 3, "k-group", "Look ahead: the text right after must match what is inside, but that text is not part of the match itself.");
          i += 3;
        } else if (head.slice(0, 3) === "(?!") {
          push(i, i + 3, "k-group", "Look ahead, negative: whatever comes next must NOT match what is inside.");
          i += 3;
        } else if (head === "(?<=") {
          push(i, i + 4, "k-group", "Look behind: the text just before must match what is inside, without becoming part of the match.");
          i += 4;
        } else if (head === "(?<!") {
          push(i, i + 4, "k-group", "Look behind, negative: the text just before must NOT match what is inside.");
          i += 4;
        } else {
          var nm = /^\(\?<([A-Za-z_$][A-Za-z0-9_$]*)>/.exec(src.slice(i));
          capN += 1;
          if (nm) {
            names.push(nm[1]);
            push(i, i + nm[0].length, "k-group", "Start of capture group " + capN + ", named “" + nm[1] + "”. Whatever it matches is kept aside under that name.");
            i += nm[0].length;
          } else {
            names.push(null);
            push(i, i + 1, "k-group", "Start of capture group " + capN + ". Whatever it matches is kept aside so you can pull it out on its own.");
            i += 1;
          }
        }
        depth += 1;
        continue;
      }

      if (c === ")") {
        depth = Math.max(0, depth - 1);
        push(i, i + 1, "k-group", "End of that group.", depth);
        i += 1;
        continue;
      }

      if (c === "|") {
        push(i, i + 1, "k-quant", "Or — either the part before this bar or the part after it will do.");
        i += 1;
        continue;
      }

      if (c === "^") {
        push(i, i + 1, "k-anchor", multi
          ? "Start of a line. With the m flag on, that means every line, not only the top of the text."
          : "The very start of the text. Turn on the m flag to make it mean the start of every line.");
        i += 1;
        continue;
      }

      if (c === "$") {
        push(i, i + 1, "k-anchor", multi
          ? "End of a line. With the m flag on, that means every line."
          : "The very end of the text. Turn on the m flag to make it mean the end of every line.");
        i += 1;
        continue;
      }

      if (c === ".") {
        push(i, i + 1, "k-esc", dotall
          ? "Any single character at all — the s flag lets it swallow line breaks too."
          : "Any single character except a line break.");
        i += 1;
        continue;
      }

      /* literal run */
      end = i;
      while (end < n && "\\[]()|^$.*+?{".indexOf(src.charAt(end)) === -1) end += 1;
      if (end === i) { push(i, i + 1, "k-lit", "A literal " + c + " character."); i += 1; continue; }
      if (end - i > 1 && end < n && isQuantStart(src, end)) end -= 1;
      var lit = src.slice(i, end);
      push(i, end, "k-lit", lit.length === 1
        ? "The character “" + lit + "”, exactly."
        : "The text “" + lit + "”, exactly, in that order.");
      i = end;
    }

    return { toks: toks, names: names, capN: capN };
  }

  /* =====================================================================
     state
     ===================================================================== */
  var state = {
    re: null, err: null, blank: false, matches: [], ms: 0, capped: false, risky: false, scanned: -1,
    active: -1, parse: { toks: [], names: [], capN: 0 }, rowIdx: 0, chipIdx: 0
  };
  var MAX_MATCHES = 2000;
  var LIST_CAP = 250;

  var pattern = $("pattern"), flagsIn = $("flags"), ta = $("text"),
      layer = $("layer"), inner = $("inner"), tip = $("tip"),
      editor = $("editor"), ribbon = $("ribbon"), bk = $("bk"),
      mlist = $("mlist"), mempty = $("mempty"), mcount = $("mcount"),
      statusPill = $("statusPill"), timing = $("timing"), live = $("live"),
      errbox = $("errbox"), errmsg = $("errmsg"), errhint = $("errhint"),
      patrow = $("patrow"), replaceIn = $("replace"), repout = $("repout"),
      specCount = $("specCount");

  /* =====================================================================
     matching — with a guard against runaway backtracking
     ===================================================================== */
  function now() {
    return (window.performance && performance.now) ? performance.now() : Date.now();
  }

  /* A repeat wrapped around another repeat — (a+)+ , (\w+|\s)* — can make the
     engine explore exponentially many ways to split the text. Spotting that
     shape up front lets us scan carefully instead of freezing the page. */
  function unbounded(t) {
    return t.kind === "k-quant" && /^(\*|\+|\{\d+,\})\??$/.test(t.text);
  }
  function optionalQuant(t) {
    return t.kind === "k-quant" && /^(\?|\{0(,\d*)?\})\??$/.test(t.text);
  }
  /* The tail of a branch with everything that is allowed to vanish peeled off.
     (\w+\s?)+ is every bit as explosive as (\w+)+ — the \s? just steps aside. */
  function endsStretchy(br) {
    var j = br.length - 1;
    while (j >= 0) {
      if (unbounded(br[j])) return true;
      if (!optionalQuant(br[j])) return false;
      j -= 1;
      if (j >= 0 && br[j].text === ")") {
        var d = 0;
        while (j >= 0) {
          if (br[j].text === ")") d += 1;
          else if (br[j].kind === "k-group" && br[j].text.charAt(0) === "(") {
            d -= 1;
            if (d === 0) break;
          }
          j -= 1;
        }
      }
      j -= 1;
    }
    return false;
  }
  function riskyPattern(toks) {
    for (var i = 1; i < toks.length; i++) {
      if (!unbounded(toks[i]) || toks[i - 1].text !== ")") continue;
      var open = -1, d = 0;
      for (var j = i - 1; j >= 0; j--) {
        if (toks[j].text === ")") d += 1;
        else if (toks[j].kind === "k-group" && toks[j].text.charAt(0) === "(") {
          d -= 1;
          if (d === 0) { open = j; break; }
        }
      }
      if (open < 0) continue;
      /* Walk the group's own branches and keep the last piece of each. A branch
         that ends on a stretchy repeat is the ambiguous kind; a branch that has
         to land on something fixed is not. */
      var lvl = toks[open].depth + 1, br = [];
      for (var k = open + 1; k < i - 1; k++) {
        var t = toks[k];
        if (t.depth === lvl && t.text === "|") {
          if (endsStretchy(br)) return true;
          br = [];
        } else {
          br.push(t);
        }
      }
      if (endsStretchy(br)) return true;
    }
    return false;
  }

  /* JavaScript cannot interrupt a RegExp once it has started: no timer, no
     escape hatch. So a pattern with that shape is read against a short head of
     the text first — short enough that even an exponential search finishes
     inside a blink — and the window only grows while each pass stays cheap. The
     first pass that costs real time is the last one, so a pattern that merely
     looks explosive still gets to read the whole text, and a truly explosive one
     costs a blink instead of a frozen tab. The panel says how far it got. */
  var RISK_START = 18;      /* first window, in characters */
  var RISK_STEP = 8;        /* grow the window by this much per pass */
  var RISK_STEP_MS = 0.5;   /* a pass slower than this is the last one */
  var RISK_PASSES = 300;    /* and never more passes than this */

  function record(m) {
    var groups = [];
    for (var k = 1; k < m.length; k++) {
      groups.push({ n: k, name: state.parse.names[k - 1] || null, v: m[k] });
    }
    return { start: m.index, end: m.index + m[0].length, text: m[0], groups: groups };
  }

  function execAll(re, text) {
    var out = [], capped = false, t0 = now();
    if (re.global || re.sticky) {
      re.lastIndex = 0;
      var m, steps = 0;
      while ((m = re.exec(text)) !== null) {
        out.push(record(m));
        if (m[0] === "") re.lastIndex += 1;
        if (out.length >= MAX_MATCHES) { capped = true; break; }
        if ((++steps % 250) === 0 && now() - t0 > 400) { capped = true; break; }
      }
    } else {
      var one = re.exec(text);
      if (one) out.push(record(one));
    }
    return { out: out, capped: capped, ms: now() - t0 };
  }

  function runMatch() {
    var full = ta.value, re = state.re;
    state.risky = riskyPattern(state.parse.toks);
    state.scanned = -1;

    if (!state.risky) {
      var plain = execAll(re, full);
      state.matches = plain.out;
      state.capped = plain.capped;
      state.ms = plain.ms;
      return;
    }

    var win = Math.min(RISK_START, full.length);
    var res = execAll(re, full.slice(0, win));
    var total = res.ms, passes = 0;
    while (win < full.length && res.ms < RISK_STEP_MS && !res.capped && passes++ < RISK_PASSES) {
      var next = Math.min(full.length, win + RISK_STEP);
      res = execAll(re, full.slice(0, next));
      total += res.ms;
      win = next;
    }
    state.matches = res.out;
    state.capped = res.capped;
    state.ms = total;
    state.scanned = win < full.length ? win : -1;
  }

  /* =====================================================================
     rendering
     ===================================================================== */
  function syncGutter() {
    var sb = ta.offsetWidth - ta.clientWidth - 2;
    if (sb < 0) sb = 0;
    layer.style.paddingRight = (12 + sb) + "px";
    layer.style.height = ta.offsetHeight + "px";
  }

  function renderLayer() {
    var text = ta.value, html = [], pos = 0;
    var list = state.matches;
    var n = Math.min(list.length, MAX_MATCHES);
    for (var k = 0; k < n; k++) {
      var m = list[k];
      if (m.start < pos) continue;
      html.push(esc(text.slice(pos, m.start)));
      html.push('<mark class="m' + (m.text === "" ? " zero" : k % 2 ? " alt" : "") +
        (k === state.active ? " on" : "") +
        '" data-i="' + k + '">' + esc(m.text) + "</mark>");
      pos = m.end;
    }
    html.push(esc(text.slice(pos)) + "\n");
    inner.innerHTML = html.join("");
    syncGutter();
    syncScroll();
  }

  function syncScroll() {
    inner.style.transform = "translateY(" + (-ta.scrollTop) + "px)";
  }

  function groupChip(g) {
    var lab = g.name ? g.name : String(g.n);
    if (g.v === undefined) {
      return '<span class="g none"><b>' + esc(lab) + "</b>no match</span>";
    }
    return '<span class="g"><b>' + esc(lab) + "</b>" +
      (g.v === "" ? "<i>empty</i>" : esc(g.v)) + "</span>";
  }

  function renderList() {
    var list = state.matches;
    mlist.innerHTML = "";
    if (state.err) {
      mempty.hidden = false;
      mempty.innerHTML = "Nothing to show — the pattern above has a syntax error. Fix it and matches come straight back.";
      mcount.textContent = "";
      return;
    }
    if (state.blank) {
      mempty.hidden = false;
      mempty.innerHTML = "Nothing to match yet — type a pattern between the slashes, or pick one of the examples above.";
      mcount.textContent = "";
      return;
    }
    if (!list.length) {
      mempty.hidden = false;
      mempty.innerHTML = state.scanned >= 0
        ? "Nothing matched in the first <code>" + state.scanned + "</code> characters. This pattern repeats a repeat, so the search stops early on purpose rather than locking up the page — shorten the text or simplify the pattern to see more."
        : "The pattern is valid, but it does not match anything in this text. Try loosening a piece — for example swap a <code>+</code> for a <code>*</code>, or turn on the <code>i</code> flag.";
      mcount.textContent = "— none";
      return;
    }
    mempty.hidden = true;
    mcount.textContent = "— " + list.length + (state.capped ? "+ (stopped early)" : "");

    var frag = document.createDocumentFragment();
    var shown = Math.min(list.length, LIST_CAP);
    for (var k = 0; k < shown; k++) {
      var m = list[k];
      var li = document.createElement("li");
      var gs = "";
      if (m.groups.length) {
        gs = '<span class="gs">' + m.groups.map(groupChip).join("") + "</span>";
      }
      li.innerHTML = '<button type="button" class="mrow' + (k === state.active ? " on" : "") +
        '" data-i="' + k + '" tabindex="' + (k === state.rowIdx ? "0" : "-1") + '">' +
        '<span class="top"><span class="idx">#' + (k + 1) + '</span>' +
        '<span class="pos">' + m.start + "–" + m.end + "</span></span>" +
        '<span class="txt">' + (m.text === "" ? "<i>empty match</i>" : esc(m.text)) + "</span>" +
        gs + "</button>";
      frag.appendChild(li);
    }
    if (list.length > shown) {
      var more = document.createElement("li");
      more.className = "more";
      more.textContent = "+ " + (list.length - shown) + " more not listed";
      frag.appendChild(more);
    }
    mlist.appendChild(frag);
  }

  function renderRibbon() {
    var toks = state.parse.toks;
    if (!toks.length) {
      ribbon.innerHTML = '<span class="empty">empty pattern — type something between the slashes</span>';
      state.chipIdx = 0;
      return;
    }
    if (state.chipIdx >= toks.length) state.chipIdx = 0;
    var html = ['<span class="delim" aria-hidden="true">/</span>'];
    for (var k = 0; k < toks.length; k++) {
      html.push('<button type="button" class="chip ' + toks[k].kind + '" data-t="' + k +
        '" tabindex="' + (k === state.chipIdx ? "0" : "-1") +
        '" aria-label="' + attr(toks[k].text + " — " + toks[k].desc) + '">' +
        esc(toks[k].text) + "</button>");
    }
    html.push('<span class="delim" aria-hidden="true">/' + esc(flagsIn.value) + "</span>");
    ribbon.innerHTML = html.join("");
  }

  function renderBreakdown() {
    var toks = state.parse.toks;
    bk.innerHTML = "";
    if (!toks.length) {
      bk.innerHTML = '<li><span class="bd"><em>Nothing to explain yet — the pattern is empty.</em></span></li>';
      return;
    }
    var frag = document.createDocumentFragment();
    for (var k = 0; k < toks.length; k++) {
      var t = toks[k];
      var li = document.createElement("li");
      li.style.setProperty("--d", String(t.depth));
      if (t.depth > 0) li.className = "nested";
      li.setAttribute("data-t", String(k));
      li.innerHTML = '<code class="tk ' + t.kind + '">' + esc(t.text) + "</code>" +
        '<span class="bd">' + esc(t.desc) + "</span>";
      frag.appendChild(li);
    }
    bk.appendChild(frag);
  }

  function renderStatus() {
    var risk = $("riskPill"), hint = $("hintline");
    if (state.err) {
      risk.hidden = true;
      hint.textContent = "fix the pattern above and the matches come straight back";
      statusPill.className = "pill bad";
      statusPill.textContent = "invalid pattern";
      timing.textContent = "";
      specCount.textContent = "invalid pattern";
      live.textContent = "Pattern error: " + state.err;
      return;
    }
    if (state.blank) {
      risk.hidden = true;
      hint.textContent = "type a pattern between the slashes to start matching";
      statusPill.className = "pill";
      statusPill.textContent = "no pattern";
      timing.textContent = "";
      specCount.textContent = "no pattern yet";
      live.textContent = "";
      return;
    }
    if (state.risky) {
      risk.hidden = false;
      risk.textContent = state.scanned >= 0
        ? "stopped at " + state.scanned + " chars"
        : "nested repeat";
      hint.textContent = state.scanned >= 0
        ? "a repeat inside a repeat can take exponentially long here, so the search stopped early"
        : "heads-up: this has a repeat inside a repeat, which can get very slow on near-miss text";
    } else {
      risk.hidden = true;
      hint.textContent = "hover or tap a highlight to see its groups";
    }
    var n = state.matches.length;
    if (n > 0) {
      statusPill.className = "pill ok";
      statusPill.textContent = n + (state.capped ? "+" : "") + (n === 1 ? " match" : " matches");
    } else {
      statusPill.className = "pill";
      statusPill.textContent = "no matches";
    }
    timing.textContent = (state.ms < 0.01 ? "<0.01" : state.ms < 10 ? state.ms.toFixed(2) : state.ms.toFixed(1)) +
      " ms" + (state.capped ? " · stopped early" : "");
    specCount.textContent = n + (state.capped ? "+" : "") + (n === 1 ? " match" : " matches");
    live.textContent = n + (n === 1 ? " match" : " matches") + " found.";
  }

  function renderReplace() {
    if (!$("repwrap").open) return;
    if (state.err || state.blank) { repout.textContent = "—"; return; }
    var src = ta.value, cut = false;
    if (state.scanned >= 0 && src.length > state.scanned) {
      src = src.slice(0, state.scanned);
      cut = true;
    }
    var out;
    try {
      var re = new RegExp(pattern.value, flagsIn.value);
      out = src.replace(re, replaceIn.value);
    } catch (e) {
      out = "—";
    }
    repout.textContent = cut ? out + "  … preview stops where the search stopped" : out;
  }

  function bracketTally() {
    /* Strip escapes first, then whole character classes: a "(" inside [(] is a
       plain character, and counting it would blame the wrong bracket. */
    var bare = pattern.value.replace(/\\[\s\S]/g, "").replace(/\[[^\]]*\]/g, "");
    return {
      bare: bare,
      opens: (bare.match(/\(/g) || []).length,
      closes: (bare.match(/\)/g) || []).length
    };
  }

  function errorHint(msg) {
    /* Engines word these differently, so match on the distinctive phrase each
       one uses rather than on any single browser's exact string. */
    var t = bracketTally();
    if (/invalid flags|invalid regular expression flags/i.test(msg)) {
      return "Flags can only be g, i, m, s, u, y, d and v — and each one only once.";
    }
    if (/unterminated .*class|missing terminating \]/i.test(msg)) {
      return "There is a “[” with no closing “]” — a character set has to be shut.";
    }
    if (/\\ at end of pattern|\\ at end of the pattern/i.test(msg)) {
      return "The pattern ends on a lone backslash, which has nothing to escape.";
    }
    if (/nothing to repeat|invalid quantifier/i.test(msg)) {
      return "A repeat sign (* + ? or {2,3}) has nothing in front of it to repeat. Escape it as \\* if you meant the character itself.";
    }
    if (/numbers out of order/i.test(msg)) {
      return "In a {min,max} repeat the first number has to be the smaller one.";
    }
    if (/range out of order|out of order in character class/i.test(msg)) {
      return "A range inside [ ] runs backwards, like [z-a]. Put the lower character first.";
    }
    if (/duplicate capture group name/i.test(msg)) {
      return "Two groups share the same (?<name>. Every group name has to be unique.";
    }
    if (/invalid named (capture|reference|back ?reference)/i.test(msg)) {
      return "A \\k<…> back-reference points at a group name this pattern never defines.";
    }
    if (/invalid group|invalid regexp group/i.test(msg)) {
      return "A “(?” opening the engine does not recognise. The ones it knows are (?:  (?=  (?!  (?<=  (?<!  and (?<name>.";
    }
    if (t.opens !== t.closes) {
      return "Counting brackets: " + t.opens + " “(” against " + t.closes +
        " “)”. Add the missing one, or escape it as \\( if you meant a plain bracket.";
    }
    if (/\[/.test(t.bare) && t.bare.indexOf("]") === -1) {
      return "There is a “[” with no closing “]” — a character set has to be shut.";
    }
    if (/\\$/.test(t.bare)) {
      return "The pattern ends on a lone backslash, which has nothing to escape.";
    }
    return "The breakdown on the right still explains everything it can parse, so you can see where it goes wrong.";
  }

  function showError(msg) {
    errbox.hidden = false;
    errmsg.textContent = msg;
    patrow.classList.add("bad");
    errhint.textContent = errorHint(msg);
  }

  function clearError() {
    errbox.hidden = true;
    errmsg.textContent = "";
    errhint.textContent = "";
    patrow.classList.remove("bad");
  }

  /* =====================================================================
     the main pass
     ===================================================================== */
  function run() {
    state.parse = parsePattern(pattern.value, flagsIn.value);
    state.err = null;
    state.blank = pattern.value === "";
    try {
      state.re = new RegExp(pattern.value, flagsIn.value);
    } catch (e) {
      state.err = e && e.message ? e.message : String(e);
      state.re = null;
    }
    if (state.err) {
      state.matches = [];
      state.capped = false;
      state.ms = 0;
      showError(state.err);
    } else if (state.blank) {
      /* an empty pattern matches the gap before every character — true, useless,
         and it would pepper the text with hundreds of phantom marks */
      clearError();
      state.matches = [];
      state.capped = false;
      state.ms = 0;
      state.risky = false;
      state.scanned = -1;
    } else {
      clearError();
      runMatch();
    }
    if (state.active >= state.matches.length) state.active = -1;
    if (state.rowIdx >= Math.min(state.matches.length, LIST_CAP)) state.rowIdx = 0;
    renderRibbon();
    renderBreakdown();
    renderLayer();
    renderList();
    renderStatus();
    renderReplace();
    syncFlagButtons();
  }

  var pending = false;
  function schedule() {
    if (pending) return;
    pending = true;
    requestAnimationFrame(function () { pending = false; run(); });
  }

  /* =====================================================================
     match focus / activation
     ===================================================================== */
  function setActive(k, opts) {
    opts = opts || {};
    state.active = k;
    state.rowIdx = k < 0 ? 0 : k;
    var marks = inner.querySelectorAll("mark");
    for (var j = 0; j < marks.length; j++) {
      marks[j].classList.toggle("on", Number(marks[j].getAttribute("data-i")) === k);
    }
    var rows = mlist.querySelectorAll(".mrow");
    var want = k < 0 ? state.rowIdx : k, tabbed = false;
    for (var r = 0; r < rows.length; r++) {
      var di = Number(rows[r].getAttribute("data-i"));
      rows[r].classList.toggle("on", di === k);
      rows[r].tabIndex = di === want ? 0 : -1;
      if (di === want) tabbed = true;
    }
    /* the list must always keep exactly one tab stop, even with nothing active */
    if (!tabbed && rows.length) {
      rows[0].tabIndex = 0;
      state.rowIdx = Number(rows[0].getAttribute("data-i"));
    }
    if (k < 0) return;
    if (opts.scrollText) {
      var mk = inner.querySelector('mark[data-i="' + k + '"]');
      if (mk) {
        var top = mk.offsetTop - 12;
        var target = top - (ta.clientHeight / 2) + mk.offsetHeight / 2;
        ta.scrollTop = Math.max(0, target);
        syncScroll();
        mk.classList.add("flash");
        setTimeout(function () { mk.classList.remove("flash"); }, 1100);
      }
    }
    if (opts.scrollRow) {
      var row = mlist.querySelector('.mrow[data-i="' + k + '"]');
      if (row && row.scrollIntoView) row.scrollIntoView({ block: "nearest" });
    }
  }

  /* =====================================================================
     tooltip on highlights
     ===================================================================== */
  function showTip(mark) {
    var k = Number(mark.getAttribute("data-i"));
    var m = state.matches[k];
    if (!m) return;
    var html = '<div class="th">MATCH #' + (k + 1) + " · chars " + m.start + "–" + m.end + "</div>" +
      '<div class="tv">' + (m.text === "" ? "<em>empty match</em>" : esc(m.text)) + "</div>";
    if (m.groups.length) {
      html += '<div class="tg">';
      for (var g = 0; g < m.groups.length; g++) {
        var gr = m.groups[g];
        html += "<div><b>" + esc(gr.name ? gr.name : "group " + gr.n) + "</b> " +
          (gr.v === undefined ? '<span class="none">did not match</span>'
            : gr.v === "" ? '<span class="none">empty</span>' : esc(gr.v)) + "</div>";
      }
      html += "</div>";
    } else {
      html += '<div class="tg"><div><span class="none">no capture groups in this pattern</span></div></div>';
    }
    tip.innerHTML = html;
    tip.hidden = false;
    var er = editor.getBoundingClientRect(), mr = mark.getBoundingClientRect();
    var tw = tip.offsetWidth, th = tip.offsetHeight;
    var left = mr.left - er.left;
    if (left + tw > er.width - 6) left = er.width - tw - 6;
    if (left < 6) left = 6;
    var top = mr.top - er.top - th - 8;
    if (top < 4) top = mr.bottom - er.top + 8;
    tip.style.left = left + "px";
    tip.style.top = top + "px";
  }
  function hideTip() { tip.hidden = true; }

  /* =====================================================================
     flags
     ===================================================================== */
  var FLAG_ORDER = "dgimsuvy";
  function syncFlagButtons() {
    var v = flagsIn.value;
    var btns = document.querySelectorAll("[data-flag]");
    for (var i = 0; i < btns.length; i++) {
      btns[i].setAttribute("aria-pressed", String(v.indexOf(btns[i].getAttribute("data-flag")) > -1));
    }
  }
  function toggleFlag(f) {
    var v = flagsIn.value;
    var has = v.indexOf(f) > -1;
    var set = {};
    for (var i = 0; i < v.length; i++) set[v.charAt(i)] = true;
    if (has) delete set[f]; else set[f] = true;
    var out = "";
    for (var j = 0; j < FLAG_ORDER.length; j++) {
      if (set[FLAG_ORDER.charAt(j)]) out += FLAG_ORDER.charAt(j);
    }
    for (var q in set) { if (FLAG_ORDER.indexOf(q) === -1) out += q; }
    flagsIn.value = out;
    run();
  }

  /* =====================================================================
     events
     ===================================================================== */
  pattern.addEventListener("input", schedule);
  flagsIn.addEventListener("input", schedule);
  ta.addEventListener("input", function () { hideTip(); schedule(); });
  ta.addEventListener("scroll", function () { syncScroll(); hideTip(); });
  window.addEventListener("resize", function () { syncGutter(); hideTip(); });
  ta.addEventListener("focus", function () { editor.classList.add("focused"); });
  ta.addEventListener("blur", function () { editor.classList.remove("focused"); });

  if (window.ResizeObserver) {
    new ResizeObserver(function () { syncGutter(); }).observe(ta);
  }

  /* caret inside a match selects it */
  function caretMatch() {
    var pos = ta.selectionStart;
    for (var k = 0; k < state.matches.length; k++) {
      var m = state.matches[k];
      if (pos >= m.start && pos <= m.end) return k;
    }
    return -1;
  }
  ["click", "keyup"].forEach(function (ev) {
    ta.addEventListener(ev, function () {
      var k = caretMatch();
      if (k !== state.active) setActive(k, { scrollRow: true });
    });
  });

  /* highlight interactions */
  inner.addEventListener("mouseover", function (e) {
    var mk = e.target.closest ? e.target.closest("mark") : null;
    if (mk) showTip(mk);
  });
  inner.addEventListener("mouseout", function (e) {
    var mk = e.target.closest ? e.target.closest("mark") : null;
    if (mk) hideTip();
  });
  inner.addEventListener("mousedown", function (e) {
    var mk = e.target.closest ? e.target.closest("mark") : null;
    if (!mk) return;
    e.preventDefault();
    var k = Number(mk.getAttribute("data-i"));
    var m = state.matches[k];
    ta.focus();
    if (m) ta.setSelectionRange(m.start, m.end);
    setActive(k, { scrollRow: true });
    showTip(mk);
  });
  document.addEventListener("mousedown", function (e) {
    if (!e.target.closest || !e.target.closest("mark")) hideTip();
  });

  /* match list */
  mlist.addEventListener("click", function (e) {
    var row = e.target.closest ? e.target.closest(".mrow") : null;
    if (!row) return;
    setActive(Number(row.getAttribute("data-i")), { scrollText: true });
    row.focus();
  });
  mlist.addEventListener("keydown", function (e) {
    var row = e.target.closest ? e.target.closest(".mrow") : null;
    if (!row) return;
    var rows = mlist.querySelectorAll(".mrow");
    var i = Number(row.getAttribute("data-i")), next = null;
    if (e.key === "ArrowDown") next = i + 1;
    else if (e.key === "ArrowUp") next = i - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = rows.length - 1;
    else return;
    if (next < 0 || next >= rows.length) return;
    e.preventDefault();
    setActive(next, { scrollText: true });
    var el = mlist.querySelector('.mrow[data-i="' + next + '"]');
    if (el) { el.focus(); if (el.scrollIntoView) el.scrollIntoView({ block: "nearest" }); }
  });

  /* ribbon <-> breakdown cross-highlight */
  function markToken(k) {
    var chips = ribbon.querySelectorAll(".chip");
    for (var i = 0; i < chips.length; i++) {
      chips[i].classList.toggle("on", Number(chips[i].getAttribute("data-t")) === k);
    }
    var rows = bk.querySelectorAll("li[data-t]");
    for (var j = 0; j < rows.length; j++) {
      rows[j].classList.toggle("on", Number(rows[j].getAttribute("data-t")) === k);
    }
  }
  function tokenHandlers(host, sel) {
    host.addEventListener("mouseover", function (e) {
      var el = e.target.closest ? e.target.closest(sel) : null;
      if (el) markToken(Number(el.getAttribute("data-t")));
    });
    host.addEventListener("mouseleave", function () {
      var f = document.activeElement;
      var held = f && f.classList && f.classList.contains("chip")
        ? Number(f.getAttribute("data-t")) : -1;
      markToken(held);
    });
  }
  tokenHandlers(ribbon, ".chip");
  tokenHandlers(bk, "li[data-t]");
  function focusChip(k) {
    state.chipIdx = k;
    var chips = ribbon.querySelectorAll(".chip");
    for (var i = 0; i < chips.length; i++) {
      chips[i].tabIndex = Number(chips[i].getAttribute("data-t")) === k ? 0 : -1;
    }
    var el = ribbon.querySelector('.chip[data-t="' + k + '"]');
    if (el) el.focus();
  }
  ribbon.addEventListener("focusin", function (e) {
    var chip = e.target.closest ? e.target.closest(".chip") : null;
    if (!chip) return;
    var k = Number(chip.getAttribute("data-t"));
    state.chipIdx = k;
    for (var i = 0, cs = ribbon.querySelectorAll(".chip"); i < cs.length; i++) {
      cs[i].tabIndex = Number(cs[i].getAttribute("data-t")) === k ? 0 : -1;
    }
    markToken(k);
    var row = bk.querySelector('li[data-t="' + k + '"]');
    if (row && row.scrollIntoView) row.scrollIntoView({ block: "nearest" });
  });
  ribbon.addEventListener("focusout", function () { markToken(-1); });
  ribbon.addEventListener("click", function (e) {
    var hit = e.target.closest ? e.target.closest(".chip") : null;
    if (hit) focusChip(Number(hit.getAttribute("data-t")));
  });
  ribbon.addEventListener("keydown", function (e) {
    var chip = e.target.closest ? e.target.closest(".chip") : null;
    if (!chip) return;
    var chips = ribbon.querySelectorAll(".chip");
    var i = Number(chip.getAttribute("data-t")), next = null;
    if (e.key === "ArrowRight") next = i + 1;
    else if (e.key === "ArrowLeft") next = i - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = chips.length - 1;
    else return;
    if (next < 0 || next >= chips.length) return;
    e.preventDefault();
    focusChip(next);
  });

  /* flags */
  $("flagbar").addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("[data-flag]") : null;
    if (b) toggleFlag(b.getAttribute("data-flag"));
  });

  /* presets */
  var loadedPreset = "email";
  document.querySelectorAll("[data-preset]").forEach(function (b) {
    b.addEventListener("click", function () {
      var key = b.getAttribute("data-preset"), p = PRESETS[key];
      if (!p) return;
      loadedPreset = key;
      pattern.value = p.pattern;
      flagsIn.value = p.flags;
      ta.value = p.text;
      replaceIn.value = p.replace;
      state.active = -1;
      state.rowIdx = 0;
      ta.scrollTop = 0;
      hideTip();
      run();
    });
  });

  /* "reset text" restores the sample that came with whichever example is loaded,
     even if the pattern has since been edited by hand. */
  $("btnReset").addEventListener("click", function () {
    ta.value = PRESETS[loadedPreset].text;
    state.active = -1;
    state.rowIdx = 0;
    ta.scrollTop = 0;
    hideTip();
    run();
  });

  /* copy */
  var copyBtn = $("btnCopy");
  copyBtn.addEventListener("click", function () {
    var txt = "/" + pattern.value + "/" + flagsIn.value;
    function done(ok) {
      copyBtn.textContent = ok ? "copied" : "press ⌘/Ctrl+C";
      live.textContent = ok ? "Pattern copied to the clipboard." : "Copying was blocked — press Command or Control C.";
      if (ok) copyBtn.style.color = "var(--run)";
      setTimeout(function () {
        copyBtn.textContent = "copy";
        copyBtn.style.color = "";
      }, 1400);
    }
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(txt).then(function () { done(true); }, function () { done(fallback(txt)); });
        return;
      }
    } catch (e) { /* fall through */ }
    done(fallback(txt));
  });
  function fallback(txt) {
    try {
      var tmp = document.createElement("textarea");
      tmp.value = txt;
      tmp.setAttribute("readonly", "");
      tmp.style.position = "absolute";
      tmp.style.left = "-9999px";
      document.body.appendChild(tmp);
      tmp.select();
      var ok = document.execCommand("copy");
      document.body.removeChild(tmp);
      return ok;
    } catch (e) { return false; }
  }

  /* replace */
  replaceIn.addEventListener("input", renderReplace);
  $("repwrap").addEventListener("toggle", renderReplace);

  /* platform theme */
  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
    }
  });

  /* boot */
  pattern.value = PRESETS.email.pattern;
  flagsIn.value = PRESETS.email.flags;
  ta.value = PRESETS.email.text;
  replaceIn.value = PRESETS.email.replace;
  run();
  setActive(0, {});
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong