Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/lineage

src/ui/page.ts

174 lines · 8.2 KB · TypeScript
import { styles } from './styles.ts'
import { clientScript } from './client-script.ts'
import type { AppVersion } from '../version-shape.ts'

export function renderAppPage(input: {
	appBasePath: string
	hostedUrl: string
	repoLabel: string
	version?: AppVersion
}): string {
	const base = input.appBasePath.endsWith('/')
		? input.appBasePath
		: `${input.appBasePath}/`
	const version = input.version
	return `<!doctype html>
<html lang="en" data-base="${escapeAttr(base)}" data-running-sha="${escapeAttr(version?.sha || '')}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="theme-color" content="#c8102e" media="(prefers-color-scheme: light)">
  <meta name="theme-color" content="#16090c" media="(prefers-color-scheme: dark)">
  <meta name="mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="apple-mobile-web-app-title" content="Lineage">
  <meta name="description" content="Commit-level lines that should count — generated files stay off the books.">
  <link rel="canonical" href="${escapeAttr(input.hostedUrl)}/">
  <meta property="og:type" content="website">
  <meta property="og:title" content="Lineage · ${escapeAttr(input.repoLabel)}">
  <meta property="og:description" content="Commit-level lines that should count — generated files stay off the books.">
  <meta property="og:image" content="${escapeAttr(input.hostedUrl)}/og-image.jpg">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta name="twitter:card" content="summary_large_image">
  <title>Lineage · ${escapeHtml(input.repoLabel)}</title>
  <link rel="manifest" href="${escapeAttr(base)}manifest.webmanifest">
  <link rel="icon" href="${escapeAttr(base)}favicon.png" type="image/png" sizes="64x64">
  <link rel="icon" href="${escapeAttr(base)}icon.svg" type="image/svg+xml">
  <link rel="apple-touch-icon" href="${escapeAttr(base)}apple-touch-icon.png">
  <style>${styles}</style>
</head>
<body>
  <main class="app" id="app">
    <header class="top">
      <div class="brand">
        <img class="mark" src="${escapeAttr(base)}pwa-192.png" width="40" height="40" alt="">
        <div class="brand-copy" data-status>
          <div class="title-row">
            <h1>Lineage</h1>
            <span class="status-mark" data-status-mark aria-hidden="true"></span>
          </div>
          <p class="status-text" data-status-text></p>
        </div>
      </div>
      <div class="actions">
        <button class="ghost" type="button" data-about>About</button>
        <div class="install-wrap" data-install-wrap>
          <button class="install install-trigger" type="button" data-install-open>
            Install
          </button>
          <div class="install-popover" id="lineage-install-popover" popover data-install-popover>
            <strong>Install Lineage</strong>
            <p>Full screen, works offline, and the last series stays on this device.</p>
            <button class="install" type="button" data-install>Install</button>
          </div>
        </div>
      </div>
    </header>
    <div class="install-hint" data-install-hint hidden>
      <span class="install-hint-icon" aria-hidden="true">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
          <path d="M12 3v10m0-10l-3.5 3.5M12 3l3.5 3.5M5 13v5a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3v-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
      </span>
      <p>Install Lineage: tap <strong>Share</strong>, then <strong>Add to Home Screen</strong>.</p>
      <button class="install-hint-dismiss" type="button" data-install-hint-dismiss aria-label="Dismiss install tip">×</button>
    </div>
    <section class="panel" data-panel aria-busy="false">
      <div class="chart-wrap">
        <div class="loading" data-loading hidden role="status" aria-live="polite">
          <div class="loading-card">
            <svg class="loading-mark" viewBox="0 0 280 92" aria-hidden="true">
              <path class="loading-area a" d="M0 78 C 40 74, 70 52, 110 48 S 180 60, 220 28 S 260 18, 280 12 V 92 H 0 Z"></path>
              <path class="loading-area b" d="M0 84 C 50 80, 90 70, 140 66 S 200 72, 280 40 V 92 H 0 Z"></path>
              <path class="loading-line" d="M0 78 C 40 74, 70 52, 110 48 S 180 60, 220 28 S 260 18, 280 12"></path>
            </svg>
            <p class="loading-kicker">Lineage</p>
            <h2 data-loading-title>Loading countable history</h2>
            <p data-loading-detail>Reading the latest countable commits and drawing the graph.</p>
            <div class="loading-bar" aria-hidden="true"><span></span></div>
          </div>
        </div>
        <div data-chart></div>
        <div class="tooltip" data-tooltip hidden></div>
      </div>
      <div class="controls">
        <div class="modes" data-modes>
          <button type="button" data-mode="cumulative" aria-pressed="true">Cumulative</button>
          <button type="button" data-mode="delta">Added / removed</button>
          <button type="button" data-mode="net">Net per commit</button>
        </div>
        <div class="presets" data-presets>
          <button type="button" data-preset="7d">7 days</button>
          <button type="button" data-preset="30d">30 days</button>
          <button type="button" data-preset="90d">90 days</button>
          <button type="button" data-preset="1y">1 year</button>
          <button type="button" data-preset="all">All</button>
        </div>
        <div class="range">
          <label>From<input id="from" type="datetime-local"></label>
          <label>To<input id="to" type="datetime-local"></label>
        </div>
        <div class="dual-range" data-dual-range>
          <div class="dual-range-track" data-range-track></div>
          <label class="sr-only" for="range-from">Range start</label>
          <input id="range-from" type="range" min="0" max="1" value="0" data-range-from>
          <label class="sr-only" for="range-to">Range end</label>
          <input id="range-to" type="range" min="0" max="1" value="1" data-range-to>
        </div>
        <div>
          <p class="eyebrow">Type</p>
          <div class="chips" data-kinds>
            <span class="chip-skeleton" aria-hidden="true"></span>
            <span class="chip-skeleton" aria-hidden="true"></span>
            <span class="chip-skeleton" aria-hidden="true"></span>
          </div>
        </div>
        <div>
          <p class="eyebrow">Language</p>
          <div class="chips" data-languages>
            <span class="chip-skeleton" aria-hidden="true"></span>
            <span class="chip-skeleton wide" aria-hidden="true"></span>
            <span class="chip-skeleton" aria-hidden="true"></span>
            <span class="chip-skeleton wide" aria-hidden="true"></span>
            <span class="chip-skeleton" aria-hidden="true"></span>
            <span class="chip-skeleton" aria-hidden="true"></span>
          </div>
        </div>
      </div>
      <section class="story">
        <p class="eyebrow">Story</p>
        <p class="story-lede">Tap a label on the chart or a card here for the longer note. These mark primitive adds, removals, and combinations — not every large diff.</p>
        <div class="story-list" data-story></div>
      </section>
      <div class="commits" data-commits></div>
    </section>
    <aside class="sheet" data-sheet hidden></aside>
    <aside class="sheet" data-about-sheet hidden></aside>
    <div class="toast" data-toast hidden>
      <div>
        <strong data-toast-title>Update available</strong>
        <p data-toast-text>A newer Lineage is ready. Reload to download it.</p>
      </div>
      <div class="toast-actions">
        <button class="install" type="button" data-toast-action>Reload</button>
        <button class="ghost" type="button" data-toast-dismiss>Later</button>
      </div>
    </div>
  </main>
  <script>${clientScript}</script>
</body>
</html>`
}

function escapeHtml(value: string): string {
	return value
		.replaceAll('&', '&amp;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
}

function escapeAttr(value: string): string {
	return escapeHtml(value).replaceAll('"', '&quot;')
}