Skip to the content

Capsid menu

app/routes/cv.tsx

547 lines and 40 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of cv.tsx
FunctionLinesComplexityCoveredCRAPMutation
headers58 to 601No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
loader67 to 841No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:72>72 to 721No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:78>78 to 781No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
meta86 to 971No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
cvJsonLd103 to 1171No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:104>104 to 1042No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
isOwner119 to 1192No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
Names121 to 1321No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:124>124 to 1293No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
ShortAuthors135 to 1525No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
Tags154 to 1604No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
CopyButton162 to 1785No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
PaperDetails180 to 22911No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:196>196 to 2001No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
EntryBody231 to 2848No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:260>260 to 2641No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:269>269 to 2781No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:273>273 to 2751No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
Entry289 to 3139No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
Stat315 to 3402No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
CvRoute342 to 5715No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:345>345 to 3451No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:346>346 to 3461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:429>429 to 4392No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:446>446 to 4461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:446>446 to 4461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:446>446 to 4562No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:464>464 to 4681No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:473>473 to 4771No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:485>485 to 4851No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:485>485 to 4851No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:485>485 to 4892No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:495>495 to 4991No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:521>521 to 5613No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:522>522 to 5221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:522>522 to 5221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:542>542 to 5582No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:547>547 to 5471No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:551>551 to 5553No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/components/enhance.tsx, app/components/page-shell.tsx, app/db/cv.ts, app/lib/content-pages.mjs, app/lib/context.ts, app/lib/cv/entries.mjs, app/lib/cv/render-charts.ts, app/lib/cv/route.ts, app/lib/cv/view.mjs, app/lib/json-ld.mjs, app/lib/scientific-names.tsx, app/lib/seo.ts. No module of this repo imports it.

Source

import { Fragment, type ReactNode } from "react";

import { Enhance } from "~/components/enhance";
import { PageShell } from "~/components/page-shell";
import { readCv } from "~/db/cv";
import { contentPageCardPath, contentPageMarkdownPath } from "~/lib/content-pages.mjs";
import { getEnv } from "~/lib/context";
import { CV_PAGE, CV_PDF_PATH, cvFacts, formatDollars, type Cv, type CvEntry } from "~/lib/cv/entries.mjs";
import { CV_HTML_TAGS } from "~/lib/cv/route";
import { renderCvCharts } from "~/lib/cv/render-charts";
import {
  AREAS,
  ROLES,
  SORTS,
  TYPES,
  areaLabel,
  facetCounts,
  groupEntries,
  headline,
  activeCount,
  isFiltered,
  matches,
  parseState,
  roleLabel,
  summaryText,
  yearOptions,
} from "~/lib/cv/view.mjs";
import { jsonLd as serializeJsonLd } from "~/lib/json-ld.mjs";
import { italicizeOrganisms } from "~/lib/scientific-names";
import {
  SITE_ORIGIN,
  isSiteOwner,
  pageMeta,
  personId,
  publicHtmlHeaders,
  publicationsJsonLd,
} from "~/lib/seo";

import type { Route } from "./+types/cv";

import "~/styles/enarratio.css";
import "~/styles/cv.css";

/*
 * THE CV (content/cv/, drawn from D1 at request time and resolved by app/lib/cv/entries.mjs, docs/CV.md). A CV
 * edit saved through Carrel is live on the next request, with no build or deploy. The whole CV is in the HTML: the query
 * string only decides which entries carry `hidden`, how they are grouped and what the counts and charts
 * say, so a shared filter URL renders the same page with script off, and the form is a plain GET.
 * app/enhance/cv.ts runs the same functions (app/lib/cv/view.mjs) on every input.
 */

const PATH = CV_PAGE.path;
/** The CV's own social card (CARDED_PAGE_ROOTS), which build:og draws from the same title and description. */
const CARD_PATH = contentPageCardPath(CV_PAGE);
const CARD = CARD_PATH ? `${SITE_ORIGIN}${CARD_PATH}` : undefined;

// headers() is written out here because check:headers reads each public route's own source.
export function headers() {
  return new Headers(publicHtmlHeaders(CV_HTML_TAGS));
}

/*
 * The CV is drawn per request, and the page's script reads it back out of the DOM (data attributes on each
 * entry), so the server and the browser never hold two copies. The route does not hydrate, so none of this
 * reaches the browser as data: the loader hands the component the resolved CV to render and nothing more.
 */
export async function loader({ request, context }: Route.LoaderArgs) {
  const cv = await readCv(getEnv(context));
  const facts = cvFacts(cv.entries);
  const url = new URL(request.url);
  const state = parseState(url.searchParams);
  const shown = facts.filter((f) => matches(f, state));
  return {
    cv,
    facts,
    jsonLd: serializeJsonLd(cvJsonLd(cv)),
    state,
    shownIds: shown.map((f) => f.id),
    groups: groupEntries(facts, state.sort),
    counts: headline(shown),
    facets: facetCounts(facts, state),
    charts: renderCvCharts(facts, state),
  };
}

export function meta() {
  return [
    ...pageMeta({ title: CV_PAGE.seoTitle, description: CV_PAGE.description, path: PATH, image: CARD }),
    {
      tagName: "link",
      rel: "alternate",
      type: "text/markdown",
      href: `${SITE_ORIGIN}${contentPageMarkdownPath(PATH)}`,
    },
    { tagName: "link", rel: "alternate", type: "application/pdf", href: `${SITE_ORIGIN}${CV_PDF_PATH}` },
  ];
}

/**
 * The page about the Person, and a ScholarlyArticle per paper the site holds, joined by the Person's @id.
 * No BreadcrumbList: the page shows no trail, and a one-step list is one Google reads as invalid.
 */
function cvJsonLd(cv: Cv) {
  const records = cv.entries.flatMap((e) => (e.paper?.record ? [e.paper.record] : []));
  return [
    {
      "@context": "https://schema.org",
      "@type": "WebPage",
      "@id": `${SITE_ORIGIN}${PATH}`,
      url: `${SITE_ORIGIN}${PATH}`,
      name: `${CV_PAGE.title}, ${cv.person.name}`,
      description: CV_PAGE.description,
      about: { "@id": personId(SITE_ORIGIN) },
    },
    ...publicationsJsonLd(SITE_ORIGIN, records),
  ];
}

const isOwner = (name: string) => isSiteOwner(name) || name === "D. Edwards";

function Names({ names }: { names: string[] }) {
  return (
    <>
      {names.map((name, i) => (
        <Fragment key={`${name}-${i}`}>
          {i > 0 ? ", " : ""}
          {isOwner(name) ? <strong className="cv-me">{name}</strong> : name}
        </Fragment>
      ))}
    </>
  );
}

/** The first three authors, and Dustin where he falls later; the full list is in the details below. */
function ShortAuthors({ authors }: { authors: string[] }) {
  if (authors.length <= 4) return <Names names={authors} />;
  const at = authors.findIndex(isOwner);
  const lead = authors.slice(0, 3);
  const owner = at >= 3 ? authors[at] : undefined;
  const rest = authors.length - lead.length - (owner ? 1 : 0);
  return (
    <>
      <Names names={lead} />
      {owner ? (
        <>
          , ... <strong className="cv-me">{owner}</strong>
        </>
      ) : null}{" "}
      and {rest} more
    </>
  );
}

function Tags({ entry }: { entry: CvEntry }) {
  const parts: string[] = [];
  if (entry.role) parts.push(roleLabel(entry.role));
  for (const area of entry.areas) parts.push(areaLabel(area));
  if (parts.length === 0) return null;
  return <p className="cv-tags">{parts.join(" · ")}</p>;
}

function CopyButton({ kind, label, entry }: { kind: "plain" | "bibtex" | "ris"; label: string; entry: CvEntry }) {
  const paper = entry.paper;
  if (!paper) return null;
  const src = kind === "bibtex" ? paper.bibtexPath : kind === "ris" ? paper.risPath : null;
  return (
    <button
      type="button"
      className="cv-copy"
      data-cv-copy={kind}
      data-cv-src={src ?? undefined}
      data-cv-for={entry.id}
      hidden
    >
      {label}
    </button>
  );
}

function PaperDetails({ entry }: { entry: CvEntry }) {
  const paper = entry.paper;
  if (!paper) return null;
  const n = paper.authors.length;
  return (
    <details className="cv-paper-more">
      <summary>
        {paper.hasAbstract ? "Abstract, citation" : "Citation"} and {n === 1 ? "author" : `all ${n} authors`}
      </summary>
      <div className="cv-paper-panel">
        <ul className="cv-links">
          {paper.pagePath ? (
            <li>
              <a href={paper.pagePath}>{paper.hasAbstract ? "Abstract" : "Paper page"}</a>
            </li>
          ) : null}
          {entry.links.map((link) => (
            <li key={link.href}>
              <a href={link.href}>{link.label}</a>
            </li>
          ))}
          {paper.pdfPath ? (
            <li>
              <a href={paper.pdfPath}>PDF</a>
            </li>
          ) : null}
        </ul>
        <p className="cv-panel-label">Citation</p>
        <p className="cv-cite" id={`${entry.id}-cite`}>
          {paper.citation}
        </p>
        {paper.bibtex ? (
          <template id={`${entry.id}-bibtex`}>{paper.bibtex}</template>
        ) : null}
        {paper.ris ? <template id={`${entry.id}-ris`}>{paper.ris}</template> : null}
        <p className="cv-cite-actions">
          <CopyButton kind="plain" label="Copy citation" entry={entry} />
          <CopyButton kind="bibtex" label="Copy BibTeX" entry={entry} />
          <CopyButton kind="ris" label="Copy RIS" entry={entry} />
          {paper.bibtexPath ? <a href={paper.bibtexPath}>BibTeX file</a> : null}
          {paper.risPath ? <a href={paper.risPath}>RIS file</a> : null}
        </p>
        <p className="cv-panel-label">Authors</p>
        <p className="cv-authors-full">
          <Names names={paper.authors} />
        </p>
      </div>
    </details>
  );
}

function EntryBody({ entry }: { entry: CvEntry }) {
  if (entry.type === "publication" && entry.paper) {
    const paper = entry.paper;
    return (
      <>
        <p className="cv-title">
          {paper.pagePath ? (
            <a href={paper.pagePath}>{italicizeOrganisms(entry.title)}</a>
          ) : (
            italicizeOrganisms(entry.title)
          )}
        </p>
        <p className="cv-authors">
          <ShortAuthors authors={paper.authors} />
        </p>
        <p className="cv-meta cv-venue">{paper.venue}</p>
        <Tags entry={entry} />
        <PaperDetails entry={entry} />
      </>
    );
  }
  return (
    <>
      <p className="cv-title">
        {entry.type === "grant" && entry.amount !== null ? (
          <span className="cv-amount">{formatDollars(entry.amount)}</span>
        ) : null}
        {entry.title}
      </p>
      {entry.meta.map((line, i) => (
        <p className="cv-meta" key={i}>
          {line}
        </p>
      ))}
      {entry.note ? <p className="cv-meta cv-note">{entry.note}</p> : null}
      {entry.duties.length > 0 ? (
        <details className="cv-duties">
          <summary>Responsibilities</summary>
          {entry.duties.map((duty) => (
            <div key={duty.heading} className="cv-duty">
              <p className="cv-panel-label">{duty.heading}</p>
              <ul>
                {duty.items.map((item) => (
                  <li key={item}>{item}</li>
                ))}
              </ul>
            </div>
          ))}
        </details>
      ) : null}
      <Tags entry={entry} />
    </>
  );
}

/** One or two lines each: the long lists read as a list, and the papers keep the room. */
const COMPACT = new Set(["grant", "award", "course", "service", "development"]);

function Entry({ entry, hidden, order }: { entry: CvEntry; hidden: boolean; order: number }) {
  return (
    <li
      className={`cv-entry cv-entry-${entry.type}${COMPACT.has(entry.type) ? " cv-compact" : ""}`}
      id={entry.id}
      data-cv-entry=""
      data-order={order}
      data-type={entry.type}
      data-section={entry.section ?? undefined}
      data-areas={entry.areas.join(" ")}
      data-role={entry.role ?? undefined}
      data-year={entry.year ?? undefined}
      data-end={entry.endYear ?? undefined}
      data-students={entry.students ?? undefined}
      data-cohort={entry.cohort ? "" : undefined}
      data-amount={entry.amount ?? undefined}
      hidden={hidden}
    >
      <span className="cv-when">{entry.when}</span>
      <div className="cv-body">
        <EntryBody entry={entry} />
      </div>
    </li>
  );
}

function Stat({
  label,
  value,
  sub,
  spark,
  name,
}: {
  label: string;
  value: string;
  sub?: ReactNode;
  spark: string;
  name: string;
}) {
  return (
    <div className="cv-stat">
      <dt>{label}</dt>
      <dd>
        <span className="cv-stat-value" data-cv-count={name}>
          {value}
        </span>
        <span className="cv-spark" data-cv-spark={name} dangerouslySetInnerHTML={{ __html: spark }} />
        {sub ? <span className="cv-stat-sub">{sub}</span> : null}
      </dd>
    </div>
  );
}

export default function CvRoute({ loaderData }: Route.ComponentProps) {
  const { cv: CV, facts: FACTS, jsonLd, state, shownIds, groups, counts, facets, charts } = loaderData;
  const shown = new Set(shownIds);
  const BY_ID = new Map(CV.entries.map((e) => [e.id, e]));
  const ORDER = new Map(CV.entries.map((e, i) => [e.id, i]));
  const YEARS = yearOptions(FACTS);
  return (
    <PageShell trail={<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd }} />}>
      <div className="cv" data-cv="">
        <header className="cv-head">
          <p className="cv-eyebrow">
            {CV_PAGE.title} · {CV.edition}
          </p>
          <h1 className="cv-name">
            {CV.person.name}, {CV.person.degree}
          </h1>
          <p className="cv-lede">
            {CV.person.title}, {CV.person.department}, {CV.person.org}.
          </p>
          <p className="cv-actions">
            <a href={CV_PDF_PATH} className="cv-action">
              Download PDF
            </a>
            <button type="button" className="cv-action" data-cv-print="" hidden>
              Print
            </button>
            <a href={contentPageMarkdownPath(PATH)} className="cv-action">
              Markdown
            </a>
            <a href="/research/publications" className="cv-action">
              All publications
            </a>
          </p>
        </header>

        <section className="cv-overview" aria-labelledby="cv-overview-title">
          <h2 id="cv-overview-title" className="sr-only">
            At a glance
          </h2>
          <dl className="cv-stats">
            <Stat label="Publications" value={String(counts.papers)} spark={charts.papers} name="papers" />
            <Stat
              label="Grants"
              value={String(counts.grants)}
              sub={<span data-cv-count="dollars">{formatDollars(counts.dollars)} awarded</span>}
              spark={charts.grants}
              name="grants"
            />
            <Stat
              label="Students mentored"
              value={String(counts.students)}
              sub="summed across listings"
              spark={charts.students}
              name="students"
            />
          </dl>
          <div className="cv-timeline">
            <p className="cv-timeline-title">Output by year</p>
            <div className="cv-timeline-plot" data-cv-timeline="" dangerouslySetInnerHTML={{ __html: charts.timeline }} />
            <p className="cv-timeline-hint" data-cv-hint="" hidden>
              Select a year&rsquo;s bar to show only that year, and select it again for every year. The key filters by type.
            </p>
          </div>
        </section>

        <form className="cv-filters" method="get" action={PATH} aria-label="Filter the CV" data-cv-form="">
          <div className="cv-filter-row cv-filter-top">
            <label className="cv-field cv-search">
              <span className="cv-label">Search</span>
              <input
                type="search"
                name="q"
                defaultValue={state.q}
                placeholder="Title, author, place, course"
                autoComplete="off"
                spellCheck={false}
              />
            </label>
          </div>
          <details className="cv-more" open data-cv-more="">
            <summary className="cv-more-summary">
              Filters and sort <span data-cv-active="">{activeCount(state) > 0 ? `(${activeCount(state)} on)` : ""}</span>
            </summary>
          <div className="cv-more-body">
          <fieldset className="cv-facet">
            <legend className="cv-label">Type</legend>
            <div className="cv-chips">
              {TYPES.map(([id, , plural]) => (
                <label key={id} className="cv-chip">
                  <input type="checkbox" name="type" value={id} defaultChecked={state.types.includes(id)} />
                  <span className="cv-chip-text">
                    {plural}
                    <span className="cv-chip-count" data-cv-facet={`type:${id}`}>
                      {facets.types[id] ?? 0}
                    </span>
                  </span>
                </label>
              ))}
            </div>
          </fieldset>
          <div className="cv-filter-row">
            <fieldset className="cv-facet">
              <legend className="cv-label">Research area</legend>
              <div className="cv-chips">
                {AREAS.filter(([id]) => FACTS.some((f) => f.areas.includes(id))).map(([id, label]) => (
                  <label key={id} className="cv-chip">
                    <input type="checkbox" name="area" value={id} defaultChecked={state.areas.includes(id)} />
                    <span className="cv-chip-text">
                      {label}
                      <span className="cv-chip-count" data-cv-facet={`area:${id}`}>
                        {facets.areas[id] ?? 0}
                      </span>
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>
            <fieldset className="cv-facet cv-years">
              <legend className="cv-label">Years</legend>
              <div className="cv-years-row">
                <select name="from" defaultValue={state.from === null ? "" : String(state.from)} aria-label="From year">
                  <option value="">Earliest</option>
                  {YEARS.map((y) => (
                    <option key={y} value={y}>
                      {y}
                    </option>
                  ))}
                </select>
                <span aria-hidden="true">to</span>
                <select name="to" defaultValue={state.to === null ? "" : String(state.to)} aria-label="To year">
                  <option value="">Latest</option>
                  {[...YEARS].reverse().map((y) => (
                    <option key={y} value={y}>
                      {y}
                    </option>
                  ))}
                </select>
              </div>
            </fieldset>
            <label className="cv-field">
              <span className="cv-label">Role</span>
              <select name="role" defaultValue={state.role ?? ""}>
                <option value="">Any role</option>
                {ROLES.filter(([id]) => FACTS.some((f) => f.role === id)).map(([id, label]) => (
                  <option key={id} value={id} data-cv-facet={`role:${id}`} data-label={label}>
                    {label} ({facets.roles[id] ?? 0})
                  </option>
                ))}
              </select>
            </label>
            <label className="cv-field">
              <span className="cv-label">Sort</span>
              <select name="sort" defaultValue={state.sort}>
                {SORTS.map(([id, label]) => (
                  <option key={id} value={id}>
                    {label}
                  </option>
                ))}
              </select>
            </label>
          </div>
          </div>
          </details>
          <div className="cv-filter-actions">
            <button type="submit" className="cv-apply" data-cv-apply="">
              Apply filters
            </button>
            <a href={PATH} className="cv-clear" data-cv-clear="" hidden={!isFiltered(state)}>
              Clear filters
            </a>
          </div>
        </form>

        <p className="cv-summary" role="status" data-cv-summary="">
          {summaryText(shownIds.length, FACTS.length)}
        </p>
        <p className="sr-only" role="status" data-cv-copied="" />

        <div className="cv-results" data-cv-results="">
          {groups.map((group) => {
            const visible = group.parts.reduce((n, part) => n + part.ids.filter((id) => shown.has(id)).length, 0);
            return (
              <section key={group.key} className="cv-group" data-cv-group={group.key} hidden={visible === 0}>
                <h2 className="cv-group-title">
                  {group.heading}{" "}
                  <span className="cv-group-count" data-cv-group-count="">
                    {visible}
                  </span>
                </h2>
                {group.key === "type-talk" ? (
                  <p className="cv-group-note">
                    The CV lists {CV.presentations.international} international and {CV.presentations.national} national
                    and regional presentations, {CV.presentations.from}-{CV.presentations.to}, most of them posters and
                    talks given by students with Dustin as senior author. Listed here: the invited talks, seminars and
                    roundtables he leads.
                  </p>
                ) : null}
                {group.key === "type-mentoring" ? (
                  <p className="cv-group-note">Counts only: the CV names each student, and this page never does.</p>
                ) : null}
                {group.parts.map((part) => (
                  <div
                    key={part.key}
                    className="cv-part"
                    data-cv-part={part.key}
                    hidden={!part.ids.some((id) => shown.has(id))}
                  >
                    {part.heading ? <h3 className="cv-part-title">{part.heading}</h3> : null}
                    <ol className="cv-list">
                      {part.ids.map((id) => {
                        const entry = BY_ID.get(id);
                        if (!entry) throw new Error(`cv: grouping named an unknown entry ${id}`);
                        return <Entry key={id} entry={entry} hidden={!shown.has(id)} order={ORDER.get(id) ?? 0} />;
                      })}
                    </ol>
                  </div>
                ))}
              </section>
            );
          })}
        </div>
        <p className="cv-foot">
          {FACTS.length} entries from the {CV.edition} CV. Each paper follows its journal record at Crossref and has its
          own page under <a href="/research/publications">Publications</a>.
        </p>
      </div>
      <Enhance module="cv" />
    </PageShell>
  );
}