Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions scripts/api-reference-site/api-reference-site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
highlightCode,
githubRepository,
moduleRoute,
mergeChangelogs,
normalizeBasePath,
normalizeGitHubStars,
normalizeOrigin,
Expand Down Expand Up @@ -189,6 +190,48 @@ Ignore this package.
`, "@typeonce/effect-machine"), undefined)
})

test("merges synchronized package changelogs without dependency-only entries", () => {
const dates = new Map([["1.2.1", "2026-08-30"], ["1.2.0", "2026-08-29"]])
const core = parseChangelog(`# Core

## 1.2.1

## 1.2.0

### Minor Changes

- abc1234: Add a core API.
`, dates)
const devtools = parseChangelog(`# Devtools

## 1.2.1

### Patch Changes

- def5678: Improve the visualizer.

- @typeonce/effect-machine@1.2.1
`, dates)
const react = parseChangelog(`# React

## 1.2.1

### Patch Changes

- @typeonce/effect-machine@1.2.1
`, dates)

assert.deepEqual(mergeChangelogs([core, devtools, react]), [{
version: "1.2.1",
date: "2026-08-30",
groups: [{ type: "patch", entries: [{ description: "Improve the visualizer." }] }]
}, {
version: "1.2.0",
date: "2026-08-29",
groups: [{ type: "minor", entries: [{ description: "Add a core API." }] }]
}])
})

test("renders a navigable changelog with release dates", () => {
const html = renderChangelogPage({
...site,
Expand Down
93 changes: 83 additions & 10 deletions scripts/api-reference-site/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,50 @@ export const parseChangelog = (markdown, releaseDates = new Map()) => {
}).filter((release) => release.groups.length > 0)
}

const dependencyReleaseLine = /^\s*(?:-\s+)?@typeonce\/[a-z0-9-]+@\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?\s*$/iu

const normalizeAggregatedDescription = (description) => description
.split("\n")
.filter((line) => !dependencyReleaseLine.test(line))
.join("\n")
.replace(/\n{3,}/gu, "\n\n")
.trim()

const releaseGroupOrder = new Map([["major", 0], ["minor", 1], ["patch", 2]])

export const mergeChangelogs = (changelogs) => {
const releases = new Map()
for (const changelog of changelogs) {
for (const release of changelog) {
const merged = releases.get(release.version) ?? {
version: release.version,
date: release.date,
groups: new Map()
}
if (merged.date === undefined && release.date !== undefined) merged.date = release.date
for (const group of release.groups) {
const entries = merged.groups.get(group.type) ?? new Map()
for (const entry of group.entries) {
const description = normalizeAggregatedDescription(entry.description)
if (description.length > 0) entries.set(description, { description })
}
merged.groups.set(group.type, entries)
}
releases.set(release.version, merged)
}
}
return [...releases.values()].map((release) => ({
version: release.version,
date: release.date,
groups: [...release.groups].map(([type, entries]) => ({
type,
entries: [...entries.values()]
})).filter((group) => group.entries.length > 0)
.sort((left, right) => (releaseGroupOrder.get(left.type) ?? 3) - (releaseGroupOrder.get(right.type) ?? 3))
})).filter((release) => release.groups.length > 0)
.sort((left, right) => right.version.localeCompare(left.version, undefined, { numeric: true }))
}

export const parseChangeset = (markdown, packageName) => {
const match = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]+)$/u.exec(markdown.trim())
if (match === null) return undefined
Expand Down Expand Up @@ -841,27 +885,52 @@ const parseChangeGroups = (markdown) => {
return groups.filter((candidate) => candidate.entries.length > 0)
}

const readFixedPackageNames = (packageName) => {
const config = readJson(join(repositoryDirectory, ".changeset", "config.json"))
const fixed = Array.isArray(config.fixed)
? config.fixed.find((group) => Array.isArray(group) && group.includes(packageName))
: undefined
return fixed === undefined ? [packageName] : fixed.filter((name) => typeof name === "string")
}

const packageDirectory = (packageName) => {
const packagesDirectory = join(repositoryDirectory, "packages")
for (const entry of readdirSync(packagesDirectory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const directory = join(packagesDirectory, entry.name)
const manifestPath = join(directory, "package.json")
if (isFile(manifestPath) && readJson(manifestPath).name === packageName) return directory
}
throw new Error(`Cannot find workspace package ${packageName}`)
}

const readChangelog = (packageName) => {
const releases = parseChangelog(
readFileSync(join(repositoryDirectory, "packages", "effect-machine", "CHANGELOG.md"), "utf8"),
readReleaseDates(packageName)
)
const packageNames = readFixedPackageNames(packageName)
const releaseDates = readReleaseDates(packageName)
const releases = mergeChangelogs(packageNames.map((name) => parseChangelog(
readFileSync(join(packageDirectory(name), "CHANGELOG.md"), "utf8"),
releaseDates
)))
const pending = readdirSync(join(repositoryDirectory, ".changeset"), { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "README.md")
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => parseChangeset(
readFileSync(join(repositoryDirectory, ".changeset", entry.name), "utf8"),
packageName
))
.flatMap((entry) => {
const markdown = readFileSync(join(repositoryDirectory, ".changeset", entry.name), "utf8")
return packageNames.map((name) => parseChangeset(markdown, name))
})
.filter((entry) => entry !== undefined)
if (pending.length === 0) return releases
const grouped = new Map()
for (const entry of pending) grouped.set(entry.type, [...grouped.get(entry.type) ?? [], entry])
for (const entry of pending) {
const descriptions = grouped.get(entry.type) ?? new Map()
descriptions.set(entry.description, entry)
grouped.set(entry.type, descriptions)
}
return [{
version: "unreleased",
groups: ["major", "minor", "patch"]
.filter((type) => grouped.has(type))
.map((type) => ({ type, entries: grouped.get(type) }))
.map((type) => ({ type, entries: [...grouped.get(type).values()] }))
}, ...releases]
}

Expand Down Expand Up @@ -1144,6 +1213,10 @@ const validateSite = (outputDirectory, site) => {
]) {
if (!isFile(safeResolve(outputDirectory, path))) throw new Error(`Missing generated site file: ${path}`)
}
const changelog = readFileSync(safeResolve(outputDirectory, "changelog/index.html"), "utf8")
if (!changelog.includes(`id="release-${slugify(site.package.version)}"`)) {
throw new Error(`Generated changelog does not include current package version ${site.package.version}`)
}
}

const safeResolve = (base, path) => {
Expand Down