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`);
}