Skip to content
Open
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
4 changes: 3 additions & 1 deletion .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ package-lock.json
.editorconfig
.nvmrc
.yarn
.vscode
.yarnrc.yml
Makefile
.eslintrc.cjs
*.config.mjs
tsconfig.json
*.sh
.scripts
Expand Down
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1 @@
nodejs 24.12
nodejs 26
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## v1.0.0

Node and browser now share one API, and every parsing function reads a `.zip` as well as local unpacked files and folders.

- NEW: Added support for parsing of packs directly from a zip file. `parsePack` now accepts zip file in addition to a folder, and the new `parseZipPack` export takes a `File`/`Blob` directly. Archives are read lazily, so only the chart files and images are decompressed and the whole pack never has to be held in memory. Song folders may sit at the root of the archive or nested inside a pack folder.
- **BREAKING** `parsePack`, `parseAllPacks`, and `parseSong` are now asynchronous and return promises.
- **BREAKING** all three accept a folder **or** a `.zip`, `parsePack` and `parseSong` also accept a `Blob`/`File` directly. `parseAllPacks` walks a `Songs` directory holding any mix of pack folders and archives. The separate `parseZipPack` is gone; use `parsePack`.
- **BREAKING** a song's `banner`, `bg`, and `jacket` are now `ImageRef` handles `{ name, path, file() }` instead of bare strings. `path` is the location on disk, or `null` for an image inside an archive, and `file()` reads the bytes on demand.
- **BREAKING** only images that actually exist are reported. A song tagging an image it doesn't ship now gets `null` instead of a filename pointing at nothing, and an empty tag gets `null` instead of `""`.
- **BREAKING** `Title.titleDir` is now the song's folder name on every platform; the on-disk location moved to the new `Title.titlePath`, which is `null` for songs inside an archive. `Pack.dir` likewise holds the folder name, with the new `Pack.path` holding the location.
- **BREAKING** the browser entry point exports `parseSong` in place of `parseSongFolderOrData`, and the `BrowserSimfile`/`BrowserTitle` are now replaced with plain `Simfile` and `Title`.
- **BREAKING** raised the minimum node version from 16.9 to 20.
- Fixed browser directory listings being truncated for large folders.

## v0.9.0

- Exposed the subtitle tag in parsed results for better noCmod support (thanks Vincent!)
Expand Down
49 changes: 38 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@

[![npm](https://img.shields.io/npm/v/simfile-parser)](https://www.npmjs.com/package/simfile-parser) [![npm bundle size](https://img.shields.io/bundlephobia/min/simfile-parser)](https://bundlephobia.com/package/simfile-parser)

Original parsing code from [city41/stepcharts](https://github.com/city41/stepcharts). Props to Matt for building a really sweet site.
Parse stepmania simfiles in javascript with zero dependencies. Works both in node (server-side or CLI) and in browser. Reads individual songs, whole packs, groups of packs, or even a pack still inside a zip file.

Works both in node (server-side or CLI) and in browser. Bun and Deno support is untested, but an interesting future to explore!
Original parsing code from [city41/stepcharts](https://github.com/city41/stepcharts). Props to Matt for building a really sweet site.

## Usage

Install with `npm install --save simfile-parser` or `yarn add simfile-parser`

```ts
// in node.js >= 16.9.0
// in node.js >= 20

import {
parseAllPacks,
Expand All @@ -20,10 +18,12 @@ import {
calculateStats,
} from "simfile-parser";

// Use one of the three parsing functions depending on your needs:
const allMyStuff = parseAllPacks("/pathToStepmania/Songs");
const aGreatPack = parsePack("/pathToStepmania/Songs/DDRMAX2");
const aGreatSong = parseSong(".../Songs/Easy as Pie 2/Abracadabra");
// Use one of the three parsing functions depending on your needs.
// Each takes a folder or a .zip, and each returns a promise.
const allMyStuff = await parseAllPacks("/pathToStepmania/Songs");
const aGreatPack = await parsePack("/pathToStepmania/Songs/DDRMAX2");
const alsoAPack = await parsePack("/downloads/DDRMAX2.zip");
const aGreatSong = await parseSong(".../Songs/Easy as Pie 2/Abracadabra");

// you can get some top level info about a song's contents too:
calculateStats(aGreatSong.charts["single-challenge"]);
Expand All @@ -39,7 +39,10 @@ calculateStats(aGreatSong.charts["single-challenge"]);

### Browser support

Support dragging packs directly into a web app by parsing in-browser!
Support dragging packs directly into a web app by parsing in-browser! The
browser entry point offers the same `parsePack` and `parseSong`, taking
anything the browser hands you: a `DataTransferItem`, an `HTMLInputElement`, a
`File`, or a `Blob`.

```ts
// requires typescript 5.0 in "Bundler" module resolution mode for typings
Expand All @@ -50,7 +53,7 @@ document.body.addEventListener("dragover", function (e) {
e.preventDefault();
});

document.body.addEventListener("drop", async function (e) {
document.body.addEventListener("drop", async function (evt) {
// also necessary to prevent browser navigating to dropped folder
evt.preventDefault();
if (!evt.dataTransfer) {
Expand All @@ -62,10 +65,34 @@ document.body.addEventListener("drop", async function (e) {
}

try {
// works for a dropped folder or a dropped .zip
const pack = await parsePack(evt.dataTransfer.items[0]);
console.log(`parsed pack "${pack.name}" with ${pack.songCount} songs`);
} catch (e) {
console.error(e);
}
});
```

An archive can also be handed over directly, for example from a `fetch`:

```ts
const response = await fetch("/packs/Club Fantastic Season 1.zip");
const pack = await parsePack(await response.blob(), "Club Fantastic");
```

In the browser `path` is always `null`, since browsers never expose real paths.

### Zipped packs

Every parsing function can read a zip file directly rather than making you unzip it first. Archives are detected by content, not by file extension. `parsePack` also accepts a `Blob` or `File`, so an archive you already have in memory never has to be written out.

Given a path, an archive is read lazily off disk: only its index and each
song's chart file are read to parse a pack, and images are located but not
loaded until you ask for them. Parsing a 55 MB pack reads **0.44 MB**, and the
archive is never held in memory.

Reading zips uses [`DecompressionStream`][ds], which needs node 20+, Chrome
103+, Firefox 113+, or Safari 16.4+. Encrypted archives are not supported.

[ds]: https://developer.mozilla.org/en-US/docs/Web/API/DecompressionStream
2 changes: 2 additions & 0 deletions jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const tsJestCfg = createDefaultEsmPreset();
export default {
...tsJestCfg,
testEnvironment: "node",
// otherwise shared test helpers get picked up as (empty) suites
testMatch: ["**/*.test.ts"],
extensionsToTreatAsEsm: [".ts"],
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simfile-parser",
"version": "0.9.0",
"version": "1.0.0-beta.0",
"description": "Read stepmania charts with javascript!",
"type": "module",
"main": "./dist/main.js",
Expand All @@ -11,7 +11,7 @@
"./browser": "./dist/browser/index.js"
},
"bin": "./dist/cli.js",
"sideEffects": "false",
"sideEffects": false,
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"format": "prettier --write src/**/*.ts",
Expand Down Expand Up @@ -39,7 +39,7 @@
"url": "https://github.com/noahm/simfile-parser/issues"
},
"engines": {
"node": ">=16.9.0"
"node": ">=20.0.0"
},
"homepage": "https://github.com/noahm/simfile-parser#readme",
"devDependencies": {
Expand Down
6 changes: 4 additions & 2 deletions src/__tests__/calculateStats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { parseSong, setErrorTolerance } from "../main";
setErrorTolerance("ignore");
const packsRoot = path.resolve(import.meta.dirname, "../../packs");

test("stats", () => {
test("stats", async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const song = parseSong(path.join(packsRoot, "Easy As Pie 2", "Abracadabra"))!;
const song = await parseSong(
path.join(packsRoot, "Easy As Pie 2", "Abracadabra"),
)!;
expect(calculateStats(song.charts["single-challenge"]))
.toMatchInlineSnapshot(`
{
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ setErrorTolerance("bail");
const packsRoot = path.resolve(import.meta.dirname, "../../packs");

describe("parseAllPacks", () => {
test("parses each pack separately", () => {
test("parses each pack separately", async () => {
expect(
parseAllPacks(packsRoot).map((p) => ({
(await parseAllPacks(packsRoot)).map((p) => ({
name: p.name,
songs: p.songCount,
})),
Expand Down
Loading
Loading