Skip to content
Closed
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
8 changes: 8 additions & 0 deletions skills/convert-site/references/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ live like a GET form (the same full action URL, method and fields) and notes "N
is (posts to <host>)"; rewiring one to the endpoint fails the gate. A site whose POST forms
all post elsewhere needs no `--endpoint` and no form id.

## Forms the static site cannot serve

A login, account, cart or checkout form needs a server, and rewiring it to a form service would
send passwords or payment details there. Never rewire one. How to handle it is a judgement for
the site: keep it switched off, link out to wherever that feature will live, or replace it with
an embed. A form kept but switched off gets an action that sends nowhere and a row in
`inert-forms.json` (`{ path, form, reason }`); the PR and runbook say it does not work.

## What stays the same

The forms gate compares each built form with `baseline/forms.json`: the same field names in
Expand Down
2 changes: 2 additions & 0 deletions skills/convert-site/scripts/css-prune.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ export function rewriteRef(ref, base, siteOrigin) {
return null;
}
if (resolved.origin === PLACEHOLDER || resolved.origin === siteOrigin) {
// A live URL with a doubled slash (https://site//a.ttf) must not become protocol-relative.
resolved.pathname = resolved.pathname.replace(/^\/{2,}/, '/');
return resolved.pathname + resolved.search + resolved.hash;
}
return absolute || value.startsWith('//') ? null : resolved.href;
Expand Down
45 changes: 40 additions & 5 deletions skills/parity-gates/scripts/verify/forms.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ path must be a page in dist. The field may hold the path itself, or an absolute
endpoints require) whose host is the baseline's canonicalOrigin (origin when meta.json has none)
and whose path is --thank-you. Without it the thank-you page is not checked, and the summary
counts each form as skipped.
A form the static site keeps but switches off (a login or checkout it cannot serve) is listed
in inert-forms.json, when that file exists, as { path, form, reason }, form being its 0-based
index among the page's forms. The gate requires live's fields and method and an action that
sends nowhere (none, "#" or javascript:), and counts the form as skipped.
`;
const THANK_YOU_FIELDS = ['_next', 'redirect'];

Expand Down Expand Up @@ -186,7 +190,23 @@ export function compareForm(path, label, expected, actual, { endpoint, honeypot,
return failures;
}

export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = '', meta = {}, paths = () => true }) {
// A form listed as inert: the same fields and method as live, and an action that sends nowhere.
const sendsNowhere = (action) => !action || action === '#' || /^javascript:/i.test(action);

export function compareInertForm(path, label, expected, actual) {
const failures = [];
const field = (name) => (label ? `${label} ${name}` : name);
const want = expected.fields.map((f) => (typeof f === 'string' ? f : f.name));
if (!same(want, actual.fields)) failures.push({ path, field: field('fields'), expected: want, actual: actual.fields });
const method = (expected.method || 'get').toLowerCase();
if (method !== actual.method) failures.push({ path, field: field('method'), expected: method, actual: actual.method });
if (!sendsNowhere(actual.action)) failures.push({ path, field: field('inert action'), expected: 'none, # or javascript:', actual: actual.action });
return failures;
}

export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = '', meta = {}, paths = () => true, inert = [] }) {
const inertKeys = new Set(inert.map((entry) => `${entry.path}#${entry.form}`));
let inertCount = 0;
const siteHost = siteHostOf(meta);
const origins = [meta.origin, meta.canonicalOrigin].map((o) => originOf(o ?? '')).filter(Boolean);
let getCount = 0;
Expand All @@ -212,6 +232,11 @@ export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = ''
}
expected.forEach((form, i) => {
const label = expected.length > 1 ? `form ${i + 1}` : '';
if (inertKeys.has(`${path}#${i}`)) {
inertCount += 1;
failures.push(...compareInertForm(path, label, form, actual[i]));
return;
}
const host = externalHost(form, path, origins);
if (host) {
keptHosts.push(host);
Expand All @@ -236,8 +261,9 @@ export function checkForms({ dist, forms, endpoint, honeypot = '', thankYou = ''
const candidates = pageFiles(thankYou, format);
if (!candidates.some((c) => files.includes(c))) failures.push({ path: thankYou, field: 'thank-you', expected: candidates[0], actual: null });
}
if (thankYou || formCount === 0) return { checked, failures, notes };
return { checked, failures, skipped: formCount, skipReason: 'thank-you not checked', notes: [...notes, 'thank-you: not checked (no --thank-you)'] };
const inertSkip = inertCount ? { skipped: inertCount, skipReason: 'inert forms' } : {};
if (thankYou || formCount === 0) return { checked, failures, notes, ...inertSkip };
return { checked, failures, skipped: formCount + inertCount, skipReason: `thank-you not checked${inertCount ? ', inert forms' : ''}`, notes: [...notes, 'thank-you: not checked (no --thank-you)'] };
}

function main() {
Expand All @@ -248,8 +274,16 @@ function main() {
const metaFile = join(args.baseline, 'meta.json');
const meta = existsSync(metaFile) ? readJson(metaFile) : {};
const origins = [meta.origin, meta.canonicalOrigin].map((o) => originOf(o ?? '')).filter(Boolean);
const postsToSite = (form, path) => isPost(form) && !externalHost(form, path, origins);
const withForms = Object.entries(forms).filter(([path, list]) => args.paths(path) && list.some((form) => postsToSite(form, path)));
const inert = existsSync('inert-forms.json') ? readJson('inert-forms.json') : [];
for (const entry of inert) {
if (!entry.path || !Number.isInteger(entry.form) || !entry.reason) {
process.stderr.write(`${NAME}: every inert-forms.json entry needs path, form and reason\n`);
process.exit(2);
}
}
const inertKeys = new Set(inert.map((entry) => `${entry.path}#${entry.form}`));
const postsToSite = (form, path, i) => isPost(form) && !externalHost(form, path, origins) && !inertKeys.has(`${path}#${i}`);
const withForms = Object.entries(forms).filter(([path, list]) => args.paths(path) && list.some((form, i) => postsToSite(form, path, i)));
if (withForms.length > 0 && !args.endpoint) {
process.stderr.write(`${NAME}: --endpoint <url> is required: the baseline has POST forms on ${withForms.map(([p]) => p).join(', ')}\n`);
process.exit(2);
Expand All @@ -262,6 +296,7 @@ function main() {
thankYou: args.thankYou,
meta,
paths: args.paths,
inert,
});
report(NAME, results, { json: args.json });
}
Expand Down
2 changes: 1 addition & 1 deletion skills/parity-gates/scripts/verify/sitemaps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const decode = (value) => {
const unescapeXml = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');

export function locs(xml) {
return [...xml.matchAll(/<loc>\s*([^<]*?)\s*<\/loc>/g)].map((m) => unescapeXml(m[1]));
return [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[\s*([^\]]*?)\s*\]\]>|([^<]*?))\s*<\/loc>/g)].map((m) => m[1] ?? unescapeXml(m[2]));
}

export const itemCount = (xml) => (xml.match(/<(?:item|entry)[\s>]/g) ?? []).length;
Expand Down
22 changes: 22 additions & 0 deletions test/gates/forms-inert.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { compareInertForm } from '../../skills/parity-gates/scripts/verify/forms.mjs';

const live = { method: 'post', action: 'https://example.com/wp-login.php', fields: ['log', 'pwd'] };

test('an inert form with the live fields and no way to send passes', () => {
const built = { method: 'post', action: '#', fields: ['log', 'pwd'] };
assert.deepEqual(compareInertForm('/log-in/', '', live, built), []);
assert.deepEqual(compareInertForm('/log-in/', '', live, { ...built, action: null }), []);
});

test('a listed form that can still submit fails', () => {
const built = { method: 'post', action: 'https://formspree.io/f/abc', fields: ['log', 'pwd'] };
const fields = compareInertForm('/log-in/', '', live, built).map((f) => f.field);
assert.deepEqual(fields, ['inert action']);
});

test('a listed form that lost a field fails', () => {
const built = { method: 'post', action: '#', fields: ['log'] };
assert.deepEqual(compareInertForm('/log-in/', '', live, built).map((f) => f.field), ['fields']);
});
Loading