Skip to the content

Capsid menu

app/kb/procedures/parse.mjs

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

Functions

The functions of parse.mjs
FunctionLinesComplexityCoveredCRAPMutation
procedurePath15 to 171100%OkCRAP 1Critical0%
headingId26 to 281100%OkCRAP 1Ok100%
readStep71 to 1673299%CriticalCRAP 32Watch69%
<anonymous:92>92 to 921100%OkCRAP 1No data: changed since the last mutation run
<anonymous:154>154 to 1541100%OkCRAP 1No data: changed since the last mutation run
<anonymous:155>155 to 1551100%OkCRAP 1No data: changed since the last mutation run
<anonymous:156>156 to 1561100%OkCRAP 1No data: changed since the last mutation run
<anonymous:157>157 to 1571100%OkCRAP 1No data: changed since the last mutation run
<anonymous:158>158 to 1581100%OkCRAP 1No data: changed since the last mutation run
<anonymous:159>159 to 1591100%OkCRAP 1No data: changed since the last mutation run
<anonymous:160>160 to 1601100%OkCRAP 1No data: changed since the last mutation run
readBlocks176 to 22920100%WatchCRAP 20Critical48.2%
flushProse187 to 1912100%OkCRAP 2Ok87.5%
<anonymous:200>200 to 2001100%OkCRAP 1No data: changed since the last mutation run
parseProcedure234 to 286477%OkCRAP 4.2No data: changed since the last mutation run
close258 to 2632100%OkCRAP 2Ok85.7%
<anonymous:266>266 to 2777100%WatchCRAP 7No data: changed since the last mutation run
<anonymous:281>281 to 2811100%OkCRAP 1No data: changed since the last mutation run
allSteps295 to 2991100%OkCRAP 1Ok100%
<anonymous:296>296 to 2971100%OkCRAP 1No data: changed since the last mutation run
<anonymous:297>297 to 2972100%OkCRAP 2No data: changed since the last mutation run
readCalcs309 to 3224100%OkCRAP 4Ok100%
stepConditions329 to 3311100%OkCRAP 1Ok100%
<anonymous:330>330 to 3301100%OkCRAP 1No data: changed since the last mutation run

Imports

It imports app/kb/procedures/marks.mjs, app/lib/yaml-plain.mjs. Imported by app/components/procedure-steps.tsx, app/kb/editor.server.ts, app/kb/procedures/compile.mjs, app/kb/procedures/primer-dependents.server.ts, app/kb/procedures/render.mjs, app/kb/procedures/save.server.ts, app/kb/procedures/validate.mjs, app/kb/registry/host.server.ts, app/lib/carrel/procedure-handler.server.ts, app/lib/health/checks.server.ts, app/lib/operator/api.server.ts, app/lib/operator/sync-tools.server.ts.

Source

// Reads a procedure file (docs/PROCEDURES.md) into its structure: the front matter as written, then the
// body as an intro and sections, each section a run of prose blocks and step lists. Nothing here renders
// or judges; validate.mjs judges and render.mjs renders, so the check and the save tool read one parse.

import { slug as githubSlug } from "github-slugger";
import matter from "gray-matter";

import { yamlPlain } from "../../lib/yaml-plain.mjs";

import { readConditions, segmentText, tokenize } from "./marks.mjs";

export const PROCEDURES_DIR = "content/procedures";

/** The repository path of a procedure's file. */
export function procedurePath(/** @type {string} */ slug) {
  return `${PROCEDURES_DIR}/${slug}.md`;
}

/**
 * The heading id rehype-slug gives the same words, by the same library, so an anchor the old pages
 * published keeps working. A repeated heading's -1, -2 suffix is render.mjs's, which slugs the page in
 * one pass as rehype-slug does.
 *
 * @param {string} text
 */
export function headingId(text) {
  return githubSlug(text);
}

const FLAG_KEYS = /** @type {const} */ ({
  CRITICAL: "critical",
  "PAUSE POINT": "pause",
  WHY: "why",
  TROUBLESHOOTING: "troubleshooting",
  EXPECT: "expect",
  SPIN: "spin",
  CALC: "calc",
  CHALLENGE: "challenge",
  SOLUTION: "solution",
});

/** The platforms a fenced block may name (docs/PROCEDURES.md, S4): a how-to's commands where they differ by system. */
export const PLATFORMS = /** @type {const} */ ({ linux: "Linux", macos: "macOS", windows: "Windows" });

/**
 * @typedef {{ critical: string[], pause: string[], why: string[], troubleshooting: string[], expect: string[], spin: string[], calc: string[], challenge: string[], solution: string[] }} Flags
 * @typedef {{ lang: string, code: string, output: string | null, platform: string | null }} Command
 * @typedef {{
 *   number: number,
 *   line: number,
 *   source: string,
 *   segments: import("./marks.mjs").Segment[],
 *   flags: Flags,
 *   commands: Command[],
 *   photos: Array<{ alt: string, src: string }>,
 * }} Step
 * @typedef {{ type: "prose", markdown: string } | { type: "steps", steps: Step[] }} Block
 * @typedef {{ title: string, id: string, custom: boolean, line: number, blocks: Block[] }} Section
 */

/**
 * The lines under a step, with its indentation removed, split into the step's own words, its flags,
 * its commands and its photos.
 *
 * @param {string} first the text after "N. "
 * @param {string[]} rest the indented lines under it, dedented
 * @param {number} line
 * @param {number} number
 * @returns {{ step: Step, problems: string[] }}
 */
function readStep(first, rest, line, number) {
  /** @type {string[]} */
  const problems = [];
  /** @type {string[]} */
  const words = [first];
  /** @type {Flags} */
  const flags = { critical: [], pause: [], why: [], troubleshooting: [], expect: [], spin: [], calc: [], challenge: [], solution: [] };
  /** @type {Command[]} */
  const commands = [];
  /** @type {Array<{ alt: string, src: string }>} */
  const photos = [];
  /** @type {keyof Flags | null} */
  let openFlag = null;

  for (let i = 0; i < rest.length; i += 1) {
    const text = rest[i] ?? "";
    // "```bash platform=linux": a language, then attributes; platform is the only one the format has.
    const fence = /^```(\S*)((?:\s+[a-z]+=\S+)*)\s*$/.exec(text);
    if (fence) {
      openFlag = null;
      const lang = fence[1] ?? "";
      const attrs = Object.fromEntries([...(fence[2] ?? "").matchAll(/([a-z]+)=(\S+)/g)].map((m) => [m[1], m[2]]));
      for (const key of Object.keys(attrs)) if (key !== "platform") problems.push(`line ${line}: step ${number} has a code block with "${key}=", and platform= is the only attribute a block takes`);
      /** @type {string[]} */
      const code = [];
      i += 1;
      while (i < rest.length && !/^```\s*$/.test(rest[i] ?? "")) {
        code.push(rest[i] ?? "");
        i += 1;
      }
      if (i >= rest.length) problems.push(`line ${line}: step ${number} has a code block that is never closed`);
      if (lang === "output") {
        const target = commands[commands.length - 1];
        if (!target) problems.push(`line ${line}: step ${number} has an output block with no command before it`);
        else target.output = code.join("\n");
      } else {
        commands.push({ lang, code: code.join("\n"), output: null, platform: attrs.platform ?? null });
      }
      continue;
    }
    const flag = /^>\s*(CRITICAL|PAUSE POINT|WHY|TROUBLESHOOTING|EXPECT|SPIN|CALC|CHALLENGE|SOLUTION)\s*:\s*(.*)$/.exec(text);
    if (flag) {
      openFlag = FLAG_KEYS[/** @type {keyof typeof FLAG_KEYS} */ (flag[1])];
      const body = (flag[2] ?? "").trim();
      // A solution answers the challenge before it (the Carpentries challenge with its nested solution), one each.
      if (openFlag === "solution" && flags.solution.length >= flags.challenge.length) {
        problems.push(`line ${line}: step ${number} has a SOLUTION with no CHALLENGE before it to answer`);
      }
      if (openFlag === "troubleshooting" || openFlag === "calc") {
        flags[openFlag].push(...body.split(/[,\s]+/).filter(Boolean));
      } else {
        flags[openFlag].push(body);
      }
      continue;
    }
    const more = /^>\s?(.*)$/.exec(text);
    if (more) {
      if (!openFlag || openFlag === "troubleshooting" || openFlag === "calc") {
        problems.push(`line ${line}: step ${number} has a quoted line that follows no flag: "${text.trim()}"`);
      } else {
        const list = flags[openFlag];
        list[list.length - 1] = `${list[list.length - 1] ?? ""}\n${more[1] ?? ""}`.replace(/^\n/, "");
      }
      continue;
    }
    openFlag = null;
    const photo = /^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/.exec(text.trim());
    if (photo) {
      photos.push({ alt: photo[1] ?? "", src: photo[2] ?? "" });
      continue;
    }
    if (text.trim()) words.push(text.trim());
  }

  const source = words.join(" ");
  return {
    step: {
      number,
      line,
      source,
      segments: tokenize(source),
      flags: {
        ...flags,
        critical: flags.critical.map((s) => s.trim()),
        pause: flags.pause.map((s) => s.trim()),
        why: flags.why.map((s) => s.trim()),
        expect: flags.expect.map((s) => s.trim()),
        spin: flags.spin.map((s) => s.trim()),
        challenge: flags.challenge.map((s) => s.trim()),
        solution: flags.solution.map((s) => s.trim()),
      },
      commands,
      photos,
    },
    problems,
  };
}

/**
 * A section's lines as blocks: numbered items become steps; everything else at the margin is prose.
 *
 * @param {string[]} lines
 * @param {number} firstLine the file line of lines[0]
 * @returns {{ blocks: Block[], problems: string[] }}
 */
function readBlocks(lines, firstLine) {
  /** @type {Block[]} */
  const blocks = [];
  /** @type {string[]} */
  const problems = [];
  /** @type {string[]} */
  let prose = [];
  /** @type {Step[] | null} */
  let steps = null;
  let inFence = false;

  const flushProse = () => {
    const markdown = prose.join("\n").trim();
    if (markdown) blocks.push({ type: "prose", markdown });
    prose = [];
  };

  for (let i = 0; i < lines.length; i += 1) {
    const text = lines[i] ?? "";
    if (text.startsWith("```")) inFence = !inFence;
    const item = inFence ? null : /^(\d+)\.\s+(.*)$/.exec(text);
    if (!item) {
      if (steps && (text.trim() === "" || /^\s{2,}/.test(text))) {
        // A blank line inside a list is still the list, unless the margin comes next.
        const next = lines.slice(i + 1).find((l) => l.trim() !== "");
        if (text.trim() === "" && (next === undefined || !/^(\s{2,}|\d+\.\s)/.test(next))) steps = null;
        continue;
      }
      steps = null;
      prose.push(text);
      continue;
    }
    flushProse();
    const start = i;
    /** @type {string[]} */
    const rest = [];
    while (i + 1 < lines.length) {
      const next = lines[i + 1] ?? "";
      if (/^\s{2,}\S/.test(next)) rest.push(next.replace(/^\s{2,4}/, ""));
      else if (next.trim() === "" && /^\s{2,}\S/.test(lines[i + 2] ?? "")) rest.push("");
      else break;
      i += 1;
    }
    const read = readStep(item[2] ?? "", rest, firstLine + start, Number(item[1]));
    problems.push(...read.problems);
    if (!steps) {
      steps = [];
      blocks.push({ type: "steps", steps });
    }
    steps.push(read.step);
  }
  flushProse();
  return { blocks, problems };
}

/**
 * @param {{ file: string, raw: string }} input
 */
export function parseProcedure({ file, raw }) {
  /** @type {string[]} */
  const problems = [];
  let parsed;
  try {
    parsed = matter(raw);
  } catch (error) {
    return {
      file,
      data: /** @type {Record<string, any>} */ ({}),
      intro: "",
      sections: /** @type {Section[]} */ ([]),
      problems: [`the front matter is not valid YAML: ${error instanceof Error ? error.message : String(error)}`],
    };
  }
  const data = /** @type {Record<string, any>} */ (yamlPlain(parsed.data));
  const headerLines = raw.slice(0, raw.length - parsed.content.length).split("\n").length - 1;
  const lines = parsed.content.split(/\r?\n/);

  /** @type {Section[]} */
  const sections = [];
  /** @type {string[]} */
  let intro = [];
  let current = /** @type {{ title: string, id: string, custom: boolean, line: number, lines: string[] } | null} */ (null);
  const close = () => {
    if (!current) return;
    const { blocks, problems: found } = readBlocks(current.lines, current.line + 1);
    problems.push(...found);
    sections.push({ title: current.title, id: current.id, custom: current.custom, line: current.line, blocks });
  };

  let inFence = false;
  lines.forEach((text, index) => {
    if (text.startsWith("```")) inFence = !inFence;
    const heading = inFence ? null : /^## (.+?)(?:\s+\{#([a-z0-9-]+)\})?\s*$/.exec(text);
    if (heading) {
      close();
      const title = (heading[1] ?? "").trim();
      current = { title, id: heading[2] ?? headingId(title), custom: Boolean(heading[2]), line: headerLines + index + 1, lines: [] };
      return;
    }
    if (current) current.lines.push(text);
    else intro.push(text);
  });
  close();

  // A step list before the first heading would have no section to belong to.
  if (intro.some((line) => /^\d+\.\s/.test(line))) {
    problems.push("the method starts before the first ## heading; put every step under a section");
  }

  return { file, data, intro: intro.join("\n").trim(), sections, problems };
}

/** @typedef {ReturnType<typeof parseProcedure>} ParsedProcedure */

/**
 * Every step in the procedure, in order.
 *
 * @param {ParsedProcedure} parsed
 */
export function allSteps(parsed) {
  return parsed.sections.flatMap((section) =>
    section.blocks.flatMap((block) => (block.type === "steps" ? block.steps : [])),
  );
}

/**
 * The calculators a step's CALC flag names, each with the values the step itself states for its fields:
 * `> CALC: webbed-plate volume=10, titer` is webbed-plate with volume 10, then titer. A `name=value` belongs
 * to the calculator before it; one with none before it is read as a calculator with no id, which validate.mjs refuses.
 *
 * @param {string[]} tokens the flag's words, as readStep split them
 * @returns {Array<{ id: string, values: Record<string, string> }>}
 */
export function readCalcs(tokens) {
  /** @type {Array<{ id: string, values: Record<string, string> }>} */
  const calcs = [];
  for (const token of tokens) {
    const eq = token.indexOf("=");
    if (eq === -1) {
      calcs.push({ id: token, values: {} });
      continue;
    }
    if (calcs.length === 0) calcs.push({ id: "", values: {} });
    /** @type {{ id: string, values: Record<string, string> }} */ (calcs[calcs.length - 1]).values[token.slice(0, eq)] = token.slice(eq + 1);
  }
  return calcs;
}

/**
 * A step's words with its marks read out, and the temperatures and spins those words state.
 *
 * @param {Step} step
 */
export function stepConditions(step) {
  return readConditions(step.segments.map((s) => segmentText(s)).join(""));
}