app/lib/editor/frontmatter.ts
292 lines and 24 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| parseTagInput | 55 to 60 | 1 | 20% | OkCRAP 1.5 | No data: no mutants in this code |
| <anonymous:58> | 58 to 58 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| normalizeBody | 63 to 65 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| readingEntries | 72 to 98 | 4 | 100% | OkCRAP 4 | No data: no mutants in this code |
| <anonymous:92> | 92 to 97 | 5 | 100% | OkCRAP 5 | No data: no mutants in this code |
| parseFurtherReading | 104 to 108 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:105> | 105 to 106 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| constructor | 114 to 118 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| plain | 126 to 135 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| tagScalar | 141 to 143 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| serializePost | 145 to 202 | 13 | 90% | WatchCRAP 13.2 | No data: no mutants in this code |
| parsePost | 204 to 240 | 5 | 100% | OkCRAP 5 | No data: no mutants in this code |
| asString | 208 to 211 | 4 | 100% | OkCRAP 4 | No data: no mutants in this code |
| <anonymous:220> | 220 to 220 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| splitReading | 252 to 258 | 1 | 20% | OkCRAP 1.5 | No data: no mutants in this code |
| <anonymous:255> | 255 to 255 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:256> | 256 to 256 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| furtherReadingFromForm | 264 to 298 | 16 | 0% | CriticalCRAP 272 | No data: no mutants in this code |
| <anonymous:267> | 267 to 267 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:268> | 268 to 268 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| fieldsFromForm | 300 to 328 | 1 | 4% | OkCRAP 1.9 | No data: no mutants in this code |
| get | 301 to 301 | 2 | 0% | WatchCRAP 6 | No data: no mutants in this code |
| <anonymous:317> | 317 to 320 | 2 | 0% | WatchCRAP 6 | No data: no mutants in this code |
| <anonymous:318> | 318 to 318 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
Imports
It imports app/lib/editor/intent.mjs, app/lib/error-message.mjs. Imported by app/components/admin/post-editor.tsx, app/components/admin/post-metadata.tsx, app/components/admin/revision-list.tsx, app/lib/admin/posts-actions.server.ts, app/lib/carrel/site-adapter.server.ts, app/lib/editor/action.server.ts, app/lib/editor/preview-link-action.server.ts, app/routes/admin.posts.$slug.edit.tsx, app/routes/admin.posts.$slug.revisions.tsx, app/routes/admin.posts.new.tsx, app/routes/admin.preview.ts.
Source
import matter from "gray-matter";
import { readIntent } from "./intent.mjs";
import { draftForIntent } from "capsomer/publish-transition";
import { errorMessage } from "../error-message.mjs";
// Punctuation-bearing scalars are written as JSON strings: valid YAML double-quoted scalars that
// escape quotes and colons without a YAML serializer.
export type PostFields = {
title: string;
slug: string;
description: string;
date: string;
tags: string[];
draft: boolean;
publishAt: string;
coverSrc: string;
coverAlt: string;
body: string;
/** Server-owned but carried: a dropped value would make a published post read as never published. */
firstPublished: string;
/** Carried through hidden inputs so a browser save cannot erase keys set by hand or by the API. */
featured: boolean;
series: string;
part: string;
furtherReading: string;
ogTitle: string;
ogDescription: string;
updated: string;
};
export const EMPTY_FIELDS: PostFields = {
title: "",
slug: "",
description: "",
date: "",
tags: [],
draft: true,
publishAt: "",
coverSrc: "",
coverAlt: "",
body: "",
firstPublished: "",
featured: false,
series: "",
part: "",
furtherReading: "",
ogTitle: "",
ogDescription: "",
updated: "",
};
/** Tags as an author types them: comma or newline separated, lower-cased, blanks dropped. */
export function parseTagInput(input: string) {
return input
.split(/[,\n]/)
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
}
/** Multipart posts turn every newline into CRLF, so save and preview must both normalize here. */
export function normalizeBody(body: string) {
return body.replace(/\r\n/g, "\n").trim();
}
/**
* The further-reading value as entries with string titles and urls, as written. A value that is not a
* JSON array is refused, not read as empty: read as empty, the editor would show no rows and a save
* would erase the post's further reading.
*/
function readingEntries(raw: string): ReadingItem[] {
if (!raw.trim()) return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
const detail = errorMessage(error);
throw new FrontmatterError(
`The further reading list did not arrive as JSON (${detail}), so nothing was saved rather ` +
`than saving the post without it.`,
"furtherReading",
);
}
if (!Array.isArray(parsed)) {
throw new FrontmatterError(
"The further reading list did not arrive as a list, so nothing was saved rather than saving " +
"the post without it.",
"furtherReading",
);
}
return parsed.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const { title, url } = item as Record<string, unknown>;
if (typeof title !== "string" || typeof url !== "string") return [];
return [{ title, url }];
});
}
/**
* Loose on purpose, since the schema judges the content. A half entry is dropped: it would fail
* the save on data the author never typed.
*/
function parseFurtherReading(raw: string) {
return readingEntries(raw).flatMap(({ title, url }) =>
title.trim() && url.trim() ? [{ title: title.trim(), url: url.trim() }] : [],
);
}
/** A field value that cannot be written into frontmatter without changing its structure. */
export class FrontmatterError extends Error {
field: string;
constructor(message: string, field: string) {
super(message);
this.name = "FrontmatterError";
this.field = field;
}
}
// What would end or reshape a plain YAML scalar: a newline, a leading indicator, `: ` or ` #`, or edge
// whitespace. These keys stay unquoted because quoting changes their type (a date, a number), so an
// unsafe value is refused by name rather than written.
const UNSAFE_PLAIN = /[\r\n]|^[\s\-?:,[\]{}#&*!|>'"%@`]|:\s|\s#|:$|\s$/;
function plain(field: keyof PostFields, key: string, value: string) {
if (UNSAFE_PLAIN.test(value)) {
throw new FrontmatterError(
`The ${key} ${JSON.stringify(value)} cannot be written into the frontmatter as it is: it ` +
`contains a newline or YAML punctuation that would change the file's structure.`,
field,
);
}
return value;
}
// A tag inside the flow sequence stays bare when it is plainly a word, so existing files keep their
// bytes; anything else (a comma, a bracket, a `#`, a quote) is written as a JSON string.
const BARE_TAG = /^[a-z0-9][a-z0-9._+-]*( [a-z0-9._+-]+)*$/;
function tagScalar(tag: string) {
return BARE_TAG.test(tag) ? tag : JSON.stringify(tag);
}
export function serializePost(fields: PostFields) {
const lines = [
"---",
`title: ${JSON.stringify(fields.title)}`,
`slug: ${plain("slug", "slug", fields.slug)}`,
`description: ${JSON.stringify(fields.description)}`,
`date: ${plain("date", "date", fields.date)}`,
`tags: [${fields.tags.map(tagScalar).join(", ")}]`,
`draft: ${fields.draft ? "true" : "false"}`,
];
if (fields.publishAt.trim()) {
lines.push(`publish_at: ${JSON.stringify(fields.publishAt.trim())}`);
}
if (fields.firstPublished.trim()) {
lines.push(
`first_published: ${plain("firstPublished", "first_published", fields.firstPublished.trim())}`,
);
}
if (fields.coverSrc.trim()) {
lines.push("cover:");
lines.push(` src: ${plain("coverSrc", "cover src", fields.coverSrc.trim())}`);
lines.push(` alt: ${JSON.stringify(fields.coverAlt)}`);
}
// Carried keys are emitted only when set, so a post that never used one is byte-identical.
if (fields.featured) lines.push("featured: true");
if (fields.series.trim()) {
lines.push(`series: ${JSON.stringify(fields.series.trim())}`);
}
// Unquoted: `part` is a number in the schema and a quoted scalar is a string.
if (fields.part.trim()) lines.push(`part: ${plain("part", "part", fields.part.trim())}`);
if (fields.ogTitle.trim()) {
lines.push(`og_title: ${JSON.stringify(fields.ogTitle.trim())}`);
}
if (fields.ogDescription.trim()) {
lines.push(`og_description: ${JSON.stringify(fields.ogDescription.trim())}`);
}
if (fields.updated.trim()) {
lines.push(`updated: ${plain("updated", "updated", fields.updated.trim())}`);
}
const reading = parseFurtherReading(fields.furtherReading);
if (reading.length > 0) {
lines.push("further_reading:");
for (const item of reading) {
lines.push(` - title: ${JSON.stringify(item.title)}`);
lines.push(` url: ${JSON.stringify(item.url)}`);
}
}
lines.push("---", "");
// One blank line after the frontmatter and one trailing newline, so repeated saves do not drift.
return `${lines.join("\n")}\n${normalizeBody(fields.body)}\n`;
}
export function parsePost(raw: string): PostFields {
const parsed = matter(raw);
const data = parsed.data as Record<string, unknown>;
const asString = (value: unknown) => {
if (value instanceof Date) return value.toISOString().slice(0, 10);
return value === undefined || value === null ? "" : String(value);
};
const cover = (data.cover ?? {}) as Record<string, unknown>;
return {
title: asString(data.title),
slug: asString(data.slug),
description: asString(data.description),
date: asString(data.date),
tags: Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [],
draft: data.draft === true,
publishAt: data.publish_at instanceof Date
? data.publish_at.toISOString()
: asString(data.publish_at),
coverSrc: asString(cover.src),
coverAlt: asString(cover.alt),
body: parsed.content.replace(/^\n+/, ""),
firstPublished: asString(data.first_published),
featured: data.featured === true,
series: asString(data.series),
part: asString(data.part),
// JSON, because a FormData value is a string.
furtherReading: Array.isArray(data.further_reading)
? JSON.stringify(data.further_reading)
: "",
ogTitle: asString(data.og_title),
ogDescription: asString(data.og_description),
updated: asString(data.updated),
};
}
export const FR_CONTROL = "frControl";
export const FR_TITLE = "frTitle";
export const FR_URL = "frUrl";
export const FR_INTERNAL = "frInternal";
/** Mirrors the schema's rule for internal links. */
const INTERNAL_PREFIX = "/writing/";
type ReadingItem = { title: string; url: string };
export function splitReading(raw: string) {
const items = readingEntries(raw);
return {
external: items.filter((item) => !item.url.startsWith(INTERNAL_PREFIX)),
internal: items.filter((item) => item.url.startsWith(INTERNAL_PREFIX)),
};
}
/**
* The hidden marker separates "the author removed every row" from "the control never rendered",
* which both arrive as no fields. A half row is kept so the schema refuses it by name.
*/
function furtherReadingFromForm(form: FormData, carried: string): string {
if (form.get(FR_CONTROL) === null) return carried;
const titles = form.getAll(FR_TITLE).map((v) => String(v));
const urls = form.getAll(FR_URL).map((v) => String(v));
const items: ReadingItem[] = [];
for (let i = 0; i < Math.max(titles.length, urls.length); i += 1) {
const title = (titles[i] ?? "").trim();
const url = (urls[i] ?? "").trim();
if (!title && !url) continue;
items.push({ title, url });
}
// Each checkbox value carries slug and title as JSON, so the form's field names do not grow with
// the blog. The title is a snapshot from when the box was ticked.
for (const value of form.getAll(FR_INTERNAL)) {
let slug = "";
let title = "";
try {
const parsed: unknown = JSON.parse(String(value));
if (parsed && typeof parsed === "object") {
const record = parsed as Record<string, unknown>;
slug = typeof record.slug === "string" ? record.slug : "";
title = typeof record.title === "string" ? record.title : "";
}
} catch {
continue;
}
if (!slug) continue;
items.push({ title: title.trim() || slug, url: `${INTERNAL_PREFIX}${slug}` });
}
return items.length > 0 ? JSON.stringify(items) : "";
}
export function fieldsFromForm(form: FormData): PostFields {
const get = (key: string) => String(form.get(key) ?? "");
return {
title: get("title"),
slug: get("slug").trim().toLowerCase(),
description: get("description"),
date: get("date"),
tags: parseTagInput(get("tags")),
// From the button pressed, not a hidden field, so it works without script. Unknown means draft.
draft: draftForIntent(readIntent(form)),
publishAt: get("publishAt"),
coverSrc: get("coverSrc"),
coverAlt: get("coverAlt"),
body: get("body"),
firstPublished: get("firstPublished"),
// The hidden "false" always submits and a ticked box adds "true" after it, so the LAST value wins;
// form.get() returns the first, which would make the checkbox do nothing.
featured: (() => {
const all = form.getAll("featured").map((v) => String(v));
return all.length > 0 && all[all.length - 1] === "true";
})(),
series: get("series"),
part: get("part"),
furtherReading: furtherReadingFromForm(form, get("furtherReading")),
ogTitle: get("ogTitle"),
ogDescription: get("ogDescription"),
updated: get("updated"),
};
}