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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| withField | 26 to 29 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| FrontmatterFields | 38 to 118 | 12 | 0% | CriticalCRAP 156 | No data: mutation testing has not run on this repo yet |
| <anonymous:56> | 56 to 56 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:63> | 63 to 63 | 5 | 0% | WatchCRAP 30 | No data: mutation testing has not run on this repo yet |
| <anonymous:66> | 66 to 66 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:70> | 70 to 94 | 9 | 0% | CriticalCRAP 90 | No data: mutation testing has not run on this repo yet |
| <anonymous:75> | 75 to 75 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| onChange | 85 to 85 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:101> | 101 to 104 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:107> | 107 to 111 | 1 | 0% | OkCRAP 2 | No 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>
);
}