From 1abe69f3e479b41a804ee651e2874f31b150a4e8 Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 3 Jun 2026 20:27:27 -0400 Subject: [PATCH] feat(docs): add weekly update drafts (#1183) --- docs/contributing/changelog-process.mdx | 20 + docs/docs.json | 1 + docs/weekly-updates.mdx | 11 + package.json | 3 +- scripts/changelog-weekly.test.ts | 79 ++++ scripts/changelog-weekly.ts | 555 ++++++++++++++++++++++++ updates/README.md | 21 + 7 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 docs/weekly-updates.mdx create mode 100644 scripts/changelog-weekly.test.ts create mode 100644 scripts/changelog-weekly.ts create mode 100644 updates/README.md diff --git a/docs/contributing/changelog-process.mdx b/docs/contributing/changelog-process.mdx index ce22a1662..744c9bce0 100644 --- a/docs/contributing/changelog-process.mdx +++ b/docs/contributing/changelog-process.mdx @@ -77,6 +77,26 @@ bun run changelog:draft 0.6.53 --write --force Without `--force`, the draft command leaves an existing `releases/vX.Y.Z.md` file unchanged and still adds the docs changelog entry if it is missing. If the docs changelog already has that version, edit the existing docs entry manually. +## Weekly digest workflow + +Weekly updates are editorial rollups, not release notes. Keep `docs/changelog.mdx` versioned and use `docs/weekly-updates.mdx` for curated weekly highlights that can also be adapted for Discord and X. + +Generate an editable weekly packet from the repository root: + +```bash +bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 --write +``` + +Run it from an up-to-date `main` branch so the selected range reflects public history, not a feature branch. + +This creates: + +- `updates/weekly/2026-06-07.md` +- `updates/social/2026-06-07.discord.md` +- `updates/social/2026-06-07.x.md` + +It also prepends a matching entry to `docs/weekly-updates.mdx`. Review and rewrite the generated files before publishing. Social drafts are never posted automatically. + ## Writing style Use plain, user-facing language. Prefer "Fixed Studio render failures when FFmpeg is missing" over "Added pre-flight check in render activity." Link to relevant docs, migration guides, or pull requests when they help users act. diff --git a/docs/docs.json b/docs/docs.json index 2e504e9ec..2a7c3353f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -54,6 +54,7 @@ "pages": [ "introduction", "changelog", + "weekly-updates", "quickstart", "showcase", "examples", diff --git a/docs/weekly-updates.mdx b/docs/weekly-updates.mdx new file mode 100644 index 000000000..9bc61580b --- /dev/null +++ b/docs/weekly-updates.mdx @@ -0,0 +1,11 @@ +--- +title: "Weekly updates" +description: "Curated weekly highlights for HyperFrames." +rss: true +--- + +Weekly HyperFrames highlights across releases, examples, docs, and community updates. + +For exact versioned release notes, see the [Changelog](/changelog). + +{/* New weekly digest entries are prepended by `bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD --write`. */} diff --git a/package.json b/package.json index 7f7fe80c3..60e032a88 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "set-version": "tsx scripts/set-version.ts", "release:prepare": "tsx scripts/release-prepare.ts", "changelog:draft": "tsx scripts/draft-changelog.ts", + "changelog:weekly": "tsx scripts/changelog-weekly.ts", "sync-schemas": "tsx scripts/sync-schemas.ts", "sync-schemas:check": "tsx scripts/sync-schemas.ts --check", "lint": "oxlint . && tsx scripts/lint-skills.ts", @@ -31,7 +32,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts", + "test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts", "generate:previews": "tsx scripts/generate-template-previews.ts", "generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts", "upload:docs-images": "bash scripts/upload-docs-images.sh", diff --git a/scripts/changelog-weekly.test.ts b/scripts/changelog-weekly.test.ts new file mode 100644 index 000000000..c6d917c41 --- /dev/null +++ b/scripts/changelog-weekly.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createWeeklyDraft, parseWeeklyOptions, weeklyPacketPaths } from "./changelog-weekly.ts"; +import { parseCommit, type RawCommit } from "./draft-changelog.ts"; + +function commit(subject: string) { + const raw: RawCommit = { + sha: "1234567890abcdef1234567890abcdef12345678", + shortSha: "1234567", + author: "Test Author", + subject, + }; + + return { + ...parseCommit(raw), + date: "2026-06-03", + }; +} + +describe("weekly changelog arguments", () => { + it("parses date range and write flags", () => { + assert.deepEqual( + parseWeeklyOptions(["--from", "2026-06-01", "--to=2026-06-07", "--write", "--force"]), + { + from: "2026-06-01", + to: "2026-06-07", + write: true, + force: true, + }, + ); + }); +}); + +describe("weekly changelog rendering", () => { + it("creates docs, source, Discord, and X drafts", () => { + const draft = createWeeklyDraft( + { + from: "2026-06-01", + to: "2026-06-07", + write: false, + force: false, + }, + [commit("feat(cli): add render hints (#42)"), commit("fix: repair playback")], + ); + + assert.match(draft.docsUpdate, /label="Week of June 1, 2026"/); + assert.match(draft.weeklyNotes, /HyperFrames weekly digest - June 1, 2026 - June 7, 2026/); + assert.match(draft.discordDraft, /This week's highlights:/); + assert.match(draft.xDraft, /Full update: TODO add docs link/); + }); + + it("keeps internal and editorial-only changes out of top highlights", () => { + const draft = createWeeklyDraft( + { + from: "2026-06-01", + to: "2026-06-07", + write: false, + force: false, + }, + [ + commit("feat(docs): add changelog release workflow (#41)"), + commit("fix(cli): validate cloud render input (#42)"), + commit("chore: update generated baselines (#43)"), + ], + ); + + assert.match(draft.discordDraft, /CLI: Validate cloud render input/); + assert.doesNotMatch(draft.discordDraft, /Docs: Add changelog release workflow/); + assert.doesNotMatch(draft.docsUpdate, /Update generated baselines/); + }); + + it("uses predictable packet paths from the week ending date", () => { + assert.deepEqual(weeklyPacketPaths("2026-06-07"), { + weeklyNotes: "updates/weekly/2026-06-07.md", + discordDraft: "updates/social/2026-06-07.discord.md", + xDraft: "updates/social/2026-06-07.x.md", + }); + }); +}); diff --git a/scripts/changelog-weekly.ts b/scripts/changelog-weekly.ts new file mode 100644 index 000000000..625bbf05e --- /dev/null +++ b/scripts/changelog-weekly.ts @@ -0,0 +1,555 @@ +#!/usr/bin/env tsx + +import { execFileSync } from "child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { pathToFileURL } from "url"; +import { parseMappedArgument, validateCliDate, type InlineValueOption } from "./cli-options.ts"; +import { + escapeForMdx, + formatScope, + parseCommit, + shouldSkipCommit, + type ParsedCommit, + type RawCommit, +} from "./draft-changelog.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const REPO_URL = "https://github.com/heygen-com/hyperframes"; +const DOCS_MARKER = + "{/* New weekly digest entries are prepended by `bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD --write`. */}"; +const WEEKLY_REVIEW_TODO = ""; + +const CATEGORY_ORDER = [ + "Breaking Changes", + "Features", + "Fixes", + "Performance", + "Catalog", + "Docs & Examples", + "Other Changes", + "Internal", +]; + +const HIGHLIGHT_CATEGORIES = new Set([ + "Breaking Changes", + "Features", + "Fixes", + "Performance", + "Catalog", +]); + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +type WeeklyOptions = { + from: string; + to: string; + write: boolean; + force: boolean; +}; + +type MutableWeeklyOptions = Partial; + +type WeeklyCommit = ParsedCommit & { + date: string; +}; + +type WeeklyDraft = { + docsUpdate: string; + weeklyNotes: string; + discordDraft: string; + xDraft: string; +}; + +type ValueOptionKey = "from" | "to"; +type BooleanOptionKey = "write" | "force"; + +const VALUE_OPTIONS = new Map([ + ["--from", "from"], + ["--to", "to"], +]); + +const BOOLEAN_OPTIONS = new Map([ + ["--write", "write"], + ["--force", "force"], +]); + +const INLINE_VALUE_OPTIONS = [ + { prefix: "--from=", key: "from" }, + { prefix: "--to=", key: "to" }, +] satisfies Array>; + +function main() { + const options = parseWeeklyOptions(process.argv.slice(2)); + const commits = getWeeklyCommits(options); + const draft = createWeeklyDraft(options, commits); + outputWeeklyDraft(options, draft); +} + +export function parseWeeklyOptions(args: string[]): WeeklyOptions { + const parsed = createDefaultOptions(); + + for (let index = 0; index < args.length; index += 1) { + index = parseArgument(args, index, parsed); + } + + return finalizeOptions(parsed); +} + +function createDefaultOptions(): MutableWeeklyOptions { + return { + write: false, + force: false, + }; +} + +function parseArgument(args: string[], index: number, parsed: MutableWeeklyOptions) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") { + printUsage(); + process.exit(0); + } + + return parseMappedArgument(args, index, parsed, { + inlineValueOptions: INLINE_VALUE_OPTIONS, + valueOptions: VALUE_OPTIONS, + booleanOptions: BOOLEAN_OPTIONS, + parsePositional: (positional) => fail(`Unexpected positional argument: ${positional}`), + fail, + }); +} + +function finalizeOptions(parsed: MutableWeeklyOptions): WeeklyOptions { + const { from, to } = requireDateRange(parsed); + + return { + from, + to, + write: parsed.write ?? false, + force: parsed.force ?? false, + }; +} + +function requireDateRange(parsed: MutableWeeklyOptions) { + if (!parsed.from || !parsed.to) { + printUsage(); + process.exit(1); + } + + validateDateRange(parsed.from, parsed.to); + return { + from: parsed.from, + to: parsed.to, + }; +} + +function validateDateRange(from: string, to: string) { + validateCliDate(from, fail); + validateCliDate(to, fail); + if (from > to) { + fail(`Invalid date range: --from ${from} is after --to ${to}.`); + } +} + +function getWeeklyCommits(options: WeeklyOptions): WeeklyCommit[] { + return getCommits(options.from, options.to) + .filter((commit) => !shouldSkipCommit(commit)) + .map((commit) => ({ + ...parseCommit(commit), + date: commit.date, + })) + .sort(compareCommitsForDigest); +} + +type RawWeeklyCommit = RawCommit & { + date: string; +}; + +function getCommits(from: string, to: string): RawWeeklyCommit[] { + const output = git([ + "log", + "--format=%H%x09%h%x09%an%x09%cs%x09%s", + "--no-merges", + `--since=${from}T00:00:00`, + `--until=${to}T23:59:59`, + ]); + + if (!output) { + return []; + } + + return output.split("\n").map(parseGitLogLine); +} + +function parseGitLogLine(line: string): RawWeeklyCommit { + const [sha = "", shortSha = "", author = "", date = "", ...subjectParts] = line.split("\t"); + return { + sha, + shortSha, + author, + date, + subject: subjectParts.join("\t"), + }; +} + +export function createWeeklyDraft(options: WeeklyOptions, commits: WeeklyCommit[]): WeeklyDraft { + const range = formatDateRange(options.from, options.to); + const highlights = selectHighlights(commits); + + return { + docsUpdate: renderDocsUpdate(options, range, commits, highlights), + weeklyNotes: renderWeeklyNotes(options, range, commits, highlights), + discordDraft: renderDiscordDraft(range, highlights), + xDraft: renderXDraft(range, highlights), + }; +} + +function outputWeeklyDraft(options: WeeklyOptions, draft: WeeklyDraft) { + if (!options.write) { + console.log(draft.weeklyNotes); + console.log("\n--- Discord draft ---\n"); + console.log(draft.discordDraft); + console.log("\n--- X draft ---\n"); + console.log(draft.xDraft); + console.log("\n--- Mintlify update block ---\n"); + console.log(draft.docsUpdate); + console.log( + "\nRun with --write to create the weekly digest packet and prepend the docs entry.", + ); + return; + } + + writeWeeklyPacket(options, draft); + prependDocsUpdate(options, draft.docsUpdate); +} + +function writeWeeklyPacket(options: WeeklyOptions, draft: WeeklyDraft) { + const paths = weeklyPacketPaths(options.to); + mkdirSync(join(ROOT, "updates", "weekly"), { recursive: true }); + mkdirSync(join(ROOT, "updates", "social"), { recursive: true }); + + writeFile(paths.weeklyNotes, draft.weeklyNotes, options.force); + writeFile(paths.discordDraft, draft.discordDraft, options.force); + writeFile(paths.xDraft, draft.xDraft, options.force); +} + +export function weeklyPacketPaths(date: string) { + return { + weeklyNotes: join("updates", "weekly", `${date}.md`), + discordDraft: join("updates", "social", `${date}.discord.md`), + xDraft: join("updates", "social", `${date}.x.md`), + }; +} + +function writeFile(relativePath: string, contents: string, force: boolean) { + try { + writeFileSync(join(ROOT, relativePath), `${contents}\n`, { flag: force ? "w" : "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + fail(`${relativePath} already exists. Pass --force to overwrite it before review.`); + } + throw error; + } + console.log(`Wrote ${relativePath}`); +} + +function prependDocsUpdate(options: WeeklyOptions, docsUpdate: string) { + const weeklyUpdatesPath = join(ROOT, "docs", "weekly-updates.mdx"); + const weeklyUpdates = readFileSync(weeklyUpdatesPath, "utf-8"); + const label = weeklyLabel(options.from); + + if (weeklyUpdates.includes(`label="${label}"`)) { + console.log(`docs/weekly-updates.mdx already has ${label}; leaving it unchanged.`); + return; + } + + if (!weeklyUpdates.includes(DOCS_MARKER)) { + fail(`Could not find insertion marker in ${weeklyUpdatesPath}`); + } + + const updated = weeklyUpdates.replace(DOCS_MARKER, `${DOCS_MARKER}\n\n${docsUpdate}`); + writeFileSync(weeklyUpdatesPath, updated); + console.log(`Prepended ${label} to ${weeklyUpdatesPath}`); +} + +function renderDocsUpdate( + options: WeeklyOptions, + range: string, + commits: WeeklyCommit[], + highlights: WeeklyCommit[], +) { + const alsoNotable = selectAlsoNotable(commits, highlights); + + return [ + "", + WEEKLY_REVIEW_TODO, + "", + "A curated summary of the most important HyperFrames changes this week.", + "", + renderHighlights(highlights, renderMdxWeeklyBullet), + "", + renderAlsoNotable(alsoNotable, renderMdxWeeklyBullet), + "", + "For exact versioned release notes, see the [Changelog](/changelog).", + "", + ].join("\n"); +} + +function renderWeeklyNotes( + options: WeeklyOptions, + range: string, + commits: WeeklyCommit[], + highlights: WeeklyCommit[], +) { + return [ + `# HyperFrames weekly digest - ${range}`, + "", + WEEKLY_REVIEW_TODO, + "", + "This digest is the editable source for the docs weekly update and social drafts.", + "", + "## Highlights", + "", + renderListOrEmpty(highlights, renderMarkdownWeeklyBullet), + "", + "## Full draft", + "", + renderGroupedChanges(commits, renderMarkdownWeeklyBullet), + "", + "## Publishing checklist", + "", + "- Remove the TODO marker after review.", + "- Run this from an up-to-date `main` branch when drafting the real weekly update.", + "- Keep the docs entry in `docs/weekly-updates.mdx` aligned with this source file.", + "- Edit the Discord and X drafts before posting.", + "- Add screenshots, rendered clips, or catalog links where they make the update clearer.", + "", + `Range: ${options.from} through ${options.to}.`, + ].join("\n"); +} + +function renderDiscordDraft(range: string, highlights: WeeklyCommit[]) { + return [ + `# HyperFrames weekly update - ${range}`, + "", + WEEKLY_REVIEW_TODO, + "", + "This week's highlights:", + "", + renderListOrEmpty(highlights, renderPlainWeeklyBullet), + "", + "Read the full update: TODO add docs link after publishing.", + ].join("\n"); +} + +function renderXDraft(range: string, highlights: WeeklyCommit[]) { + const threadItems = + highlights.length > 0 + ? highlights.map((commit, index) => `${index + 1}. ${plainWeeklySummary(commit)}`) + : ["TODO: add the most important user-facing highlights from this week."]; + + return [ + `HyperFrames weekly update - ${range}`, + "", + WEEKLY_REVIEW_TODO, + "", + ...threadItems, + "", + "Full update: TODO add docs link after publishing.", + ].join("\n"); +} + +function renderHighlights( + highlights: WeeklyCommit[], + renderBullet: (commit: WeeklyCommit) => string, +) { + return ["## Highlights", "", renderListOrEmpty(highlights, renderBullet)].join("\n"); +} + +function renderAlsoNotable( + commits: WeeklyCommit[], + renderBullet: (commit: WeeklyCommit) => string, +) { + if (commits.length === 0) { + return "## Also notable\n\n- TODO: add any supporting changes worth mentioning."; + } + + return ["## Also notable", "", commits.map(renderBullet).join("\n")].join("\n"); +} + +function renderGroupedChanges( + commits: WeeklyCommit[], + renderBullet: (commit: WeeklyCommit) => string, +) { + if (commits.length === 0) { + return "No notable changes were found in the selected date range."; + } + + return CATEGORY_ORDER.flatMap((category) => { + const categoryCommits = commits.filter((commit) => commit.category === category); + if (categoryCommits.length === 0) { + return []; + } + + return [`## ${category}`, "", ...categoryCommits.map(renderBullet), ""]; + }) + .join("\n") + .trim(); +} + +function renderListOrEmpty( + commits: WeeklyCommit[], + renderBullet: (commit: WeeklyCommit) => string, +) { + if (commits.length === 0) { + return "- TODO: add the most important user-facing highlights from this week."; + } + + return commits.map(renderBullet).join("\n"); +} + +function renderMarkdownWeeklyBullet(commit: WeeklyCommit) { + const scope = commit.scope ? `**${formatScope(commit.scope)}:** ` : ""; + return `- ${scope}${capitalize(commit.summary)} (${commitLinks(commit).join(", ")})`; +} + +function renderMdxWeeklyBullet(commit: WeeklyCommit) { + const scope = commit.scope ? `**${escapeForMdx(formatScope(commit.scope))}:** ` : ""; + return `- ${scope}${escapeForMdx(capitalize(commit.summary))} (${commitLinks(commit).join(", ")})`; +} + +function renderPlainWeeklyBullet(commit: WeeklyCommit) { + return `- ${plainWeeklySummary(commit)}`; +} + +function plainWeeklySummary(commit: WeeklyCommit) { + const scope = commit.scope ? `${formatScope(commit.scope)}: ` : ""; + return `${scope}${capitalize(commit.summary)}`; +} + +function commitLinks(commit: WeeklyCommit) { + const links = [`[${commit.shortSha}](${REPO_URL}/commit/${commit.sha})`]; + if (commit.prNumber) { + links.push(`[#${commit.prNumber}](${REPO_URL}/pull/${commit.prNumber})`); + } + return links; +} + +function selectHighlights(commits: WeeklyCommit[]) { + const highlighted = commits.filter(isHighImpactCandidate); + const fallback = commits.filter((commit) => commit.category !== "Internal"); + return (highlighted.length > 0 ? highlighted : fallback).slice(0, 5); +} + +function selectAlsoNotable(commits: WeeklyCommit[], highlights: WeeklyCommit[]) { + const highlightedShas = new Set(highlights.map((commit) => commit.sha)); + return commits + .filter((commit) => commit.category !== "Internal") + .filter((commit) => !highlightedShas.has(commit.sha)) + .slice(0, 5); +} + +function isHighImpactCandidate(commit: WeeklyCommit) { + return HIGHLIGHT_CATEGORIES.has(commit.category) && !isEditorialOnlyScope(commit.scope); +} + +function isEditorialOnlyScope(scope: string | undefined) { + if (!scope) { + return false; + } + + return ["docs", "readme", "skills"].includes(scope.toLowerCase()); +} + +function compareCommitsForDigest(a: WeeklyCommit, b: WeeklyCommit) { + const categoryDelta = categoryRank(a.category) - categoryRank(b.category); + if (categoryDelta !== 0) { + return categoryDelta; + } + + return b.date.localeCompare(a.date); +} + +function categoryRank(category: string) { + const index = CATEGORY_ORDER.indexOf(category); + return index === -1 ? CATEGORY_ORDER.length : index; +} + +function weeklyLabel(from: string) { + return `Week of ${formatDate(from)}`; +} + +function formatDateRange(from: string, to: string) { + return `${formatDate(from)} - ${formatDate(to)}`; +} + +function formatDate(date: string) { + const { year, month, day } = dateParts(date); + return `${MONTHS[month - 1]} ${day}, ${year}`; +} + +function dateParts(date: string) { + const [year = "0", month = "0", day = "0"] = date.split("-"); + return { + year, + month: Number(month), + day: Number(day), + }; +} + +function capitalize(value: string) { + if (!value) { + return value; + } + return value[0].toUpperCase() + value.slice(1); +} + +function git(args: string[]) { + return execFileSync("git", args, { + cwd: ROOT, + encoding: "utf-8", + }).trim(); +} + +function printUsage() { + console.log(`changelog:weekly drafts an editable weekly digest packet. + +Usage: + bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD [--write] [--force] + +Examples: + bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 + bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 --write + bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 --write --force +`); +} + +function fail(message: string): never { + console.error(`changelog:weekly: ${message}`); + process.exit(1); +} + +function isDirectRun(scriptPath: string | undefined) { + return scriptPath ? import.meta.url === pathToFileURL(scriptPath).href : false; +} + +if (isDirectRun(process.argv[1])) { + main(); +} diff --git a/updates/README.md b/updates/README.md new file mode 100644 index 000000000..70e948e9a --- /dev/null +++ b/updates/README.md @@ -0,0 +1,21 @@ +# Weekly updates + +Weekly digest source files and social drafts live here. + +Generate the next editorial packet with: + +```bash +bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD --write +``` + +Run the command from an up-to-date `main` branch so the draft reflects the public Git history for the selected week. + +The command creates: + +- `updates/weekly/YYYY-MM-DD.md` +- `updates/social/YYYY-MM-DD.discord.md` +- `updates/social/YYYY-MM-DD.x.md` + +It also prepends a matching entry to `docs/weekly-updates.mdx`. + +Review and rewrite the generated files before publishing. Social drafts are distribution copy for humans to post manually; they are not posted automatically.