import { existsSync, readFileSync, writeFileSync, renameSync, cpSync, rmSync } from "node:fs"; import { join } from "node:path"; import { readFrontmatter, fmString, fmArray, titleCase } from "./util.mjs"; import { readSlugs, ensureEntry } from "./blogData.mjs"; import { updateEntry } from "./calendar.mjs"; import { buildSite } from "./build.mjs"; import { gitCommitPaths } from "./git.mjs"; /** * Promote a draft into the Astro blog and build the site. * Shared by approve.mjs (manual) and publish-tick.mjs (auto). Does NOT do the SEO gate — * callers decide whether the draft is allowed to publish. * @param {object} cfg parsed config.json * @param {string} slug * @param {{dateOverride?: string}} opts dateOverride = ISO timestamp to stamp as the post date * @returns {Promise<{dest:string,url:string,taxonomyAdded:string[]}>} */ export async function promoteDraft(cfg, slug, { dateOverride } = {}) { const root = cfg.paths.projectRoot; const draftDir = join(root, cfg.paths.draftsDir, slug); const mdxPath = join(draftDir, "index.mdx"); if (!existsSync(mdxPath)) throw new Error(`no draft at ${mdxPath}`); if (!existsSync(join(draftDir, "cover.jpg"))) throw new Error("draft is missing cover.jpg"); // Stamp a specific published date/time into the frontmatter (randomized publish time). if (dateOverride) { const raw = readFileSync(mdxPath, "utf8"); writeFileSync(mdxPath, raw.replace(/^date:.*$/m, `date: ${dateOverride}`)); } const fm = readFrontmatter(readFileSync(mdxPath, "utf8")); const author = fmString(fm, "author"); const category = fmString(fm, "category"); const tags = fmArray(fm, "tags"); const blogDataFile = join(root, cfg.paths.blogDataFile); const known = readSlugs(blogDataFile); if (author && !known.authors.has(author)) { throw new Error(`author "${author}" is not in blog-data.js — add it first`); } const added = []; if (category && !known.categories.has(category)) { ensureEntry(blogDataFile, "categories", category, titleCase(category)); added.push(`category:${category}`); } for (const t of tags) { if (!known.tags.has(t)) { ensureEntry(blogDataFile, "tags", t, titleCase(t)); added.push(`tag:${t}`); } } const dest = join(root, cfg.paths.blogContentDir, slug); if (existsSync(dest)) throw new Error(`destination already exists: ${dest}`); try { renameSync(draftDir, dest); } catch { cpSync(draftDir, dest, { recursive: true }); rmSync(draftDir, { recursive: true, force: true }); } const calendarPath = join(root, cfg.paths.calendar); await updateEntry( calendarPath, slug, dateOverride ? { status: "published", publishedAt: dateOverride } : { status: "published" } ); await buildSite({ projectRoot: root, appDir: cfg.paths.appDir }); const out = join(root, "public", "blog", slug, "index.html"); if (!existsSync(out)) throw new Error(`expected output not found: ${out}`); // Version the published content so the working tree stays clean for the // Developer Console's git worktree/merge operations. Best-effort — a git // hiccup must never keep already-built content off the live site. const commit = await gitCommitPaths( root, [join(cfg.paths.blogContentDir, slug), cfg.paths.blogDataFile], `content: publish ${slug}` ); return { dest, url: `${cfg.site.url}/blog/${slug}/`, taxonomyAdded: added, commit }; }