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
3 changes: 3 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ jobs:
run: |
node skills/hallmark/scripts/theme.mjs index
git diff --exit-code -- skills/hallmark/references/themes/index.json

- name: Failed renders preserve the before inventory
run: node --test skills/hallmark/scripts/before-inventory.test.mjs
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Hallmark never starts on a design that already exists unless you name it:
/hallmark audit ./index.html
```

A full redesign keeps the content and the wiring and changes the system and the layout; say *keep the layout* for a restyle or *polish* for only the tells. `sloplint --before` records every link, field, handler and heading first, `sloplint --kept` fails the redesign if one went missing, and the handoff is a side-by-side before and after.
A full redesign keeps the content and the wiring and changes the system and the layout; say *keep the layout* for a restyle or *polish* for only the tells. `sloplint --before` records every link, field, handler and heading first, `sloplint --kept` fails the redesign if one went missing, and the handoff is a side-by-side before and after. If a requested browser render fails or cannot run, `--before` exits 2 and preserves any existing `before.json`; rerun after fixing the render.

## Install

Expand Down
62 changes: 62 additions & 0 deletions skills/hallmark/scripts/before-inventory.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';

const source = readFileSync(new URL('./sloplint.mjs', import.meta.url), 'utf8');
function run(mode, prior = true, render = true) {
const dir = mkdtempSync(join(tmpdir(), 'hallmark-before-'));
try {
writeFileSync(join(dir, 'sloplint.mjs'), source);
// Inject the renderer's documented result protocol, leaving the production CLI,
// inventory collection, output writing and exit handling unchanged.
writeFileSync(join(dir, 'sloplint-render.mjs'), `export async function renderCheck(urls) {
if (${JSON.stringify(mode)} === 'throw') throw new Error('browser launch failed');
const result = [];
result.failed = ${JSON.stringify(mode)} === 'failed' || ${JSON.stringify(mode)} === 'mixed'
? [{file: urls[urls.length - 1], message: 'HTTP 404'}] : [];
if (${JSON.stringify(mode)} === 'not-run') result.notRun = 'no browser available';
result.inventories = ${JSON.stringify(mode)} === 'success' || ${JSON.stringify(mode)} === 'mixed'
? {[urls[0]]: {links: ['/book'], fields: [], handlers: [], hooks: [], headings: [], text: []}} : {};
return result;
}`);
mkdirSync(join(dir, '.hallmark'));
const before = join(dir, '.hallmark/before.json');
const original = '{"v":1,"operator":"known-good-baseline"}\n';
if (prior) writeFileSync(before, original);
writeFileSync(join(dir, 'page.html'), '<!doctype html><html><body><a href="/book">Book</a></body></html>');
const args = [join(dir, 'sloplint.mjs'), '--before', '--json'];
if (render) args.push('--url', 'http://localhost:4173/', ...(mode === 'mixed' ? ['--url', 'http://localhost:4173/missing'] : []));
else args.push(join(dir, 'page.html'));
const result = spawnSync(process.execPath, args, {cwd: dir, encoding: 'utf8'});
assert.ifError(result.error);
return {...result, before: existsSync(before) ? readFileSync(before, 'utf8') : null, original};
} finally { rmSync(dir, {recursive: true, force: true}); }
}
for (const mode of ['failed', 'mixed', 'not-run', 'throw']) {
test(`incomplete ${mode} render preserves an existing before inventory`, () => {
const r = run(mode);
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.equal(r.before, r.original);
assert.match(r.stderr, /before|inventory|record/i);
});
}
test('failed first render does not create a before inventory', () => {
const r = run('failed', false);
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.equal(r.before, null);
});
test('complete rendered inventory replaces the prior record', () => {
const r = run('success');
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.notEqual(r.before, r.original);
assert.ok(JSON.parse(r.before).pages['http://localhost:4173/']);
});
test('static before mode still records a page with lint findings', () => {
const r = run('static', true, false);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.notEqual(r.before, r.original);
assert.ok(JSON.parse(r.before).wiring.links.length);
});
7 changes: 7 additions & 0 deletions skills/hallmark/scripts/sloplint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4139,6 +4139,7 @@ for (const [key, ctx] of contexts) {
}

let renderNotRun = null;
let renderFailed = false;
let domInventories = {};
if (opts.render) {
const isShell = (f) => { try { const x = readFileSync(f, 'utf8'); return /<script\b[^>]*type\s*=\s*["']module["'][^>]*\bsrc\s*=\s*["'][^"']+\.(?:tsx?|jsx?|mjs|vue|svelte)["']/i.test(x); } catch { return false; } };
Expand All @@ -4148,6 +4149,7 @@ if (opts.render) {
const mod = await import(new URL('./sloplint-render.mjs', import.meta.url).href);
const extra = await mod.renderCheck([...htmlFiles, ...opts.urls], { genre: opts.genre, postureByFile, heroPostureByFile, photoShapeByFile, jobByFile, stage: opts.stage, selectors: opts.before || opts.kept ? Object.fromEntries([...new Set(contexts.flatMap(([, c]) => c.docs))].filter((d) => d.kind === 'html').map((d) => [d.file, scriptSelectors(d)])) : {}, scope: opts.scope === 'app' || [...waiversByKey.values()].some((x) => x.APPSCREEN) ? 'app' : opts.scope, before: opts.before });
domInventories = extra.inventories || {};
renderFailed = !!extra.failed?.length;
const allWaivers = { WAIVED: new Set([...waiversByKey.values()].flatMap((x) => [...x.WAIVED])), ANSWERED: new Set([...waiversByKey.values()].flatMap((x) => [...x.ANSWERED])), DISMISSED: new Set([...waiversByKey.values()].flatMap((x) => [...(x.DISMISSED || [])])), VERBATIM: [...waiversByKey.values()].some((x) => x.VERBATIM), APPSCREEN: [...waiversByKey.values()].some((x) => x.APPSCREEN), LAYOUT_KEPT: [...waiversByKey.values()].map((x) => x.LAYOUT_KEPT).find(Boolean) || false };
for (const f of extra) {
const w = /^https?:/.test(f.file) ? allWaivers : (waiversByKey.get(f.file) || waiversByKey.get(dirname(f.file)));
Expand All @@ -4166,6 +4168,11 @@ if (opts.render) {
}
}

if (opts.before && opts.render && (renderNotRun || renderFailed)) {
console.error('sloplint: before inventory not recorded: the requested render was incomplete; fix it and rerun. Any existing before.json is unchanged.');
process.exit(2);
}

const SNAP = join(process.cwd(), '.hallmark', 'before.json');
const squash = (t) => t.replace(/\s+/g, ' ').trim();
const escRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
Expand Down
Loading