Traduzir um módulo e reagir a eventos
Ler arquivos do pacote, traduzir interface e assinar eventos sem acumular listeners.

O projeto que você vai criar
Crie extensão traduzida com catálogos originais en/pt-BR/es. Reutilize o ciclo para painéis vivos. Catálogos locais pertencem ao módulo; pacotes globais de idioma têm tutorial próprio. O lançador comum já demonstra modal nativo acessível, guarda de fechamento e descarte.
Host alvo: Gravewright 0.3.0. As ações rodam somente quando você clica Run action; leituras e escritas usam a conta conectada. O painel de demonstração está em inglês. Cada receita abaixo informa os dados de entrada e o resultado concreto.
1. Crie os arquivos
Crie uma pasta localization-events. Salve os arquivos mostrados abaixo em UTF-8, ou baixe o projeto completo. manifest.json fica na raiz. Não há dependências npm nem build de JavaScript.
localization-events/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txt
messages.json2. Empacote, instale e ative
Partindo da pasta do projeto, execute no 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.zipNo Linux/macOS, substitua python por python3 no mesmo comando. A listagem precisa mostrar manifest.json na raiz. Como proprietário, abra Inside → Módulos → Instalar módulo → Instalar de ZIP, selecione o ZIP e clique Instalar. Como GM, abra a campanha, use o botão Gravewright da mesa → Extensions e clique Activate na versão 1.0.0 de Translate a module and react to changes. Feche o menu e clique no botão Translate a module and react to changes. Se precisar de detalhes de cada tela, veja seu primeiro módulo.
O projeto fica inativo até a seleção na mesa. Teste uma extensão de demonstração por vez, pois seus botões usam a mesma posição. Para novos bytes, mude version para 1.0.1, gere outro ZIP, instale e ative essa versão; não edite o pacote instalado.
3. Execute cada receita
Traduzir módulo sem alterar catálogo do host
O pacote inclui messages.json com en/pt-BR/es. O catálogo usa namespace do módulo e não substitui o pacote oficial de idioma.
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};
}Resultado esperado: Mensagem traduzida, placeholder substituído e número formatado.
Como conferir: Troque idioma da conta e repita. Nomes autorais não são traduzidos.
Ler arquivos do próprio pacote
Caminhos são relativos e vinculados à versão instalada. Use assets.url para imagem do pacote e styles.use para sua folha de estilos.
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')};
}Resultado esperado: Mesmo arquivo lido por URL/text/bytes/json.
Como conferir: Tente ../private.json; travessia deve falhar sem ler dados da instalação.
Observar alterações autorizadas e encerrar escuta
Execute enquanto altera ator de teste em outro navegador. A escuta curta observa estado autorizado e depois encerra. Em painel permanente, assine uma vez na montagem e entregue disposer a onDispose.
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};
}Resultado esperado: Snapshots podem agrupar alterações e incluir leitura inicial. São avisos para atualizar, não log de auditoria.
Como conferir: Desative durante a espera; nenhum dado antigo deve aparecer depois.
Aplicar idioma temporário pelo contrato antigo
Use apenas ao manter código antigo com locale.apply. O helper altera idioma/catálogo da página enquanto o contexto existe; não salva preferência da conta. Módulos novos devem usar namespace privado de api.i18n.
async ({host}) => host.call('locale.apply',{id:'es',messages:{greeting:'Hola, {name}'}})Resultado esperado: null, idioma temporário es na página e evento antigo de idioma.
Como conferir: Desative o módulo e recarregue para voltar ao idioma escolhido na conta.
4. Entenda e adapte os arquivos completos
main.mjs desenha apenas o lançador e o formulário. recipes.mjs contém as ações: para adaptar o comportamento, altere a função run da receita desejada, mantendo sua assinatura. O formulário passa tipos reais (number, boolean e File), bloqueia requisições simultâneas e desenha resultados com textContent. Não há avaliação de código fornecido pelo usuário. api, host, assets e storage pertencem à montagem; signal cancela trabalho ao desativar. O modal não fecha durante uma operação pendente e é liberado ao abortar a montagem. A função de negócio continua responsável por revisões, permissões e sequência transacional.
manifest.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
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
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
.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
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
{
"en": {
"welcome": "Welcome, {name}"
},
"pt-BR": {
"welcome": "Boas-vindas, {name}"
},
"es": {
"welcome": "Bienvenido, {name}"
}
}5. Permissões, falhas e teste final
Execute gravações apenas em campanha nova de desenvolvimento com recursos originais. Esconder controles não substitui autorização no servidor. Teste GM, jogador só de leitura, UUID ausente, ID de outra campanha, falha de rede e desativação durante requisição. conflict exige nova leitura e decisão do usuário; não force repetição silenciosa. Cada nova ação lógica precisa de requestId novo. Reutilize um UUID apenas para repetir aquela operação quando o serviço suporta recibos. Leitura e escrita são transações separadas; confira estado salvo após falha parcial.
Como usar a IA para estender este projeto
Adapte o módulo completo localization-events para [objetivo] no Gravewright 0.3.0. Leia o serviço público e a receita correspondente. Preserve projeções autorizadas, revisões opacas, UUIDs, cancelamento e descarte. Entregue arquivos completos, nova versão imutável e testes explícitos de GM/jogador e falhas.