/*
[RFC-0194] Page Substance Score: a deterministic build-time measure of whether a generated page
is substantively unique, not a near-duplicate template with a swapped noun. Composed from four
components — independent data blocks, unique-vs-family token ratio, signal blocks, and internal
link diversity. A page below the Blueprint's substanceMin is forced to noindex regardless of how
many records matched it. Pure — no I/O, no LLM.
- Do not call an LLM or the network — the gate must be deterministic.
- Do not mutate page content to raise a score.
- RFC-0194: initial scorer.
- RFC-0274: walk nested visible props so substance/duplicate checks see rendered semantic text.
- RFC-1257: structured CtaTarget + scenario-plumbed contact links
*/
import type { PageEntry } from "./types.ts";
export interface SubstanceComponents {
independentBlocks: number;
uniqueTokenRatio: number; // 0..1
signalBlocks: number;
linkDiversity: number;
}
export interface SubstanceScore {
value: number; // 0..100
components: SubstanceComponents;
}
/** Lowercase word tokens of length > 3 (cheap, deterministic, language-agnostic). */
export function tokenize(text: string): string[] {
return (text.toLowerCase().match(/[a-zà-ÿ0-9]+/gi) ?? []).filter((t) => t.length > 3);
}
const NON_VISIBLE_TEXT_KEYS = new Set([
"id",
"slug",
"pageId",
"href",
"url",
"src",
"source",
"image",
"imageName",
"leadImage",
"backgroundImage",
"poster",
"photo",
"video",
"variant",
"kind",
"type",
"use",
]);
function collectVisibleText(value: unknown, key = ""): string[] {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return [];
if (NON_VISIBLE_TEXT_KEYS.has(key)) return [];
if (/^(\/|https?:|mailto:|tel:)/i.test(trimmed)) return [];
return [trimmed];
}
if (Array.isArray(value)) return value.flatMap((item) => collectVisibleText(item, key));
if (value && typeof value === "object") {
const out: string[] = [];
for (const [childKey, childValue] of Object.entries(value as Record)) {
out.push(...collectVisibleText(childValue, childKey));
}
return out;
}
return [];
}
/** Concatenate a page's rendered semantic text, including nested visible block props. */
export function pageText(page: PageEntry): string {
const parts: string[] = [page.title, page.description].filter(Boolean);
for (const block of page.blocks) {
parts.push(...collectVisibleText(block.props));
}
return parts.join(" ");
}
/** token → number of pages whose token set contains it. */
export function buildTokenDocFreq(pages: readonly PageEntry[]): Map {
const freq = new Map();
for (const page of pages) {
const seen = new Set(tokenize(pageText(page)));
for (const token of seen) freq.set(token, (freq.get(token) ?? 0) + 1);
}
return freq;
}
/** Count distinct internal-link targets referenced in a page's block props. */
function countLinkTargets(page: PageEntry): number {
const targets = new Set();
const visit = (value: unknown): void => {
if (typeof value === "string") {
if (/^(\/|https?:)/.test(value)) targets.add(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (value && typeof value === "object") {
const record = value as Record;
// RFC-1257: structured CtaTarget nodes — an internal target counts its
// pageId as a link destination; external hrefs fall through to the
// string visitor below.
if (record.kind === "internal" && typeof record.pageId === "string") {
targets.add(record.pageId);
return;
}
for (const v of Object.values(record)) visit(v);
}
};
for (const block of page.blocks) visit(block.props);
return targets.size;
}
/**
* Score a page's substance. `docFreq`/`totalPages` come from the family baseline (buildTokenDocFreq);
* a token is "unique" when it appears in at most ceil(totalPages * 0.5) pages. `signalBlockIds` are
* block `type`s the Blueprint marks as substance-bearing; when omitted, any block with a non-empty
* `lead`/`body` counts as a signal.
*/
export function scoreSubstance(
page: PageEntry,
ctx: { docFreq: Map; totalPages: number; signalBlockIds?: string[] },
): SubstanceScore {
const independentBlocks = page.blocks.length;
const tokens = tokenize(pageText(page));
const distinct = new Set(tokens);
const uniqueThreshold = Math.max(1, Math.ceil(ctx.totalPages * 0.5));
let uniqueCount = 0;
for (const token of distinct) {
if ((ctx.docFreq.get(token) ?? 0) <= uniqueThreshold) uniqueCount += 1;
}
const uniqueTokenRatio = distinct.size === 0 ? 0 : uniqueCount / distinct.size;
const signalSet = ctx.signalBlockIds ? new Set(ctx.signalBlockIds) : null;
let signalBlocks = 0;
for (const block of page.blocks) {
const props = (block.props ?? {}) as Record;
const isSignal = signalSet
? signalSet.has(block.type ?? block.use ?? "")
: collectVisibleText(props).join(" ").length > 40;
if (isSignal) signalBlocks += 1;
}
const linkDiversity = countLinkTargets(page);
const value = Math.round(
Math.min(100, independentBlocks * 12) * 0.3 +
uniqueTokenRatio * 100 * 0.4 +
Math.min(100, signalBlocks * 18) * 0.2 +
Math.min(100, linkDiversity * 20) * 0.1,
);
return {
value,
components: { independentBlocks, uniqueTokenRatio, signalBlocks, linkDiversity },
};
}