Skip to the content

Capsid menu

app/components/editor/frontmatter-fields.tsx

112 lines and 10 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of frontmatter-fields.tsx
FunctionLinesComplexityCoveredCRAPMutation
withField26 to 2920%WatchCRAP 6No data: mutation testing has not run on this repo yet
FrontmatterFields38 to 118120%CriticalCRAP 156No data: mutation testing has not run on this repo yet
<anonymous:56>56 to 5610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:63>63 to 6350%WatchCRAP 30No data: mutation testing has not run on this repo yet
<anonymous:66>66 to 6610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:70>70 to 9490%CriticalCRAP 90No data: mutation testing has not run on this repo yet
<anonymous:75>75 to 7520%WatchCRAP 6No data: mutation testing has not run on this repo yet
onChange85 to 8510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:101>101 to 10420%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:107>107 to 11110%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports app/lib/frontmatter.ts, app/lib/kind-words.ts, app/lib/legal.ts. Imported by app/routes/ai-draft.tsx, app/routes/editor.tsx.

Source

// A post's frontmatter as labelled fields: editable in the editor, read-only on the AI draft page.
// The editor keeps the one source string; these fields read from it and write back through
// setField, so a key they do not show is never touched (app/lib/frontmatter.ts).

import { Field } from "capsomer/react/field";

import { joinSource, splitSource } from "@dustinedwards/site-api/frontmatter";
import { BANNER_WORD, FIELDS, readFields, setField, type FieldKey } from "~/lib/frontmatter";
import { kindNoun, kindWord } from "~/lib/kind-words";
import { legalTypeOf, marker, sectionsUsed } from "~/lib/legal";

const HINTS: Partial<Record<FieldKey, string>> = {
  tags: "Separated by commas.",
  key_takeaways: "One per line.",
  date: "Year, month, day: 2026-10-04.",
  site_name: "Filled into shared text as {{site_name}}.",
  operator_name: "Filled into shared text as {{operator_name}}.",
  contact: "An email address or a path. Shared text: {{contact}}.",
  jurisdiction: "Shared text: {{jurisdiction}}.",
  data_held: "What this site collects, separated by commas. Shared text: {{data_held}}.",
  last_updated: "Set when the Owner publishes a changed text. Edit it only to correct it.",
  banner: `Shows the word "${BANNER_WORD}" on the page until you are satisfied with it.`,
};

/** Sets one field in a source and returns the whole source. A source with no frontmatter is returned as it was. */
function withField(source: string, key: FieldKey, value: string): string {
  const parts = splitSource(source);
  return parts.front === null ? source : joinSource({ ...parts, front: setField(parts.front, key, value) });
}

export type SectionChoice = { key: string; title: string };

/**
 * `kind` is the site's kind for the item. A post (or an item with no kind) gets the post's fields and a
 * legal page the legal fields; any other kind keeps its content in keys of its own (a phage's host, a
 * cohort's researchers), so it gets its whole frontmatter as written, for the site to check on save.
 */
export function FrontmatterFields({ source, onChange, readOnly, sections = [], kind = null }: { source: string; onChange?: (next: string) => void; readOnly?: boolean; sections?: SectionChoice[]; kind?: string | null }) {
  const parts = splitSource(source);
  const { front, body } = parts;
  if (front === null) return null;
  const values = readFields(front);
  const legal = legalTypeOf(source) !== null;
  if (!legal && kind && kind !== "post") {
    return (
      <fieldset className="app-fm">
        <legend>{kindWord(kind)} details</legend>
        <div className="app-fm-field" data-wide="">
          <Field label="Frontmatter" help={`The site's own keys for this ${kindNoun(kind)}, as it stores them. The site checks them when you save.`}>
            <textarea
              className="cap-input app-fm-raw"
              value={front}
              readOnly={readOnly || !onChange}
              rows={Math.min(Math.max(front.split("\n").length + 1, 4), 24)}
              spellCheck={false}
              onChange={(event) => onChange?.(joinSource({ ...parts, front: event.target.value }))}
            />
          </Field>
        </div>
      </fieldset>
    );
  }
  const shown = FIELDS.filter((f) => (f.on === "all" || f.on === (legal ? "legal" : "post")) && (!readOnly || values[f.key] !== ""));
  if (shown.length === 0) return null;
  const used = sectionsUsed(body);
  const unused = sections.filter((s) => !used.includes(s.key));
  return (
    <fieldset className="app-fm">
      <legend>{legal ? "Legal page details" : "Post details"}</legend>
      {shown.map((f) => {
        if (f.kind === "flag") {
          return (
            <div key={f.key} className="app-fm-field" data-wide="">
              <Field label={f.label} help={HINTS[f.key]}>
                <input type="checkbox" checked={values[f.key] !== ""} disabled={readOnly || !onChange} onChange={(event) => onChange?.(withField(source, f.key, event.target.checked ? BANNER_WORD : ""))} />
              </Field>
            </div>
          );
        }
        const wide = f.kind !== "line" && f.key !== "tags";
        const props = {
          className: "cap-input",
          value: values[f.key],
          readOnly: readOnly || !onChange,
          onChange: (event: { target: { value: string } }) => onChange?.(withField(source, f.key, event.target.value)),
        };
        return (
          <div key={f.key} className="app-fm-field" data-wide={wide ? "" : undefined}>
            <Field label={f.label} help={HINTS[f.key]}>
              {f.kind === "text" || f.kind === "lines" ? <textarea {...props} rows={f.kind === "lines" ? 4 : 2} /> : <input {...props} type="text" />}
            </Field>
          </div>
        );
      })}
      {legal && !readOnly && onChange && unused.length > 0 ? (
        <div className="app-fm-field" data-wide="">
          <Field label="Add a shared section" help="Written at the end of the text. It is filled in from the details above each time the page is saved to the site; edit its wording on the Legal tab, not here.">
            <select
              className="cap-input"
              value=""
              onChange={(event) => {
                const key = event.target.value;
                if (key) onChange(`${source.replace(/\n*$/, "")}\n\n${marker(key)}\n`);
              }}
            >
              <option value="">Choose a section</option>
              {unused.map((s) => (
                <option key={s.key} value={s.key}>
                  {s.title}
                </option>
              ))}
            </select>
          </Field>
        </div>
      ) : null}
    </fieldset>
  );
}