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
21 changes: 21 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,24 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

# A push to main that lands a new version in CHANGELOG.md is what starts a release. The check is
# cheap and says so out loud on every push, so nothing has to be remembered at merge time.
release-detect:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
actions: write # dispatch release.yml
steps:
- uses: actions/checkout@v7

- uses: oven-sh/setup-bun@v2

- name: Install node_modules
run: bun install --frozen-lockfile --ignore-scripts

- name: Detect a new release
env:
GH_TOKEN: ${{ github.token }}
run: bun run release:detect
58 changes: 58 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Release

# Dispatched by the release-detect job in main.yml when CHANGELOG.md describes a version that is
# not on npm yet. It can also be run by hand, which is the way to retry a release that failed
# halfway: publishing and creating the GitHub release are both skipped when they already happened.
on:
workflow_dispatch:
inputs:
version:
description: "Version to release. Must be the latest release in CHANGELOG.md."
required: true
type: string

permissions:
contents: read

jobs:
release:
runs-on: ubuntu-latest
# The npm trusted publisher is configured for this environment, so a publish can only come from
# a job that runs in it.
environment: release
permissions:
contents: write # create the tag and the GitHub release
id-token: write # OIDC token for npm trusted publishing

steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0

- uses: oven-sh/setup-bun@v2

- uses: actions/setup-node@v6
with:
node-version: 24
registry-url: https://registry.npmjs.org
package-manager-cache: false

# Trusted publishing needs npm 11.5.1 or later, which is newer than what some runner images
# ship with.
- name: Update npm
run: npm install -g npm@latest

- name: Setup dotnet
uses: actions/setup-dotnet@v6
with:
global-json-file: global.json

# --ignore-scripts skips the root postinstall build, because the package's own prepack builds
# the daemon and the plugin when `bun pm pack` runs.
- name: Install node_modules
run: bun install --frozen-lockfile --ignore-scripts

- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: bun run release:publish ${{ inputs.version }}
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -492,9 +492,9 @@ output/

# VSCode
.vscode/
# copied from the repo root by prepublishOnly
/packages/vite-plugin-fable/Directory.Build.props
/packages/vite-plugin-fable/Directory.Packages.props
# copied from the repo root by scripts/release-publish.ts
/packages/vite-plugin-fable/README.md
/packages/vite-plugin-fable/LICENSE

# TypeScript build output for the Vite plugin
packages/vite-plugin-fable/dist/
Expand Down
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) from version [0.1.0] moving forward.

## [Unreleased]
## [0.3.0] - 2026-08-29

### Changed

- The plugin is written in TypeScript. `index.js` and `types.d.ts` became `index.ts` and `types.ts`, compiled to `dist/` by `tsc` during `prepublishOnly`; the package now ships type declarations. Behaviour is unchanged.
- The plugin is written in TypeScript. `index.js` and `types.d.ts` became `index.ts` and `types.ts`, compiled to `dist/` by `tsc` during `prepack`; the package now ships type declarations. Behaviour is unchanged.
- `bun install` at the repo root now builds the daemon and the plugin (`bun run build`), so a fresh clone works without a manual build step. The plugin's own `postinstall` is now only the consumer hook and delegates to `build:daemon`.
- Fable.Daemon now targets `net10.0`; the .NET 10 runtime is required.
- Updated Fable.Compiler to 5.14.1 and `@fable-org/fable-library-js` to 2.5.1.
Expand All @@ -21,13 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- dotnet tools updated: Fantomas 8.0.0-alpha-025, fsdocs-tool 22.1.0, dotnet-outdated-tool 4.8.1. Sources reformatted with Fantomas 8 and CI now runs `dotnet fantomas check .`.
- Vite 8 is the peer dependency. The JSX transform after Fable compilation uses Vite's `transformWithOxc` (rolldown/oxc); `esbuild` is no longer a peer dependency.
- All JavaScript dependencies updated (`@babel/code-frame` 8, `ts-lsp-client` 1.1.1, TypeScript 7, React 19 and `@vitejs/plugin-react` 6 in the sample).
- The repository is a Bun workspace with isolated installs: the plugin lives in `packages/vite-plugin-fable` and `sample-project` depends on it via `workspace:*`. `Directory.*.props` stay at the repo root and are copied into the package by `prepublishOnly`; the changelog updater lives in `scripts/`. Shared versions (`vite`, `vite-plugin-inspect`, `@fable-org/fable-library-js`) are declared once in the root workspace catalog and referenced as `catalog:`.
- The repository is a Bun workspace with isolated installs: the plugin lives in `packages/vite-plugin-fable` and `sample-project` depends on it via `workspace:*`. `Directory.*.props` stay at the repo root, which is where the daemon is built from, and the release scripts live in `scripts/`. Shared versions (`vite`, `vite-plugin-inspect`, `@fable-org/fable-library-js`) are declared once in the root workspace catalog and referenced as `catalog:`.
- `vite-plugin-fable` ships the daemon prebuilt and no longer has a `postinstall` script. It used to run `dotnet publish` on the consumer's machine, which package managers increasingly refuse to run by default (bun only for `trustedDependencies`, pnpm behind `onlyBuiltDependencies`, `npm --ignore-scripts` in CI); when it was skipped the install still succeeded, no `bin/` was produced, and the failure surfaced much later as a confusing `buildStart` error. The package now carries a framework-dependent publish of `Fable.Daemon` that runs anywhere the .NET runtime does, so `--ignore-scripts` installs work and nothing is compiled at install time. The .NET 10 SDK is still required, because reading a `.fsproj` means asking `dotnet msbuild` about it. The published bits are portable IL rather than the ReadyToRun build the old `postinstall` produced, which costs roughly 0.7s of JIT per `vite dev` start and per `vite build`. A missing daemon assembly now fails with a message naming it instead of `dotnet`'s own error arriving behind "the daemon stopped unexpectedly". `cracking.fsx` still ships, and works against the bundled `bin/` without anything being built first.
- The daemon's debug server answers JSON under `/api`, so what it cracked, compiled, cached and served can be read by anything that is not a browser: `/api/status`, `/api/project`, `/api/files` (including the JavaScript emitted for one file), `/api/diagnostics` (unfiltered), `/api/cache` (whether the design time build was reused and which input invalidated it), `/api/requests` and `/api/logs`. Read-only, served from a snapshot the message loop publishes so it never queues behind a compile, and every response carries a revision that increments per served request. The log viewer page is unchanged. A running daemon writes `$TMPDIR/vite-plugin-fable/daemon-<pid>.json`, and `VITE_PLUGIN_FABLE_DEBUG_PORT` moves it off 9014 when two dev servers would collide.
- The `debug` plugin option starts the daemon's debug server too. It used to be plugin-side only, so the daemon's own output could only be reached by also setting `VITE_PLUGIN_FABLE_DEBUG`.
- Diagnostics for files under `fable_modules` are no longer reported. They are about the sources Fable restored for the packages a project depends on, which nobody using the plugin wrote or can edit. The new `fableModulesDiagnostics` option reports them again, including errors, which is worth turning on when a package itself is what looks broken.
- `fable-library` is located with `import.meta.resolve` instead of guessing `node_modules` paths.
- Prettier replaced by oxfmt (`bun run format`, `bun run format:check`).
- Releases are cut from CHANGELOG.md. A push to `main` whose changelog names a version that is not on npm dispatches `release.yml`, which publishes the package and creates the GitHub release for it; both steps are skipped when they already happened, so a run that failed halfway can be dispatched again by hand. npm trusted publishing (OIDC) authenticates the upload instead of a long-lived token, which is the one thing here that is not bun: `bun publish` cannot use the workflow's OIDC token yet ([oven-sh/bun#22423](https://github.com/oven-sh/bun/issues/22423)), so `npm publish` uploads a tarball that `bun pm pack` produced. Packing with bun is what resolves the `catalog:` ranges, which npm would otherwise publish verbatim for consumers to choke on. The published version is taken from the changelog at release time rather than committed to `package.json`, the package's `prepublishOnly` became a `prepack` (what `bun pm pack` runs), and it gained the `repository` field that provenance requires and the README and LICENSE that only get published when they sit next to the manifest. `scripts/changelog-updater.ts` is gone, replaced by `scripts/release-detect.ts` and `scripts/release-publish.ts`; it handed the changelog CLI an absolute path that the CLI joined onto its own working directory, so `prepublishOnly` could not have worked.

### Fixed

Expand All @@ -52,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `vite build` now fails when F# does. A cracking or compile failure, or any error-severity diagnostic, aborts the build instead of logging and exiting 0 with broken output; an F# file Fable never compiled is reported rather than handed to the JavaScript parser as raw F#. `vite dev` is unchanged: the server stays up so the browser overlay can show the diagnostic.
- Plugin errors are logged through Vite's `logger.error` rather than `logger.warn`.
- oxlint runs over the repository, with `@nojaf/oxlint-plugin-annotate-non-primitives` requiring an explicit type annotation wherever the type is not obvious from the initializer. Every `lint` script runs oxlint before the TypeScript checks, and CI runs them on every PR.
- `scripts/changelog-updater.js` is now TypeScript and type-checked by `tsconfig.scripts.json`. `docs/scripts/command.js` stays JavaScript: the docs pages load it directly through an import map with no bundler, so porting it would mean adding a build step to a pipeline that has none.
- The scripts in `scripts/` are TypeScript and type-checked by `tsconfig.scripts.json`. `docs/scripts/command.js` stays JavaScript: the docs pages load it directly through an import map with no bundler, so porting it would mean adding a build step to a pipeline that has none.
- The plugin package is laid out as `src/` and `tests/`, with `bun test` covering the Vite hooks against a stub daemon. `bun run test:plugin` runs them; CI runs them on every PR.
- The daemon lives behind a `FableDaemon` interface in its own module. Process lifetime, the JSON-RPC endpoint and the positional wire format no longer leak into the plugin, which can now be run against a stub daemon in tests.
- The daemon process is spawned without a shell, so a `dotnet` that is not on `PATH` now fails immediately with an actionable message instead of hanging the dev server forever. Its stderr is drained (an undrained pipe would deadlock the daemon once the buffer filled), requests fail fast if the daemon exits, and the daemon is also stopped on `SIGINT`.
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@
"test:plugin": "bun run --cwd packages/vite-plugin-fable test",
"test:daemon": "dotnet test",
"sample": "bun run --cwd sample-project dev",
"docs": "dotnet fsdocs watch --noapidocs --projects packages/vite-plugin-fable/Fable.Daemon/Fable.Daemon.fsproj"
"docs": "dotnet fsdocs watch --noapidocs --projects packages/vite-plugin-fable/Fable.Daemon/Fable.Daemon.fsproj",
"release:detect": "bun scripts/release-detect.ts",
"release:publish": "bun scripts/release-publish.ts"
},
"devDependencies": {
"@nojaf/oxlint-plugin-annotate-non-primitives": "^0.1.1",
"@types/bun": "^1.4.0",
"copy-to-clipboard": "^4.0.2",
"keep-a-changelog": "^3.1.0",
"lit": "^3.3.3",
"oxfmt": "0.65.0",
"oxlint": "^1.80.0",
Expand Down
9 changes: 7 additions & 2 deletions packages/vite-plugin-fable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
"description": "",
"keywords": [],
"homepage": "http://fable.io/vite-plugin-fable/",
"bugs": "https://github.com/fable-compiler/vite-plugin-fable/issues",
"license": "Apache-2.0",
"author": "nojaf",
"repository": {
"type": "git",
"url": "git+https://github.com/fable-compiler/vite-plugin-fable.git",
"directory": "packages/vite-plugin-fable"
},
"funding": "https://nojaf.com/",
"files": [
"dist",
Expand All @@ -28,7 +34,7 @@
"test": "bun test",
"lint": "oxlint --deny-warnings && bun run lint:types",
"lint:types": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json && tsc --noEmit -p tsconfig.docs.json",
"prepublishOnly": "bun run build:daemon && bun run build && bun ../../scripts/changelog-updater.ts"
"prepack": "bun run build:daemon && bun run build"
},
"dependencies": {
"@babel/code-frame": "^8.0.0",
Expand All @@ -40,7 +46,6 @@
"devDependencies": {
"@types/bun": "^1.4.0",
"@types/node": "^26.4.0",
"keep-a-changelog": "^3.1.0",
"typescript": "7.0.2",
"vite": "catalog:"
},
Expand Down
21 changes: 0 additions & 21 deletions scripts/changelog-updater.ts

This file was deleted.

33 changes: 33 additions & 0 deletions scripts/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import path from "node:path";
import { type Changelog, parser, type Release } from "keep-a-changelog";

export const repoRoot: string = path.resolve(import.meta.dir, "..");
export const packageName: string = "vite-plugin-fable";
export const packageDir: string = path.join(repoRoot, "packages", packageName);

export interface ChangelogRelease {
/** The version as written in CHANGELOG.md, without a `v` prefix. */
version: string;
/** The body of the release section, without its heading. */
notes: string;
}

/**
* The most recent released version in CHANGELOG.md. `[Unreleased]` carries no date, which is what
* distinguishes it from a real release: a version is released by giving it a number and a date.
*/
export async function latestRelease(): Promise<ChangelogRelease> {
const markdown: string = await Bun.file(path.join(repoRoot, "CHANGELOG.md")).text();
const changelog: Changelog = parser(markdown);
const release: Release | undefined = changelog.releases.find((release: Release): boolean =>
Boolean(release.date && release.version),
);

if (!release?.version) {
throw new Error("CHANGELOG.md has no dated release; nothing can be released from it.");
}

const [, ...body]: string[] = release.toString().split("\n");

return { version: release.version, notes: body.join("\n").trim() };
}
35 changes: 35 additions & 0 deletions scripts/release-detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { $ } from "bun";
import { type ChangelogRelease, latestRelease, packageName } from "./changelog";

// Decides whether CHANGELOG.md describes a version that is not on npm yet, and dispatches
// .github/workflows/release.yml when it does. Runs on every push to main.
//
// npm is what the release is compared against rather than the git tags, because npm is what a
// publish collides with: a version that is already there cannot be published a second time,
// whatever the tags say.

const { version }: ChangelogRelease = await latestRelease();
const published: string[] = await $`bun info ${packageName} versions --json`.json();

if (published.includes(version)) {
console.log(`No release needed. ${packageName}@${version} is already on npm.`);
process.exit(0);
}

const latestPublished: string | undefined = published.toSorted(Bun.semver.order).at(-1);

if (latestPublished && Bun.semver.order(version, latestPublished) < 0) {
throw new Error(
`CHANGELOG.md releases ${version}, which is older than ${latestPublished} on npm.`,
);
}

console.log(`${version} is not on npm yet (latest published is ${latestPublished ?? "nothing"}).`);

if (process.env.CI !== "true") {
console.log("Not running in CI, so the release workflow is not dispatched.");
process.exit(0);
}

await $`gh workflow run release.yml --ref main -f version=${version}`;
console.log(`Dispatched release.yml for ${version}.`);
Loading