Files
ai-for-dummies/.agents/scripts/rendered-text-diff.mjs
T
Marcos Paulo 6974800d2e
verify-and-publish / gate (push) Successful in 29m29s
verify-and-publish / publish (push) Has been skipped
fix(rules): show GATILHO in Portuguese, and diff the PT half too
The skill detail panel's label read TRIGGER in both languages. The copy
data carries the Portuguese in `skillTriggerLabelPt`, but the island asks
for `skillTriggerLabel`, which is "TRIGGER" under both locales -- so the
translated value was never reachable. Legacy renders it inline:
`language === 'pt' ? 'GATILHO' : 'TRIGGER'`.

Fixed by putting the Portuguese where the lookup goes, `pt.skillTriggerLabel`,
and dropping the unreachable `skillTriggerLabelPt` from both locales. Nothing
else reads that key.

rendered-text-diff.mjs grows a `--pt` flag and now reports both directions.
English parity was hiding this: a page can paint every English string and
still leave a block untranslated, because the Portuguese half is a separate
set of nodes, and a string the Astro page renders but the legacy page does
not is equally wrong -- it means a translation was invented or an English
string was left where the legacy page swaps it.

/rules/ is now 119 of 119 in both languages, zero either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 00:09:28 +00:00

118 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// Compare the *rendered* text of a legacy page against its Astro replacement.
//
// node .agents/scripts/rendered-text-diff.mjs full-guide
// node .agents/scripts/rendered-text-diff.mjs full-guide --pt
//
// Why this exists: scripts/verify.mjs reads the legacy files, so a migrated
// page can drop half its content and still pass the gate. Task 15d shipped
// /full-guide/ missing 86 rendered spans -- the entire verification section,
// the hands-on exercise brief, and both "Clone from Gitea" links -- and every
// check was green.
//
// Static HTML comparison is useless here: the guide's tab panels are injected
// by an island at runtime, so half the legacy page's markup has no static
// counterpart. This walks the live DOM instead and skips anything the browser
// is not painting -- which also drops the hidden Portuguese half of each
// bilingual pair, so the two sides line up.
//
// Requires playwright (devDependency) and two static servers; it starts both.
import { spawn } from 'node:child_process';
import { cpSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { chromium } from 'playwright';
// `index` is the landing page: it lives at the repository root, not in a
// directory of its own, so it needs a different path on the legacy side.
const route = process.argv[2];
if (!route) {
console.error('usage: rendered-text-diff.mjs <route> [--pt] e.g. full-guide, or index');
process.exit(2);
}
// `--pt` clicks the language toggle on both pages first. English parity is
// only half the contract: a page can render every English string and still
// leave a restored block untranslated, because the Portuguese half is a
// separate set of nodes. Only /full-guide/ and /rules/ have a toggle.
const portuguese = process.argv.includes('--pt');
const legacyPath = route === 'index' ? 'index.html' : `${route}/index.html`;
const astroPath = route === 'index' ? '' : `${route}/`;
// The built site expects to be served under the configured base path.
const staging = mkdtempSync(join(tmpdir(), 'af-rtd-'));
cpSync('dist', join(staging, 'ai-for-dummies'), { recursive: true });
const serve = (dir, port) =>
spawn('python3', ['-m', 'http.server', String(port), '-d', dir], { stdio: 'ignore' });
const servers = [serve('.', 4197), serve(staging, 4196)];
const stop = () => {
servers.forEach((s) => s.kill());
rmSync(staging, { recursive: true, force: true });
};
// Visible text nodes, in document order, whitespace collapsed.
const visibleText = () => {
const out = [];
const walk = (node) => {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent.replace(/\s+/g, ' ').trim();
if (text) out.push(text);
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
if (child.tagName === 'SCRIPT' || child.tagName === 'STYLE') continue;
const style = getComputedStyle(child);
if (child.hidden || style.display === 'none' || style.visibility === 'hidden') continue;
walk(child);
}
};
walk(document.body);
return out;
};
try {
await new Promise((r) => setTimeout(r, 1500));
const browser = await chromium.launch();
const grab = async (url) => {
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } });
await page.goto(url, { waitUntil: 'load' });
// The islands hydrate and render their initial panel on load; without this
// every panel's copy reads as missing.
await page.waitForTimeout(1200);
if (portuguese) {
const toggle = await page.$('[data-lang="pt"]');
if (!toggle) throw new Error(`no language toggle on ${url}`);
await toggle.click();
await page.waitForTimeout(1200);
}
const spans = await page.evaluate(visibleText);
await page.close();
return spans;
};
const legacy = await grab(`http://localhost:4197/${legacyPath}`);
const astro = await grab(`http://localhost:4196/ai-for-dummies/${astroPath}`);
await browser.close();
const rendered = new Set(astro);
const missing = legacy.filter((span) => !rendered.has(span));
// Both directions. A string the Astro page paints and the legacy page does
// not is just as wrong: it means a translation was invented, or an English
// string was left standing where the legacy page swaps it.
const legacySpans = new Set(legacy);
const extra = astro.filter((span) => !legacySpans.has(span));
const mode = portuguese ? 'pt' : 'en';
console.log(
`${mode} · legacy ${legacy.length} spans · astro ${astro.length} spans · missing ${missing.length} · extra ${extra.length}`,
);
for (const span of missing) console.log(` - ${span}`);
for (const span of extra) console.log(` + ${span}`);
process.exitCode = missing.length === 0 && extra.length === 0 ? 0 : 1;
} finally {
stop();
}