Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions .agents/add-validator-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Skill: add-validator-rule

Cria uma nova rule de validação no `payment-template-validator`.

## Uso

```
/add-validator-rule <nome-da-rule> — <descrição curta do que ela valida>
```

Exemplo:
```
/add-validator-rule requiredFiles — garante que payment.html, i18n/pt-BR.json e icon estão presentes
```

---

## O que o skill faz

1. Cria `packages/validator/src/rules/<nome>.js` com a assinatura padrão
2. Registra a rule em `packages/validator/src/rules/index.js`
3. Cria `packages/validator/src/tests/<nome>.test.js` com testes para o happy path e pelo menos um caso de erro
4. Roda `npm test` para confirmar que tudo passa
5. Mostra o output da CLI contra `src/` para confirmar que o template default continua passando

---

## Contrato de uma rule

Toda rule é um arquivo em `packages/validator/src/rules/` que exporta uma função com esta assinatura:

```js
function nomeDaRule(template, opts = {}) {
// template: { html, css, i18n, i18nFiles, icon, assets, templateDir }
// opts: ValidateOptions passado pelo chamador
// retorna: ValidationError[] — vazio se passar, com erros se falhar
}

// Nome da rule (usado no registry e nas mensagens de erro)
nomeDaRule.ruleName = 'nomeDaRule';

// Contexto exibido na CLI ao lado do ✓/✗ (opcional mas recomendado)
nomeDaRule.describe = function(template) {
return 'descrição curta do que foi verificado';
};

module.exports = nomeDaRule;
```

## Tipo ValidationError

```js
{
rule: string, // nome da rule (igual a ruleName)
message: string, // mensagem em pt-BR, clara o suficiente para o parceiro corrigir
file?: string, // path do arquivo que causou o erro (quando aplicável)
severity?: 'error' | 'warning', // default: 'error'
}
```

## Objeto template

| Campo | Tipo | O que contém |
|---|---|---|
| `html` | `FileSlot\|null` | `partials/payment.html` ou `payment.html` |
| `css` | `FileSlot\|null` | `assets/css/less/style.css` ou `style.css` |
| `i18n` | `FileSlot\|null` | `i18n/pt-BR.json` (slot principal) |
| `i18nFiles` | `FileSlot[]` | todos os `i18n/*.json` ordenados |
| `icon` | `FileSlot\|null` | `assets/img/icon.png` |
| `assets` | `FileRef[]` | demais arquivos não-slot |
| `templateDir` | `string` | path absoluto da raiz do template |

`FileSlot` tem `.path`, `.size`, `.content` (string utf-8, lazy) e `.buffer` (Buffer, lazy).
`FileRef` tem só `.path` e `.size`.

## Registrar no index

Após criar o arquivo da rule, adicione no `packages/validator/src/rules/index.js`:

```js
module.exports = [
require('./maxFileSize'),
require('./i18nKeyConsistency'),
require('./htmlSafety'),
require('./noExternalRefs'),
require('./<nomeDaRule>'), // ← adicionar aqui
];
```

## Template de teste

```js
const { test } = require('node:test');
const assert = require('node:assert/strict');
const nomeDaRule = require('../rules/nomeDaRule');

test('nomeDaRule — passa quando [condição válida]', () => {
const template = { /* montar o template mínimo necessário */ };
assert.equal(nomeDaRule(template).length, 0);
});

test('nomeDaRule — falha quando [condição de erro]', () => {
const template = { /* montar template com o problema */ };
const errors = nomeDaRule(template);
assert.equal(errors.length, 1);
assert.equal(errors[0].rule, 'nomeDaRule');
assert.match(errors[0].message, /texto esperado/);
});
```

## Checklist de entrega

- [ ] Arquivo da rule criado com `ruleName` e `describe` definidos
- [ ] Rule registrada em `rules/index.js`
- [ ] Testes cobrindo happy path + pelo menos 1 erro + edge case (template sem o campo relevante)
- [ ] `npm test` passando (37+ testes)
- [ ] `node validator/cli.js src/` retorna exit 0 (template default não quebra com a nova rule)
1 change: 1 addition & 0 deletions .claude/skills
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@import "https://fonts.googleapis.com/css2?family=Roboto";
.payment-title { background: url("https://cdn.partner.com/bg.png") no-repeat; }
5 changes: 5 additions & 0 deletions examples/bad-templates/external-refs/partials/payment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<fieldset class="box-payment-group2">
<img src="https://cdn.partner.com/logo.png" alt="logo">
<link href="https://fonts.googleapis.com/css2?family=Roboto" rel="stylesheet">
<h3>Pagar</h3>
</fieldset>
5 changes: 5 additions & 0 deletions examples/bad-templates/html-unsafe/partials/payment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<fieldset class="box-payment-group2">
<script src="/assets/js/init.js"></script>
<h3 class="payment-title" onclick="eval(window.track)">Pagar</h3>
<button onmouseover="highlight(this)">Confirmar</button>
</fieldset>
1 change: 1 addition & 0 deletions examples/bad-templates/i18n-inconsistente/i18n/en-US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"payment.title":"Payment","payment.cta":"Pay now"}
1 change: 1 addition & 0 deletions examples/bad-templates/i18n-inconsistente/i18n/es.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"payment.title":"Pago","payment.cta":"Pagar ahora","payment.extra":"Algo extra"}
1 change: 1 addition & 0 deletions examples/bad-templates/i18n-inconsistente/i18n/pt-BR.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"payment.title":"Pagamento","payment.cta":"Pagar agora","payment.help":"Ajuda"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<fieldset class="box-payment-group2"><h3 class="payment-title">Pagar</h3></fieldset>
Binary file added examples/bad-templates/max-file-size/logo.bin
Binary file not shown.
1 change: 1 addition & 0 deletions examples/bad-templates/max-file-size/partials/payment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<fieldset class="box-payment-group2"><h3 class="payment-title">Pagar</h3></fieldset>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.payment { background: url("https://cdn.evil.com/track.png"); }
1 change: 1 addition & 0 deletions examples/bad-templates/tudo-errado/i18n/en-US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"payment.title":"Payment"}
1 change: 1 addition & 0 deletions examples/bad-templates/tudo-errado/i18n/pt-BR.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"payment.title":"Pagamento","payment.cta":"Pagar"}
5 changes: 5 additions & 0 deletions examples/bad-templates/tudo-errado/partials/payment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<fieldset class="box-payment-group2">
<script src="/assets/js/init.js"></script>
<h3 class="payment-title" onclick="eval(window.track)">Pagar</h3>
<button onmouseover="highlight(this)">Confirmar</button>
</fieldset>
Binary file added examples/bad-templates/tudo-errado/payload.bin
Binary file not shown.
53 changes: 53 additions & 0 deletions examples/good-example/assets/css/less/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[data-payment-template] .payment-title {
font-size: 16px;
font-weight: bold;
margin: 0 0 12px;
color: #333;
}

[data-payment-template] .payment-description {
margin-bottom: 16px;
}

[data-payment-template] .payment-description-text {
font-size: 14px;
color: #666;
line-height: 1.5;
}

[data-payment-template] .payment-benefits {
list-style: none;
padding: 0;
margin: 0 0 16px;
display: flex;
gap: 12px;
}

[data-payment-template] .payment-benefit {
flex: 1;
padding: 10px;
background: #f9f9f9;
border-radius: 4px;
}

[data-payment-template] .payment-benefit-title {
font-size: 13px;
font-weight: bold;
margin: 0 0 4px;
color: #333;
}

[data-payment-template] .payment-benefit-desc {
font-size: 12px;
color: #666;
margin: 0;
}

[data-payment-template] .payment-logo {
text-align: center;
margin-top: 12px;
}

[data-payment-template] .payment-logo img {
max-height: 48px;
}
Binary file added examples/good-example/assets/img/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions examples/good-example/i18n/en-US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"payment.title": "Example Payment",
"payment.instructions": "After checkout, you will be redirected to complete your payment.",
"payment.benefit.secure": "Secure",
"payment.benefit.secureDesc": "Certified and protected environment.",
"payment.benefit.fast": "Fast",
"payment.benefit.fastDesc": "Approval in seconds."
}
8 changes: 8 additions & 0 deletions examples/good-example/i18n/es.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"payment.title": "Pago Ejemplo",
"payment.instructions": "Al finalizar la compra, será redirigido para completar el pago.",
"payment.benefit.secure": "Seguro",
"payment.benefit.secureDesc": "Entorno certificado y protegido.",
"payment.benefit.fast": "Rápido",
"payment.benefit.fastDesc": "Aprobación en segundos."
}
8 changes: 8 additions & 0 deletions examples/good-example/i18n/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"payment.title": "Paiement Exemple",
"payment.instructions": "Après la commande, vous serez redirigé pour finaliser le paiement.",
"payment.benefit.secure": "Sécurisé",
"payment.benefit.secureDesc": "Environnement certifié et protégé.",
"payment.benefit.fast": "Rapide",
"payment.benefit.fastDesc": "Approbation en quelques secondes."
}
8 changes: 8 additions & 0 deletions examples/good-example/i18n/pt-BR.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"payment.title": "Pagamento Exemplo",
"payment.instructions": "Ao finalizar a compra, você será redirecionado para concluir o pagamento.",
"payment.benefit.secure": "Seguro",
"payment.benefit.secureDesc": "Ambiente certificado e protegido.",
"payment.benefit.fast": "Rápido",
"payment.benefit.fastDesc": "Aprovação em segundos."
}
24 changes: 24 additions & 0 deletions examples/good-example/partials/payment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<fieldset class="box-payment-group2 box-payment-option box-payment-newpayment newPaymentGroup">
<h3 class="payment-title" data-i18n="payment.title">Pagamento Exemplo</h3>

<div class="payment-description">
<p class="payment-description-text" data-i18n="payment.instructions">
Ao finalizar a compra, você será redirecionado para concluir o pagamento.
</p>
</div>

<ul class="payment-benefits">
<li class="payment-benefit">
<p class="payment-benefit-title" data-i18n="payment.benefit.secure">Seguro</p>
<p class="payment-benefit-desc" data-i18n="payment.benefit.secureDesc">Ambiente certificado e protegido.</p>
</li>
<li class="payment-benefit">
<p class="payment-benefit-title" data-i18n="payment.benefit.fast">Rápido</p>
<p class="payment-benefit-desc" data-i18n="payment.benefit.fastDesc">Aprovação em segundos.</p>
</li>
</ul>

<div class="payment-logo">
<img src="assets/img/icon.png" alt="logo">
</div>
</fieldset>
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
"type": "git",
"url": "http://github.com/augustocb/pink"
},
"scripts": {
"validate": "node validator/cli.js",
"test": "node --test packages/validator/src/tests"
},
"devDependencies": {
"connect-livereload": "^0.5.4",
"grunt": "^1.0.1",
Expand Down
6 changes: 6 additions & 0 deletions packages/validator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "@vtex/payment-template-validator",
"version": "0.1.0",
"main": "src/index.js",
"license": "MIT"
}
10 changes: 10 additions & 0 deletions packages/validator/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const { run } = require('./runner');
const rules = require('./rules');

async function validate(templateDir, opts) {
const { errors, details } = run(templateDir, rules, opts || {});
const ok = errors.every(e => e.severity === 'warning');
return { ok, errors, details };
}

module.exports = { validate, rules };
87 changes: 87 additions & 0 deletions packages/validator/src/rules/assetsReferenced.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
const path = require('path');

const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']);

function isExternalOrData(ref) {
return /^https?:\/\//i.test(ref) || /^data:/i.test(ref);
}

function collectRefs(content, pattern) {
const refs = new Set();
let match;
const re = new RegExp(pattern, 'gi');
while ((match = re.exec(content)) !== null) {
const ref = match[1];
if (!isExternalOrData(ref) && !ref.startsWith('/')) {
refs.add(ref);
}
}
return refs;
}

function assetsReferenced(template) {
const refs = new Set();

if (template.html) {
for (const r of collectRefs(template.html.content, /(?:src|href)\s*=\s*["']([^"'#?]+)/)) refs.add(r);
}
if (template.css) {
for (const r of collectRefs(template.css.content, /url\s*\(\s*["']?([^"')]+)/)) refs.add(r);
}

// Resolve refs to absolute paths relative to templateDir
const resolveRef = (ref) => {
if (template.html && !ref.startsWith('./') && !ref.startsWith('../')) {
return path.resolve(template.templateDir, ref);
}
return path.resolve(template.templateDir, ref);
};

// Include slot files (icon, html, css, i18n) as valid reference targets
const slotPaths = [template.html, template.css, template.i18n, template.icon]
.filter(Boolean).map(s => s.path);
const assetPaths = new Set([...(template.assets || []).map(a => a.path), ...slotPaths]);
const imageAssets = (template.assets || []).filter(a => IMAGE_EXTS.has(path.extname(a.path).toLowerCase()));
const imageAssetPaths = new Set(imageAssets.map(a => a.path));

const errors = [];

// Broken references: ref in HTML/CSS → file not in assets
for (const ref of refs) {
if (path.extname(ref) === '' || !IMAGE_EXTS.has(path.extname(ref).toLowerCase())) continue;
const abs = resolveRef(ref);
if (!assetPaths.has(abs)) {
errors.push({
rule: 'assetsReferenced',
message: `Referência quebrada: "${ref}" não encontrado nos assets (vai dar 404 em produção)`,
severity: 'error',
});
}
}

// Orphan assets: image file present but never referenced
const referencedAbs = new Set([...refs].map(resolveRef));
for (const asset of imageAssets) {
const name = path.basename(asset.path);
// Check by basename too (HTML often references just the filename)
const isReferenced = referencedAbs.has(asset.path) ||
[...refs].some(r => path.basename(r) === name || r === name);
if (!isReferenced) {
errors.push({
rule: 'assetsReferenced',
message: `Asset órfão: "${name}" presente mas nunca referenciado em HTML ou CSS`,
file: asset.path,
severity: 'warning',
});
}
}

return errors;
}

assetsReferenced.ruleName = 'assetsReferenced';
assetsReferenced.describe = function(template) {
const imgs = (template.assets || []).filter(a => IMAGE_EXTS.has(path.extname(a.path).toLowerCase())).length;
return `${imgs} imagem(ns) nos assets`;
};
module.exports = assetsReferenced;
Loading