Cookbook

Every TypeScript recipe below is sourced from a file compiled by npm run typecheck:docs.

Mount a reactive browser application

ts
import { createApp, html } from '@gluonjs/core';
import { ref } from '@gluonjs/reactivity';

const count = ref(2);

createApp(() => html`
  <main>
    <h1>Count ${count.value}</h1>
    <button type="button" @click=${() => { count.value += 1; }}>Increment</button>
  </main>
`).mount(document.querySelector('#app')!);

Build a searchable keyed list with owned styles

ts
import {
  createApp,
  createStyleSheetOwner,
  css,
  html,
  repeat,
} from '@gluonjs/core';
import {
  computed,
  ref,
} from '@gluonjs/reactivity';

interface Product {
  readonly id: string;
  readonly name: string;
  readonly price: string;
}

const products: readonly Product[] = [
  { id: 'orbit-lamp', name: 'Orbit Lamp', price: '$128' },
  { id: 'field-tote', name: 'Field Tote', price: '$84' },
];
const query = ref('');
const visibleProducts = computed(() => {
  const needle = query.value.trim().toLowerCase();
  return needle
    ? products.filter((product) => product.name.toLowerCase().includes(needle))
    : products;
});
const styles = css`
  main { max-inline-size: 40rem; margin: 2rem auto; font: 1rem/1.5 system-ui; }
  label, article { display: grid; gap: 0.5rem; }
  section { display: grid; gap: 1rem; margin-block-start: 1.5rem; }
  article { padding: 1rem; border: 1px solid #d8d8d8; }
`;
const styleOwner = createStyleSheetOwner(document);
styleOwner.retain(styles);

const app = createApp(() => html`
  <main>
    <h1>Products</h1>
    <label>
      Search
      <input
        type="search"
        .value=${query.value}
        @input=${(event: Event) => {
          query.value = (event.currentTarget as HTMLInputElement).value;
        }}
      >
    </label>
    <p>${visibleProducts.value.length} result(s)</p>
    <section>
      ${repeat(
        visibleProducts.value,
        (product) => product.id,
        (product) => html`
          <article>
            <strong>${product.name}</strong>
            <span>${product.price}</span>
          </article>
        `,
      )}
    </section>
  </main>
`);

app.onUnmounted(() => styleOwner.dispose());
const mounted = app.mount(document.querySelector('#app')!);
window.addEventListener('pagehide', () => mounted.unmount(), { once: true });

Compose Atom, Molecule, and Organism boundaries

ts
import {
  Button,
  defineUiAtom,
} from '@gluonjs/atoms';
import {
  css,
  html,
} from '@gluonjs/core';
import { defineMolecule } from '@gluonjs/molecules';
import { defineOrganism } from '@gluonjs/organisms';

export const StockBadge = defineUiAtom<
  { readonly status: 'available' | 'back-order' },
  'span'
>({
  displayName: 'StockBadge',
  tag: 'span',
  nativeProps: ({ status }) => ({
    class: `stock-badge stock-badge--${status}`,
    children: status === 'available' ? 'In stock' : 'Back order',
  }),
  style: {
    id: 'example-stock-badge',
    sheet: css`
      .stock-badge { font-weight: 700; }
      .stock-badge--available { color: #315d19; }
      .stock-badge--back-order { color: #815400; }
    `,
  },
});

export const ProductSummary = defineMolecule((props: Readonly<{
  name: string;
  price: string;
  status: 'available' | 'back-order';
}>) => html`
  <article>
    <h2>${props.name}</h2>
    <p>${props.price}</p>
    ${StockBadge({ status: props.status })}
  </article>
`, 'ProductSummary');

export const CatalogSection = defineOrganism((props: Readonly<{
  heading: string;
}>) => html`
  <section aria-labelledby="catalog-heading">
    <h1 id="catalog-heading">${props.heading}</h1>
    ${ProductSummary({
      name: 'Orbit Lamp',
      price: '$128',
      status: 'available',
    })}
    ${Button({ label: 'View all products' })}
  </section>
`, 'CatalogSection');

Publish a Custom Element

ts
import { GluonElement, defineElement, html } from '@gluonjs/core';

export class GluonCounter extends GluonElement {
  count = 0;

  increment(): void {
    this.count += 1;
    this.emit('change', { value: this.count });
    void this.requestUpdate();
  }

  protected override render() {
    return html`<button type="button" @click=${() => this.increment()}>${this.count}</button>`;
  }
}

defineElement('gluon-counter', GluonCounter);

Compose Router and Store ownership

ts
import { createApp, html } from '@gluonjs/core';
import { createRouter, createRouterPlugin, createWebHistory, RouterView } from '@gluonjs/router';
import { createStoreManager, defineStore } from '@gluonjs/store';

const counter = defineStore('counter', () => ({ count: 0 }), {
  actions: (store) => ({ increment: () => { store.count += 1; } }),
});
const stores = createStoreManager();
const state = counter.use(stores);
const router = createRouter({
  history: createWebHistory(),
  routes: [{ path: '/', component: () => html`<button @click=${state.increment}>${state.count}</button>` }],
});

await router.isReady();
const app = createApp(() => html`<main>${RouterView()}</main>`);
app.use(createRouterPlugin(router));
app.onUnmounted(() => stores.dispose());
app.mount(document.querySelector('#app')!);

Render on the server

ts
import { html } from '@gluonjs/core';
import { renderToString, serializeSsrState } from '@gluonjs/ssr';

const state = { route: '/products/orbit-lamp', bag: [] };
const rendered = await renderToString(html`<main><h1>Orbit Lamp</h1></main>`);

export const responseBody = `<!doctype html>
<main id="app">${rendered}</main>
<script type="application/json" data-gluon-state>${serializeSsrState(state)}</script>`;

Test through public utilities

ts
import { html } from '@gluonjs/core';
import { cleanupFixtures, mountComponent } from '@gluonjs/test-utils';

const fixture = mountComponent(
  ({ label }: Readonly<{ label: string }>) => html`<button>${label}</button>`,
  { props: { label: 'Save' } },
);

if (fixture.get('button').textContent !== 'Save') throw new Error('Expected Save');
await cleanupFixtures();

Host a Gluon element from Vue

The host treats the Gluon component as a standards-based Custom Element. It passes the production product and configuration as properties, supplies native slots, and observes configuration-change and add-to-bag; it does not translate the Gluon component into a Vue component.

ts
import { adoptStyles, unadoptStyles } from '@gluonjs/core';
import { createApp as createVueApp, type App } from 'vue';
import { shopStyles } from '../../examples/shop/src/styles.js';
import { registerProductConfigurator } from '../../examples/shop/src/product-configurator.js';
import VueProductHost from './VueProductHost.vue';
import { vueHostStyles } from './vue-host-styles.js';

export interface VueHostMount {
  readonly app: App<Element>;
  unmount(): void;
}

export function mountVueHost(target: string | Element = '#vue-host'): VueHostMount {
  registerProductConfigurator();
  adoptStyles(document, shopStyles, vueHostStyles);
  const app = createVueApp(VueProductHost);
  app.mount(target);
  return Object.freeze({
    app,
    unmount() {
      app.unmount();
      unadoptStyles(document, vueHostStyles, shopStyles);
    },
  });
}

if (document.querySelector('#vue-host')) mountVueHost();

Run the compiled plain HTML host or the Vue host.