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
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:
- name: Build
run: npm run build

# The player is the host half: Web Audio, transport, host defaults/clamps. It builds against
# the language package IN THIS REPO (a file: link), so a grammar change that breaks playback
# fails here rather than after publish.
- name: Player (@spacedevin/deck-player)
run: npm test -w @spacedevin/deck-player

# The Rust crate is emitted from the SAME src/index.tish as dist/deck.js, which is what stops the
# JS host and tish-gba's build-time bake from drifting. Nothing verified that: a change that broke
# the rust-lib emit, or that made the two targets parse differently, would have gone unnoticed
Expand Down Expand Up @@ -187,6 +193,32 @@ jobs:
npm pack
mv spacedevin-deck-*.tgz spacedevin-deck-npm-package.tgz

# The player releases in lockstep: same tag, same version. Its dependency on the language
# package is `file:../..` so the workspace links locally and CI tests the source in this repo —
# that has to become the real published version before packing, or the tarball is uninstallable.
#
# Caret, not an exact pin. `tish build` inlines the parser into dist/deck-player.js, so the
# dependency only matters to a consumer compiling from src/index.tish via the `tish` export
# condition — and for them a compatible minor is fine. An exact pin would just force npm to
# install a second copy alongside a consumer's own @spacedevin/deck.
- name: Set player version and pin its dependency
run: |
node -e "
const fs = require('fs');
const p = './packages/player/package.json';
const j = JSON.parse(fs.readFileSync(p));
j.version = process.env.VERSION;
j.dependencies['@spacedevin/deck'] = '^' + process.env.VERSION;
fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
"
env:
VERSION: ${{ steps.next_version.outputs.version }}

- name: Create npm package tarball (@spacedevin/deck-player)
run: |
npm pack -w @spacedevin/deck-player
mv spacedevin-deck-player-*.tgz spacedevin-deck-player-npm-package.tgz

- name: Create or update release branch and push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down Expand Up @@ -270,3 +302,10 @@ jobs:
-H "Content-Type: application/octet-stream" \
--data-binary @spacedevin-deck-npm-package.tgz \
"${UPLOAD_URL}?name=spacedevin-deck-npm-package.tgz&label=npm%20package%20(@spacedevin/deck)"
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/octet-stream" \
--data-binary @spacedevin-deck-player-npm-package.tgz \
"${UPLOAD_URL}?name=spacedevin-deck-player-npm-package.tgz&label=npm%20package%20(@spacedevin/deck-player)"
35 changes: 34 additions & 1 deletion .github/workflows/npm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
# Downloads the npm package tarball from the release (no rebuild).
#
# AUTH: npm OIDC trusted publishing (no long-lived NPM_TOKEN). One-time setup on
# npmjs.com for `@spacedevin/deck`: Settings > Trusted Publisher > GitHub Actions:
# npmjs.com — needed SEPARATELY for `@spacedevin/deck` AND `@spacedevin/deck-player`, since trusted
# publishers are per package. Settings > Trusted Publisher > GitHub Actions:
# Organization or user: spacedevin
# Repository: deck
# Workflow filename: npm-release.yml
# Environment: (leave blank)
# Requires npm >= 11.5.1 (upgraded below) and the id-token: write permission.
#
# Both packages ship from one release at one version. The player is published second because its
# dependency is pinned to that exact version.

name: NPM release

Expand Down Expand Up @@ -83,6 +87,24 @@ jobs:
fi
curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/octet-stream" "$DECK_URL" -o spacedevin-deck-npm-package.tgz

# The player ships in lockstep from the same release. Same retry loop, same reason. A
# release predating the player has no such asset, so its absence is not fatal — it just
# means there is nothing to publish.
for attempt in 1 2 3 4 5; do
PLAYER_URL=$(curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.rel.outputs.id }}/assets?per_page=100" \
| jq -r '.[] | select(.name == "spacedevin-deck-player-npm-package.tgz" and .state == "uploaded") | .url')
[ -n "$PLAYER_URL" ] && [ "$PLAYER_URL" != "null" ] && break
echo "player tarball not uploaded yet (attempt $attempt/5) — waiting 30s"
sleep 30
done
if [ -n "$PLAYER_URL" ] && [ "$PLAYER_URL" != "null" ]; then
curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/octet-stream" "$PLAYER_URL" -o spacedevin-deck-player-npm-package.tgz
else
echo "No spacedevin-deck-player-npm-package.tgz on this release — skipping the player publish."
fi

- name: Setup Node
uses: actions/setup-node@v4
with:
Expand All @@ -98,6 +120,17 @@ jobs:
- name: Publish @spacedevin/deck to npm
run: npm publish spacedevin-deck-npm-package.tgz --access public

# Second, and only after the language package is live: the player's dependency was pinned to
# this exact version at pack time, so publishing it first would put an uninstallable package on
# the registry for as long as the other step takes.
- name: Publish @spacedevin/deck-player to npm
run: |
if [ -f spacedevin-deck-player-npm-package.tgz ]; then
npm publish spacedevin-deck-player-npm-package.tgz --access public
else
echo "No player tarball — nothing to publish."
fi

- name: Update release description with npm URL
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Build the docs site and deploy it to GitHub Pages.
#
# The site is generated from the markdown ALREADY IN THIS REPO (README, docs/*.md, the player's docs)
# — see site/build.mjs. Adding a page means adding a `.md` file; there is nothing to register here.
#
# One-time setup: repo Settings > Pages > Build and deployment > Source = "GitHub Actions".
# The site lands at https://spacedevin.github.io/deck/, which is why SITE_BASE defaults to /deck/.

name: Pages

on:
push:
branches: [main]
# Only rebuild when something the site is built FROM changes.
# Globs, not filenames: the generator discovers markdown, so a NEW .md in one of these trees has
# to trigger a rebuild too. Listing specific files would silently skip it.
paths:
- "README.md"
- "docs/**/*.md"
- "packages/player/**/*.md"
- "site/**"
- ".github/workflows/pages.yml"
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# Never let two deploys race; queue instead, and don't cancel a run that is mid-deploy.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
name: Build site
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Install dependencies
run: npm ci

# `npm run site` builds both bundles first: the language build supplies `.deck` syntax
# highlighting from its own keyword tables, and the player build supplies the play buttons.
# Without either the site still builds — those blocks are just plain (the log says so).
- name: Build site
run: npm run site

- name: Configure Pages
uses: actions/configure-pages@v5

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: site/out

deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist/
*.log
.DS_Store
crate/
site/out/
51 changes: 50 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Language-only package for the **`.deck`** patch language.

**Entry:** `src/index.tish`

## Repo layout

This repo publishes **two** packages. The rules below are this one's.

| Path | Package | What |
|------|---------|------|
| `/` (this file) | `@spacedevin/deck` | the language: tokenize, parse, registries, highlight |
| `packages/player/` | `@spacedevin/deck-player` | the host: Web Audio playback, transport, `<deck-player>` |

The "out of scope" list below — **including audio** — is about `@spacedevin/deck`. `src/` stays
audio-free; everything that list excludes lives in `packages/player/`, which has its own
[AGENTS.md](packages/player/AGENTS.md). The player depends on this package and never the reverse.

## In scope

- Tokenize / `parseProgram` → AST
Expand All @@ -28,17 +41,53 @@ Language-only package for the **`.deck`** patch language.
- Project IR / JSON schemas
- Apply / emit to a host project model
- Session, co-DJ, ownership, skills
- Audio / Web Audio engines
- Audio / Web Audio engines — see `packages/player/`
- Instrument catalogs or builtin macro *contents* (hosts `registerBuiltinMacros`)
- HTML / CSS highlight styling
- Graph editor mutators

## Docs site

`site/build.mjs` renders the markdown **already in this repo** to
[spacedevin.github.io/deck](https://spacedevin.github.io/deck/) on every push to `main`.

Adding a page means **adding a `.md` file** under `docs/` or `packages/player/` — there is no route,
nav entry, or registration to update. The title comes from `title:` frontmatter, else a per-section
override in `site/build.mjs`, else the first `#` heading, else the filename; `description:` becomes
the lede.

Don't put frontmatter in a file listed in a package's `files` — npm renders a README verbatim, and a
`---` fence after a text line is a setext H2, so it shows up as a stray rule and a giant
`description:` heading on npmjs.com. Use the section override for those.

**Fence tags.** `tish`, `bash`, `js`, `rust`, `html`, `json`, `yaml` and `deck` all highlight
(`site/highlight.mjs`). In `docs/` an untagged fence defaults to `deck`, since every one of them is.

`.deck` highlighting is driven by **this package's own** `isKeyword` / `isInlineKeyword` /
`isStepToken` / `classifyLine`, not a second keyword list — add a keyword to
`src/deckfile/Highlight.tish` and the site picks it up. The player's `bootDeckRegistries()` runs at
build time too, so host vocabulary (`wave`, `layer`, …) colours as well.

**Play buttons** are decided by *parsing*, not by the fence tag: a `deck` block gets one when
`parseSong` reports no errors and yields at least one channel that actually sounds. So a complete
song is playable wherever it appears, and the grammar's `<placeholder>` notation never offers a
button it can't honour. Runnable songs belong in [docs/EXAMPLES.md](docs/EXAMPLES.md); keep the
grammar reference as reference.

**`llms.txt`** and `llms-full.txt` are generated from the same pages as the HTML, so they can't fall
behind — the usual fate of a hand-written one. A new `.md` appears in both automatically.

Sources are read **in place**. `docs/*.md` are package exports and ship in the tarball, so a
`content/` copy would fork the canonical text. `npm run site:serve` previews locally.

## Docs ownership

| Doc | Audience |
|-----|----------|
| [README.md](README.md) | Install + API map |
| [docs/DECK_GRAMMAR.md](docs/DECK_GRAMMAR.md) | **Canonical** language reference |
| [docs/AST.md](docs/AST.md) | What `parseProgram` / `parseTrackBody` return |
| [docs/EXAMPLES.md](docs/EXAMPLES.md) | Complete runnable songs |
| [docs/DECK_EXTENSION.md](docs/DECK_EXTENSION.md) | gen_block dialect registration + common dialects |
| [docs/HOST.md](docs/HOST.md) | How a host boots registries |
| [examples/](examples/) | Runnable parse / boot / helper demos |
Expand Down
57 changes: 56 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

Streamable **`.deck`** patch **language** for Tish hosts (e.g. Deckard).

```deck
deck 1
bpm 132

track Lead id lead gen gameBoyDmg
gen type pulse duty 25 vol 11
note 72 0 0.5 v 110
note 76 0.5 0.5 v 95
note 79 1 1 v 105
note 76 2 0.5 v 100
note 72 2.5 1.5 v 110

track Bass id bass gen gameBoyDmg
gen type wave wave_shape saw vol 15
note 36 0 2 v 120
note 43 2 2 v 110

track Kick id kick gen gbaDirectSound
gen waveform triangle pitch_drop -14
adsr a 0 d 0.08 s 0 r 0
step_pitch 36
steps x . . . x . . x x . . . x . . .
```

That is a whole song: a tempo, three tracks, and what each one plays — melody as notes on
a beat grid, drums as a step pattern. **[Press play on it](https://spacedevin.github.io/deck/)**
— the docs site synthesises it in the browser with
[`@spacedevin/deck-player`](packages/player/), the same engine that drives the GBA build.

More in **[Examples](docs/EXAMPLES.md)**; the full surface in the
**[grammar](docs/DECK_GRAMMAR.md)**.

## Install

```bash
Expand Down Expand Up @@ -44,6 +76,25 @@ npm run examples

Apply/emit to project IR · session/co-DJ · audio engines · instrument catalogs · builtin macro catalogs · HTML highlight CSS · graph editor mutators.

## Playback

Hearing a `.deck` file is a host job, so it is a second package in this repo:
**[`@spacedevin/deck-player`](packages/player/)** — Web Audio chip synths, a lookahead transport, and
a `<deck-player>` element.

```bash
npm install @spacedevin/deck-player
```

```js
import { createDeckPlayer } from '@spacedevin/deck-player'
let player = createDeckPlayer()
player.load(source)
player.play()
```

It depends on this package and never the reverse — the language stays audio-free.

## Rust

The same `src/index.tish` also emits a Rust library crate, so a Rust consumer (tish-gba's build-time
Expand All @@ -63,12 +114,16 @@ One source, three targets — Tish, JS, Rust — checked against one corpus.

## Docs

**[spacedevin.github.io/deck](https://spacedevin.github.io/deck/)** — the same markdown, as a site.

- **[Language grammar](docs/DECK_GRAMMAR.md)** — canonical `.deck` surface
- **[Examples](docs/EXAMPLES.md)** — complete runnable songs (playable on the site)
- **[AST shape](docs/AST.md)** — what `parseProgram` / `parseTrackBody` return
- **[gen_block extensions](docs/DECK_EXTENSION.md)** — dialect registration + common `patch` / `matrix_fm`
- **[Host integration](docs/HOST.md)** — boot order, registries, what hosts implement
- **[AGENTS.md](AGENTS.md)** — in/out of scope for package edits

npm also exports `./grammar` and `./extension` to those markdown files.
npm also exports `./grammar`, `./ast`, `./examples`, `./extension` and `./host` to those markdown files.

## Release

Expand Down
Loading
Loading