npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@signageos/changelog

v0.1.2

Published

Readme

signageOS changelog

Parse, emit and validate signageOS-style CHANGELOG.md files (Keep a Changelog format).

API

parseChangelog(text): ChangelogRoot

Parses a changelog into an AST of preamble + versions (## …) → sections (### …) → entries. It is deliberately tolerant: a file that violates every rule still parses, so that validation can report on it. Two inputs are errors, both thrown as a ParseError: a section heading before any version, and a file that mixes CRLF and LF line endings.

The block of markdown link reference definitions a Keep a Changelog file usually closes with ([1.0.0]: https://…/compare/…) is root.epilogue, a text blob like the preamble at the other end, and absent when the file has none. What it claims is structural — the run of lines at the bottom that is neither an entry nor a heading, separated from the last entry by a blank line and bounded above by the first version heading, so a file with no versions is all preamble. Whether those lines are really link definitions is epilogue-format's question. Nothing maintains the block: it rides through every operation untouched, and a fragment has no epilogue at all, since parseSections returns sections with no root to hang one on — a definition at the bottom of a fragment stays an entry, which entry-format reports.

The file's line ending is detected and recorded as root.source.eol, and the AST is built from a normalized (LF) copy of the source, kept as root.source.text. No node's text ever contains a \r, so nothing consuming the AST has to handle line endings — emitChangelog restores them. One consequence: a range indexes into root.source.text rather than into the text passed in, which differ in a CRLF file. A loc is the same either way.

parseSections(text): ChangelogSection[]

Parses a changelog fragment — ### Section headings with their entries and nothing above them, e.g. the .changelog/<feature>.md file a branch carries until it lands — into detached sections, ready for appendSections. The same tolerance and the same line-ending handling as parseChangelog, mixed CRLF/LF included; an empty fragment is an empty list, while a ## version heading in it and a line above the first ### heading are both a ParseError.

Every section returned shares one SourceFile ({ text, eol }) — the fragment's normalized text and detected line ending — recorded as its source. It is what lets validateSections translate a fix back into the fragment file's own coordinates and ending; a section built by createSection instead carries no source.

emitChangelog(node): string

Turns any node back into source text, losslessly — emitChangelog(parseChangelog(text)) === text for any input the parser accepts, valid or not. Emitting a root restores root.source.eol; any other node emits LF, since only a root records an ending.

validateChangelog(root, options?): ChangelogDiagnostic[]

Checks the AST against the changelog rules and returns one diagnostic per violation, ordered by position in the source. Each diagnostic carries the offending node, its range/loc, the ruleId, a severity and — for preamble-format and trailing-empty-line — a fix descriptor ({ range, text }) that resolves it. A fix is expressed in the coordinates and the line endings of the text you passed to parseChangelog, so applying it needs no adjustment.

import { parseChangelog, validateChangelog } from '@signageos/changelog';

for (const { ruleId, message, loc } of validateChangelog(parseChangelog(text))) {
	console.log(`${loc.start.line}:${loc.start.column} ${message} (${ruleId})`);
}

Every rule is an error by default; options.rules overrides a severity or turns a rule 'off', and options.allowedSections replaces the default section names (Added, Fixed, Changed, Removed, Deprecated, Security).

| Rule | Requires | | ---------------------- | ------------------------------------------------------- | | preamble-format | the exact Keep a Changelog header | | epilogue-format | the closing block to hold [name]: <url> definitions | | version-format | ## [<semver>] - <date> version headings | | version-date | a usable release date, or unknown | | date-format | that date to be an existing YYYY-MM-DD date | | section-name | one of the allowed section names | | entry-format | entries to be - bullets, optionally tab-indented | | trailing-empty-line | an empty line after the last entry of a section | | entry-trailing-line | only the last entry of a section to carry a blank line | | empty-section | every section to have at least one entry | | single-line-title | nothing between a version heading and its first section | | heading-indentation | headings without leading or trailing whitespace | | irregular-whitespace | no two whitespace characters in a row | | unreleased-placement | [Unreleased] to come first, and only once | | version-order | released versions to be listed newest first, by date | | duplicate-version | every version to appear once |

The [Unreleased] version is exempt from the version and date rules.

validateSections(sections, options?): ChangelogDiagnostic[]

validateChangelog's counterpart for a changelog fragment: checks the sections of one fragment — e.g. parseSections(text) — against the same rule table and the same options. Only the seven rules that guard on section/title/entry can ever fire, since a fragment has no root/version/preamble for the rest to check: section-name, entry-format, heading-indentation, irregular-whitespace, empty-section, trailing-empty-line and entry-trailing-line.

import { parseSections, validateSections } from '@signageos/changelog';

for (const { ruleId, message, loc } of validateSections(parseSections(fragmentText))) {
	console.log(`${loc.start.line}:${loc.start.column} ${message} (${ruleId})`);
}

A fix is translated into the coordinates and line ending of whichever section reported it, using its source — so it applies to the fragment file the section came from, not to the changelog it might later be spliced into. A section with no source gets no translation.

parseErrorToDiagnostic(error, text): ChangelogDiagnostic

Turns a ParseError into a diagnostic of the same shape, so a caller has one thing to render for both parse failures and rule violations.

walkChangelog(node): Generator<{ node, parent }>

Every node of the subtree under node with the node that owns it, each node before its children, in source order. Accepts any node, not just a ChangelogRoot — validateSections calls it with one ChangelogSection at a time.

Manipulation

Every operation is pure: it splices new nodes into a copy of the tree and never mutates its input. The host file's line ending is preserved, blank-line structure stays valid (layout is the emitter's), and an operation never introduces new diagnostics — an invalid file stays exactly as invalid as it was. An operation that cannot be applied (a missing target version, a version that cannot be ordered) throws a ManipulationError.

Positions are the one thing an operation leaves behind: new nodes carry [0, 0] placeholders and root.source.text is still the text the tree was parsed from, because nothing here reads either. Emit the result, or run it through renormalizeChangelog when positions are needed again — to validate the result, or to report on it.

addEntry(root, { text, section, version? }): ChangelogRoot

Appends an entry (text, without the - bullet) to a section of a version. The section is created when missing; version defaults to 'Unreleased', which is also created (as the first version) when missing — a missing released version throws instead.

appendSections(root, { sections, version? }): ChangelogRoot

Appends whole sections to a version: a section the version already has takes the incoming entries, one it does not is appended after the existing ones. version behaves as in addEntry ('Unreleased' by default, created when missing; a missing released version throws), and addEntry is this operation's one-entry case.

Entries are appended as they come — an entry already present verbatim is appended again rather than dropped, so appending the same fragment twice shows up in the diff instead of looking like nothing happened.

This is the other half of parseSections: the changelog fragment a branch carries as .changelog/<feature>.md, spliced into the changelog when the branch lands.

import { appendSections, emitChangelog, parseChangelog, parseSections } from '@signageos/changelog';

const root = appendSections(parseChangelog(text), { sections: parseSections('### Added\n- idk\n\n### Fixed\n- bug\n') });
console.log(emitChangelog(root));

addVersion(root, { version, date?, sections? }): ChangelogRoot

Inserts a new version block at the position that keeps versions in descending semver order, [Unreleased] first. Takes the same options as createVersion; a version that already exists throws.

setVersionTitle(root, target, { version?, date? }): ChangelogRoot

Rewrites one version heading — e.g. setVersionTitle(root, 'Unreleased', { version: '1.2.0', date: '2026-08-11' }) renames [Unreleased] to a release — leaving every other line of the file alone. Omitted options keep the current value; renaming [Unreleased] to a released version requires a date.

releaseChangelog(root, { version, date }): ChangelogRoot

Releases [Unreleased]: rewrites that one heading as ## [<version>] - <date>. Exactly one line of the file changes — nothing is reordered, no entry is touched, and no fresh [Unreleased] is created above the release (compose addVersion(root, { version: 'Unreleased' }) for that), which is what makes a release a reviewable one-line diff.

Both options are required. The version is the caller's, because the changelog is not where version history lives — a git tag is, and deriving the number from the file would be a second, disagreeing source of it. date is the caller's for the same kind of reason: nothing in the library reads a clock (YYYY-MM-DD, or 'unknown').

Nothing is validated — a changelog with a wrong preamble, an indented heading or a section named Fixes still releases, because someone who has to ship a release does not stop being allowed to. Call validateChangelog first if you want that gate.

detectBumpLevel(root): 'major' | 'minor' | 'patch'

The bump the section headings under [Unreleased] call for, highest match winning. This is the convention detect-semver-level in @signageos/lib-ci established:

| Section under [Unreleased] | Level | | --------------------------------- | ------- | | Changed or Removed | major | | Added | minor | | Fixed, Deprecated, Security | patch | | no sections at all | patch |

It keys on the heading, not on the entries under it, so an empty ### Changed still bumps major (empty-section reports it). A heading it does not recognise contributes no level, so ### Remove alone gives patch — section-name is what reports the typo.

Applying the level is the caller's: this answers how big the release is, not what to call it. A changelog with no [Unreleased] version throws a ManipulationError, since there is nothing to release.

Builders and queries

createEntry({ text }), createSection({ name, entries? }) and createVersion({ version, date?, sections? }) compose detached, emittable AST nodes bottom-up — entries may be given as plain strings, sections as createSection results. Text a validation rule would report — empty, indented, carrying its own - bullet or two whitespace characters in a row — is a ManipulationError rather than a node:

import { addVersion, createSection, emitChangelog, parseChangelog } from '@signageos/changelog';

const root = addVersion(parseChangelog(text), {
	version: '1.2.0',
	date: '2026-08-11',
	sections: [createSection({ name: 'Fixed', entries: ['Race condition in bar'] })],
});
console.log(emitChangelog(root));

findVersion(root, ref) and findSection(version, name) resolve the 'Unreleased'-or-version-number refs the operations use; renormalizeChangelog(root) re-parses the emitted source to give a hand-built or mutated tree real positions.

CLI

The same operations over a file, for CI jobs and devtools:

npx @signageos/changelog add-entry "Race condition in bar" --section Fixed
cat .changelog/new-feature.md | npx @signageos/changelog append-sections
npx @signageos/changelog show --body >> release-notes.md

Every command works on ./CHANGELOG.md unless --file says otherwise, and the ones that change it write it in place — pass --stdout to print the result and leave the file alone. --file - reads standard input, which implies --stdout.

| Command | Does | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | init [--force] | Creates the changelog: the canonical preamble and an empty [Unreleased] | | add-entry <text…> --section <name> [--version <ref>] | addEntry per text; the section and [Unreleased] are created when missing | | append-sections [<fragment>…] [--version <ref>] [--allow-empty] | appendSections of the fragment files named, or of one read from standard input | | add-version <version> [--date <date>] | addVersion — an empty block at its descending-semver position; the date defaults to unknown | | set-version-title <target> [--version <ref>] [--date <date>] | setVersionTitle — rewrites one heading, e.g. renames [Unreleased] to a release | | release --version <ref> [--date <date>] | releaseChangelog — releases [Unreleased] as that version; the date defaults to today's | | show [version] [--body] | Prints one version's block; version defaults to latest, the newest released one | | versions [--format text\|json] | Lists the versions, newest first | | bump-level | Prints the level the sections under [Unreleased] call for, e.g. minor | | validate | Prints the changelog's lint diagnostics to stderr; exits 1 if any are found | | validate-fragments <file…> | validateSections per fragment file named, diagnostics labeled by path; exits 1 if any are found |

append-sections is the fragment workflow: a branch that would rather not touch CHANGELOG.md — every branch that does conflicts with the next — writes its entries into a .changelog/<feature>.md of ### Section blocks, and the job that lands it appends them. Fragments concatenate in the order they are named, and sections of the same name collapse into one:

npx @signageos/changelog append-sections .changelog/*.md && rm .changelog/*.md
cat .changelog/*.md | npx @signageos/changelog append-sections # the same thing over a pipe

An empty fragment file contributes nothing, so a placeholder a glob picks up does not stop the append; a fragment read from standard input has to carry sections, since the usual way to get an empty pipe is a cat of the wrong path. A file that is not empty but has no ### heading in it fails either way.

A named fragment that cannot be read at all — missing, or an unmatched glob the shell passed through literally, e.g. .changelog/*.md with an empty directory — is a warning on stderr and zero sections rather than a command failure. By default the command still requires that at least one named fragment was actually opened, so a release build catches a fragment step that silently produced nothing; --allow-empty drops that requirement for a build that legitimately has none to add:

npx @signageos/changelog append-sections .changelog/*.md --allow-empty # ok even with no fragments in .changelog/

A release job asks the changelog how big the release is, and its own version history what to call it:

level="$(npx @signageos/changelog bump-level)"                          # minor
version="$(npx semver "$(git describe --tags --abbrev=0)" -i "$level")" # 1.3.0

npx @signageos/changelog release --version "$version"
npx @signageos/changelog add-version Unreleased # a release creates no new [Unreleased]; this does
npm version "$version" --no-git-tag-version

Exit codes are 0 for success, 1 for a command that failed (the file is missing, does not parse, or the operation cannot be applied) and 2 for an invocation that is wrong (an unknown command or option, a missing argument). changelog --help lists the commands and changelog <command> --help explains one.

validate and validate-fragments are the only commands that run the lint rules; every other command fails only on a file too broken to parse.