bhcto.com

lib/briefs.js

// Parse FAR_tierA_content_briefs.md into structured per-brief objects.
// The brief file is human-authored Markdown: 15 H3 sections "### Brief N — Title",
// each a bullet list with bold field labels. There is no frontmatter/JSON, so we
// parse with text heuristics. Output is a plain object per brief.

const fs = require("fs");
const path = require("path");

const BRIEFS_FILE = path.join(__dirname, "..", "NIR_tierA_content_briefs.md");

function slugify(s) {
  return String(s)
    .toLowerCase()
    .replace(/['"’]/g, "")
    .replace(/&/g, " and ")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 80);
}

// Pull the value of a "- **Label:** value" bullet from a brief chunk.
function field(chunk, label) {
  const re = new RegExp(`^\\s*-\\s*\\*\\*${label}:\\*\\*\\s*(.+)$`, "im");
  const m = chunk.match(re);
  return m ? m[1].trim() : null;
}

// A target-keyword value looks like:
//   "how long does withdrawal last" (secondary: "withdrawal timeline," "...")
//   "heroin withdrawal symptoms" / "heroin withdrawal timeline"
// Return { primary, queries[] } — queries are the distinct quoted/slashed phrases,
// with the (secondary: ...) parenthetical folded in.
function parseKeywords(raw) {
  if (!raw) return { primary: null, queries: [] };
  // Collect every quoted phrase anywhere in the string (primary + secondary).
  const quoted = [...raw.matchAll(/[""']([^""']+)[""']/g)].map((m) =>
    m[1].replace(/[,.;]+$/, "").trim(),
  );
  let queries = quoted;
  if (queries.length === 0) {
    // No quotes — split on "/" and strip any "(secondary: ...)".
    queries = raw
      .replace(/\(secondary:[^)]*\)/i, "")
      .split("/")
      .map((s) => s.trim())
      .filter(Boolean);
  }
  // Dedupe, keep order.
  const seen = new Set();
  queries = queries.filter((q) => {
    const k = q.toLowerCase();
    if (seen.has(k)) return false;
    seen.add(k);
    return true;
  });
  return { primary: queries[0] || null, queries };
}

function parseBriefs() {
  const md = fs.readFileSync(BRIEFS_FILE, "utf8");
  // Capture the file-level "Standing rules" preamble (everything before the first
  // SERIES header) — it carries universal mandates every post must honor.
  const preambleEnd = md.search(/^##\s+SERIES/m);
  const standingRules = preambleEnd > 0 ? md.slice(0, preambleEnd).trim() : "";

  // Split on H3 brief headers, keeping the header with its body.
  const parts = md.split(/^### Brief /m).slice(1);
  const briefs = [];
  for (const part of parts) {
    const chunk = "### Brief " + part;
    const headerLine = part.split("\n", 1)[0]; // "3 — How Long Does Withdrawal Last? ..."
    const hm = headerLine.match(/^(\S+)\s*[—-]\s*(.+?)\s*(\{#[^}]+\})?\s*$/);
    const briefNumber = hm ? hm[1] : null;
    const heading = hm ? hm[2].trim() : headerLine.trim();

    const workingTitle = (field(chunk, "Working title") || heading).replace(
      /^["""]|["""]$/g,
      "",
    );
    const keywordRaw =
      field(chunk, "Target keyword") || field(chunk, "Target keywords");
    const { primary, queries } = parseKeywords(keywordRaw);

    briefs.push({
      briefNumber,
      heading,
      slug: slugify(workingTitle || heading),
      workingTitle,
      targetKeyword: primary,
      targetKeywordRaw: keywordRaw,
      queries,
      wordTarget: field(chunk, "Word target"),
      outlineText: field(chunk, "Outline"),
      cites: field(chunk, "Cite") || field(chunk, "Drug-specific cite"),
      internalLinks: field(chunk, "Internal links"),
      angle: field(chunk, "Angle") || field(chunk, "Notes"),
      rawBriefMarkdown: chunk.trim(),
    });
  }
  return { standingRules, briefs };
}

module.exports = { parseBriefs, slugify };

// CLI: `node lib/briefs.js` prints a parse summary for verification.
if (require.main === module) {
  const { standingRules, briefs } = parseBriefs();
  console.log(`Parsed ${briefs.length} briefs. Standing-rules chars: ${standingRules.length}`);
  for (const b of briefs) {
    console.log(
      `\n#${b.briefNumber}  slug=${b.slug}\n  title: ${b.workingTitle}\n  primaryKW: ${b.targetKeyword}\n  queries: ${JSON.stringify(b.queries)}\n  wordTarget: ${b.wordTarget} | cites? ${!!b.cites} | links? ${!!b.internalLinks}`,
    );
  }
  const missing = briefs.filter((b) => !b.targetKeyword);
  if (missing.length) console.log(`\nWARNING: ${missing.length} briefs missing a keyword`);
}