HomeDeveloper docsTranslate a module and react to changes
Implementation tutorials 7 min read

Translate a module and react to changes

Read package files, format translated UI and subscribe without leaking listeners.

Installed Translate a module and react to changes module after running its catalog recipe. Gravewright 0.3.0, English interface.
Installed Translate a module and react to changes module after running its catalog recipe. Gravewright 0.3.0, English interface. · Open full screenshot ↗

The project you will build

Create a translated extension with original en/pt-BR/es catalogs. Reuse the lifecycle for persistent live panels. Local catalogs belong to this module; installation-wide language packs use the separate language-package tutorial. The common runner itself demonstrates a native accessible modal with close guard and disposal.

Target host: Gravewright 0.3.0. Actions run only when you click Run action, using the signed-in account. The demonstration panel is English. Each recipe specifies inputs and an observable result.

1. Create the files

Create a folder named localization-events. Save the complete files below as UTF-8, or download the project. manifest.json belongs at the root. No npm dependencies or JavaScript build is required.

text
localization-events/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt
  messages.json

2. Package, install and activate

From the project folder, run in PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../localization-events-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt messages.json
python -m zipfile -l ../localization-events-1.0.0.zip

On Linux/macOS, replace python with python3 in the same commands. The archive listing must show manifest.json at its root. As installation owner, open Inside → Modules → Install module → Install from ZIP, select the ZIP and click Install. As GM, open the campaign, select its Gravewright button → Extensions and Activate version 1.0.0 of Translate a module and react to changes. Close that menu and click Translate a module and react to changes. See your first module for screen-by-screen instructions.

The package stays inactive until selected for the table. Test one demonstration extension at a time because their launchers share a position. For changed bytes, increment version to 1.0.1, build another ZIP, install and activate that version; do not edit installed files.

3. Run each recipe

Translate a module without changing host catalogs

The package includes messages.json with en/pt-BR/es keys. This uses the module namespace and does not require replacing the official language pack.

javascript
async ({api,assets,onDispose}) => {
  const catalogs=await assets.json('messages.json');
  const changes=[];
  const unsubscribe=api.i18n.onChange(locale=>changes.push(locale));onDispose(unsubscribe);
  const release=api.i18n.register(catalogs);onDispose(release);
  const result={locale:api.i18n.locale,has:api.i18n.has('welcome'),message:api.i18n.t('welcome',{name:'Ari'}),number:api.i18n.number(1234.5,{maximumFractionDigits:1})};
  release();unsubscribe();return {...result,changes};
}

Expected result: Localized message, placeholder substitution and formatted number.

How to verify: Switch account language and rerun. Authored names are not translated.

Read package assets safely

Package paths are relative and installation-version bound. Use assets.url for a local package image and styles.use for its stylesheet.

javascript
async ({assets}) => {
  const text=await assets.text('messages.json');
  const bytes=await assets.bytes('messages.json');
  return {url:assets.url('messages.json'),characters:text.length,bytes:bytes.byteLength,catalogs:await assets.json('messages.json')};
}

Expected result: Same file through URL/text/bytes/json helpers.

How to verify: Try ../private.json; traversal must fail instead of reading installation data.

Observe authorized changes and stop listening

Run while changing a disposable actor from another browser. This brief subscription samples authorized state, then unsubscribes. For a permanent panel, subscribe once during mount and pass the disposer to onDispose.

javascript
async ({api,signal,onDispose}) => {
  const observed=[];
  const unsubscribe=api.events.on('actors.changed',snapshot=>{if(!signal.aborted)observed.push(snapshot.actors.map(actor=>({id:actor.id,name:actor.name})));});
  onDispose(unsubscribe);
  await new Promise(resolve=>{
    const timer=setTimeout(done,1500);
    function done(){clearTimeout(timer);signal.removeEventListener('abort',done);resolve();}
    signal.addEventListener('abort',done,{once:true});
    if(signal.aborted)done();
  });
  unsubscribe();return {observed};
}

Expected result: Snapshots may coalesce changes and include the initial authorized read. They are refresh hints, not an audit log.

How to verify: Deactivate during the wait; no old data should render afterward.

Apply a legacy page language overlay

Use only when maintaining locale.apply code. This legacy helper changes the current page language/catalog until its context is disposed; it does not persist an account setting. New modules should use their private api.i18n namespace.

javascript
async ({host}) => host.call('locale.apply',{id:'es',messages:{greeting:'Hola, {name}'}})

Expected result: null, a temporary es page language and a legacy locale event.

How to verify: Deactivate the module and reload to return to the explicitly chosen account locale.

4. Understand and adapt the complete files

main.mjs renders the launcher and form. recipes.mjs owns behavior: change the chosen recipe’s run function while keeping its signature. The form passes real number, boolean and File values, blocks overlapping requests and renders using textContent. It does not evaluate user-provided code. api, host, assets and storage belong to the mount; signal cancels work on deactivation. The modal stays open during pending work and is released when the mount aborts. Each business function remains responsible for revisions, permissions and transaction sequencing.

manifest.json

json
{
  "id": "tutorial.localization-events",
  "name": "Translate a module and react to changes",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Read package files, format translated UI and subscribe without leaking listeners.",
  "author": "Gravewright documentation",
  "license": "MIT"
}

main.mjs

javascript
import {recipes} from './recipes.mjs';
export default {
  start({styles}) { styles.use('styles.css'); },
  register({register}) {
    register("table.interface", ({root,api,host,assets,storage,context,events,viewport,signal,onDispose}) => {
      const doc=root.ownerDocument;
      const button=doc.createElement('button');
      button.type='button';button.className='tutorial-launcher';
      button.textContent="Translate a module and react to changes";
      root.append(button);
      for(const type of ['pointerdown','mousedown'])button.addEventListener(type,event=>event.stopPropagation(),{signal});
      let modal,pending=false;
      button.addEventListener('click',event=>{
        event.stopPropagation();
        if(modal?.isConnected) { modal.focus();return; }
        const panel=doc.createElement('section');
        panel.className="tutorial-workbench-localization-events";
        panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');
        panel.setAttribute('aria-label',button.textContent);panel.tabIndex=-1;
        const title=doc.createElement('h2');title.textContent=button.textContent;panel.append(title);
        const choose=doc.createElement('select');choose.setAttribute('aria-label','Action');
        for(const recipe of recipes) {
          const option=doc.createElement('option');option.value=recipe.id;option.textContent=recipe.label;choose.append(option);
        }
        const form=doc.createElement('form'),fields=doc.createElement('div');
        const description=doc.createElement('p');
        const run=doc.createElement('button');run.type='submit';run.textContent='Run action';
        const close=doc.createElement('button');close.type='button';close.textContent='Close';
        const output=doc.createElement('pre');output.setAttribute('role','status');output.setAttribute('aria-live','polite');
        form.append(description,fields,run,close,output);panel.append(choose,form);
        function renderFields(){
          const recipe=recipes.find(item=>item.id===choose.value);fields.replaceChildren();
          description.textContent=recipe.description;
          for(const field of recipe.fields||[]) {
            const label=doc.createElement('label');label.textContent=field.label;
            const input=doc.createElement('input');input.name=field.name;input.type=field.type||'text';
            if(field.type==='number')input.step=String(field.step??'any');
            if(field.type==='checkbox')input.checked=!!field.value;
            else if(field.type!=='file')input.value=String(field.value??'');
            input.required=!!field.required;
            if(field.accept)input.accept=field.accept;
            label.append(input);fields.append(label);
          }
          output.textContent='';
        }
        choose.addEventListener('change',renderFields,{signal});renderFields();
        form.addEventListener('submit',async event=>{
          event.preventDefault();if(pending)return;
          const recipe=recipes.find(item=>item.id===choose.value),values={};
          for(const input of fields.querySelectorAll('input')) {
            values[input.name]=input.type==='file'?input.files[0]:input.type==='checkbox'?input.checked:input.type==='number'?Number(input.value):input.value;
          }
          pending=true;choose.disabled=true;run.disabled=true;close.disabled=true;
          for(const input of fields.querySelectorAll('input'))input.disabled=true;
          output.textContent='Running…';
          try {
            const value=await recipe.run({api,host,assets,storage,context,events,viewport,signal,onDispose},values);
            if(!signal.aborted)output.textContent=typeof value==='string'?value:JSON.stringify(value??null,null,2);
          } catch(error) {
            if(!signal.aborted)output.textContent=String(error.code||error.message||'unavailable');
          } finally {
            pending=false;
            if(!signal.aborted){choose.disabled=false;run.disabled=false;close.disabled=false;for(const input of fields.querySelectorAll('input'))input.disabled=false;}
          }
        },{signal});
        close.addEventListener('click',()=>void modal.close(),{signal});
        modal=api.ui.openModal(panel,{canClose:()=>!pending||signal.aborted});
      },{signal});
      onDispose(()=>{button.remove();if(modal)void modal.close();});
    });
  },
  stop() {}
};

recipes.mjs

javascript
export const recipes=[
{
  id:"catalog",
  label:"Translate a module without changing host catalogs",
  description:"The package includes messages.json with en/pt-BR/es keys. This uses the module namespace and does not require replacing the official language pack.",
  fields:[],
  run:async ({api,assets,onDispose}) => {
  const catalogs=await assets.json('messages.json');
  const changes=[];
  const unsubscribe=api.i18n.onChange(locale=>changes.push(locale));onDispose(unsubscribe);
  const release=api.i18n.register(catalogs);onDispose(release);
  const result={locale:api.i18n.locale,has:api.i18n.has('welcome'),message:api.i18n.t('welcome',{name:'Ari'}),number:api.i18n.number(1234.5,{maximumFractionDigits:1})};
  release();unsubscribe();return {...result,changes};
}
},
{
  id:"package-assets",
  label:"Read package assets safely",
  description:"Package paths are relative and installation-version bound. Use assets.url for a local package image and styles.use for its stylesheet.",
  fields:[],
  run:async ({assets}) => {
  const text=await assets.text('messages.json');
  const bytes=await assets.bytes('messages.json');
  return {url:assets.url('messages.json'),characters:text.length,bytes:bytes.byteLength,catalogs:await assets.json('messages.json')};
}
},
{
  id:"events",
  label:"Observe authorized changes and stop listening",
  description:"Run while changing a disposable actor from another browser. This brief subscription samples authorized state, then unsubscribes. For a permanent panel, subscribe once during mount and pass the disposer to onDispose.",
  fields:[],
  run:async ({api,signal,onDispose}) => {
  const observed=[];
  const unsubscribe=api.events.on('actors.changed',snapshot=>{if(!signal.aborted)observed.push(snapshot.actors.map(actor=>({id:actor.id,name:actor.name})));});
  onDispose(unsubscribe);
  await new Promise(resolve=>{
    const timer=setTimeout(done,1500);
    function done(){clearTimeout(timer);signal.removeEventListener('abort',done);resolve();}
    signal.addEventListener('abort',done,{once:true});
    if(signal.aborted)done();
  });
  unsubscribe();return {observed};
}
},
{
  id:"locale-compat",
  label:"Apply a legacy page language overlay",
  description:"Use only when maintaining locale.apply code. This legacy helper changes the current page language/catalog until its context is disposed; it does not persist an account setting. New modules should use their private api.i18n namespace.",
  fields:[],
  run:async ({host}) => host.call('locale.apply',{id:'es',messages:{greeting:'Hola, {name}'}})
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.localization-events"] .tutorial-launcher { position: fixed; left: 16px; bottom: 112px; z-index: 10; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); font-size: 14px; pointer-events: auto; max-width: min(440px, calc(100vw - 32px)); white-space: normal; }
.tutorial-workbench-localization-events { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-localization-events label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-localization-events input, .tutorial-workbench-localization-events select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-localization-events pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-localization-events button { margin-right: var(--space-3); }

LICENSE.txt

text
MIT License

Copyright (c) 2026 Gravewright contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

messages.json

json
{
  "en": {
    "welcome": "Welcome, {name}"
  },
  "pt-BR": {
    "welcome": "Boas-vindas, {name}"
  },
  "es": {
    "welcome": "Bienvenido, {name}"
  }
}

5. Permissions, failures and final test

Perform writes only in a new development campaign with original resources. The server remains authoritative when controls are hidden. Test GM, read-only player, a missing UUID, another campaign’s ID, network failure and deactivation during a pending request. A conflict requires a fresh read and a user decision; do not silently force retries. Every new logical write needs a new requestId. Only reuse one to retry that exact operation when the service supports receipts. Read and write steps are separate transactions, so inspect persisted state after a partial failure.

How to use AI to extend this project

text
Adapt this complete localization-events module for [specific goal] on Gravewright 0.3.0. Read the corresponding public service and recipe. Preserve authorized projections, opaque revisions, request UUIDs, cancellation and cleanup. Provide complete files and a new immutable version, plus explicit GM/player and failure tests.

All tutorials · API