- Shell 33.6%
- TypeScript 31.4%
- CSS 26.3%
- HTML 7.1%
- JavaScript 1.6%
| .gitea/workflows | ||
| docs | ||
| protocols | ||
| public | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| biome.json | ||
| commitlint.config.cjs | ||
| DESIGN.md | ||
| lefthook.yml | ||
| LICENSE | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.json | ||
| wxt.config.ts | ||
Greasy Sprocket — Extension Skeleton Template
Base para TODAS las extensiones de Greasy Sprocket. Clona este repo, renombra y rellena los placeholders. Así el footer, CI, listing y hooks de calidad son idénticos en toda la flota y un fix se propaga copiando el fichero canónico.
Fleet defaults (17-sep-2026, lecciones SessionVault v0.1.1→v0.1.4)
- Sidepanel es el UI por defecto:
setPanelBehavior({openPanelOnActionClick:true})en top-level del background (NO dentro de onClicked) +setUninstallURL. NUNCA crear entrypointpopup/— WXT inyectadefault_popupy sombreada el panel. - Permisos base en wxt.config.ts:
sidePanel+storage+alarms(ya descomentados). Si la ext no usa alarms, BORRA el permiso pero TAMBIÉN borra cualquier uso de browser.alarms — usarlo sin permiso mata el SW: "Service worker registration failed (code 15)". - Versión: una sola fuente,
package.json. No escribas un literal enwxt.config.ts: WXT toma la versión depackage.jsonyscripts/check-manifest-version.shcomprueba que el manifest DENTRO del ZIP coincide con ella. Dos literales = dos verdades. - i18n completo en _locales (en/es): toda clave usada en data-i18n debe existir en messages.json o el texto sale vacío.
Cómo crear una extensión nueva
# 1. Crear repo vacío en Gitea (API) y clonarlo
git clone https://git.greasysprocket.com/monyi/<ext-id>.git
cd <ext-id>
# 2. Copiar el esqueleto (sin .git del template)
cp -r ~/gs-work/ext-template/{.gitea,docs,public,src,tests,biome.json,commitlint.config.cjs,lefthook.yml,package.json,pnpm-workspace.yaml,tsconfig.json,.gitignore} .
# 3. Renombrar placeholders (ver SECTIONS abajo)
grep -rn "EXT_ID_HERE\|EXT_NAME_HERE\|EXT_DESCRIPTION_HERE" .
# 4. pnpm install && pnpm wxt prepare && pnpm run validate
# 5. Primer commit + push
Placeholders a rellenar
| Placeholder | Dónde | Ejemplo (tabsnooze) |
|---|---|---|
EXT_ID_HERE |
footer ☕ (?ext=), storage keys, ci.yml ya usa el nombre del repo |
sessionvault |
EXT_NAME_HERE |
manifest name, footer title, listing | TabSnooze |
EXT_DESCRIPTION_HERE |
package.json + wxt.config.ts + listing | "Snooze tabs for later..." |
EXT_PERMISSIONS_HERE |
wxt.config.ts (solo las que uses, con justificación en listing) | ["alarms","tabs","storage","contextMenus"] |
Qué trae el esqueleto
-
UI sidepanel por defecto (sin popup):
src/entrypoints/sidepanel/con footer canónico, theme toggle persistido,side_panel.default_path, permisosidePanelyminimum_chrome_version: 114. El icono abre el panel víasetPanelBehavior(fallbackopenfire-open-then-flag en background). -
Footer canónico (inline en
src/entrypoints/sidepanel/index.html+ bloque.gs-footer*en sustyle.css): 💬 Support / 🐛 Feedback / ☕ Support ext / Greasy Sprocket. IGUAL en todas las extensiones — solo cambia el?ext=del ☕. Si se arregla algo aquí, se copia a la flota. -
CI Gitea Actions (
.gitea/workflows/ci.yml): validate (typecheck+lint+test+build) en push a main + release job que sube el ZIP a la Release en tagsv*. Requiere runner activo en la instancia. -
i18n scaffold:
public/_locales/en/messages.json+es/messages.json(14 claves estándar del footer y popup genérico) ydefault_locale: "en"ya en el manifest — sin esto Chrome rechaza la carga. -
docs/cws-listing.md plantilla: single purpose, justificación de cada permiso, descripciones, checkboxes de privacidad.
-
docs/screenshots/ — vacío, para las 3 capturas 1280×800 (receta:
references/cws-screenshotsdel skill browser-extension-publish). -
Calidad: Biome + commitlint + lefthook (mismo config que tabsnooze).
-
Tests: Vitest con ejemplo de test de lógica pura en
tests/.
Reglas de flota (no negociables)
-
UI por defecto = sidepanel (decisión flota 15-sep-2026). Popup solo como stub de compatibilidad. El icono abre el panel vía
setPanelBehavior(y fallbacksidePanel.openfire-open-then-flag). CSS del panel:body { width: 100% },#app { height: 100vh; display:flex; flex-direction:column }, contenidoflex:1— NUNCA ancho fijo 320px. -
Sin nag screens. Nada se abre solo, nunca. El apoyo es el icono de support del footer (pasivo: solo abre la página si el usuario lo pulsa).
-
Footer idéntico en todas las extensiones (este template es la fuente de verdad): dos iconos —
/support(sin parámetro) y/feedback?ext=<slug>— más el enlace de texto a greasysprocket.com. El?ext=va SOLO en feedback:/supportno lo lee, y el formulario lo necesita para atribuir la respuesta. El slug debe existir enext-ids.tsdel sitio. -
Sin ko-fi ni URLs externas en el footer: solo greasysprocket.com.
-
default_locale+_locales/van juntos — siempre los dos o ninguno. -
Permisos mínimos: solo los que el listing justifica.
<all_urls>solo con decisión explícita de producto (jsonifier lo usa para detectar JSON servido con cualquier URL) y asumiendo el aviso de la store: "leer y cambiar todos tus datos en todos los sitios". -
Manifest version = tag version = Release version. El publicador (
gs-publish-release.py) rechaza publicar sitag != package.json.versiono si el manifest del ZIP no coincide. -
Pre-submit: seguir el checklist del skill
browser-extension-publish(incluye verificar el ZIP descargado de la Release, no solo el build local). -
Cada extensión tiene su propia privacy policy en la web (página
/privacypor extensión o sección dedicada).
Footer canónico
Vive en src/entrypoints/sidepanel/ (index.html + style.css). Requiere que el tema defina
--border y --text-muted.
<footer class="gs-footer">
<a href="https://greasysprocket.com/support" target="_blank" rel="noopener"
class="gs-footer-icon" title="Support" aria-label="Support"><svg aria-hidden="true" ...>…</svg></a>
<a href="https://greasysprocket.com/feedback?ext=EXT_SLUG_HERE" target="_blank" rel="noopener"
class="gs-footer-icon" title="Feedback" aria-label="Feedback"><svg aria-hidden="true" ...>…</svg></a>
<a href="https://greasysprocket.com" target="_blank" rel="noopener" class="gs-footer-text">Greasy Sprocket</a>
</footer>
Un solo enlace a /support: el icono de taza de ko-fi se retiró en oct-2026 porque duplicaba
Support y su ?ext= no lo leía nadie. El gate E5 falla si hay dos support o si falta
feedback?ext=.
Release
# 1. subir la version en package.json (patch/minor segun el cambio)
pnpm validate # typecheck + lint + tests
bash scripts/check-design.sh
pnpm build
bash scripts/build-zip.sh # -> dist/<repo>-<version>-chrome.zip + gate del manifest
# 2. publicar (valida tag == package.json.version y el manifest del ZIP)
python3 ~/work/infra/bin/gs-publish-release.py . --zip dist/<repo>-<version>-chrome.zip
# 3. verificar por la ruta real del navegador
curl -s https://greasysprocket.com/updates/<repo>.xml # version + codebase nuevos
Sin bump de version no hay release: es la causa de que la flota sirviera builds de agosto mientras main tenia meses de trabajo.
Reglas MV3 (de la doc oficial developer.chrome.com, aprendidas en la flota)
- Listeners síncronos top-level: el SW MV3 se descarga al quedar inactivo; todos los
addListenervan en el scope global dedefineBackground, nunca tras unawait. - Timers → chrome.alarms:
setTimeout/setIntervalmueren con el SW (auto-lock, polling). Persistir el estado enstoragepara re-armar tras el wake. - onMessage con respuesta async:
return trueexplícito en el listener (o promesa desde Chrome 148 — NO usar como único camino hasta que el rollout sea completo). - sidePanel.open() es gesture-gated: JAMÁS un
awaitantes deopen(). Puede lanzar EXCEPCIÓN SÍNCRONA si no hay tabId —try/catch, no solo.catch. Fallbacks: tabId → windowId →setPanelBehavior({openPanelOnActionClick:true}). minimum_chrome_version: declarar si se usan APIs recientes (sidePanel → "114").- i18n:
default_locale+_locales/juntos siempre; cero claves EN sin traducir en el texto ES (grep visual antes de taggear).