term-transcript 0.3.0-beta.1

Snapshotting and snapshot testing for CLI / REPL applications
Documentation
{{~#*inline "define_const"}}
{
  {{! Bottom margin for each input or output block }}
  "BLOCK_MARGIN": 6,
  {{! Additional padding for each user input block }}
  "USER_INPUT_PADDING": 4,
  {{! Padding within the rendered terminal window in pixels }}
  "WINDOW_PADDING": 10,
  {{! Line height in pixels }}
  "LINE_HEIGHT": 18,
  {{! Height of the window frame }}
  "WINDOW_FRAME_HEIGHT": 22,
  {{! Pixels scrolled vertically per each animation frame }}
  "PIXELS_PER_SCROLL": 52,
  {{! Right offset of the scrollbar relative to the right border of the frame }}
  "SCROLLBAR_RIGHT_OFFSET": 7,
  {{! Height of the scrollbar in pixels }}
  "SCROLLBAR_HEIGHT": 40
}
{{/inline~}}

{{!
  Computes content height based on line count in interactions.
  Expected hash inputs: `interactions`, `const`.
}}
{{~#*inline "compute_content_height"}}
  {{#scope lines=0 margins=0}}
    {{#each interactions}}
      {{lines set=(add (lines) (count_lines input.text) (count_lines output_html format="html"))}}
      {{#if (eq 0 (len output_html))}}
        {{margins set=(add (margins) 1)}}
      {{else}}
        {{margins set=(add (margins) 2)}}
      {{/if}}
    {{/each}}
    {{#if (gt (margins) 0)}}
      {{! The last margin is not displayed. }}
      {{margins set=(sub (margins) 1)}}
    {{/if}}
    {{add (mul (lines) const.LINE_HEIGHT)
          (mul (margins) const.BLOCK_MARGIN)
          (mul (len interactions) const.USER_INPUT_PADDING) }}
  {{/scope}}
{{/inline~}}

{{!
  Computes scroll animation parameters.
  Expected hash inputs: `content_height`, `const`, `scroll`, `width`
}}
{{~#*inline "compute_scroll_animation"}}
  {{#if (gte scroll.max_height content_height)}}
  {{! No need for scroll animation }}
    null
  {{else}}
    {{#scope
      steps=(div (sub content_height scroll.max_height) const.PIXELS_PER_SCROLL round="up")
      y_step=0
      view_box=""
      scrollbar_y=""
      sep=""
    }}
      {{y_step set=(div (sub scroll.max_height const.SCROLLBAR_HEIGHT) (steps))}}
      {{#each (range 0 (add (steps) 1))}}
        {{#sep}}{{#if @first}}""{{else}}";"{{/if}}{{/sep}}
        {{#view_box}}"{{view_box}}{{sep}}0 {{mul ../const.PIXELS_PER_SCROLL @index}} {{../width}} {{../scroll.max_height}}"{{/view_box}}
        {{#scrollbar_y}}"{{scrollbar_y}}{{sep}}0 {{mul (y_step) @index round="nearest"}}"{{/scrollbar_y}}
      {{/each}}

      {
        "duration": {{mul scroll.interval (steps)}},
        "view_box": "{{view_box}}",
        "scrollbar_x": {{sub width const.SCROLLBAR_RIGHT_OFFSET}},
        "scrollbar_y": "{{scrollbar_y}}"
      }
    {{/scope}}
  {{/if}}
{{/inline~}}

{{! Root template }}
{{~#*inline "root"}}
<!-- Created with {{{creator.name}}} v{{{creator.version}}} ({{{creator.repo}}}) -->
<svg viewBox="0 {{#if window_frame}}-{{const.WINDOW_FRAME_HEIGHT}}{{else}}0{{/if}} {{width}} {{height}}" width="{{width}}" height="{{height}}" xmlns="http://www.w3.org/2000/svg">
{{>styles}}
{{>background}}
{{>content}}
{{~#if (scroll_animation)}}
{{>scrollbar}}
{{/if}}
</svg>
{{/inline~}}

{{! CSS definitions }}
{{~#*inline "styles"}}
  <style>
    :root {
      {{~#each palette.colors}}

      --{{@key}}: {{this}}; --i-{{@key}}: {{lookup ../palette.intense_colors @key}};
      {{~/each}}

      --hl-black: rgba(255, 255, 255, 0.1);

      {{~#if has_failures}}

      --hl-red: rgba(255, 0, 65, 0.15);
      {{~/if}}

    }
    .container {
      padding: 0 {{const.WINDOW_PADDING}}px;
      color: var(--white);
      line-height: {{const.LINE_HEIGHT}}px;
    }
    .container pre {
      padding: 0;
      margin: 0;
      font: 14px {{font_family}};
      line-height: inherit;
    }
    .user-input {
      {{#if (eq line_numbers "continuous")}}
      display: flex;
      {{/if}}
      margin: 0 -{{const.WINDOW_PADDING}}px {{const.BLOCK_MARGIN}}px;
      color: var(--white);
      background: var(--hl-black);
      padding: 2px {{const.WINDOW_PADDING}}px;
    }
    {{~#if (eq line_numbers "continuous")}}

    .user-input > pre { flex-grow: 1; }
    {{~/if}}

    .term-output { {{#if line_numbers}}display: flex; {{/if}}margin-bottom: {{const.BLOCK_MARGIN}}px; }
    {{~#if line_numbers}}

    .term-output > pre { flex-grow: 1; }
    pre.line-numbers {
      flex-grow: 0;
      width: 1.5rem;
      text-align: right;
      padding-right: .5rem;
      opacity: 0.35;
      user-select: none;
    }
    {{/if}}
    {{~#if has_failures}}

    .user-input-failure {
      border-left: 2px solid var(--red);
      border-right: 2px solid var(--red);
      background: var(--hl-red);
    }
    {{/if}}
    {{~#if (scroll_animation)}}

    .scrollbar { fill: rgba(255, 255, 255, 0.35); }
    {{~/if}}

    .bold,.prompt { font-weight: bold; }
    .italic { font-style: italic; }
    .underline { text-decoration: underline; }
    .dimmed { opacity: 0.7; }
    {{~#if wrap}}

    .hard-br {
      position: relative;
      margin-left: 5px;
    }
    .hard-br:before {
      content: '↓';
      font-size: 16px;
      height: 16px;
      position: absolute;
      bottom: 0;
      transform: rotate(45deg);
      opacity: 0.8;
    }
    {{~/if}}

    .fg0 { color: var(--black); } .bg0 { background: var(--black); }
    .fg1 { color: var(--red); } .bg1 { background: var(--red); }
    .fg2 { color: var(--green); } .bg2 { background: var(--green); }
    .fg3 { color: var(--yellow); } .bg3 { background: var(--yellow); }
    .fg4 { color: var(--blue); } .bg4 { background: var(--blue); }
    .fg5 { color: var(--magenta); } .bg5 { background: var(--magenta); }
    .fg6 { color: var(--cyan); } .bg6 { background: var(--cyan); }
    .fg7 { color: var(--white); } .bg7 { background: var(--white); }
    .fg8 { color: var(--i-black); } .bg8 { background: var(--i-black); }
    .fg9 { color: var(--i-red); } .bg9 { background: var(--i-red); }
    .fg10 { color: var(--i-green); } .bg10 { background: var(--i-green); }
    .fg11 { color: var(--i-yellow); } .bg11 { background: var(--i-yellow); }
    .fg12 { color: var(--i-blue); } .bg12 { background: var(--i-blue); }
    .fg13 { color: var(--i-magenta); } .bg13 { background: var(--i-magenta); }
    .fg14 { color: var(--i-cyan); } .bg14 { background: var(--i-cyan); }
    .fg15 { color: var(--i-white); } .bg15 { background: var(--i-white); }
  </style>
{{/inline~}}

{{! Terminal background }}
{{~#*inline "background"}}
  <rect width="100%" height="100%" y="{{#if window_frame}}-{{const.WINDOW_FRAME_HEIGHT}}{{else}}0{{/if}}" rx="4.5" style="fill: var(--black);" />
  {{~#if window_frame}}

  <rect width="100%" height="26" y="-22" clip-path="inset(0 0 -10 0 round 4.5)" style="fill: var(--hl-black);"/>
  <circle cx="17" cy="-9" r="7" style="fill: var(--red);"/>
  <circle cx="37" cy="-9" r="7" style="fill: var(--yellow);"/>
  <circle cx="57" cy="-9" r="7" style="fill: var(--green);"/>
  {{~/if}}

{{/inline~}}

{{~#*inline "content"}}
  <svg x="0" y="{{const.WINDOW_PADDING}}" width="{{width}}" height="{{screen_height}}" viewBox="0 0 {{width}} {{screen_height}}">
    {{~#if (scroll_animation)}}
    {{~#with (scroll_animation)}}

    <animate attributeName="viewBox" values="{{view_box}}" dur="{{duration}}s" repeatCount="indefinite" calcMode="discrete" />
    {{~/with}}
    {{~/if}}

    <foreignObject width="{{width}}" height="{{content_height}}">
      <div xmlns="http://www.w3.org/1999/xhtml" class="container">
        {{~#each interactions}}

        <div class="user-input{{#if failure}} user-input-failure{{/if}}"
          {{~#if (ne exit_status null)}} data-exit-status="{{exit_status}}"{{/if~}}
          {{~#if failure}} title="This command exited with non-zero code"{{/if}}>
          {{~#if (eq ../line_numbers "continuous")}}{{>number_input_lines}}{{/if~}}
          <pre><span class="prompt">{{ input.prompt }}</span> {{ input.text }}</pre></div>
        <div class="term-output">{{#if ../line_numbers}}{{>number_output_lines}}{{/if}}<pre>{{{output_html}}}</pre></div>
        {{~/each}}

      </div>
    </foreignObject>
  </svg>
{{/inline~}}

{{~#*inline "scrollbar"}}
{{#with (scroll_animation)}}
  <rect class="scrollbar" x="{{scrollbar_x}}" y="10" width="5" height="40">
    <animateTransform attributeName="transform" attributeType="XML" type="translate" values="{{scrollbar_y}}" dur="{{duration}}s" repeatCount="indefinite" calcMode="discrete" />
  </rect>
{{/with}}
{{/inline~}}

{{~#*inline "number_input_lines"~}}
  <pre class="line-numbers">
    {{~#each (range 0 (count_lines input.text))~}}
      {{add this (line_number)}}{{#if @last}}{{else}}<br/>{{/if}}
    {{~/each~}}
  </pre>
  {{~line_number set=(add (line_number) (count_lines input.text))~}}
{{~/inline~}}

{{~#*inline "number_output_lines"}}
<pre class="line-numbers">
  {{~#each (range 0 (count_lines output_html format="html"))~}}
    {{add this (line_number)}}{{#if @last}}{{else}}<br/>{{/if}}
  {{~/each~}}
</pre>
{{~#if (ne ../line_numbers "each_output")~}}
  {{line_number set=(add (line_number) (count_lines output_html format="html"))}}
{{~/if~}}
{{~/inline~}}

{{! Main logic }}
{{#with this as |$|}}
{{#with (eval "define_const") as |const|}}
{{#with $}}
{{#scope
  content_height=(eval "compute_content_height" const=const interactions=interactions)
  scroll_animation=null
  screen_height=0
  height=0
  line_number=1
}}
  {{~#if scroll~}}
    {{scroll_animation set=(eval "compute_scroll_animation"
      const=const
      scroll=scroll
      width=width
      content_height=(content_height)
    )}}
  {{~/if~}}
  {{~#if (scroll_animation)~}}
    {{screen_height set=scroll.max_height}}
  {{~else~}}
    {{screen_height set=(content_height)}}
  {{~/if~}}
  {{~height set=(add (screen_height) (mul const.WINDOW_PADDING 2))~}}
  {{~#if window_frame~}}
    {{height set=(add (height) const.WINDOW_FRAME_HEIGHT)}}
  {{~/if~}}
{{>root~}} {{! <-- All rendering happens here }}
{{/scope}}
{{/with}}
{{/with}}
{{/with}}