term-transcript 0.4.0

Snapshotting and snapshot testing for CLI / REPL applications
Documentation
{{!
  Computes content height based on line count in interactions.
  Expected hash inputs: `interactions`, `const`.
}}
{{~#*inline "compute_content_height"}}
  {{#scope lines=0 margins=0 displayed_interactions=0}}
    {{#each interactions}}
      {{#if (not input.hidden)}}
        {{lines set=(add (lines) (count_lines input.text))}}
        {{margins set=(add (margins) 1)}}
        {{displayed_interactions set=(add (displayed_interactions) 1)}}
      {{/if}}
      {{lines set=(add (lines) (count_lines output_html format="html"))}}
      {{#if (ne 0 (len output_html))}}
        {{margins set=(add (margins) 1)}}
      {{/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 (displayed_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) scroll.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 ../scroll.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">
  <switch>
    <g requiredExtensions="http://www.w3.org/1999/xhtml">
      {{>styles}}
      {{>background}}

      {{~>content}}
      {{~#if (scroll_animation)}}
      {{>scrollbar}}
      {{/if}}
    </g>
    {{>unsupported_error}}
  </switch>
</svg>
{{/inline~}}

{{! NB. The warning text should fit in one 80-char line to not potentially overflow the viewbox. }}
{{~#*inline "unsupported_error"}}
<text x="10" y="{{const.LINE_HEIGHT}}" style="font: 14px {{font_family}}; fill: {{ palette.colors.red }};">
  HTML embedding not supported.
  Consult <tspan style="text-decoration: underline; text-decoration-thickness: 1px;"><a href="https://github.com/slowli/term-transcript/blob/HEAD/FAQ.md">term-transcript docs</a></tspan> for details.
</text>
{{/inline~}}

{{! CSS definitions }}
{{~#*inline "styles"}}
<style>
  {{~#if additional_styles}}

  {{{additional_styles}}}
  {{~/if}}

  .container {
    padding: 0 {{const.WINDOW_PADDING}}px;
    color: {{ palette.colors.white }};
    line-height: {{const.LINE_HEIGHT}}px;
  }
  .container pre {
    padding: 0;
    margin: 0;
    font: 14px {{font_family}};
    line-height: inherit;
  }
  .input {
    {{~#if (eq line_numbers "continuous")}}

    display: flex;
    {{~/if}}

    margin: 0 -{{const.WINDOW_PADDING}}px {{const.BLOCK_MARGIN}}px;
    color: {{ palette.colors.white }};
    background: rgba(255, 255, 255, 0.1);
    padding: 2px {{const.WINDOW_PADDING}}px;
  }
  .input-hidden { display: none; }
  {{~#if (eq line_numbers "continuous")}}

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

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

  .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}}

  .input-failure {
    border-left: 2px solid {{ palette.colors.red }};
    border-right: 2px solid {{ palette.colors.red }};
    background: rgba(255, 0, 65, 0.15);
  }
  {{/if}}
  {{~#if (scroll_animation)}}

  .scrollbar { fill: #fff; fill-opacity: 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: {{ palette.colors.black }}; } .bg0 { background: {{ palette.colors.black }}; }
  .fg1 { color: {{ palette.colors.red }}; } .bg1 { background: {{ palette.colors.red }}; }
  .fg2 { color: {{ palette.colors.green }}; } .bg2 { background: {{ palette.colors.green }}; }
  .fg3 { color: {{ palette.colors.yellow }}; } .bg3 { background: {{ palette.colors.yellow }}; }
  .fg4 { color: {{ palette.colors.blue }}; } .bg4 { background: {{ palette.colors.blue }}; }
  .fg5 { color: {{ palette.colors.magenta }}; } .bg5 { background: {{ palette.colors.magenta }}; }
  .fg6 { color: {{ palette.colors.cyan }}; } .bg6 { background: {{ palette.colors.cyan }}; }
  .fg7 { color: {{ palette.colors.white }}; } .bg7 { background: {{ palette.colors.white }}; }
  .fg8 { color: {{ palette.intense_colors.black }}; } .bg8 { background: {{ palette.intense_colors.black }}; }
  .fg9 { color: {{ palette.intense_colors.red }}; } .bg9 { background: {{ palette.intense_colors.red }}; }
  .fg10 { color: {{ palette.intense_colors.green }}; } .bg10 { background: {{ palette.intense_colors.green }}; }
  .fg11 { color: {{ palette.intense_colors.yellow }}; } .bg11 { background: {{ palette.intense_colors.yellow }}; }
  .fg12 { color: {{ palette.intense_colors.blue }}; } .bg12 { background: {{ palette.intense_colors.blue }}; }
  .fg13 { color: {{ palette.intense_colors.magenta }}; } .bg13 { background: {{ palette.intense_colors.magenta }}; }
  .fg14 { color: {{ palette.intense_colors.cyan }}; } .bg14 { background: {{ palette.intense_colors.cyan }}; }
  .fg15 { color: {{ palette.intense_colors.white }}; } .bg15 { background: {{ palette.intense_colors.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: {{ palette.colors.black }};" />
{{~#if window_frame}}

<rect width="100%" height="26" y="-22" clip-path="inset(0 0 -10 0 round 4.5)" style="fill: #fff; fill-opacity: 0.1;"/>
<circle cx="17" cy="-9" r="7" style="fill: {{ palette.colors.red }};"/>
<circle cx="37" cy="-9" r="7" style="fill: {{ palette.colors.yellow }};"/>
<circle cx="57" cy="-9" r="7" style="fill: {{ palette.colors.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="input{{#if failure}} input-failure{{/if}}{{#if input.hidden}} input-hidden{{/if}}"
              {{~#if (ne exit_status null)}} data-exit-status="{{exit_status}}"{{/if~}}
              {{~#if failure}} title="This command exited with non-zero code"{{/if}}>
              {{~#if (and (eq ../line_numbers "continuous") (not input.hidden))}}{{>number_input_lines}}{{/if~}}
              <pre><span class="prompt">{{ input.prompt }}</span> {{ input.text }}</pre></div>
            <div class="output">
              {{~#if ../line_numbers~}}
                {{~>number_output_lines~}}
                {{~#if (ne ../line_numbers "each_output")~}}
                  {{~line_number set=(add (line_number) (count_lines output_html format="html"))~}}
                {{~/if~}}
              {{~/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>
{{~/inline~}}

{{! Main logic }}
{{#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}}