#!/usr/bin/env node
import path from "node:path";
import { docsDir, normalizeUrl, parseArgs, resolveOutputDir, writeJson } from "./lib/common.mjs";
import { EXTRACTION_SCHEMA_VERSION, createGap, freezeSpec } from "./lib/extraction-contract.mjs";
const LOCALE_SEGMENT = /^[a-z]{2}(?:-[a-z]{2})?$/i;
export async function resolveHomePage(requestedUrl, { fetchImpl = fetch } = {}) {
const requested = normalizeUrl(requestedUrl);
requested.search = "";
const candidate = homeCandidate(requested);
let response;
try {
response = await fetchImpl(candidate, {
redirect: "follow",
signal: AbortSignal.timeout(15_000),
headers: { accept: "text/html,application/xhtml+xml", "user-agent": "wix-headless-replatform/0083" },
});
} catch (error) {
const gap = createGap({
id: "gap:page-resolution:navigation",
ownerUnitId: "page-resolution:home",
scope: "global",
missingFields: ["source.resolvedUrl"],
reason: `Home-page candidate could not be resolved: ${error.message}`,
evidenceRefs: [],
unblockAction: "Provide a reachable public home-page URL or restore source availability.",
});
return { artifact: null, gaps: [gap] };
}
if (!response.ok) {
const gap = createGap({
id: "gap:page-resolution:http",
ownerUnitId: "page-resolution:home",
scope: "global",
missingFields: ["source.resolvedUrl"],
reason: `Home-page candidate returned HTTP ${response.status}.`,
evidenceRefs: [],
unblockAction: "Provide a public home-page URL that returns a successful HTML response.",
});
return { artifact: null, gaps: [gap] };
}
const resolved = normalizeUrl(response.url || candidate);
resolved.hash = "";
const html = await response.text();
const canonical = extractCanonical(html, resolved) || resolved.toString();
const artifact = freezeSpec({
schemaVersion: EXTRACTION_SCHEMA_VERSION,
kind: "page-resolution",
id: "page-resolution:home",
pageKey: "home",
destinationRoute: "/",
status: "accepted",
source: {
requestedUrl: requested.toString(),
candidateUrl: candidate,
resolvedUrl: resolved.toString(),
canonicalUrl: canonical,
redirectsFollowed: requested.toString() !== resolved.toString(),
},
evidence: [{ kind: "read-only-navigation", responseStatus: response.status }],
gapRefs: [],
});
return { artifact, gaps: [] };
}
function homeCandidate(requested) {
const parts = requested.pathname.split("/").filter(Boolean);
const localePrefix = parts[0] && LOCALE_SEGMENT.test(parts[0]) ? `/${parts[0]}/` : "/";
return new URL(localePrefix, requested.origin).toString();
}
function extractCanonical(html, baseUrl) {
const match = String(html).match(/]*rel=["'][^"']*canonical[^"']*["'][^>]*href=["']([^"']+)["'][^>]*>|]*href=["']([^"']+)["'][^>]*rel=["'][^"']*canonical[^"']*["'][^>]*>/i);
const href = match?.[1] || match?.[2];
if (!href) return null;
try { return new URL(href, baseUrl).toString(); } catch { return null; }
}
async function main() {
const args = parseArgs();
const sourceUrl = args._[0] || args.url;
if (!sourceUrl) throw new Error("source URL is required");
const outputDir = resolveOutputDir(sourceUrl, args.out);
const result = await resolveHomePage(sourceUrl);
const target = path.join(docsDir(outputDir), "page-resolution.spec.json");
await writeJson(target, result.artifact || { schemaVersion: EXTRACTION_SCHEMA_VERSION, kind: "page-resolution", gaps: result.gaps });
if (result.gaps.some((gap) => gap.scope === "global")) {
const error = new Error(result.gaps[0].reason);
error.code = "PAGE_RESOLUTION_BLOCKED";
throw error;
}
console.log(JSON.stringify({ path: target, resolvedUrl: result.artifact.source.resolvedUrl, hash: result.artifact.hash }, null, 2));
}
if (import.meta.url === `file://${process.argv[1]}`) main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });