brumate

Publishing components

A package can ship .brm components for other apps to use. It ships them as source, and the Vite plugin of the app compiles them with the app's own files.

What a package can ship

A package cannot ship routes. The routes of an app come only from its own src/routes.

Set up package.json

{
  "name": "@acme/ui",
  "version": "1.0.0",
  "type": "module",
  "brumate": { "version": 1, "dir": "src" },
  "exports": {
    "./*.brm": "./src/*.brm",
    "./*.brm.ts": "./src/*.brm.ts",
    "./greeting": "./src/greeting.ts"
  },
  "files": ["src"],
  "sideEffects": ["**/*.css"],
  "peerDependencies": {
    "brumate": "^0.1.0"
  }
}

The app adds the package to its dependencies or devDependencies, and imports from it:

<script>
  import Button from '@acme/ui/Button.brm';
</script>

<Button label="Save" />

How the plugin finds packages

When Vite starts, the plugin reads the package.json of the app. For each dependency whose package.json has a brumate field, it:

Dependencies of a package

Vite does not pre-bundle the package, so it does not pre-bundle the CommonJS dependencies that its browser code imports either. Add them to optimizeDeps.include of the app, with the package name before them:

// vite.config.ts of the app
export default defineConfig({
  plugins: [brumate()],
  optimizeDeps: { include: ['@acme/ui > some-cjs-dependency'] },
});

The plugin does not add them itself, because a dependency that only server code uses would be bundled for the browser. Dependencies that ship ES modules need nothing.

IDs

The ID of a component comes from the name of the package and the path of the file in it. src/Button.brm of @acme/ui is Button_ and a hash of @acme/ui/src/Button.brm. The IDs of server functions of a 'use server' file come from the same name.

Compile errors and warnings name the file the same way, such as @acme/ui/src/Button.brm:4:3.

Styles

A <style> block of a package component is scoped to it, as in the app. The app cannot style the inside of a package component with its own selectors. A package can:

SCSS and Less work if the app installs sass or less. List the one you use as a peer dependency.

Tailwind CSS does not look in node_modules for class names. An app that uses Tailwind adds the package to its sources:

@import 'tailwindcss';
@source '../node_modules/@acme/ui/src';

Server functions

A server function of a package becomes an endpoint of the app only when the app reaches its file. The plugin follows the imports of the app's files into the package, and the imports of those files. A 'use server' file or a component that nothing reaches has no endpoint, so installing a package does not add endpoints by itself.

A server function that the app reaches can be called by anyone, with any arguments, not only by the components of the package.

See Security.

Testing a package

A package tests its components with brumate/testing, as an app does. Add the plugin to its Vitest config:

// vitest.config.ts
import brumate from 'brumate/vite';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [brumate()],
  test: { environment: 'happy-dom' },
});

answerCalls runs the server functions of the package in the test:

import { expect, it } from 'vitest';
import { answerCalls, mount } from 'virtual:brumate/testing';
import Greeter from '../src/Greeter.brm?brumate-server';

it('greets', async () => {
  const restore = answerCalls();
  const page = await mount(Greeter, { name: 'Ada' });
  page.click('button');
  await page.waitFor(() => expect(page.text('output')).toBe('Hello, Ada'));
  page.unmount();
  restore();
});

Type checking

brumate-tsc and the editor plugin follow the exports of the package to its .brm files, so the app gets the props of each component. A prop with the wrong type is an error in the app.

Versions

The app compiles the package with its own version of brumate. The compiled code and the page format come from that version, so the package must use only what that version of the language supports.

A complete package

acme-ui/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    Counter.brm
    counter.brm.ts
    Greeter.brm
    greeting.ts
  test/
    greeter.test.ts
// src/counter.brm.ts
import { $memo, $signal } from 'brumate/signals';

export function counter(start: number) {
  let count = $signal(start);
  const double = $memo(count * 2);

  function increment() {
    count++;
  }

  return { count, double, increment };
}
<!-- src/Counter.brm -->
<script>
  import { $props } from 'brumate/signals';
  import { counter } from './counter.brm.ts';

  const { start = 0 } = $props<{ start?: number }>();
  const { count, double, increment } = counter(start);
</script>

<button onClick=increment>Clicked {count} times</button>
<p>Double: {double}</p>

<style>
  button {
    font: inherit;
  }
</style>
// src/greeting.ts
'use server';

export async function greet(name: string): Promise<string> {
  return `Hello, ${name}`;
}
<!-- src/Greeter.brm -->
<script>
  import { $props, $signal } from 'brumate/signals';
  import { greet } from './greeting.ts';

  const { name } = $props<{ name: string }>();
  let message = $signal('');
</script>

<button onClick=(async () => (message = await greet(name)))>Greet {name}</button>
<output>{message}</output>

examples/ui-kit is a package like this one, and examples/kit-app is an app that uses it.