pagegraph 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -7
- package/dist/audit.d.ts +6 -0
- package/dist/audit.js +7 -3
- package/dist/audit.js.map +1 -1
- package/dist/{checks-6noG_xTS.d.ts → checks-_X8yjMvV.d.ts} +11 -1
- package/dist/cli.js +1556 -409
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +28 -21
- package/dist/index.js.map +1 -1
- package/dist/{links-BFJc-pJ9.js → links-DAa8KVzg.js} +16 -2
- package/dist/links-DAa8KVzg.js.map +1 -0
- package/package.json +1 -1
- package/dist/links-BFJc-pJ9.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import * as NFS from "node:fs";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import * as NodeChildProcess from "node:child_process";
|
|
5
5
|
import { execFileSync, spawn } from "node:child_process";
|
|
6
6
|
import { PassThrough } from "node:stream";
|
|
@@ -9,7 +9,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
9
9
|
import * as OS from "node:os";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import * as Path from "node:path";
|
|
12
|
-
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
13
13
|
import * as NodeUrl from "node:url";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
15
|
import * as readline from "node:readline";
|
|
@@ -35710,7 +35710,7 @@ const runWith = (command, config) => {
|
|
|
35710
35710
|
};
|
|
35711
35711
|
//#endregion
|
|
35712
35712
|
//#region package.json
|
|
35713
|
-
var version = "0.
|
|
35713
|
+
var version = "0.9.0";
|
|
35714
35714
|
//#endregion
|
|
35715
35715
|
//#region src/core/projections.ts
|
|
35716
35716
|
const escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -36166,12 +36166,13 @@ const findConfigFile = (from) => {
|
|
|
36166
36166
|
const isStringArray = (value) => Array.isArray(value) && value.every(isString);
|
|
36167
36167
|
const isCoverageRules = (value) => Array.isArray(value) && value.every((rule) => isObject$1(rule) && isString(rule["path"]) && Number.isSafeInteger(rule["minInbound"]) && rule["minInbound"] > 0);
|
|
36168
36168
|
const isStringRecord = (value) => isObject$1(value) && Object.values(value).every(isString);
|
|
36169
|
+
const isWorkflowTimeout = (value) => Number.isSafeInteger(value) && value > 0 && value <= 2147483647;
|
|
36169
36170
|
const isStringArrayRecord = (value) => isObject$1(value) && Object.values(value).every(isStringArray);
|
|
36170
36171
|
const isWorkflowConfig = (value) => {
|
|
36171
36172
|
if (!isObject$1(value) || !isObject$1(value["opencode"])) return false;
|
|
36172
36173
|
const opencode = value["opencode"];
|
|
36173
36174
|
const context = value["context"];
|
|
36174
|
-
return isString(opencode["configDirectory"]) && isString(opencode["defaultModel"]) && (opencode["models"] === void 0 || isStringRecord(opencode["models"])) && (context === void 0 || isObject$1(context) && (context["files"] === void 0 || isStringArray(context["files"])) && (context["byWorkflow"] === void 0 || isStringArrayRecord(context["byWorkflow"]))) && (value["runsDirectory"] === void 0 || isString(value["runsDirectory"]));
|
|
36175
|
+
return isString(opencode["configDirectory"]) && isString(opencode["defaultModel"]) && (opencode["models"] === void 0 || isStringRecord(opencode["models"])) && (opencode["timeoutMs"] === void 0 || isWorkflowTimeout(opencode["timeoutMs"])) && (context === void 0 || isObject$1(context) && (context["files"] === void 0 || isStringArray(context["files"])) && (context["byWorkflow"] === void 0 || isStringArrayRecord(context["byWorkflow"]))) && (value["runsDirectory"] === void 0 || isString(value["runsDirectory"]));
|
|
36175
36176
|
};
|
|
36176
36177
|
/**
|
|
36177
36178
|
* `seo.config.ts` is the consumer's file and may be plain JS, so its types are
|
|
@@ -37524,6 +37525,15 @@ const decodeRenderedEdgeArtifact = (input) => {
|
|
|
37524
37525
|
const bodyTruncated = provenance["bodyTruncated"];
|
|
37525
37526
|
if (bodyTruncated !== void 0 && typeof bodyTruncated !== "boolean") throw new Error("rendered-edge artifact `bodyTruncated` must be a boolean");
|
|
37526
37527
|
if (typeof provenance["truncated"] !== "boolean") throw new Error("rendered-edge artifact `crawl.truncated` must be a boolean");
|
|
37528
|
+
const skipped = provenance["skipped"];
|
|
37529
|
+
if (skipped !== void 0 && (!Array.isArray(skipped) || !skipped.every((url) => typeof url === "string"))) throw new Error("rendered-edge artifact `crawl.skipped` must be an array of URLs");
|
|
37530
|
+
const nonHtml = provenance["nonHtml"];
|
|
37531
|
+
if (nonHtml !== void 0 && (!Array.isArray(nonHtml) || !nonHtml.every((item) => item !== null && typeof item === "object" && typeof item.url === "string" && typeof item.finalUrl === "string" && typeof item.contentType === "string"))) throw new Error("rendered-edge artifact `crawl.nonHtml` must be an array of { url, finalUrl, contentType }");
|
|
37532
|
+
if (Array.isArray(nonHtml)) for (const item of nonHtml) {
|
|
37533
|
+
requiredAbsoluteUrl(item.url, "crawl.nonHtml.url");
|
|
37534
|
+
requiredAbsoluteUrl(item.finalUrl, "crawl.nonHtml.finalUrl");
|
|
37535
|
+
}
|
|
37536
|
+
for (const field of ["sitemapTruncated", "sitemapUsed"]) if (provenance[field] !== void 0 && typeof provenance[field] !== "boolean") throw new Error(`rendered-edge artifact \`crawl.${field}\` must be a boolean`);
|
|
37527
37537
|
return {
|
|
37528
37538
|
kind: ARTIFACT_KIND,
|
|
37529
37539
|
schemaVersion: 1,
|
|
@@ -37536,7 +37546,12 @@ const decodeRenderedEdgeArtifact = (input) => {
|
|
|
37536
37546
|
truncated: provenance["truncated"],
|
|
37537
37547
|
bodyTruncated: bodyTruncated === true,
|
|
37538
37548
|
truncatedPages: truncatedPages ?? [],
|
|
37539
|
-
failures: decodeArtifactFailures(provenance["failures"])
|
|
37549
|
+
failures: decodeArtifactFailures(provenance["failures"]),
|
|
37550
|
+
...nonHtml === void 0 ? {} : { nonHtml },
|
|
37551
|
+
...provenance["discoveryFailures"] === void 0 ? {} : { discoveryFailures: decodeArtifactFailures(provenance["discoveryFailures"]) },
|
|
37552
|
+
...skipped === void 0 ? {} : { skipped },
|
|
37553
|
+
...provenance["sitemapTruncated"] === void 0 ? {} : { sitemapTruncated: provenance["sitemapTruncated"] },
|
|
37554
|
+
...provenance["sitemapUsed"] === void 0 ? {} : { sitemapUsed: provenance["sitemapUsed"] }
|
|
37540
37555
|
},
|
|
37541
37556
|
nodes: decodeArtifactNodes(value["nodes"]),
|
|
37542
37557
|
edges: decodeArtifactEdges(value["edges"])
|
|
@@ -37751,7 +37766,7 @@ const canonicalUrl = (html) => {
|
|
|
37751
37766
|
};
|
|
37752
37767
|
const countMatches = (value, pattern) => [...value.matchAll(pattern)].length;
|
|
37753
37768
|
const extractDocumentSignals = (body, contentType, requestedUrl) => {
|
|
37754
|
-
const isHtml = contentType.includes("text/html");
|
|
37769
|
+
const isHtml = contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
|
|
37755
37770
|
const isMarkdown = contentType.includes("text/markdown");
|
|
37756
37771
|
const looksJson = contentType.includes("json") || requestedUrl.pathname.endsWith(".json");
|
|
37757
37772
|
const titleMatch = isHtml ? /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(body) : null;
|
|
@@ -37846,6 +37861,8 @@ const probeHttp = async (request, options) => {
|
|
|
37846
37861
|
let currentUrl = request.url;
|
|
37847
37862
|
let response = null;
|
|
37848
37863
|
for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
|
|
37864
|
+
if (options.sameOrigin !== void 0 && currentUrl.origin !== options.sameOrigin) throw new Error(`Redirected off-origin to ${currentUrl.href}`);
|
|
37865
|
+
if (options.allowUrl !== void 0 && !options.allowUrl(currentUrl)) throw new Error(`Redirected to robots-disallowed URL ${currentUrl.href}`);
|
|
37849
37866
|
response = await requestPinned(currentUrl, request, options);
|
|
37850
37867
|
headersAt = performance.now();
|
|
37851
37868
|
const location = response.headers.location;
|
|
@@ -37863,7 +37880,7 @@ const probeHttp = async (request, options) => {
|
|
|
37863
37880
|
} : await readBoundedBody(response, options.maxBodyBytes);
|
|
37864
37881
|
const contentType = typeof response.headers["content-type"] === "string" ? response.headers["content-type"] : "";
|
|
37865
37882
|
const body = new TextDecoder().decode(bounded.bytes);
|
|
37866
|
-
const anchors = options.captureAnchors === true && contentType
|
|
37883
|
+
const anchors = options.captureAnchors === true && (contentType === "" || /text\/html|application\/xhtml\+xml/i.test(contentType)) ? extractAnchors(body, currentUrl.href) : void 0;
|
|
37867
37884
|
return {
|
|
37868
37885
|
kind: request.kind,
|
|
37869
37886
|
method: request.method,
|
|
@@ -37880,6 +37897,7 @@ const probeHttp = async (request, options) => {
|
|
|
37880
37897
|
capturedBodySha256: bounded.bytes.byteLength === 0 ? null : createHash("sha256").update(bounded.bytes).digest("hex"),
|
|
37881
37898
|
bodyTruncated: bounded.truncated,
|
|
37882
37899
|
bodyExcerpt: bodyExcerpt(body, contentType),
|
|
37900
|
+
...options.captureBody === true ? { body } : {},
|
|
37883
37901
|
document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl),
|
|
37884
37902
|
anchors,
|
|
37885
37903
|
error: null
|
|
@@ -37901,6 +37919,7 @@ const probeHttp = async (request, options) => {
|
|
|
37901
37919
|
capturedBodySha256: null,
|
|
37902
37920
|
bodyTruncated: false,
|
|
37903
37921
|
bodyExcerpt: null,
|
|
37922
|
+
...options.captureBody === true ? { body: void 0 } : {},
|
|
37904
37923
|
document: null,
|
|
37905
37924
|
error: errorMessage$2(error)
|
|
37906
37925
|
};
|
|
@@ -42011,6 +42030,845 @@ const layer = /*#__PURE__*/ layerMergedContext(/*#__PURE__*/ succeed$2(/* @__PUR
|
|
|
42011
42030
|
return send(void 0);
|
|
42012
42031
|
})));
|
|
42013
42032
|
//#endregion
|
|
42033
|
+
//#region src/audit/crawl.ts
|
|
42034
|
+
/** Anchors that are never HTML documents; skipped so they do not consume the budget. */
|
|
42035
|
+
const NON_PAGE = /\.(?:avif|css|csv|gif|ico|jpe?g|js|json|map|mp4|pdf|png|rss|svg|txt|webm|webp|woff2?|xml|zip)$/i;
|
|
42036
|
+
/** The graph keys pages by path, so dedupe crawled URLs the same way. */
|
|
42037
|
+
const pageKey = (url) => normalizePath(url);
|
|
42038
|
+
const XML_LOC = /<loc\b[^>]*>([\s\S]*?)<\/loc>/gi;
|
|
42039
|
+
const xmlText = (value) => value.trim().replace(/&(amp|lt|gt|quot|apos|#x[0-9a-f]+|#[0-9]+);/gi, (entity, code) => {
|
|
42040
|
+
const named = {
|
|
42041
|
+
amp: "&",
|
|
42042
|
+
lt: "<",
|
|
42043
|
+
gt: ">",
|
|
42044
|
+
quot: "\"",
|
|
42045
|
+
apos: "'"
|
|
42046
|
+
};
|
|
42047
|
+
if (named[code.toLowerCase()] !== void 0) return named[code.toLowerCase()];
|
|
42048
|
+
const numeric = code.startsWith("#x") || code.startsWith("#X") ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
|
|
42049
|
+
if (!Number.isInteger(numeric) || numeric === 0 || numeric < 32 && numeric !== 9 && numeric !== 10 && numeric !== 13 || numeric >= 55296 && numeric <= 57343 || numeric > 1114111) throw new Error(`Invalid XML character reference: ${entity}`);
|
|
42050
|
+
return String.fromCodePoint(numeric);
|
|
42051
|
+
});
|
|
42052
|
+
const robotsRules = (body) => {
|
|
42053
|
+
const groups = [];
|
|
42054
|
+
const sitemaps = [];
|
|
42055
|
+
let group;
|
|
42056
|
+
let hasDirective = false;
|
|
42057
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
42058
|
+
const line = raw.replace(/\s+#.*$/, "").trim();
|
|
42059
|
+
const match = /^([^:]+):\s*(.*)$/.exec(line);
|
|
42060
|
+
if (!match) continue;
|
|
42061
|
+
const name = match[1].trim().toLowerCase();
|
|
42062
|
+
const value = match[2].trim();
|
|
42063
|
+
if (name === "sitemap") {
|
|
42064
|
+
sitemaps.push(value);
|
|
42065
|
+
continue;
|
|
42066
|
+
}
|
|
42067
|
+
if (name === "user-agent") {
|
|
42068
|
+
if (group === void 0 || hasDirective) {
|
|
42069
|
+
group = {
|
|
42070
|
+
agents: [],
|
|
42071
|
+
rules: []
|
|
42072
|
+
};
|
|
42073
|
+
groups.push(group);
|
|
42074
|
+
hasDirective = false;
|
|
42075
|
+
}
|
|
42076
|
+
group.agents.push(value.toLowerCase());
|
|
42077
|
+
continue;
|
|
42078
|
+
}
|
|
42079
|
+
if (name === "allow" || name === "disallow") {
|
|
42080
|
+
hasDirective = true;
|
|
42081
|
+
if (group !== void 0 && value.startsWith("/")) group.rules.push({
|
|
42082
|
+
path: value,
|
|
42083
|
+
allow: name === "allow"
|
|
42084
|
+
});
|
|
42085
|
+
}
|
|
42086
|
+
}
|
|
42087
|
+
const score = (agent) => agent === "*" ? 0 : "pagegraph/0.4".startsWith(agent) ? agent.length : -1;
|
|
42088
|
+
const best = Math.max(-1, ...groups.flatMap((entry) => entry.agents.map(score)));
|
|
42089
|
+
return {
|
|
42090
|
+
rules: groups.filter((entry) => best >= 0 && entry.agents.some((agent) => score(agent) === best)).flatMap((entry) => entry.rules),
|
|
42091
|
+
sitemaps
|
|
42092
|
+
};
|
|
42093
|
+
};
|
|
42094
|
+
const allowedByRobots = (path, rules) => {
|
|
42095
|
+
let chosen;
|
|
42096
|
+
let bestLength = -1;
|
|
42097
|
+
for (const rule of rules) {
|
|
42098
|
+
const anchored = rule.path.endsWith("$");
|
|
42099
|
+
const parts = (anchored ? rule.path.slice(0, -1) : rule.path).split("*");
|
|
42100
|
+
if (!path.startsWith(parts[0])) continue;
|
|
42101
|
+
let offset = parts[0].length;
|
|
42102
|
+
let matches = true;
|
|
42103
|
+
for (let at = 1; at < parts.length; at++) {
|
|
42104
|
+
const part = parts[at];
|
|
42105
|
+
const found = path.indexOf(part, offset);
|
|
42106
|
+
if (found < 0) {
|
|
42107
|
+
matches = false;
|
|
42108
|
+
break;
|
|
42109
|
+
}
|
|
42110
|
+
offset = found + part.length;
|
|
42111
|
+
}
|
|
42112
|
+
if (!matches || anchored && (parts.length === 1 ? path !== parts[0] : !path.endsWith(parts.at(-1)))) continue;
|
|
42113
|
+
const length = rule.path.replace(/[\*$]/g, "").length;
|
|
42114
|
+
if (length > bestLength || length === bestLength && rule.allow) {
|
|
42115
|
+
chosen = rule;
|
|
42116
|
+
bestLength = length;
|
|
42117
|
+
}
|
|
42118
|
+
}
|
|
42119
|
+
return chosen?.allow ?? true;
|
|
42120
|
+
};
|
|
42121
|
+
/** Run `run` over `items` with at most `limit` in flight, preserving order. */
|
|
42122
|
+
const mapLimit = async (items, limit, run) => {
|
|
42123
|
+
const results = Array.from({ length: items.length });
|
|
42124
|
+
let index = 0;
|
|
42125
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
42126
|
+
for (;;) {
|
|
42127
|
+
const current = index++;
|
|
42128
|
+
if (current >= items.length) return;
|
|
42129
|
+
results[current] = await run(items[current]);
|
|
42130
|
+
}
|
|
42131
|
+
});
|
|
42132
|
+
await Promise.all(workers);
|
|
42133
|
+
return results.map((result, at) => {
|
|
42134
|
+
if (result === void 0) throw new Error(`Crawl worker ${at} produced no result`);
|
|
42135
|
+
return result;
|
|
42136
|
+
});
|
|
42137
|
+
};
|
|
42138
|
+
/**
|
|
42139
|
+
* Crawl `seed`'s origin breadth-first and return one {@link RenderedPage} per
|
|
42140
|
+
* successfully fetched HTML page. The seed must be an absolute `http(s)` URL;
|
|
42141
|
+
* private and reserved destinations are rejected unless `allowPrivate` is set.
|
|
42142
|
+
*/
|
|
42143
|
+
const crawlRenderedPages = async (seed, options) => {
|
|
42144
|
+
let seedUrl;
|
|
42145
|
+
try {
|
|
42146
|
+
seedUrl = new URL(seed);
|
|
42147
|
+
} catch {
|
|
42148
|
+
throw new Error(`Not a valid URL: ${seed}`);
|
|
42149
|
+
}
|
|
42150
|
+
if (seedUrl.protocol !== "https:" && seedUrl.protocol !== "http:") throw new Error(`Unsupported URL protocol: ${seedUrl.protocol}`);
|
|
42151
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new Error(`Crawl limit must be a positive integer: ${options.limit}`);
|
|
42152
|
+
const origin = seedUrl.origin;
|
|
42153
|
+
const probeOptions = {
|
|
42154
|
+
allowPrivate: options.allowPrivate,
|
|
42155
|
+
timeoutMs: options.timeoutMs,
|
|
42156
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
42157
|
+
captureAnchors: true,
|
|
42158
|
+
sameOrigin: origin
|
|
42159
|
+
};
|
|
42160
|
+
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
42161
|
+
const visited = /* @__PURE__ */ new Set([pageKey(seedUrl)]);
|
|
42162
|
+
/** Final paths already rendered, so a redirect cannot add the same page twice. */
|
|
42163
|
+
const rendered = /* @__PURE__ */ new Set();
|
|
42164
|
+
const canonicalByPath = /* @__PURE__ */ new Map();
|
|
42165
|
+
const pages = [];
|
|
42166
|
+
const failures = [];
|
|
42167
|
+
const nonHtml = [];
|
|
42168
|
+
const truncatedBodies = [];
|
|
42169
|
+
const discoveryFailures = [];
|
|
42170
|
+
const skipped = [];
|
|
42171
|
+
let sitemapTruncated = false;
|
|
42172
|
+
let sitemapUsed = false;
|
|
42173
|
+
let attempts = 0;
|
|
42174
|
+
let truncated = false;
|
|
42175
|
+
let root = pageKey(seedUrl);
|
|
42176
|
+
let frontier = [{
|
|
42177
|
+
url: seedUrl,
|
|
42178
|
+
depth: 0
|
|
42179
|
+
}];
|
|
42180
|
+
const discoveryProbe = async (url, kind, allowUrl) => probeHttp({
|
|
42181
|
+
kind,
|
|
42182
|
+
method: "GET",
|
|
42183
|
+
accept: "text/plain, application/xml, text/xml",
|
|
42184
|
+
url
|
|
42185
|
+
}, {
|
|
42186
|
+
...probeOptions,
|
|
42187
|
+
captureAnchors: false,
|
|
42188
|
+
captureBody: true,
|
|
42189
|
+
allowUrl
|
|
42190
|
+
});
|
|
42191
|
+
const robots = await discoveryProbe(new URL("/robots.txt", origin), "robots");
|
|
42192
|
+
if (!robots.ok && robots.status !== 404) discoveryFailures.push({
|
|
42193
|
+
url: new URL("/robots.txt", origin).href,
|
|
42194
|
+
error: robots.error ?? `HTTP ${robots.status}`
|
|
42195
|
+
});
|
|
42196
|
+
if (robots.bodyTruncated) discoveryFailures.push({
|
|
42197
|
+
url: robots.requestedUrl,
|
|
42198
|
+
error: "robots.txt body truncated"
|
|
42199
|
+
});
|
|
42200
|
+
const policy = robots.ok && !robots.bodyTruncated ? robotsRules(robots.body ?? "") : {
|
|
42201
|
+
rules: [],
|
|
42202
|
+
sitemaps: []
|
|
42203
|
+
};
|
|
42204
|
+
const permitted = (url) => {
|
|
42205
|
+
if (allowedByRobots(`${url.pathname}${url.search}`, policy.rules)) return true;
|
|
42206
|
+
skipped.push(url.href);
|
|
42207
|
+
return false;
|
|
42208
|
+
};
|
|
42209
|
+
const sitemapUrls = [];
|
|
42210
|
+
const sitemapQueue = (policy.sitemaps.length > 0 ? policy.sitemaps : [new URL("/sitemap.xml", origin).href]).flatMap((entry) => {
|
|
42211
|
+
try {
|
|
42212
|
+
return [new URL(entry, origin)];
|
|
42213
|
+
} catch {
|
|
42214
|
+
return [];
|
|
42215
|
+
}
|
|
42216
|
+
});
|
|
42217
|
+
const seenSitemaps = /* @__PURE__ */ new Set();
|
|
42218
|
+
const seenSitemapPages = /* @__PURE__ */ new Set();
|
|
42219
|
+
while (sitemapQueue.length > 0) {
|
|
42220
|
+
const url = sitemapQueue.shift();
|
|
42221
|
+
if (url.origin !== origin) {
|
|
42222
|
+
discoveryFailures.push({
|
|
42223
|
+
url: url.href,
|
|
42224
|
+
error: "Advertised sitemap is off-origin and was not fetched"
|
|
42225
|
+
});
|
|
42226
|
+
continue;
|
|
42227
|
+
}
|
|
42228
|
+
if (seenSitemaps.has(url.href) || !permitted(url)) continue;
|
|
42229
|
+
if (seenSitemaps.size >= Math.max(1, Math.min(options.limit, 16))) {
|
|
42230
|
+
sitemapTruncated = true;
|
|
42231
|
+
break;
|
|
42232
|
+
}
|
|
42233
|
+
seenSitemaps.add(url.href);
|
|
42234
|
+
const probe = await discoveryProbe(url, "sitemap", permitted);
|
|
42235
|
+
if (!probe.ok) {
|
|
42236
|
+
if (probe.status !== 404 || policy.sitemaps.length > 0) discoveryFailures.push({
|
|
42237
|
+
url: url.href,
|
|
42238
|
+
error: probe.error ?? `HTTP ${probe.status}`
|
|
42239
|
+
});
|
|
42240
|
+
continue;
|
|
42241
|
+
}
|
|
42242
|
+
const body = probe.body ?? "";
|
|
42243
|
+
if (probe.bodyTruncated || !/<(?:urlset|sitemapindex)\b/i.test(body)) {
|
|
42244
|
+
discoveryFailures.push({
|
|
42245
|
+
url: url.href,
|
|
42246
|
+
error: probe.bodyTruncated ? "sitemap body truncated" : "malformed sitemap"
|
|
42247
|
+
});
|
|
42248
|
+
continue;
|
|
42249
|
+
}
|
|
42250
|
+
const index = /<sitemapindex\b/i.test(body);
|
|
42251
|
+
const entries = [...body.matchAll(index ? /<sitemap\b[^>]*>([\s\S]*?)<\/sitemap>/gi : /<url\b[^>]*>([\s\S]*?)<\/url>/gi)];
|
|
42252
|
+
for (const entry of entries) {
|
|
42253
|
+
XML_LOC.lastIndex = 0;
|
|
42254
|
+
const loc = XML_LOC.exec(entry[1] ?? "")?.[1];
|
|
42255
|
+
if (loc === void 0) continue;
|
|
42256
|
+
let target;
|
|
42257
|
+
try {
|
|
42258
|
+
if (/&(?!(?:amp|lt|gt|quot|apos|#x[0-9a-f]+|#[0-9]+);)[^;\s]+;/i.test(loc)) throw new Error("unsupported XML entity");
|
|
42259
|
+
const decoded = xmlText(loc);
|
|
42260
|
+
target = new URL(decoded, url);
|
|
42261
|
+
} catch {
|
|
42262
|
+
discoveryFailures.push({
|
|
42263
|
+
url: url.href,
|
|
42264
|
+
error: `Invalid sitemap <loc>: ${loc.slice(0, 120)}`
|
|
42265
|
+
});
|
|
42266
|
+
continue;
|
|
42267
|
+
}
|
|
42268
|
+
if (index) {
|
|
42269
|
+
sitemapQueue.push(target);
|
|
42270
|
+
continue;
|
|
42271
|
+
}
|
|
42272
|
+
if (target.origin !== origin || !permitted(target)) continue;
|
|
42273
|
+
if (NON_PAGE.test(target.pathname)) continue;
|
|
42274
|
+
const key = pageKey(target);
|
|
42275
|
+
if (seenSitemapPages.has(key)) continue;
|
|
42276
|
+
if (seenSitemapPages.size >= options.limit * 4) {
|
|
42277
|
+
sitemapTruncated = true;
|
|
42278
|
+
break;
|
|
42279
|
+
}
|
|
42280
|
+
seenSitemapPages.add(key);
|
|
42281
|
+
sitemapUrls.push(target);
|
|
42282
|
+
}
|
|
42283
|
+
if (sitemapTruncated) break;
|
|
42284
|
+
}
|
|
42285
|
+
sitemapUsed = sitemapUrls.length > 0;
|
|
42286
|
+
const sitemapListed = new Set(sitemapUrls.map(pageKey));
|
|
42287
|
+
if (!permitted(seedUrl)) frontier = [];
|
|
42288
|
+
const probePage = async (item) => {
|
|
42289
|
+
const request = {
|
|
42290
|
+
kind: "page-html",
|
|
42291
|
+
method: "GET",
|
|
42292
|
+
accept: "text/html, */*;q=0.1",
|
|
42293
|
+
url: item.url
|
|
42294
|
+
};
|
|
42295
|
+
const probe = await probeHttp(request, {
|
|
42296
|
+
...probeOptions,
|
|
42297
|
+
allowUrl: permitted
|
|
42298
|
+
});
|
|
42299
|
+
if (!probe.ok || probe.finalUrl === null) return {
|
|
42300
|
+
item,
|
|
42301
|
+
page: null,
|
|
42302
|
+
failure: {
|
|
42303
|
+
url: item.url.href,
|
|
42304
|
+
error: probe.error ?? `HTTP ${probe.status ?? "no response"}`
|
|
42305
|
+
},
|
|
42306
|
+
links: [],
|
|
42307
|
+
bodyTruncated: false,
|
|
42308
|
+
canonical: null
|
|
42309
|
+
};
|
|
42310
|
+
if (probe.responseHeaders["content-type"] !== void 0 && !/text\/html|application\/xhtml\+xml/i.test(probe.responseHeaders["content-type"])) {
|
|
42311
|
+
const expectedHtml = item.depth === 0 || sitemapListed.has(pageKey(item.url)) || sitemapListed.has(pageKey(new URL(probe.finalUrl)));
|
|
42312
|
+
return {
|
|
42313
|
+
item,
|
|
42314
|
+
page: null,
|
|
42315
|
+
failure: expectedHtml ? {
|
|
42316
|
+
url: item.url.href,
|
|
42317
|
+
error: "Response is not HTML"
|
|
42318
|
+
} : null,
|
|
42319
|
+
...expectedHtml ? {} : { nonHtml: {
|
|
42320
|
+
url: item.url.href,
|
|
42321
|
+
finalUrl: probe.finalUrl,
|
|
42322
|
+
contentType: probe.responseHeaders["content-type"]
|
|
42323
|
+
} },
|
|
42324
|
+
links: [],
|
|
42325
|
+
bodyTruncated: false,
|
|
42326
|
+
canonical: null
|
|
42327
|
+
};
|
|
42328
|
+
}
|
|
42329
|
+
if (new URL(probe.finalUrl).origin !== origin) return {
|
|
42330
|
+
item,
|
|
42331
|
+
page: null,
|
|
42332
|
+
failure: {
|
|
42333
|
+
url: item.url.href,
|
|
42334
|
+
error: `Redirected off-origin to ${probe.finalUrl}`
|
|
42335
|
+
},
|
|
42336
|
+
links: [],
|
|
42337
|
+
bodyTruncated: false,
|
|
42338
|
+
canonical: null
|
|
42339
|
+
};
|
|
42340
|
+
const anchors = probe.anchors ?? [];
|
|
42341
|
+
const links = anchors.flatMap((anchor) => {
|
|
42342
|
+
if (!anchor.internal) return [];
|
|
42343
|
+
let target;
|
|
42344
|
+
try {
|
|
42345
|
+
target = new URL(anchor.href);
|
|
42346
|
+
} catch {
|
|
42347
|
+
return [];
|
|
42348
|
+
}
|
|
42349
|
+
if (target.origin !== origin) return [];
|
|
42350
|
+
if (NON_PAGE.test(target.pathname)) return [];
|
|
42351
|
+
return [target];
|
|
42352
|
+
});
|
|
42353
|
+
return {
|
|
42354
|
+
item,
|
|
42355
|
+
page: {
|
|
42356
|
+
url: probe.finalUrl,
|
|
42357
|
+
anchors
|
|
42358
|
+
},
|
|
42359
|
+
failure: null,
|
|
42360
|
+
links,
|
|
42361
|
+
bodyTruncated: probe.bodyTruncated,
|
|
42362
|
+
canonical: probe.document?.canonicalUrl ?? null
|
|
42363
|
+
};
|
|
42364
|
+
};
|
|
42365
|
+
while (frontier.length > 0) {
|
|
42366
|
+
const remaining = options.limit - attempts;
|
|
42367
|
+
if (remaining <= 0) {
|
|
42368
|
+
truncated = true;
|
|
42369
|
+
break;
|
|
42370
|
+
}
|
|
42371
|
+
const batch = frontier.length > remaining ? frontier.slice(0, remaining) : frontier;
|
|
42372
|
+
if (frontier.length > batch.length) truncated = true;
|
|
42373
|
+
frontier = [];
|
|
42374
|
+
attempts += batch.length;
|
|
42375
|
+
const probed = await mapLimit(batch, concurrency, probePage);
|
|
42376
|
+
const discovered = [];
|
|
42377
|
+
for (const result of probed) {
|
|
42378
|
+
if (result.failure !== null) failures.push(result.failure);
|
|
42379
|
+
if (result.nonHtml !== void 0) nonHtml.push(result.nonHtml);
|
|
42380
|
+
if (result.page === null) continue;
|
|
42381
|
+
const page = result.page;
|
|
42382
|
+
const finalKey = pageKey(new URL(page.url));
|
|
42383
|
+
if (result.canonical !== null) try {
|
|
42384
|
+
const canonical = new URL(result.canonical, page.url);
|
|
42385
|
+
if (canonical.origin === origin && pageKey(canonical) !== finalKey) canonicalByPath.set(finalKey, pageKey(canonical));
|
|
42386
|
+
} catch {}
|
|
42387
|
+
if (result.item.depth === 0) root = finalKey;
|
|
42388
|
+
if (rendered.has(finalKey)) continue;
|
|
42389
|
+
rendered.add(finalKey);
|
|
42390
|
+
visited.add(finalKey);
|
|
42391
|
+
pages.push(page);
|
|
42392
|
+
if (result.bodyTruncated) truncatedBodies.push(page.url);
|
|
42393
|
+
for (const link of result.links) {
|
|
42394
|
+
if (!permitted(link)) continue;
|
|
42395
|
+
const key = pageKey(link);
|
|
42396
|
+
if (visited.has(key)) continue;
|
|
42397
|
+
visited.add(key);
|
|
42398
|
+
discovered.push({
|
|
42399
|
+
url: link,
|
|
42400
|
+
depth: result.item.depth + 1
|
|
42401
|
+
});
|
|
42402
|
+
}
|
|
42403
|
+
}
|
|
42404
|
+
if (attempts === 1) for (const url of sitemapUrls) {
|
|
42405
|
+
const key = pageKey(url);
|
|
42406
|
+
if (visited.has(key) || !permitted(url)) continue;
|
|
42407
|
+
visited.add(key);
|
|
42408
|
+
discovered.push({
|
|
42409
|
+
url,
|
|
42410
|
+
depth: 1
|
|
42411
|
+
});
|
|
42412
|
+
}
|
|
42413
|
+
frontier = discovered;
|
|
42414
|
+
}
|
|
42415
|
+
const renderedByPath = new Map(pages.map((page) => [pageKey(new URL(page.url)), page]));
|
|
42416
|
+
const retainedPages = pages.filter((page) => {
|
|
42417
|
+
const canonical = canonicalByPath.get(pageKey(new URL(page.url)));
|
|
42418
|
+
const canonicalPage = canonical === void 0 ? void 0 : renderedByPath.get(canonical);
|
|
42419
|
+
return canonicalPage === void 0 || JSON.stringify(page.anchors) !== JSON.stringify(canonicalPage.anchors);
|
|
42420
|
+
});
|
|
42421
|
+
const canonicalRoot = canonicalByPath.get(root);
|
|
42422
|
+
if (canonicalRoot !== void 0 && !retainedPages.some((page) => pageKey(new URL(page.url)) === root)) root = canonicalRoot;
|
|
42423
|
+
return {
|
|
42424
|
+
origin,
|
|
42425
|
+
seed: seedUrl.href,
|
|
42426
|
+
root,
|
|
42427
|
+
pages: retainedPages,
|
|
42428
|
+
failures,
|
|
42429
|
+
nonHtml,
|
|
42430
|
+
truncatedBodies,
|
|
42431
|
+
truncated,
|
|
42432
|
+
discoveryFailures,
|
|
42433
|
+
skipped: [...new Set(skipped)],
|
|
42434
|
+
sitemapTruncated,
|
|
42435
|
+
sitemapUsed,
|
|
42436
|
+
limit: options.limit
|
|
42437
|
+
};
|
|
42438
|
+
};
|
|
42439
|
+
//#endregion
|
|
42440
|
+
//#region src/core/link-candidates.ts
|
|
42441
|
+
/**
|
|
42442
|
+
* Canonical path key: query and hash stripped, trailing slashes removed, "/"
|
|
42443
|
+
* preserved. This is the same path key the graph and the rendered-link core
|
|
42444
|
+
* use, so a served anchor like `/blog/a?ref=nav` still matches the graph pair
|
|
42445
|
+
* `/blog/a`.
|
|
42446
|
+
*/
|
|
42447
|
+
const normalize = (path) => {
|
|
42448
|
+
const trimmed = (path.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "");
|
|
42449
|
+
return trimmed === "" ? "/" : trimmed;
|
|
42450
|
+
};
|
|
42451
|
+
const directedEdgeKey = (from, to) => `${normalize(from)}\u0000${normalize(to)}`;
|
|
42452
|
+
/** First path segment, or undefined for the root page. */
|
|
42453
|
+
const topSegment = (path) => {
|
|
42454
|
+
const segment = path.split("/").filter(Boolean)[0];
|
|
42455
|
+
return segment === void 0 || segment === "" ? void 0 : segment;
|
|
42456
|
+
};
|
|
42457
|
+
/** A page with no nested segment (`/`, `/pricing`, `/blog`) is root-level. */
|
|
42458
|
+
const isRootLevel = (path) => path.split("/").filter(Boolean).length <= 1;
|
|
42459
|
+
/**
|
|
42460
|
+
* The cluster a pair shares, or undefined when they share none. Section-first:
|
|
42461
|
+
* a shared top-level section wins. The kind fallback is local to root-level
|
|
42462
|
+
* pages, so it never pairs two nested pages from different sections.
|
|
42463
|
+
*/
|
|
42464
|
+
const candidateClusterOf = (a, b) => {
|
|
42465
|
+
const aSegment = topSegment(a.path);
|
|
42466
|
+
const bSegment = topSegment(b.path);
|
|
42467
|
+
if (aSegment !== void 0 && aSegment === bSegment) return {
|
|
42468
|
+
key: aSegment,
|
|
42469
|
+
reason: `same top-level section "/${aSegment}"`
|
|
42470
|
+
};
|
|
42471
|
+
if (isRootLevel(a.path) && isRootLevel(b.path) && a.kind === b.kind) return {
|
|
42472
|
+
key: `kind:${a.kind}`,
|
|
42473
|
+
reason: `same kind "${a.kind}"`
|
|
42474
|
+
};
|
|
42475
|
+
};
|
|
42476
|
+
/** A `--cluster` filter matches a section by name, or a kind via its bare label. */
|
|
42477
|
+
const matchesClusterFilter = (cluster, filter) => {
|
|
42478
|
+
const normalized = filter.replace(/^\/+/, "").toLowerCase();
|
|
42479
|
+
const key = cluster.toLowerCase();
|
|
42480
|
+
return key === normalized || key === `kind:${normalized}`;
|
|
42481
|
+
};
|
|
42482
|
+
/**
|
|
42483
|
+
* Enumerate reviewable contextual-link candidates from the declared graph.
|
|
42484
|
+
*
|
|
42485
|
+
* Excluded: self-pairs, pages that are not sitemap-eligible, pairs already
|
|
42486
|
+
* declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}
|
|
42487
|
+
* is supplied — pairs already rendered as an anchor. Queries, hashes, and
|
|
42488
|
+
* trailing slashes are normalized so both sides compare by the graph's path key.
|
|
42489
|
+
*/
|
|
42490
|
+
const generateLinkCandidates = (graph, options = {}) => {
|
|
42491
|
+
const limit = options.limit ?? 50;
|
|
42492
|
+
const filters = options.clusters ?? [];
|
|
42493
|
+
const rendered = new Set((options.renderedEdges ?? []).filter((edge) => edge.region === void 0 || edge.region === "body").map((edge) => directedEdgeKey(edge.from, edge.to)));
|
|
42494
|
+
const declared = new Set(graph.edges.filter((edge) => edge.type === "related").map((edge) => directedEdgeKey(edge.from, edge.to)));
|
|
42495
|
+
const eligible = [...graph.nodes.values()].filter(isSitemapEligible);
|
|
42496
|
+
const all = [];
|
|
42497
|
+
for (let i = 0; i < eligible.length; i++) for (let j = i + 1; j < eligible.length; j++) {
|
|
42498
|
+
const a = eligible[i];
|
|
42499
|
+
const b = eligible[j];
|
|
42500
|
+
const cluster = candidateClusterOf(a, b);
|
|
42501
|
+
if (cluster === void 0) continue;
|
|
42502
|
+
if (filters.length > 0 && !filters.some((filter) => matchesClusterFilter(cluster.key, filter))) continue;
|
|
42503
|
+
for (const [source, destination] of [[a.path, b.path], [b.path, a.path]]) {
|
|
42504
|
+
const key = directedEdgeKey(source, destination);
|
|
42505
|
+
if (declared.has(key) || rendered.has(key)) continue;
|
|
42506
|
+
all.push({
|
|
42507
|
+
source,
|
|
42508
|
+
destination,
|
|
42509
|
+
cluster: cluster.key,
|
|
42510
|
+
reason: cluster.reason
|
|
42511
|
+
});
|
|
42512
|
+
}
|
|
42513
|
+
}
|
|
42514
|
+
const compare = (x, y) => x.cluster < y.cluster ? -1 : x.cluster > y.cluster ? 1 : x.source < y.source ? -1 : x.source > y.source ? 1 : x.destination < y.destination ? -1 : x.destination > y.destination ? 1 : 0;
|
|
42515
|
+
all.sort(compare);
|
|
42516
|
+
const counts = /* @__PURE__ */ new Map();
|
|
42517
|
+
for (const pair of all) counts.set(pair.cluster, (counts.get(pair.cluster) ?? 0) + 1);
|
|
42518
|
+
const candidates = all.slice(0, limit);
|
|
42519
|
+
return {
|
|
42520
|
+
candidates,
|
|
42521
|
+
total: all.length,
|
|
42522
|
+
truncated: all.length > candidates.length,
|
|
42523
|
+
clusters: [...counts.entries()].map(([key, count]) => ({
|
|
42524
|
+
key,
|
|
42525
|
+
candidates: count
|
|
42526
|
+
}))
|
|
42527
|
+
};
|
|
42528
|
+
};
|
|
42529
|
+
/**
|
|
42530
|
+
* Decode a rendered-edge dump supplied to the CLI: either a bare array of
|
|
42531
|
+
* `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).
|
|
42532
|
+
* Throws on any other shape — this is user input, not a provider payload.
|
|
42533
|
+
*/
|
|
42534
|
+
const decodeRenderedEdges = (input) => {
|
|
42535
|
+
const array = Array.isArray(input) ? input : input !== null && typeof input === "object" && Array.isArray(input.edges) ? input.edges : input !== null && typeof input === "object" && Array.isArray(input.internalEdges) ? input.internalEdges : void 0;
|
|
42536
|
+
if (array === void 0) throw new Error("expected an array of { from, to } edges, or an object with an `edges` array");
|
|
42537
|
+
return array.map((item) => {
|
|
42538
|
+
if (item === null || typeof item !== "object") throw new Error("each rendered edge must be an object with string `from` and `to`");
|
|
42539
|
+
const { from, to, region } = item;
|
|
42540
|
+
if (typeof from !== "string" || typeof to !== "string") throw new Error("each rendered edge must have string `from` and `to`");
|
|
42541
|
+
if (region !== void 0 && ![
|
|
42542
|
+
"nav",
|
|
42543
|
+
"header",
|
|
42544
|
+
"footer",
|
|
42545
|
+
"body"
|
|
42546
|
+
].includes(region)) throw new Error("rendered edge region must be nav, header, footer, or body");
|
|
42547
|
+
return region === void 0 ? {
|
|
42548
|
+
from,
|
|
42549
|
+
to
|
|
42550
|
+
} : {
|
|
42551
|
+
from,
|
|
42552
|
+
to,
|
|
42553
|
+
region
|
|
42554
|
+
};
|
|
42555
|
+
});
|
|
42556
|
+
};
|
|
42557
|
+
//#endregion
|
|
42558
|
+
//#region src/core/link-suggestions.ts
|
|
42559
|
+
/** Select pages around low-inbound directions without materializing the full pair universe. */
|
|
42560
|
+
const selectSuggestionPages = (graph, options) => {
|
|
42561
|
+
const eligible = [...graph.nodes.values()].filter(isSitemapEligible).sort((a, b) => a.path.localeCompare(b.path));
|
|
42562
|
+
const byPath = new Map(eligible.map((node) => [node.path, node]));
|
|
42563
|
+
for (const path of [...options.targets, ...options.sources]) if (!byPath.has(path)) throw new Error(`Requested page is not a sitemap-eligible graph path: ${path}`);
|
|
42564
|
+
const selected = /* @__PURE__ */ new Set([...options.targets, ...options.sources]);
|
|
42565
|
+
if (selected.size > options.pageLimit || options.targets.size > 0 && options.sources.size === 0 && options.targets.size >= options.pageLimit) throw new Error("--page-limit must leave room for a candidate source beyond requested targets");
|
|
42566
|
+
const normalized = (path) => path.split(/[?#]/, 1)[0].replace(/\/+$/, "") || "/";
|
|
42567
|
+
const key = (source, destination) => `${normalized(source)}\u0000${normalized(destination)}`;
|
|
42568
|
+
const excluded = /* @__PURE__ */ new Set([...graph.edges.filter((edge) => edge.type === "related").map((edge) => key(edge.from, edge.to)), ...options.renderedEdges.filter((edge) => edge.region === void 0 || edge.region === "body").map((edge) => key(edge.from, edge.to))]);
|
|
42569
|
+
const inbound = /* @__PURE__ */ new Map();
|
|
42570
|
+
for (const edge of graph.edges) if (edge.type === "related") inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1);
|
|
42571
|
+
const sections = /* @__PURE__ */ new Map();
|
|
42572
|
+
const rootKinds = /* @__PURE__ */ new Map();
|
|
42573
|
+
for (const node of eligible) {
|
|
42574
|
+
const segments = node.path.split("/").filter(Boolean);
|
|
42575
|
+
const section = segments[0];
|
|
42576
|
+
if (section) {
|
|
42577
|
+
if (!sections.has(section)) sections.set(section, []);
|
|
42578
|
+
sections.get(section).push(node);
|
|
42579
|
+
}
|
|
42580
|
+
if (segments.length <= 1) {
|
|
42581
|
+
if (!rootKinds.has(node.kind)) rootKinds.set(node.kind, []);
|
|
42582
|
+
rootKinds.get(node.kind).push(node);
|
|
42583
|
+
}
|
|
42584
|
+
}
|
|
42585
|
+
const requestedSources = eligible.filter((node) => options.sources.has(node.path));
|
|
42586
|
+
const requestedSections = /* @__PURE__ */ new Map();
|
|
42587
|
+
const requestedRootKinds = /* @__PURE__ */ new Map();
|
|
42588
|
+
const relevant = /* @__PURE__ */ new Map();
|
|
42589
|
+
for (const source of requestedSources) {
|
|
42590
|
+
const segments = source.path.split("/").filter(Boolean);
|
|
42591
|
+
const section = segments[0];
|
|
42592
|
+
if (section) {
|
|
42593
|
+
if (!requestedSections.has(section)) requestedSections.set(section, []);
|
|
42594
|
+
requestedSections.get(section).push(source);
|
|
42595
|
+
for (const node of sections.get(section) ?? []) relevant.set(node.path, node);
|
|
42596
|
+
}
|
|
42597
|
+
if (segments.length <= 1) {
|
|
42598
|
+
if (!requestedRootKinds.has(source.kind)) requestedRootKinds.set(source.kind, []);
|
|
42599
|
+
requestedRootKinds.get(source.kind).push(source);
|
|
42600
|
+
for (const node of rootKinds.get(source.kind) ?? []) relevant.set(node.path, node);
|
|
42601
|
+
}
|
|
42602
|
+
}
|
|
42603
|
+
const relevantDestinations = options.sources.size === 0 ? eligible : [...relevant.values()];
|
|
42604
|
+
const destinations = (options.targets.size === 0 ? relevantDestinations : relevantDestinations.filter((node) => options.targets.has(node.path))).sort((a, b) => (inbound.get(a.path) ?? 0) - (inbound.get(b.path) ?? 0) || a.path.localeCompare(b.path));
|
|
42605
|
+
const directionBudget = Math.max(100, options.pageLimit * 100);
|
|
42606
|
+
let examined = 0;
|
|
42607
|
+
for (const destination of destinations) {
|
|
42608
|
+
if (selected.size >= options.pageLimit || examined >= directionBudget) break;
|
|
42609
|
+
const segments = destination.path.split("/").filter(Boolean);
|
|
42610
|
+
const section = segments[0];
|
|
42611
|
+
const sources = [...new Map([...section ? (options.sources.size > 0 ? requestedSections : sections).get(section) ?? [] : [], ...segments.length <= 1 ? (options.sources.size > 0 ? requestedRootKinds : rootKinds).get(destination.kind) ?? [] : []].map((node) => [node.path, node])).values()];
|
|
42612
|
+
for (const source of sources) {
|
|
42613
|
+
if (source.path === destination.path) continue;
|
|
42614
|
+
if (++examined > directionBudget) break;
|
|
42615
|
+
const cluster = candidateClusterOf(source, destination);
|
|
42616
|
+
if (!cluster || options.clusters.length > 0 && !options.clusters.some((filter) => matchesClusterFilter(cluster.key, filter)) || excluded.has(key(source.path, destination.path))) continue;
|
|
42617
|
+
const needed = Number(!selected.has(source.path)) + Number(!selected.has(destination.path));
|
|
42618
|
+
if (selected.size + needed > options.pageLimit) continue;
|
|
42619
|
+
selected.add(source.path);
|
|
42620
|
+
selected.add(destination.path);
|
|
42621
|
+
if (selected.size >= options.pageLimit) break;
|
|
42622
|
+
}
|
|
42623
|
+
}
|
|
42624
|
+
return eligible.filter((node) => selected.has(node.path));
|
|
42625
|
+
};
|
|
42626
|
+
const decode = (text) => text.replace(/ | /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, "\"").replace(/'|'/gi, "'").replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
|
|
42627
|
+
/** Restrict placements to visible main/article copy, ignoring site chrome and scripts. */
|
|
42628
|
+
const extractPageSentences = (html) => {
|
|
42629
|
+
const visibleHtml = html.replace(/<(script|style|noscript|svg|nav|header|footer|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
|
|
42630
|
+
const containerHtml = visibleHtml.replace(/<(pre|code|table|dl)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
|
|
42631
|
+
const container = /<main\b/i.test(containerHtml) ? "main" : "article";
|
|
42632
|
+
if (!new RegExp(`<${container}\\b`, "i").test(containerHtml)) return [];
|
|
42633
|
+
const suppressedTags = /* @__PURE__ */ new Set([
|
|
42634
|
+
"script",
|
|
42635
|
+
"style",
|
|
42636
|
+
"noscript",
|
|
42637
|
+
"svg",
|
|
42638
|
+
"nav",
|
|
42639
|
+
"header",
|
|
42640
|
+
"footer",
|
|
42641
|
+
"template",
|
|
42642
|
+
"a",
|
|
42643
|
+
"pre",
|
|
42644
|
+
"code",
|
|
42645
|
+
"table",
|
|
42646
|
+
"dl",
|
|
42647
|
+
"kbd",
|
|
42648
|
+
"samp"
|
|
42649
|
+
]);
|
|
42650
|
+
const blockTags = /* @__PURE__ */ new Set([
|
|
42651
|
+
"p",
|
|
42652
|
+
"li",
|
|
42653
|
+
"blockquote",
|
|
42654
|
+
"section",
|
|
42655
|
+
"div",
|
|
42656
|
+
"h1",
|
|
42657
|
+
"h2",
|
|
42658
|
+
"h3",
|
|
42659
|
+
"h4",
|
|
42660
|
+
"h5",
|
|
42661
|
+
"h6"
|
|
42662
|
+
]);
|
|
42663
|
+
const voidTags = /* @__PURE__ */ new Set([
|
|
42664
|
+
"area",
|
|
42665
|
+
"base",
|
|
42666
|
+
"br",
|
|
42667
|
+
"col",
|
|
42668
|
+
"embed",
|
|
42669
|
+
"hr",
|
|
42670
|
+
"img",
|
|
42671
|
+
"input",
|
|
42672
|
+
"link",
|
|
42673
|
+
"meta",
|
|
42674
|
+
"param",
|
|
42675
|
+
"source",
|
|
42676
|
+
"track",
|
|
42677
|
+
"wbr"
|
|
42678
|
+
]);
|
|
42679
|
+
const stack = [];
|
|
42680
|
+
const blocks = [];
|
|
42681
|
+
let current = "";
|
|
42682
|
+
let inside = 0;
|
|
42683
|
+
const flush = () => {
|
|
42684
|
+
if (current.trim()) blocks.push(current);
|
|
42685
|
+
current = "";
|
|
42686
|
+
};
|
|
42687
|
+
for (const token of visibleHtml.match(/<[^>]*>|[^<]+/g) ?? []) {
|
|
42688
|
+
if (!token.startsWith("<")) {
|
|
42689
|
+
if (inside > 0 && !stack.some((entry) => entry.suppressed)) current += token;
|
|
42690
|
+
continue;
|
|
42691
|
+
}
|
|
42692
|
+
const tag = /^<\s*(\/?)\s*([a-z][\w:-]*)\b([^>]*)>/i.exec(token);
|
|
42693
|
+
if (!tag) continue;
|
|
42694
|
+
const name = tag[2].toLowerCase();
|
|
42695
|
+
if (tag[1] === "/") {
|
|
42696
|
+
if (name === container && inside > 0) inside--;
|
|
42697
|
+
const at = stack.findLastIndex((entry) => entry.name === name);
|
|
42698
|
+
if (blockTags.has(name) || suppressedTags.has(name) || name === container || at >= 0 && stack[at].suppressed) flush();
|
|
42699
|
+
if (at >= 0) stack.splice(at);
|
|
42700
|
+
continue;
|
|
42701
|
+
}
|
|
42702
|
+
const attrs = tag[3] ?? "";
|
|
42703
|
+
const suppressed = /(?:^|\s)hidden(?:\s|=|$)/i.test(attrs) || /\baria-hidden\s*=\s*(?:"true"|'true'|true)(?:\s|$)/i.test(attrs) || /\bstyle\s*=\s*(?:"[^"]*(?:display\s*:\s*none|visibility\s*:\s*hidden)[^"]*"|'[^']*(?:display\s*:\s*none|visibility\s*:\s*hidden)[^']*')/i.test(attrs) || suppressedTags.has(name) || /\brole\s*=\s*(?:"(?:table|grid)"|'(?:table|grid)'|(?:table|grid))(?:\s|$)/i.test(attrs);
|
|
42704
|
+
if (name === container) {
|
|
42705
|
+
flush();
|
|
42706
|
+
inside++;
|
|
42707
|
+
}
|
|
42708
|
+
if (suppressed || blockTags.has(name)) flush();
|
|
42709
|
+
if (!voidTags.has(name) && !/\/\s*>$/.test(token)) stack.push({
|
|
42710
|
+
name,
|
|
42711
|
+
suppressed
|
|
42712
|
+
});
|
|
42713
|
+
}
|
|
42714
|
+
flush();
|
|
42715
|
+
return blocks.flatMap((block) => decode(block).replace(/\s+/g, " ").split(/(?<=[.!?])\s+/)).map((part) => part.trim()).filter((part) => part.length >= 30 && part.length <= 500 && !/;\s*(?:or|and)?$/i.test(part));
|
|
42716
|
+
};
|
|
42717
|
+
const stop = new Set("a an as at about after again also and are but by can for from had has have in into is it more most of on or our over that the their them there these this those through to was we were with your you what when where which while will would".split(" "));
|
|
42718
|
+
const words = (value) => (value.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? []).filter((word) => !stop.has(word));
|
|
42719
|
+
const clauseBreaks = /* @__PURE__ */ new Set([
|
|
42720
|
+
"unless",
|
|
42721
|
+
"because",
|
|
42722
|
+
"although",
|
|
42723
|
+
"whereas",
|
|
42724
|
+
"whether",
|
|
42725
|
+
"until",
|
|
42726
|
+
"however",
|
|
42727
|
+
"therefore",
|
|
42728
|
+
"otherwise"
|
|
42729
|
+
]);
|
|
42730
|
+
const topicConnectors = /* @__PURE__ */ new Set([
|
|
42731
|
+
"a",
|
|
42732
|
+
"an",
|
|
42733
|
+
"and",
|
|
42734
|
+
"the"
|
|
42735
|
+
]);
|
|
42736
|
+
const normalizeHyphens = (value) => value.replace(/[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/g, "-");
|
|
42737
|
+
const anchorPhrases = (sentence) => {
|
|
42738
|
+
const tokens = [...sentence.matchAll(/[A-Za-z][A-Za-z0-9\u2010-\u2015\u2212\uFE58\uFE63\uFF0D-]*/g)].map((match) => ({
|
|
42739
|
+
text: match[0],
|
|
42740
|
+
start: match.index,
|
|
42741
|
+
end: match.index + match[0].length
|
|
42742
|
+
}));
|
|
42743
|
+
const phrases = [];
|
|
42744
|
+
for (let start = 0; start < tokens.length; start++) {
|
|
42745
|
+
if (stop.has(tokens[start].text.toLowerCase()) || clauseBreaks.has(tokens[start].text.toLowerCase())) continue;
|
|
42746
|
+
if (new Set(words(tokens[start].text)).size >= 2) phrases.push(tokens[start].text);
|
|
42747
|
+
for (let end = start + 1; end < Math.min(tokens.length, start + 5); end++) {
|
|
42748
|
+
if (!/^\s+$/.test(sentence.slice(tokens[end - 1].end, tokens[end].start))) break;
|
|
42749
|
+
if (clauseBreaks.has(tokens[end].text.toLowerCase())) break;
|
|
42750
|
+
if (stop.has(tokens[end].text.toLowerCase())) continue;
|
|
42751
|
+
const phrase = sentence.slice(tokens[start].start, tokens[end].end);
|
|
42752
|
+
if (new Set(words(phrase)).size >= 2) phrases.push(phrase);
|
|
42753
|
+
}
|
|
42754
|
+
}
|
|
42755
|
+
return phrases;
|
|
42756
|
+
};
|
|
42757
|
+
const indexTargetPage = (page) => {
|
|
42758
|
+
const passages = page.sentences.map((sentence) => ({
|
|
42759
|
+
sentence,
|
|
42760
|
+
normalized: normalizeHyphens(sentence.toLowerCase()).replace(/\s+/g, " ").trim(),
|
|
42761
|
+
terms: new Set(words(sentence))
|
|
42762
|
+
}));
|
|
42763
|
+
const byTerm = /* @__PURE__ */ new Map();
|
|
42764
|
+
passages.forEach((passage, index) => {
|
|
42765
|
+
for (const term of passage.terms) {
|
|
42766
|
+
if (!byTerm.has(term)) byTerm.set(term, []);
|
|
42767
|
+
byTerm.get(term).push(index);
|
|
42768
|
+
}
|
|
42769
|
+
});
|
|
42770
|
+
return {
|
|
42771
|
+
passages,
|
|
42772
|
+
byTerm,
|
|
42773
|
+
terms: new Set(passages.flatMap((passage) => [...passage.terms]))
|
|
42774
|
+
};
|
|
42775
|
+
};
|
|
42776
|
+
/** Deterministic, content-grounded ranking; no placement is emitted without shared terms. */
|
|
42777
|
+
const rankLinkSuggestions = (pairs, pages, inbound, limit) => {
|
|
42778
|
+
const byPath = new Map(pages.map((page) => [page.path, page]));
|
|
42779
|
+
const frequency = /* @__PURE__ */ new Map();
|
|
42780
|
+
for (const page of pages) for (const term of new Set(page.sentences.flatMap(words))) frequency.set(term, (frequency.get(term) ?? 0) + 1);
|
|
42781
|
+
const targetIndexes = /* @__PURE__ */ new Map();
|
|
42782
|
+
const scored = [];
|
|
42783
|
+
for (const pair of pairs) {
|
|
42784
|
+
const source = byPath.get(pair.source);
|
|
42785
|
+
const target = byPath.get(pair.destination);
|
|
42786
|
+
if (!source || !target || pair.destination === "/") continue;
|
|
42787
|
+
const destinationSegments = pair.destination.split("/").filter(Boolean);
|
|
42788
|
+
const destinationSlug = destinationSegments.at(-1) ?? "";
|
|
42789
|
+
const destinationTerms = words(destinationSlug.replace(/[-_]/g, " "));
|
|
42790
|
+
if (destinationSegments.length === 1 && destinationTerms.length === 0) continue;
|
|
42791
|
+
let targetIndex = targetIndexes.get(pair.destination);
|
|
42792
|
+
if (!targetIndex) {
|
|
42793
|
+
targetIndex = indexTargetPage(target);
|
|
42794
|
+
targetIndexes.set(pair.destination, targetIndex);
|
|
42795
|
+
}
|
|
42796
|
+
const namedSection = destinationSegments.length >= 2 && ["compare", "legal"].includes(destinationSegments[0]);
|
|
42797
|
+
const namedDestination = destinationTerms.filter((term) => destinationSegments.length === 1 || namedSection || term.length > 3 && targetIndex.terms.has(term));
|
|
42798
|
+
let best;
|
|
42799
|
+
for (const sentence of source.sentences) {
|
|
42800
|
+
if ([...new Set(words(sentence))].filter((term) => targetIndex.terms.has(term)).length < 2) continue;
|
|
42801
|
+
const normalizedSource = normalizeHyphens(sentence.toLowerCase()).replace(/\s+/g, " ").trim();
|
|
42802
|
+
for (const anchor of anchorPhrases(sentence)) {
|
|
42803
|
+
const anchorTerms = [...new Set(words(anchor))];
|
|
42804
|
+
const passageIndex = (anchorTerms.map((term) => targetIndex.byTerm.get(term) ?? []).sort((a, b) => a.length - b.length)[0] ?? []).find((index) => anchorTerms.every((term) => targetIndex.passages[index].terms.has(term)));
|
|
42805
|
+
if (passageIndex === void 0) continue;
|
|
42806
|
+
const passage = targetIndex.passages[passageIndex];
|
|
42807
|
+
if (namedDestination.length > 0 && !anchorTerms.some((term) => namedDestination.includes(term))) {
|
|
42808
|
+
if (destinationSegments.length === 1 || namedSection) continue;
|
|
42809
|
+
const targetWords = (passage.sentence.toLowerCase().match(/[a-z][a-z0-9]*/g) ?? []).filter((term) => !topicConnectors.has(term));
|
|
42810
|
+
if (!targetWords.some((term, index) => {
|
|
42811
|
+
if (!namedDestination.includes(term)) return false;
|
|
42812
|
+
return [[index - 2, index - 1], [index + 1, index + 2]].some(([first, second]) => anchorTerms.includes(targetWords[first] ?? "") && anchorTerms.includes(targetWords[second] ?? ""));
|
|
42813
|
+
})) continue;
|
|
42814
|
+
}
|
|
42815
|
+
if (!(normalizeHyphens(anchor).match(/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g) ?? []).every((compound) => passage.normalized.includes(compound.toLowerCase()))) continue;
|
|
42816
|
+
if (normalizedSource === passage.normalized) continue;
|
|
42817
|
+
const topical = anchorTerms.length;
|
|
42818
|
+
const rarity = Math.round(anchorTerms.reduce((sum, term) => sum + 1 / (frequency.get(term) ?? 1), 0) * 100) / 100;
|
|
42819
|
+
const inboundNeed = Math.round(100 / (1 + (inbound.get(pair.destination) ?? 0))) / 100;
|
|
42820
|
+
const graph = pair.cluster.startsWith("kind:") ? .5 : 1;
|
|
42821
|
+
const scores = {
|
|
42822
|
+
topical,
|
|
42823
|
+
rarity,
|
|
42824
|
+
inboundNeed,
|
|
42825
|
+
graph
|
|
42826
|
+
};
|
|
42827
|
+
const score = Math.round((topical + rarity + inboundNeed + graph) * 100) / 100;
|
|
42828
|
+
const item = {
|
|
42829
|
+
...pair,
|
|
42830
|
+
sentence,
|
|
42831
|
+
anchor,
|
|
42832
|
+
targetSentence: passage.sentence,
|
|
42833
|
+
score,
|
|
42834
|
+
scores,
|
|
42835
|
+
reason: `${pair.reason}; ${anchorTerms.join(", ")} connect the served passages`
|
|
42836
|
+
};
|
|
42837
|
+
if (!best || item.score > best.score || item.score === best.score && (item.anchor.length < best.anchor.length || item.anchor.length === best.anchor.length && (item.sentence < best.sentence || item.sentence === best.sentence && item.anchor < best.anchor))) best = item;
|
|
42838
|
+
}
|
|
42839
|
+
}
|
|
42840
|
+
if (best) scored.push(best);
|
|
42841
|
+
}
|
|
42842
|
+
scored.sort((a, b) => b.score - a.score || a.source.localeCompare(b.source) || a.destination.localeCompare(b.destination));
|
|
42843
|
+
return {
|
|
42844
|
+
candidates: scored.slice(0, limit),
|
|
42845
|
+
total: scored.length
|
|
42846
|
+
};
|
|
42847
|
+
};
|
|
42848
|
+
const pathValid = (value) => typeof value === "string" && /^\/(?!\/)[^?#]*$/.test(value);
|
|
42849
|
+
const decodeLinksSuggestionReport = (value, origin) => {
|
|
42850
|
+
if (!value || typeof value !== "object") throw new Error("Suggestion report must be an object");
|
|
42851
|
+
const report = value;
|
|
42852
|
+
if (report.kind !== "links-candidates" || report.schemaVersion !== 2 || report.origin !== origin || !Array.isArray(report.candidates)) throw new Error(`Expected schema-version-2 suggestions for ${origin}`);
|
|
42853
|
+
if (!Number.isSafeInteger(report.limit) || report.limit < 1 || !Number.isSafeInteger(report.pageLimit) || report.pageLimit < 1 || report.maxBodyBytes !== void 0 && (!Number.isSafeInteger(report.maxBodyBytes) || report.maxBodyBytes < 1 || report.maxBodyBytes > 1e7) || !Number.isSafeInteger(report.total) || report.total < report.candidates.length || typeof report.truncated !== "boolean" || !Array.isArray(report.skipped)) throw new Error("Invalid suggestion report counts");
|
|
42854
|
+
const seen = /* @__PURE__ */ new Set();
|
|
42855
|
+
for (const candidate of report.candidates) {
|
|
42856
|
+
if (!pathValid(candidate.source) || !pathValid(candidate.destination) || candidate.source === candidate.destination || typeof candidate.cluster !== "string" || typeof candidate.sentence !== "string" || !candidate.sentence.trim() || typeof candidate.anchor !== "string" || !candidate.anchor.trim() || !candidate.sentence.includes(candidate.anchor) || typeof candidate.targetSentence !== "string" || !candidate.targetSentence.trim() || typeof candidate.reason !== "string" || typeof candidate.score !== "number" || !Number.isFinite(candidate.score) || !candidate.scores || [
|
|
42857
|
+
candidate.scores.topical,
|
|
42858
|
+
candidate.scores.rarity,
|
|
42859
|
+
candidate.scores.inboundNeed,
|
|
42860
|
+
candidate.scores.graph
|
|
42861
|
+
].some((part) => typeof part !== "number" || !Number.isFinite(part))) throw new Error("Malformed or ungrounded suggestion candidate");
|
|
42862
|
+
const key = `${candidate.source}\u0000${candidate.destination}\u0000${candidate.anchor}`;
|
|
42863
|
+
if (seen.has(key)) throw new Error("Duplicate suggestion candidate");
|
|
42864
|
+
seen.add(key);
|
|
42865
|
+
}
|
|
42866
|
+
return {
|
|
42867
|
+
...report,
|
|
42868
|
+
maxBodyBytes: report.maxBodyBytes ?? 1e7
|
|
42869
|
+
};
|
|
42870
|
+
};
|
|
42871
|
+
//#endregion
|
|
42014
42872
|
//#region src/workflows/prompts/summary.md
|
|
42015
42873
|
var summary_default = "# PageGraph {{workflow}}\n\n- Run: `{{runId}}`\n- Model: `{{model}}`\n- Pages: {{pages}}\n- Decisions: {{decisions}} ({{review}} review)\n- Executor searches: {{executorSearches}}\n- Executor calls: {{executorCalls}}\n- Changed files: {{changedFiles}}\n\n{{summary}}\n";
|
|
42016
42874
|
//#endregion
|
|
@@ -42084,6 +42942,20 @@ const writeRunBundle = (root, runsDirectory, run) => {
|
|
|
42084
42942
|
writeFileSync(resolve(directory, "summary.md"), summary(run), "utf8");
|
|
42085
42943
|
return directory;
|
|
42086
42944
|
};
|
|
42945
|
+
const writeResearchCheckpoint = (root, runsDirectory, checkpoint) => {
|
|
42946
|
+
const directory = resolve(root, runsDirectory, checkpoint.id);
|
|
42947
|
+
mkdirSync(directory, { recursive: true });
|
|
42948
|
+
const path = resolve(directory, "research.json");
|
|
42949
|
+
writeFileSync(path, `${JSON.stringify(checkpoint, null, 2)}\n`, "utf8");
|
|
42950
|
+
return path;
|
|
42951
|
+
};
|
|
42952
|
+
const writeResearchFailure = (root, runsDirectory, id, details) => {
|
|
42953
|
+
const directory = resolve(root, runsDirectory, id);
|
|
42954
|
+
mkdirSync(directory, { recursive: true });
|
|
42955
|
+
const path = resolve(directory, "research-failure.json");
|
|
42956
|
+
writeFileSync(path, `${JSON.stringify(details, null, 2)}\n`, "utf8");
|
|
42957
|
+
return path;
|
|
42958
|
+
};
|
|
42087
42959
|
//#endregion
|
|
42088
42960
|
//#region node_modules/effect/dist/unstable/ai/Decision.js
|
|
42089
42961
|
/**
|
|
@@ -43281,6 +44153,30 @@ const selectGraph = (graph, options) => {
|
|
|
43281
44153
|
collisions: graph.collisions
|
|
43282
44154
|
};
|
|
43283
44155
|
};
|
|
44156
|
+
const selectNeighborhood = (graph, targetPaths) => {
|
|
44157
|
+
const inbound = [];
|
|
44158
|
+
const outbound = [];
|
|
44159
|
+
for (const edge of graph.edges) {
|
|
44160
|
+
if (targetPaths.has(edge.to) && !targetPaths.has(edge.from)) {
|
|
44161
|
+
const node = graph.nodes.get(edge.from);
|
|
44162
|
+
if (node !== void 0) inbound.push({
|
|
44163
|
+
edge,
|
|
44164
|
+
node: serializeNode(node)
|
|
44165
|
+
});
|
|
44166
|
+
}
|
|
44167
|
+
if (targetPaths.has(edge.from) && !targetPaths.has(edge.to)) {
|
|
44168
|
+
const node = graph.nodes.get(edge.to);
|
|
44169
|
+
if (node !== void 0) outbound.push({
|
|
44170
|
+
edge,
|
|
44171
|
+
node: serializeNode(node)
|
|
44172
|
+
});
|
|
44173
|
+
}
|
|
44174
|
+
}
|
|
44175
|
+
return {
|
|
44176
|
+
inbound,
|
|
44177
|
+
outbound
|
|
44178
|
+
};
|
|
44179
|
+
};
|
|
43284
44180
|
const collectContextFiles = (root, workflow, context) => {
|
|
43285
44181
|
const paths = [...context?.files ?? [], ...context?.byWorkflow?.[workflow] ?? []];
|
|
43286
44182
|
return [...new Set(paths)].map((path) => ({
|
|
@@ -43288,14 +44184,18 @@ const collectContextFiles = (root, workflow, context) => {
|
|
|
43288
44184
|
content: readFileSync(resolve(root, path), "utf8")
|
|
43289
44185
|
}));
|
|
43290
44186
|
};
|
|
43291
|
-
const collectWorkflowEvidence = (graph, options, root, workflow, context) =>
|
|
43292
|
-
|
|
43293
|
-
|
|
43294
|
-
|
|
44187
|
+
const collectWorkflowEvidence = (graph, options, root, workflow, context) => {
|
|
44188
|
+
const selected = selectGraph(graph, options);
|
|
44189
|
+
return {
|
|
44190
|
+
graph: serializeGraph(selected),
|
|
44191
|
+
neighborhood: selectNeighborhood(graph, new Set(selected.nodes.keys())),
|
|
44192
|
+
sources: collectContextFiles(root, workflow, context)
|
|
44193
|
+
};
|
|
44194
|
+
};
|
|
43295
44195
|
//#endregion
|
|
43296
44196
|
//#region src/workflows/git.ts
|
|
43297
|
-
const run$1 = (
|
|
43298
|
-
cwd
|
|
44197
|
+
const run$1 = (cwd, args) => execFileSync("git", args, {
|
|
44198
|
+
cwd,
|
|
43299
44199
|
encoding: "utf8",
|
|
43300
44200
|
stdio: [
|
|
43301
44201
|
"ignore",
|
|
@@ -43304,38 +44204,36 @@ const run$1 = (root, args) => execFileSync("git", args, {
|
|
|
43304
44204
|
]
|
|
43305
44205
|
}).trim();
|
|
43306
44206
|
const nul = (value) => value.split("\0").filter((item) => item.length > 0);
|
|
43307
|
-
const fingerprint = (root, path) => {
|
|
44207
|
+
const fingerprint = (root, gitRoot, path) => {
|
|
43308
44208
|
const absolute = resolve(root, path);
|
|
43309
|
-
const
|
|
43310
|
-
const status = run$1(
|
|
44209
|
+
const gitPath = relative(gitRoot, absolute);
|
|
44210
|
+
const status = run$1(gitRoot, [
|
|
43311
44211
|
"status",
|
|
43312
44212
|
"--short",
|
|
43313
44213
|
"--",
|
|
43314
|
-
|
|
44214
|
+
gitPath
|
|
43315
44215
|
]);
|
|
44216
|
+
let content;
|
|
44217
|
+
if (!existsSync(absolute)) content = Buffer.from("<deleted>");
|
|
44218
|
+
else if (statSync(absolute).isDirectory()) {
|
|
44219
|
+
const stage = run$1(gitRoot, [
|
|
44220
|
+
"ls-files",
|
|
44221
|
+
"--stage",
|
|
44222
|
+
"--",
|
|
44223
|
+
gitPath
|
|
44224
|
+
]);
|
|
44225
|
+
if (stage.split(/\s+/, 1)[0] !== "160000") throw new Error(`Cannot fingerprint directory reported as a Git file: ${path}`);
|
|
44226
|
+
const submoduleRoot = realpathSync(run$1(absolute, ["rev-parse", "--show-toplevel"]));
|
|
44227
|
+
if (submoduleRoot !== realpathSync(absolute)) throw new Error(`Git submodule is unavailable: ${path}`);
|
|
44228
|
+
content = Buffer.from(`gitlink:${stage}\0${JSON.stringify(inspectGit(submoduleRoot))}`);
|
|
44229
|
+
} else content = readFileSync(absolute);
|
|
43316
44230
|
return createHash("sha256").update(status).update("\0").update(content).digest("hex");
|
|
43317
44231
|
};
|
|
43318
44232
|
const inspectGit = (root) => {
|
|
44233
|
+
const absoluteRoot = realpathSync(root);
|
|
44234
|
+
let gitTopLevel;
|
|
43319
44235
|
try {
|
|
43320
|
-
|
|
43321
|
-
"diff",
|
|
43322
|
-
"--name-only",
|
|
43323
|
-
"-z",
|
|
43324
|
-
"HEAD"
|
|
43325
|
-
]));
|
|
43326
|
-
const untracked = nul(run$1(root, [
|
|
43327
|
-
"ls-files",
|
|
43328
|
-
"--others",
|
|
43329
|
-
"--exclude-standard",
|
|
43330
|
-
"-z"
|
|
43331
|
-
]));
|
|
43332
|
-
const files = [.../* @__PURE__ */ new Set([...tracked, ...untracked])].sort();
|
|
43333
|
-
return {
|
|
43334
|
-
head: run$1(root, ["rev-parse", "HEAD"]),
|
|
43335
|
-
dirty: files.length > 0,
|
|
43336
|
-
files,
|
|
43337
|
-
fingerprints: Object.fromEntries(files.map((path) => [path, fingerprint(root, path)]))
|
|
43338
|
-
};
|
|
44236
|
+
gitTopLevel = run$1(absoluteRoot, ["rev-parse", "--show-toplevel"]);
|
|
43339
44237
|
} catch {
|
|
43340
44238
|
return {
|
|
43341
44239
|
dirty: false,
|
|
@@ -43343,6 +44241,27 @@ const inspectGit = (root) => {
|
|
|
43343
44241
|
fingerprints: {}
|
|
43344
44242
|
};
|
|
43345
44243
|
}
|
|
44244
|
+
const gitRoot = realpathSync(gitTopLevel);
|
|
44245
|
+
const tracked = nul(run$1(gitRoot, [
|
|
44246
|
+
"diff",
|
|
44247
|
+
"--name-only",
|
|
44248
|
+
"-z",
|
|
44249
|
+
"HEAD"
|
|
44250
|
+
]));
|
|
44251
|
+
const untracked = nul(run$1(gitRoot, [
|
|
44252
|
+
"ls-files",
|
|
44253
|
+
"--others",
|
|
44254
|
+
"--exclude-standard",
|
|
44255
|
+
"--full-name",
|
|
44256
|
+
"-z"
|
|
44257
|
+
]));
|
|
44258
|
+
const files = [.../* @__PURE__ */ new Set([...tracked, ...untracked])].map((path) => relative(absoluteRoot, resolve(gitRoot, path))).sort();
|
|
44259
|
+
return {
|
|
44260
|
+
head: run$1(gitRoot, ["rev-parse", "HEAD"]),
|
|
44261
|
+
dirty: files.length > 0,
|
|
44262
|
+
files,
|
|
44263
|
+
fingerprints: Object.fromEntries(files.map((path) => [path, fingerprint(absoluteRoot, gitRoot, path)]))
|
|
44264
|
+
};
|
|
43346
44265
|
};
|
|
43347
44266
|
const changedFiles = (before, after) => [.../* @__PURE__ */ new Set([...before.files, ...after.files])].filter((path) => before.fingerprints[path] !== after.fingerprints[path]).sort();
|
|
43348
44267
|
//#endregion
|
|
@@ -43478,9 +44397,20 @@ const decodeToolPart = decodeUnknownOption(Struct({
|
|
|
43478
44397
|
state: Struct({
|
|
43479
44398
|
status: String$6,
|
|
43480
44399
|
input: optionalKey(Unknown),
|
|
43481
|
-
content: optionalKey(Unknown)
|
|
44400
|
+
content: optionalKey(Unknown),
|
|
44401
|
+
error: optionalKey(Unknown),
|
|
44402
|
+
metadata: optionalKey(Unknown)
|
|
43482
44403
|
})
|
|
43483
44404
|
}));
|
|
44405
|
+
const decodeCodeModeMetadata = decodeUnknownOption(Struct({ toolCalls: ArraySchema(Struct({
|
|
44406
|
+
tool: String$6,
|
|
44407
|
+
status: Literals$2([
|
|
44408
|
+
"running",
|
|
44409
|
+
"completed",
|
|
44410
|
+
"error"
|
|
44411
|
+
]),
|
|
44412
|
+
input: optionalKey(Unknown)
|
|
44413
|
+
})) }));
|
|
43484
44414
|
const toolParts = (transcript) => decodeTranscript(transcript).messages.flatMap((message) => {
|
|
43485
44415
|
const assistant = decodeAssistantMessage(message);
|
|
43486
44416
|
if (isNone(assistant)) return [];
|
|
@@ -43489,15 +44419,6 @@ const toolParts = (transcript) => decodeTranscript(transcript).messages.flatMap(
|
|
|
43489
44419
|
return isSome(decoded) ? [decoded.value] : [];
|
|
43490
44420
|
});
|
|
43491
44421
|
});
|
|
43492
|
-
const toolText = (value) => {
|
|
43493
|
-
if (typeof value === "string") return value;
|
|
43494
|
-
if (Array.isArray(value)) return value.map(toolText).join("\n");
|
|
43495
|
-
if (value === null || typeof value !== "object") return "";
|
|
43496
|
-
const record = value;
|
|
43497
|
-
if (record["type"] === "text" && typeof record["text"] === "string") return record["text"];
|
|
43498
|
-
return Object.values(record).map(toolText).join("\n");
|
|
43499
|
-
};
|
|
43500
|
-
const pathsIn = (value) => [...toolText(value).matchAll(/tools\.([A-Za-z0-9_.-]+)/g)].map((match) => match[1] ?? "").filter(Boolean);
|
|
43501
44422
|
const toolPathPattern = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/;
|
|
43502
44423
|
const pathsInSearchOutput = (value) => {
|
|
43503
44424
|
const found = /* @__PURE__ */ new Set();
|
|
@@ -43526,27 +44447,50 @@ const pathsInSearchOutput = (value) => {
|
|
|
43526
44447
|
const evidenceRecord = (part) => ({
|
|
43527
44448
|
tool: part.name,
|
|
43528
44449
|
input: part.state.input ?? null,
|
|
43529
|
-
output: part.state.content ?? null
|
|
44450
|
+
output: part.state.content ?? part.state.error ?? null
|
|
43530
44451
|
});
|
|
43531
44452
|
const collectExecutorEvidence = (transcript) => {
|
|
43532
44453
|
const discovered = /* @__PURE__ */ new Set();
|
|
43533
44454
|
const searches = [];
|
|
43534
44455
|
const calls = [];
|
|
43535
44456
|
for (const part of toolParts(transcript)) {
|
|
43536
|
-
const
|
|
43537
|
-
|
|
43538
|
-
|
|
44457
|
+
const metadata = decodeCodeModeMetadata(part.state.metadata);
|
|
44458
|
+
if (isSome(metadata)) {
|
|
44459
|
+
const invocations = metadata.value.toolCalls;
|
|
44460
|
+
for (const invocation of invocations) {
|
|
44461
|
+
if (invocation.status !== "completed") continue;
|
|
44462
|
+
const record = codeModeEvidenceRecord(invocation, part.state.content);
|
|
44463
|
+
if (invocation.tool === "executor.search") {
|
|
44464
|
+
for (const path of pathsInSearchOutput(part.state.content)) if (path !== "executor.search") discovered.add(path);
|
|
44465
|
+
searches.push(record);
|
|
44466
|
+
continue;
|
|
44467
|
+
}
|
|
44468
|
+
if ([...discovered].some((path) => matchesToolPath(invocation.tool, path))) calls.push(record);
|
|
44469
|
+
}
|
|
44470
|
+
continue;
|
|
44471
|
+
}
|
|
44472
|
+
if (part.state.status === "completed" && (part.name === "executor_search" || part.name === "executor.search")) {
|
|
43539
44473
|
for (const path of pathsInSearchOutput(part.state.content)) if (path !== "executor.search") discovered.add(path);
|
|
43540
44474
|
searches.push(evidenceRecord(part));
|
|
43541
44475
|
continue;
|
|
43542
44476
|
}
|
|
43543
|
-
if ([...discovered].some((path) => part.name
|
|
44477
|
+
if (part.state.status === "completed" && [...discovered].some((path) => matchesToolPath(part.name, path))) calls.push(evidenceRecord(part));
|
|
43544
44478
|
}
|
|
43545
44479
|
return {
|
|
43546
44480
|
searches,
|
|
43547
44481
|
calls
|
|
43548
44482
|
};
|
|
43549
44483
|
};
|
|
44484
|
+
const codeModeEvidenceRecord = (invocation, codeModeOutput) => ({
|
|
44485
|
+
tool: invocation.tool,
|
|
44486
|
+
input: invocation.input ?? null,
|
|
44487
|
+
output: {
|
|
44488
|
+
status: invocation.status,
|
|
44489
|
+
scope: "shared CodeMode execution result",
|
|
44490
|
+
content: codeModeOutput ?? null
|
|
44491
|
+
}
|
|
44492
|
+
});
|
|
44493
|
+
const matchesToolPath = (tool, path) => tool === path || tool === path.replaceAll(".", "_");
|
|
43550
44494
|
const interactionEventError = (event, sessionId) => {
|
|
43551
44495
|
if (event === null || typeof event !== "object") return void 0;
|
|
43552
44496
|
const record = event;
|
|
@@ -43559,15 +44503,61 @@ const interactionEventError = (event, sessionId) => {
|
|
|
43559
44503
|
if (form !== null && typeof form === "object" && form["sessionID"] === sessionId) return /* @__PURE__ */ new Error("OpenCode requested a form; embedded PageGraph workflows are noninteractive.");
|
|
43560
44504
|
}
|
|
43561
44505
|
};
|
|
44506
|
+
const recordWorkflowActivity = (activity, event) => {
|
|
44507
|
+
if (event.type === "log.synced") return;
|
|
44508
|
+
activity.lastActivity = {
|
|
44509
|
+
type: event.type,
|
|
44510
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
44511
|
+
seq: event.durable.seq
|
|
44512
|
+
};
|
|
44513
|
+
if (event.type === "session.step.started") {
|
|
44514
|
+
activity.modelSteps += 1;
|
|
44515
|
+
activity.lastModel = `${event.data.model.providerID}/${event.data.model.id}`;
|
|
44516
|
+
return;
|
|
44517
|
+
}
|
|
44518
|
+
if (event.type === "session.tool.input.started") {
|
|
44519
|
+
activity.toolNames.set(event.data.id, event.data.name);
|
|
44520
|
+
activity.lastTool = {
|
|
44521
|
+
id: event.data.id,
|
|
44522
|
+
name: event.data.name,
|
|
44523
|
+
status: "input"
|
|
44524
|
+
};
|
|
44525
|
+
return;
|
|
44526
|
+
}
|
|
44527
|
+
if (event.type === "session.tool.called") {
|
|
44528
|
+
activity.lastTool = {
|
|
44529
|
+
id: event.data.id,
|
|
44530
|
+
name: activity.toolNames.get(event.data.id) ?? event.data.id,
|
|
44531
|
+
status: "called"
|
|
44532
|
+
};
|
|
44533
|
+
return;
|
|
44534
|
+
}
|
|
44535
|
+
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
|
44536
|
+
activity.completedTools += 1;
|
|
44537
|
+
activity.lastTool = {
|
|
44538
|
+
id: event.data.id,
|
|
44539
|
+
name: activity.toolNames.get(event.data.id) ?? event.data.id,
|
|
44540
|
+
status: event.type === "session.tool.success" ? "succeeded" : "failed"
|
|
44541
|
+
};
|
|
44542
|
+
}
|
|
44543
|
+
};
|
|
44544
|
+
const describeWorkflowActivity = (activity, stage) => {
|
|
44545
|
+
const lastActivity = activity.lastActivity ? `${activity.lastActivity.type} at ${activity.lastActivity.at}${activity.lastActivity.seq === void 0 ? "" : ` (seq ${activity.lastActivity.seq})`}` : `none observed during ${stage}`;
|
|
44546
|
+
const lastTool = activity.lastTool ? `; last tool ${activity.lastTool.name} (${activity.lastTool.id}, ${activity.lastTool.status})` : "";
|
|
44547
|
+
const model = activity.lastModel ? `; last model ${activity.lastModel}` : "";
|
|
44548
|
+
return `Model steps: ${activity.modelSteps}; completed tools: ${activity.completedTools}; last activity: ${lastActivity}${model}${lastTool}.`;
|
|
44549
|
+
};
|
|
43562
44550
|
const waitForIdle = async (host, sessionId, options) => {
|
|
43563
|
-
const controller = new AbortController();
|
|
43564
|
-
const
|
|
44551
|
+
const controller = options.signal === void 0 ? new AbortController() : void 0;
|
|
44552
|
+
const signal = options.signal ?? controller.signal;
|
|
44553
|
+
const timer = controller === void 0 ? void 0 : setTimeout(() => controller.abort(), options.timeoutMs);
|
|
43565
44554
|
try {
|
|
43566
44555
|
for await (const event of host.sessions.log({
|
|
43567
44556
|
sessionID: sessionId,
|
|
43568
44557
|
follow: true,
|
|
43569
44558
|
after: options.after
|
|
43570
|
-
}, { signal
|
|
44559
|
+
}, { signal })) {
|
|
44560
|
+
if (options.activity !== void 0) recordWorkflowActivity(options.activity, event);
|
|
43571
44561
|
const interactionError = interactionEventError(event, sessionId);
|
|
43572
44562
|
if (interactionError !== void 0) throw interactionError;
|
|
43573
44563
|
if (event.type === "session.execution.succeeded" && event.data.sessionID === sessionId) return event.durable.seq;
|
|
@@ -43575,13 +44565,44 @@ const waitForIdle = async (host, sessionId, options) => {
|
|
|
43575
44565
|
if (event.type === "session.execution.interrupted" && event.data.sessionID === sessionId) throw new Error(`OpenCode session was interrupted: ${event.data.reason}.`);
|
|
43576
44566
|
}
|
|
43577
44567
|
} catch (cause) {
|
|
43578
|
-
if (controller
|
|
44568
|
+
if (controller?.signal.aborted) throw new Error(`OpenCode session did not complete within ${options.configuredTimeoutMs ?? options.timeoutMs}ms.`, { cause });
|
|
43579
44569
|
throw cause;
|
|
43580
44570
|
} finally {
|
|
43581
|
-
clearTimeout(timer);
|
|
44571
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
43582
44572
|
}
|
|
43583
44573
|
throw new Error("OpenCode session log ended before the SEO agent completed.");
|
|
43584
44574
|
};
|
|
44575
|
+
const runWorkflowTurn = async (host, sessionId, prompt, options) => {
|
|
44576
|
+
const controller = new AbortController();
|
|
44577
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
44578
|
+
let stage = options.stage;
|
|
44579
|
+
try {
|
|
44580
|
+
await host.sessions.prompt({
|
|
44581
|
+
sessionID: sessionId,
|
|
44582
|
+
text: prompt,
|
|
44583
|
+
...options.skills === void 0 ? {} : { skills: options.skills.map((id) => ({ id })) }
|
|
44584
|
+
}, { signal: controller.signal });
|
|
44585
|
+
if (stage === "prompt admission") stage = "model execution";
|
|
44586
|
+
return await waitForIdle(host, sessionId, {
|
|
44587
|
+
timeoutMs: options.timeoutMs,
|
|
44588
|
+
configuredTimeoutMs: options.configuredTimeoutMs,
|
|
44589
|
+
after: options.after,
|
|
44590
|
+
signal: controller.signal,
|
|
44591
|
+
activity: options.activity
|
|
44592
|
+
});
|
|
44593
|
+
} catch (cause) {
|
|
44594
|
+
if (!controller.signal.aborted) throw cause;
|
|
44595
|
+
let interruption = "unknown";
|
|
44596
|
+
try {
|
|
44597
|
+
interruption = (await host.sessions.interrupt({ sessionID: sessionId }, { signal: AbortSignal.timeout(5e3) })).interrupted ? "active execution interrupted" : "no active execution to interrupt";
|
|
44598
|
+
} catch (interruptCause) {
|
|
44599
|
+
interruption = `interrupt request failed: ${interruptCause instanceof Error ? interruptCause.message : String(interruptCause)}`;
|
|
44600
|
+
}
|
|
44601
|
+
throw new Error(`OpenCode session did not complete within ${options.configuredTimeoutMs}ms during ${stage}; ${describeWorkflowActivity(options.activity, stage)} Timeout handling: ${interruption}.`, { cause });
|
|
44602
|
+
} finally {
|
|
44603
|
+
clearTimeout(timer);
|
|
44604
|
+
}
|
|
44605
|
+
};
|
|
43585
44606
|
const acquireWorkflowHost = async (options) => {
|
|
43586
44607
|
const model = parseModel(options.model ?? options.config.defaultModel);
|
|
43587
44608
|
const configDirectory = resolve(options.root, options.config.configDirectory);
|
|
@@ -43603,18 +44624,21 @@ const acquireWorkflowHost = async (options) => {
|
|
|
43603
44624
|
sessionID: sessionId,
|
|
43604
44625
|
permissions: [...workflowOptions.permissions]
|
|
43605
44626
|
});
|
|
43606
|
-
const configuredTimeoutMs = options.timeoutMs ?? 18e4;
|
|
44627
|
+
const configuredTimeoutMs = options.timeoutMs ?? options.config.timeoutMs ?? 18e4;
|
|
43607
44628
|
const deadline = Date.now() + configuredTimeoutMs;
|
|
43608
44629
|
const remaining = () => Math.max(0, deadline - Date.now());
|
|
43609
|
-
|
|
43610
|
-
|
|
43611
|
-
|
|
43612
|
-
|
|
43613
|
-
}
|
|
43614
|
-
let cursor = await
|
|
44630
|
+
const activity = {
|
|
44631
|
+
modelSteps: 0,
|
|
44632
|
+
completedTools: 0,
|
|
44633
|
+
toolNames: /* @__PURE__ */ new Map()
|
|
44634
|
+
};
|
|
44635
|
+
let cursor = await runWorkflowTurn(host, sessionId, prompt, {
|
|
43615
44636
|
timeoutMs: remaining(),
|
|
43616
44637
|
configuredTimeoutMs,
|
|
43617
|
-
|
|
44638
|
+
stage: "prompt admission",
|
|
44639
|
+
after: cursors.get(sessionId),
|
|
44640
|
+
skills: workflowOptions.skills,
|
|
44641
|
+
activity
|
|
43618
44642
|
});
|
|
43619
44643
|
cursors.set(sessionId, cursor);
|
|
43620
44644
|
let transcript = await host.sessions.export({
|
|
@@ -43632,14 +44656,12 @@ const acquireWorkflowHost = async (options) => {
|
|
|
43632
44656
|
}]
|
|
43633
44657
|
});
|
|
43634
44658
|
try {
|
|
43635
|
-
await host
|
|
43636
|
-
sessionID: sessionId,
|
|
43637
|
-
text: STATE_REPAIR_PROMPT
|
|
43638
|
-
});
|
|
43639
|
-
cursor = await waitForIdle(host, sessionId, {
|
|
44659
|
+
cursor = await runWorkflowTurn(host, sessionId, STATE_REPAIR_PROMPT, {
|
|
43640
44660
|
timeoutMs: remaining(),
|
|
43641
44661
|
configuredTimeoutMs,
|
|
43642
|
-
|
|
44662
|
+
stage: "workflow state repair",
|
|
44663
|
+
after: cursor,
|
|
44664
|
+
activity
|
|
43643
44665
|
});
|
|
43644
44666
|
cursors.set(sessionId, cursor);
|
|
43645
44667
|
transcript = await host.sessions.export({
|
|
@@ -43691,9 +44713,43 @@ const acquireWorkflowHost = async (options) => {
|
|
|
43691
44713
|
var action_default = "Continue PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nObserved state:\n{{state}}\n\nJev decisions:\n{{decisions}}\n\nApply only records in the resolved set whose verdict calls for a change. Never apply review records;\nleave them in the artifact for a human. If nothing is safely resolved, return no-change.\n\nRepository mutation mode: {{mutationMode}}. {{mutationInstruction}}\n\nReturn only one JSON object with this shape:\n{\"summary\":\"...\",\"files\":[\"relative/path\"],\"outcome\":\"applied|dry-run|no-change\"}\n";
|
|
43692
44714
|
//#endregion
|
|
43693
44715
|
//#region src/workflows/prompts/research.md
|
|
43694
|
-
var research_default = "You are running PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nUse these skills: {{skills}}. Treat the supplied project evidence as the deterministic baseline.\nSearch Executor's live catalog
|
|
44716
|
+
var research_default = "You are running PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nUse these skills: {{skills}}. Treat the supplied project evidence as the deterministic baseline.\nThe graph contains the selected target pages. Its neighborhood preserves incoming and outgoing\nrelationships to pages outside that selection; those neighbors are context, not additional targets.\nWhen suggestion evidence is supplied, preserve each candidate's exact source sentence as `context`,\nverbatim anchor, source and destination paths. Do not invent additional link placements.\nReturn at most {{limit}} items or opportunities. Keep exploration scoped to these targets and stop\nwhen the collected evidence supports the requested decision; do not expand into a site-wide audit.\n\nSearch Executor's live catalog with a short intent phrase, then use Code Mode's local `search` for\nthe exact returned path to inspect its current argument signature before calling it in a later turn.\nCatalog discovery returns tool names and descriptions, not complete input schemas. Never guess\narguments from a description. If discovery returns no matches, simplify the query. Compose whichever\ncurrent integrations best support this workflow; tool names and providers are discovered dynamically.\nCall at least one relevant discovered tool and preserve source references and provider errors in the\nstructured state. Do not treat an errored call or empty dataset as proof of indexing or traffic.\nDistinguish project market settings from the country, language, device, and date filters actually\napplied to provider data; report only the segments supported by the call and its response.\n\nReturn only one JSON object matching this JSON Schema:\n{{stateSchema}}\n\nMarket: {{market}}\nLanguage: {{language}}\nDevice: {{device}}\nRefresh requested: {{refresh}}\nDo not ask questions, request permissions, or create interactive forms. Fail clearly if required\ninput is missing.\nSeed queries: {{queries}}\nCompetitor seeds: {{competitors}}\nAuthority-domain seeds: {{domains}}\nDeterministic evidence:\n{{evidence}}\n";
|
|
43695
44717
|
//#endregion
|
|
43696
44718
|
//#region src/workflows/run.ts
|
|
44719
|
+
const readCurrentSuggestionSentences = async (origin, path, allowPrivate, maxBodyBytes) => {
|
|
44720
|
+
const options = {
|
|
44721
|
+
sameOrigin: origin,
|
|
44722
|
+
allowPrivate,
|
|
44723
|
+
timeoutMs: 15e3,
|
|
44724
|
+
maxBodyBytes
|
|
44725
|
+
};
|
|
44726
|
+
const robots = await probeHttp({
|
|
44727
|
+
kind: "robots",
|
|
44728
|
+
method: "GET",
|
|
44729
|
+
accept: "text/plain",
|
|
44730
|
+
url: new URL("/robots.txt", origin)
|
|
44731
|
+
}, {
|
|
44732
|
+
...options,
|
|
44733
|
+
captureBody: true
|
|
44734
|
+
});
|
|
44735
|
+
if (!robots.ok && robots.status !== 404 || robots.bodyTruncated) throw new Error("Could not verify current robots policy for suggestion freshness");
|
|
44736
|
+
const rules = robots.ok ? robotsRules(robots.body ?? "").rules : [];
|
|
44737
|
+
const url = new URL(path, origin);
|
|
44738
|
+
if (!allowedByRobots(`${url.pathname}${url.search}`, rules)) throw new Error(`Suggestion page is robots-disallowed: ${path}`);
|
|
44739
|
+
const probe = await probeHttp({
|
|
44740
|
+
kind: "page-html",
|
|
44741
|
+
method: "GET",
|
|
44742
|
+
accept: "text/html",
|
|
44743
|
+
url
|
|
44744
|
+
}, {
|
|
44745
|
+
...options,
|
|
44746
|
+
captureBody: true,
|
|
44747
|
+
allowUrl: (next) => allowedByRobots(`${next.pathname}${next.search}`, rules)
|
|
44748
|
+
});
|
|
44749
|
+
const contentType = probe.responseHeaders["content-type"];
|
|
44750
|
+
if (!probe.ok || probe.bodyTruncated || !probe.body || !probe.finalUrl || contentType !== void 0 && !/text\/html|application\/xhtml\+xml/i.test(contentType) || new URL(probe.finalUrl).pathname.replace(/\/+$/, "") !== path.replace(/\/+$/, "")) throw new Error(`Could not verify current served copy for suggestion page ${path}: ${probe.error ?? "unreadable or redirected page"}`);
|
|
44751
|
+
return extractPageSentences(probe.body);
|
|
44752
|
+
};
|
|
43697
44753
|
const defaultDecide = async (inputs, spec) => {
|
|
43698
44754
|
const layer$14 = model("jev-latest").pipe(provide$2(layerConfig()), provide$2(layer));
|
|
43699
44755
|
return runPromise(gen(function* () {
|
|
@@ -43720,6 +44776,7 @@ const capState = (state, limit) => {
|
|
|
43720
44776
|
};
|
|
43721
44777
|
const researchPrompt = (spec, evidence, options) => TextTemplate.from(research_default).values({
|
|
43722
44778
|
workflow: spec.id,
|
|
44779
|
+
limit: options.limit,
|
|
43723
44780
|
instructions: spec.researchInstructions,
|
|
43724
44781
|
skills: spec.skills.join(", "),
|
|
43725
44782
|
stateSchema: JSON.stringify(spec.stateJsonSchema),
|
|
@@ -43761,6 +44818,20 @@ const runWorkflow = async (input, dependencies = {}) => {
|
|
|
43761
44818
|
});
|
|
43762
44819
|
if (spec.mutatesFiles) mutation.assertStartAllowed();
|
|
43763
44820
|
const evidence = collectWorkflowEvidence(input.graph, input.options, input.root, spec.id, workflows.context);
|
|
44821
|
+
const suggestionReport = input.options.suggestions === void 0 ? void 0 : (() => {
|
|
44822
|
+
if (spec.id !== "improve.links") throw new Error("--suggestions is only valid for improve links");
|
|
44823
|
+
const report = decodeLinksSuggestionReport(JSON.parse(readFileSync(resolve(input.root, input.options.suggestions), "utf8")), new URL(input.config.origin).origin);
|
|
44824
|
+
const selectedSources = selectGraph(input.graph, input.options).nodes;
|
|
44825
|
+
for (const candidate of report.candidates) {
|
|
44826
|
+
if (!input.graph.nodes.has(candidate.source) || !input.graph.nodes.has(candidate.destination)) throw new Error(`Suggestion references a path absent from seo.config.ts: ${candidate.source} → ${candidate.destination}`);
|
|
44827
|
+
if (!selectedSources.has(candidate.source)) throw new Error(`Suggestion source is outside workflow page/kind/limit targets: ${candidate.source}`);
|
|
44828
|
+
}
|
|
44829
|
+
return report;
|
|
44830
|
+
})();
|
|
44831
|
+
const suppliedEvidence = suggestionReport === void 0 ? evidence : {
|
|
44832
|
+
...evidence,
|
|
44833
|
+
suggestions: suggestionReport
|
|
44834
|
+
};
|
|
43764
44835
|
const host = await (dependencies.acquireHost ?? acquireWorkflowHost)({
|
|
43765
44836
|
root: input.root,
|
|
43766
44837
|
config: workflows.opencode,
|
|
@@ -43769,67 +44840,184 @@ const runWorkflow = async (input, dependencies = {}) => {
|
|
|
43769
44840
|
try {
|
|
43770
44841
|
const researchPermissions = repositoryMutationPermissionRules(input.root);
|
|
43771
44842
|
const actionPermissions = spec.mutatesFiles && !input.options.dryRun ? mutation.sessionPermissions : researchPermissions;
|
|
43772
|
-
|
|
44843
|
+
let researched = await host.research(researchPrompt(spec, suppliedEvidence, input.options), {
|
|
43773
44844
|
skills: spec.skills,
|
|
43774
44845
|
permissions: researchPermissions
|
|
43775
44846
|
});
|
|
43776
|
-
const
|
|
43777
|
-
|
|
43778
|
-
|
|
43779
|
-
|
|
44847
|
+
const assertResearchReadOnly = () => {
|
|
44848
|
+
const researchFiles = changedFiles(gitAtStart, inspectGit(input.root));
|
|
44849
|
+
if (researchFiles.length > 0) throw new Error(`${spec.id} changed repository files during its read-only research turn: ${researchFiles.join(", ")}`);
|
|
44850
|
+
};
|
|
44851
|
+
assertResearchReadOnly();
|
|
44852
|
+
const decodeResearchState = (value) => spec.decodeState(capState(value, input.options.limit));
|
|
44853
|
+
let state;
|
|
44854
|
+
try {
|
|
44855
|
+
state = decodeResearchState(researched.state);
|
|
44856
|
+
} catch (firstError) {
|
|
44857
|
+
const original = researched;
|
|
44858
|
+
let repaired;
|
|
44859
|
+
try {
|
|
44860
|
+
repaired = await host.continue(researched.sessionId, [
|
|
44861
|
+
`The previous ${spec.id} research JSON did not match the required state schema: ${firstError instanceof Error ? firstError.message : String(firstError)}.`,
|
|
44862
|
+
`Return only one corrected JSON object matching this schema: ${JSON.stringify(spec.stateJsonSchema)}.`,
|
|
44863
|
+
"Use only evidence already collected. Do not call tools, edit files, or add commentary."
|
|
44864
|
+
].join(" "), {
|
|
44865
|
+
skills: spec.skills,
|
|
44866
|
+
permissions: researchPermissions
|
|
44867
|
+
});
|
|
44868
|
+
assertResearchReadOnly();
|
|
44869
|
+
state = decodeResearchState(repaired.state);
|
|
44870
|
+
researched = repaired;
|
|
44871
|
+
} catch (repairError) {
|
|
44872
|
+
const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
|
|
44873
|
+
const path = writeResearchFailure(input.root, runsDirectory, id, {
|
|
44874
|
+
kind: "pagegraph-workflow-research-failure",
|
|
44875
|
+
workflow: spec.id,
|
|
44876
|
+
original,
|
|
44877
|
+
repaired,
|
|
44878
|
+
firstError: String(firstError),
|
|
44879
|
+
repairError: String(repairError)
|
|
44880
|
+
});
|
|
44881
|
+
throw new Error(`${spec.id} research state failed schema validation after one read-only repair. Research failure artifact: ${path}`, { cause: repairError });
|
|
44882
|
+
}
|
|
44883
|
+
}
|
|
43780
44884
|
const decisionInputs = spec.decisionInputs(state);
|
|
44885
|
+
if (suggestionReport !== void 0) {
|
|
44886
|
+
const valid = new Set(suggestionReport.candidates.map((item) => `${item.source}\u0000${item.destination}\u0000${item.anchor}\u0000${item.sentence}`));
|
|
44887
|
+
for (const item of decisionInputs) {
|
|
44888
|
+
const link = item;
|
|
44889
|
+
if (!valid.has(`${link.from}\u0000${link.to}\u0000${link.anchor}\u0000${link.context}`)) throw new Error("Research proposed a link absent from the supplied suggestions");
|
|
44890
|
+
}
|
|
44891
|
+
}
|
|
43781
44892
|
for (const item of decisionInputs) {
|
|
43782
44893
|
const error = spec.validateDecisionInput(item);
|
|
43783
44894
|
if (error !== void 0) throw new Error(error);
|
|
43784
44895
|
}
|
|
43785
|
-
const
|
|
43786
|
-
const
|
|
43787
|
-
|
|
43788
|
-
permissions: actionPermissions
|
|
43789
|
-
}) : researched;
|
|
43790
|
-
const gitAfter = inspectGit(input.root);
|
|
43791
|
-
const files = changedFiles(gitAtStart, gitAfter);
|
|
43792
|
-
if ((!spec.mutatesFiles || input.options.dryRun) && files.length > 0) throw new Error(`${spec.id} changed repository files while running in read-only mode: ${files.join(", ")}`);
|
|
43793
|
-
const run = {
|
|
43794
|
-
kind: "pagegraph-workflow-run",
|
|
44896
|
+
const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
|
|
44897
|
+
const checkpoint = {
|
|
44898
|
+
kind: "pagegraph-workflow-research-checkpoint",
|
|
43795
44899
|
schemaVersion: 1,
|
|
43796
44900
|
id,
|
|
43797
44901
|
workflow: spec.id,
|
|
43798
44902
|
startedAt: started.toISOString(),
|
|
43799
|
-
|
|
44903
|
+
checkpointedAt: now().toISOString(),
|
|
43800
44904
|
project: {
|
|
43801
44905
|
root: input.root,
|
|
43802
44906
|
head: gitAtStart.head,
|
|
43803
|
-
dirtyAtStart: gitAtStart.dirty
|
|
44907
|
+
dirtyAtStart: gitAtStart.dirty,
|
|
44908
|
+
filesAtStart: gitAtStart.files
|
|
43804
44909
|
},
|
|
43805
44910
|
options: input.options,
|
|
43806
|
-
model: host.model,
|
|
43807
|
-
targets: {
|
|
43808
|
-
pages: evidence.graph.nodes.map((node) => node.path),
|
|
43809
|
-
queries: input.options.queries,
|
|
43810
|
-
kinds: input.options.kinds
|
|
43811
|
-
},
|
|
43812
44911
|
evidence: {
|
|
43813
|
-
...
|
|
43814
|
-
executor:
|
|
44912
|
+
...suppliedEvidence,
|
|
44913
|
+
executor: researched.executor
|
|
43815
44914
|
},
|
|
43816
|
-
|
|
43817
|
-
|
|
43818
|
-
files,
|
|
43819
|
-
providerCalls: acted.executor.calls.map((call) => call.tool)
|
|
43820
|
-
},
|
|
43821
|
-
result: spec.mutatesFiles ? acted.state : state,
|
|
44915
|
+
state,
|
|
44916
|
+
decisionInputs,
|
|
43822
44917
|
opencode: {
|
|
43823
44918
|
agent: "seo",
|
|
43824
|
-
sessionId:
|
|
43825
|
-
|
|
44919
|
+
sessionId: researched.sessionId,
|
|
44920
|
+
model: host.model,
|
|
44921
|
+
transcript: researched.transcript
|
|
43826
44922
|
}
|
|
43827
44923
|
};
|
|
43828
|
-
const
|
|
43829
|
-
|
|
43830
|
-
|
|
43831
|
-
|
|
43832
|
-
|
|
44924
|
+
const checkpointPath = writeResearchCheckpoint(input.root, runsDirectory, checkpoint);
|
|
44925
|
+
const gitAtCheckpoint = inspectGit(input.root);
|
|
44926
|
+
try {
|
|
44927
|
+
const decisionReport = await (dependencies.decide ?? defaultDecide)(decisionInputs, spec);
|
|
44928
|
+
const accepted = spec.id === "improve.links" ? decisionReport.resolved.filter((record) => record.verdict === "add" || record.verdict === "update") : void 0;
|
|
44929
|
+
const acceptedIndexes = /* @__PURE__ */ new Set();
|
|
44930
|
+
if (accepted !== void 0) for (const record of accepted) {
|
|
44931
|
+
const match = /^workflow-links:(\d+)$/.exec(record.decisionId);
|
|
44932
|
+
const index = match === null ? -1 : Number(match[1]);
|
|
44933
|
+
const item = decisionInputs[index];
|
|
44934
|
+
if (!item || record.inputRef !== `${item.from} → ${item.to}`) throw new Error(`Decision does not match a researched link: ${record.decisionId}`);
|
|
44935
|
+
acceptedIndexes.add(index);
|
|
44936
|
+
}
|
|
44937
|
+
const actionItems = accepted === void 0 ? void 0 : state.items.filter((_, index) => acceptedIndexes.has(index));
|
|
44938
|
+
if (actionItems !== void 0 && actionItems.length > 0 && suggestionReport !== void 0) {
|
|
44939
|
+
const readSentences = dependencies.readSuggestionSentences ?? readCurrentSuggestionSentences;
|
|
44940
|
+
const current = /* @__PURE__ */ new Map();
|
|
44941
|
+
for (const item of actionItems) {
|
|
44942
|
+
const candidate = suggestionReport.candidates.find((entry) => entry.source === item.from && entry.destination === item.to && entry.anchor === item.anchor && entry.sentence === item.context);
|
|
44943
|
+
if (!candidate) throw new Error("Accepted link is absent from the supplied suggestions");
|
|
44944
|
+
for (const path of [candidate.source, candidate.destination]) if (!current.has(path)) current.set(path, await readSentences(suggestionReport.origin, path, input.options.allowPrivate === true, suggestionReport.maxBodyBytes));
|
|
44945
|
+
if (!current.get(candidate.source).includes(candidate.sentence) || !current.get(candidate.destination).includes(candidate.targetSentence)) throw new Error(`Suggestion is stale; served copy changed for ${candidate.source} → ${candidate.destination}. Regenerate links candidates --site.`);
|
|
44946
|
+
}
|
|
44947
|
+
}
|
|
44948
|
+
const actionState = actionItems === void 0 ? state : {
|
|
44949
|
+
...state,
|
|
44950
|
+
items: actionItems
|
|
44951
|
+
};
|
|
44952
|
+
const actionDecisions = accepted === void 0 ? decisionReport : {
|
|
44953
|
+
...decisionReport,
|
|
44954
|
+
resolved: accepted,
|
|
44955
|
+
review: [],
|
|
44956
|
+
counts: {
|
|
44957
|
+
inputs: accepted.length,
|
|
44958
|
+
resolved: accepted.length,
|
|
44959
|
+
review: 0
|
|
44960
|
+
},
|
|
44961
|
+
verdicts: {
|
|
44962
|
+
add: accepted.filter((record) => record.verdict === "add").length,
|
|
44963
|
+
update: accepted.filter((record) => record.verdict === "update").length
|
|
44964
|
+
}
|
|
44965
|
+
};
|
|
44966
|
+
const mayAct = spec.mutatesFiles && (actionItems === void 0 || actionItems.length > 0);
|
|
44967
|
+
const acted = mayAct ? await host.continue(researched.sessionId, actionPrompt(spec, actionState, actionDecisions, mutation), {
|
|
44968
|
+
skills: spec.skills,
|
|
44969
|
+
permissions: actionPermissions
|
|
44970
|
+
}) : researched;
|
|
44971
|
+
const gitAfter = inspectGit(input.root);
|
|
44972
|
+
const files = changedFiles(gitAtCheckpoint, gitAfter);
|
|
44973
|
+
if ((!spec.mutatesFiles || input.options.dryRun) && files.length > 0) throw new Error(`${spec.id} changed repository files while running in read-only mode: ${files.join(", ")}`);
|
|
44974
|
+
const run = {
|
|
44975
|
+
kind: "pagegraph-workflow-run",
|
|
44976
|
+
schemaVersion: 1,
|
|
44977
|
+
id,
|
|
44978
|
+
workflow: spec.id,
|
|
44979
|
+
startedAt: started.toISOString(),
|
|
44980
|
+
finishedAt: now().toISOString(),
|
|
44981
|
+
project: {
|
|
44982
|
+
root: input.root,
|
|
44983
|
+
head: gitAtStart.head,
|
|
44984
|
+
dirtyAtStart: gitAtStart.dirty
|
|
44985
|
+
},
|
|
44986
|
+
options: input.options,
|
|
44987
|
+
model: host.model,
|
|
44988
|
+
targets: {
|
|
44989
|
+
pages: evidence.graph.nodes.map((node) => node.path),
|
|
44990
|
+
queries: input.options.queries,
|
|
44991
|
+
kinds: input.options.kinds
|
|
44992
|
+
},
|
|
44993
|
+
evidence: {
|
|
44994
|
+
...suppliedEvidence,
|
|
44995
|
+
executor: acted.executor
|
|
44996
|
+
},
|
|
44997
|
+
decisions: [decisionArtifact(spec, decisionInputs, decisionReport)],
|
|
44998
|
+
changes: {
|
|
44999
|
+
files,
|
|
45000
|
+
providerCalls: acted.executor.calls.map((call) => call.tool)
|
|
45001
|
+
},
|
|
45002
|
+
result: mayAct ? acted.state : accepted !== void 0 ? {
|
|
45003
|
+
summary: "No link suggestions accepted.",
|
|
45004
|
+
outcome: "no-change",
|
|
45005
|
+
files: []
|
|
45006
|
+
} : state,
|
|
45007
|
+
opencode: {
|
|
45008
|
+
agent: "seo",
|
|
45009
|
+
sessionId: acted.sessionId,
|
|
45010
|
+
transcript: acted.transcript
|
|
45011
|
+
}
|
|
45012
|
+
};
|
|
45013
|
+
return {
|
|
45014
|
+
run,
|
|
45015
|
+
directory: writeRunBundle(input.root, runsDirectory, run)
|
|
45016
|
+
};
|
|
45017
|
+
} catch (cause) {
|
|
45018
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
45019
|
+
throw new Error(`${message}\nResearch checkpoint: ${checkpointPath}`, { cause });
|
|
45020
|
+
}
|
|
43833
45021
|
} finally {
|
|
43834
45022
|
await host.close();
|
|
43835
45023
|
}
|
|
@@ -43851,6 +45039,7 @@ const configFlag = String$2("opencode-config").pipe(withDescription$2("OpenCode
|
|
|
43851
45039
|
const outFlag = String$2("out").pipe(withDescription$2("Run-artifact directory override"), optional$1);
|
|
43852
45040
|
const dryRunFlag$1 = Boolean$1("dry-run").pipe(withDescription$2("Suppress file writes; discovered Executor tools remain available"), withDefault(false));
|
|
43853
45041
|
const allowDirtyFlag$1 = Boolean$1("allow-dirty").pipe(withDescription$2("Permit file changes when the working tree is already dirty"), withDefault(false));
|
|
45042
|
+
const suggestionsFlag = String$2("suggestions").pipe(withDescription$2("Schema-version-2 links candidate JSON to evaluate"), optional$1);
|
|
43854
45043
|
const baseFlags = {
|
|
43855
45044
|
page: pageFlag,
|
|
43856
45045
|
kind: kindFlag,
|
|
@@ -43894,7 +45083,9 @@ const optionsFrom = (flags, extra = {}) => {
|
|
|
43894
45083
|
domains: strings(flags.domain),
|
|
43895
45084
|
refresh: flags.refresh === true,
|
|
43896
45085
|
dryRun: extra.dryRun === true,
|
|
43897
|
-
allowDirty: extra.allowDirty === true
|
|
45086
|
+
allowDirty: extra.allowDirty === true,
|
|
45087
|
+
allowPrivate: flags.allowPrivate === true,
|
|
45088
|
+
suggestions: optionalString(flags.suggestions)
|
|
43898
45089
|
};
|
|
43899
45090
|
};
|
|
43900
45091
|
const resultSummary = (result) => {
|
|
@@ -44298,70 +45489,70 @@ var seo_default = "---\ndescription: Execute PageGraph SEO workflows from reposi
|
|
|
44298
45489
|
var opencode_default = "{\n \"default_agent\": \"seo\",\n \"plugins\": [\n // Add your Executor plugin package or local path here.\n // \"opencode-plugin-executor\"\n ]\n}\n";
|
|
44299
45490
|
//#endregion
|
|
44300
45491
|
//#region src/cli/preset/opencode/skills/ai-search/SKILL.md
|
|
44301
|
-
var SKILL_default$
|
|
45492
|
+
var SKILL_default$11 = "---\nname: ai-search\ndescription: Evaluate answer extractability, entity clarity, support, and citation readiness.\n---\n\n# AI Search Analysis\n\nStart from the page's visible content, structured data, route context, and cited sources. Read\n[Executor Starters](references/executor.md) before collecting current AI-search observations.\n\nEvaluate whether an answer engine can identify the subject, extract a concise answer, understand\nentity relationships, and trace material claims to first-party or trustworthy support. Distinguish\nobserved citations from inferred readiness. Do not claim a visibility lift from formatting alone.\n\nReturn the structured state requested by the workflow. This workflow produces evidence and a\ndiagnosis; it does not edit pages or submit them to an answer engine.\n";
|
|
44302
45493
|
//#endregion
|
|
44303
45494
|
//#region src/cli/preset/opencode/skills/ai-search/references/executor.md
|
|
44304
45495
|
var executor_default$10 = "# Executor Starters for AI Search Analysis\n\nThese snippets are starting points, not an inventory. Search for current citation, brand-mention,\nanswer-engine, and owned-performance capabilities available through Executor.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"AI search citations answer visibility brand mentions\", limit: 8 }),\n tools.executor.search({ query: \"Google Search Console generative AI performance page\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst visibility = (await search({ query: \"tools.<exact-ai-visibility-path>\", limit: 1 })).items[0];\nreturn visibility.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n domain,\n queries,\n market,\n language,\n});\n~~~\n\nRecord engine or surface, query, market, collection date, cited URL, response excerpt, sampling\nmethod, task status, errors, and cost. Missing citations in a small sample do not prove absence.\nDo not mutate provider projects or publish content.\n";
|
|
44305
45496
|
//#endregion
|
|
44306
45497
|
//#region src/cli/preset/opencode/skills/authority-research/SKILL.md
|
|
44307
|
-
var SKILL_default$
|
|
45498
|
+
var SKILL_default$10 = "---\nname: authority-research\ndescription: Qualify legitimate citation, directory, editorial, partner, and community opportunities.\n---\n\n# Authority Research\n\nUse PageGraph's pages to identify the asset that could reasonably earn each citation. Read\n[Executor Starters](references/executor.md) before collecting backlink or live-page evidence.\n\nUse backlink gaps only to nominate sources. Verify the current page and qualify topical relevance,\nlegitimacy, editorial fit, audience overlap, and eligibility. Reject paid-link schemes, bulk\ndirectories, expired pages, scraped lists, and opportunities with no suitable destination asset.\n\nReturn the structured state requested by the workflow. Contact discovery, outreach, submission,\nand provider project updates are separate operations and remain outside this research workflow.\n";
|
|
44308
45499
|
//#endregion
|
|
44309
45500
|
//#region src/cli/preset/opencode/skills/authority-research/references/executor.md
|
|
44310
45501
|
var executor_default$9 = "# Executor Starters for Authority Research\n\nThese snippets are starting points, not an inventory. Discover backlink and live-page capabilities\nbefore composing exact calls.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"OpenSEO DataForSEO backlink domain intersection\", limit: 8 }),\n tools.executor.search({ query: \"web search fetch directory editorial partner eligibility\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst gap = (await search({ query: \"tools.<exact-backlink-gap-path>\", limit: 1 })).items[0];\nreturn gap.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n targets: [ownedDomain, ...competitors],\n excludeInternal: true,\n limit: 100,\n});\n~~~\n\nRecord source URL, target domain, dates, limits, authority metrics, task status, errors, and cost.\nProvider metrics nominate prospects; current pages determine legitimacy. Do not discover personal\ncontact data, send outreach, submit listings, or mutate OpenSEO project state.\n";
|
|
44311
45502
|
//#endregion
|
|
44312
45503
|
//#region src/cli/preset/opencode/skills/competitive-landscape/SKILL.md
|
|
44313
|
-
var SKILL_default$
|
|
45504
|
+
var SKILL_default$9 = "---\nname: competitive-landscape\ndescription: Identify current search competitors and evidence-backed openings across representative queries.\n---\n\n# Competitive Landscape\n\nUse PageGraph's public-page corpus and product context to decide which domains compete for the same\nneed. Read [Executor Starters](references/executor.md) before collecting live market evidence.\n\nSample representative queries, find domains that recur, and compare their page types, intent,\ncoverage, differentiation, and authority signals. Separate direct product competitors, publishers,\ndirectories, communities, and incidental results. Study only the leaders whose evidence could\nchange a decision.\n\nReturn the structured state requested by the workflow. Do not copy competitor claims, treat every\nranking keyword as desirable, or update a provider's saved competitor list.\n";
|
|
44314
45505
|
//#endregion
|
|
44315
45506
|
//#region src/cli/preset/opencode/skills/competitive-landscape/references/executor.md
|
|
44316
45507
|
var executor_default$8 = "# Executor Starters for a Competitive Landscape\n\nThese snippets are starting points, not an inventory. Discover current tools and call only exact\nreturned paths after resolving their signatures.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"OpenSEO SERP competitors recurring domains\", limit: 8 }),\n tools.executor.search({ query: \"domain overview ranked keywords backlink authority\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst serp = (await search({ query: \"tools.<exact-serp-path>\", limit: 1 })).items[0];\nreturn serp.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n queries,\n market,\n language,\n device,\n});\n~~~\n\nPrefer relevant cached OpenSEO context or research when an existing project matches the domain\nunambiguously. Use external traffic and keyword values as estimates. Record query, market, device,\ndate, result depth, task status, errors, and cost. Do not update project context or competitors.\n";
|
|
44317
45508
|
//#endregion
|
|
44318
45509
|
//#region src/cli/preset/opencode/skills/content-analysis/SKILL.md
|
|
44319
|
-
var SKILL_default$
|
|
45510
|
+
var SKILL_default$8 = "---\nname: content-analysis\ndescription: Evaluate a page's substance, intent coverage, answer quality, and differentiation.\n---\n\n# Content Analysis\n\nStart from the supplied source, rendered page, route relationships, and project truth. Read\n[Executor Starters](references/executor.md) when live owned-site or competitive evidence is needed.\n\nJudge whether the page answers its intended need, supports material claims, avoids sibling-page\ncannibalization, and offers useful depth for its format. Use word counts and competitor excerpts as\ndescriptive signals, never as universal targets. Keep product accuracy ahead of search demand.\n\nReturn the structured state requested by the workflow. This workflow diagnoses content; it does not\nedit source files.\n";
|
|
44320
45511
|
//#endregion
|
|
44321
45512
|
//#region src/cli/preset/opencode/skills/content-analysis/references/executor.md
|
|
44322
45513
|
var executor_default$7 = "# Executor Starters for Content Analysis\n\nThese snippets are starting points, not an inventory. Discover owned query/page performance and\ncurrent result-page evidence when they can change the diagnosis.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console query page performance\", limit: 8 }),\n tools.executor.search({ query: \"OpenSEO current SERP competitor content\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst owned = (await search({ query: \"tools.<exact-owned-performance-path>\", limit: 1 })).items[0];\nreturn owned.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"PAGE\", \"QUERY\"],\n});\n~~~\n\nKeep Search Console or Bing performance separate from provider estimates and live-page\nobservations. Record reporting dates, filters, market, limits, task status, errors, and cost. Do not\nchange content or provider state.\n";
|
|
44323
45514
|
//#endregion
|
|
44324
45515
|
//#region src/cli/preset/opencode/skills/content-improvement/SKILL.md
|
|
44325
|
-
var SKILL_default$
|
|
45516
|
+
var SKILL_default$7 = "---\nname: content-improvement\ndescription: Research and apply bounded content changes supported by PageGraph and live evidence.\n---\n\n# Content Improvement\n\nResearch the target page exactly as described by [Executor Starters](references/executor.md). During\nthe research turn, diagnose intent, substance, differentiation, support, and sibling-page ownership\nwithout editing files.\n\nDuring the host-authorized action turn, apply only recommendations accepted by the decision report.\nPreserve the repository's public positioning and voice, edit the owning source rather than generated\noutput, and keep the change local to the selected page and intent. Do not invent metrics, customers,\ncapabilities, or citations.\n\nLeave the resulting repository diff uncommitted for review. Provider state remains read-only.\n";
|
|
44326
45517
|
//#endregion
|
|
44327
45518
|
//#region src/cli/preset/opencode/skills/content-improvement/references/executor.md
|
|
44328
45519
|
var executor_default$6 = "# Executor Starters for Content Improvement\n\nThese snippets are starting points, not an inventory. Discover owned performance and current SERP\nevidence during the read-only research turn.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console page query performance\", limit: 8 }),\n tools.executor.search({ query: \"OpenSEO current SERP intent competitor pages\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst serp = (await search({ query: \"tools.<exact-serp-path>\", limit: 1 })).items[0];\nreturn serp.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n query,\n market,\n language,\n device,\n});\n~~~\n\nRecord market, language, device, dates, limits, task status, errors, and cost. Search evidence may\nsupport a source edit only after the host evaluates it; never edit during research or write to an\nOpenSEO project.\n";
|
|
44329
45520
|
//#endregion
|
|
44330
45521
|
//#region src/cli/preset/opencode/skills/internal-linking/SKILL.md
|
|
44331
|
-
var SKILL_default$
|
|
45522
|
+
var SKILL_default$6 = "---\nname: internal-linking\ndescription: Research and apply useful contextual links supported by route and page evidence.\n---\n\n# Internal Linking\n\nUse PageGraph's nodes and edges to find relevant source-target pairs, then inspect both pages before\nproposing a link. Read [Executor Starters](references/executor.md) only when owned search evidence\ncan clarify page ownership or discoverability.\n\nPrefer links that help a reader continue a task, strengthen a clear hierarchy, or connect an\norphaned but relevant page. Reject forced anchors, repeated links, unsupported wording, and links\nwhose target does not satisfy the promised intent.\n\nDuring the host-authorized action turn, apply only accepted links at the owning source or route\ndeclaration. Preserve generated graph contracts, use natural anchor text, leave the diff uncommitted,\nand keep provider state read-only.\n";
|
|
44332
45523
|
//#endregion
|
|
44333
45524
|
//#region src/cli/preset/opencode/skills/internal-linking/references/executor.md
|
|
44334
45525
|
var executor_default$5 = "# Executor Starters for Internal Linking\n\nThese snippets are starting points, not an inventory. PageGraph's graph is primary; discover owned\nquery/page or crawl evidence only when it resolves ownership or discoverability.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console query page internal ownership\", limit: 8 }),\n tools.executor.search({ query: \"Bing Webmaster crawl links URL information\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst owned = (await search({ query: \"tools.<exact-owned-pages-path>\", limit: 1 })).items[0];\nreturn owned.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"PAGE\", \"QUERY\"],\n});\n~~~\n\nRecord dates, page and query filters, crawl state, task status, errors, and cost. Search performance\ncan support a graph recommendation but does not override the source content. Do not submit URLs,\nrequest indexing, or mutate provider projects.\n";
|
|
44335
45526
|
//#endregion
|
|
44336
45527
|
//#region src/cli/preset/opencode/skills/keyword-research/SKILL.md
|
|
44337
|
-
var SKILL_default$
|
|
45528
|
+
var SKILL_default$5 = "---\nname: keyword-research\ndescription: Discover current search demand, validate intent, and map opportunities to public pages.\n---\n\n# Keyword Research\n\nUse PageGraph's route and page evidence as the ownership baseline. Read\n[Executor Starters](references/executor.md) before collecting live evidence.\n\nStart from the supplied seeds and consumer product context. Expand only into queries that match the\nactual product, audience, and market. Use owned-site performance to establish current visibility,\nprovider metrics to estimate demand and competition, and current SERPs to validate intent. A volume\nestimate alone does not establish product fit or justify a new page.\n\nMap each supported query to the strongest existing page. Propose a new page only when no current\npage can satisfy the distinct intent without becoming unfocused. Preserve evidence and caveats for\nevery opportunity.\n\nReturn the structured state requested by the workflow. Do not save keywords, update provider\nprojects, submit URLs, or edit repository files during research.\n";
|
|
44338
45529
|
//#endregion
|
|
44339
45530
|
//#region src/cli/preset/opencode/skills/keyword-research/references/executor.md
|
|
44340
45531
|
var executor_default$4 = "# Executor Starters for Keyword Research\n\nThese snippets are starting points, not an inventory. Search Executor in one Code Mode turn, retain\nthe exact returned paths, and call those paths in a later turn after resolving their current\nsignatures.\n\nDiscover Google Search Console or Bing query/page performance, OpenSEO cached project context and\nkeyword research, provider keyword metrics, and live SERP results. Prefer a relevant cached OpenSEO\nresult when it already covers the requested market, language, device, and freshness. When a matching\nOpenSEO project can be resolved unambiguously, its project context and research log are supporting\nevidence; the consumer repository remains authoritative for product truth.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console performance query page\", limit: 8 }),\n tools.executor.search({ query: \"OpenSEO project context cached keyword research\", limit: 8 }),\n tools.executor.search({ query: \"keyword volume difficulty current SERP results\", limit: 8 }),\n]);\n~~~\n\nIn a later turn, replace placeholders with exact discovered paths:\n\n~~~ts\nconst call = (path, input) => path.split(\".\").reduce((node, segment) => node[segment], tools)(input);\nconst owned = (await search({ query: \"tools.<exact-owned-performance-path>\", limit: 1 })).items[0];\nconst demand = (await search({ query: \"tools.<exact-demand-path>\", limit: 1 })).items[0];\nreturn Promise.all([\n call(owned.path, { siteUrl, startDate, endDate, dimensions: [\"PAGE\", \"QUERY\"] }),\n call(demand.path, { keywords, market, language, device }),\n]);\n~~~\n\nTreat Search Console and Bing as owned-site evidence, OpenSEO and DataForSEO as provider evidence,\nand current result pages as observed evidence. Record market, language, device, reporting dates,\nlimits, task status, errors, and cost metadata when returned. Do not create or update an OpenSEO\nproject, save keywords, or perform another provider write.\n";
|
|
44341
45532
|
//#endregion
|
|
44342
45533
|
//#region src/cli/preset/opencode/skills/metadata-improvement/SKILL.md
|
|
44343
|
-
var SKILL_default$
|
|
45534
|
+
var SKILL_default$4 = "---\nname: metadata-improvement\ndescription: Research and apply truthful title and description improvements at the owning source.\n---\n\n# Metadata Improvement\n\nUse the route's intent, rendered head, sibling ownership, and live evidence described in\n[Executor Starters](references/executor.md). During research, compare candidate titles and\ndescriptions for accuracy, distinctness, intent alignment, and usefulness rather than fixed length\nor keyword-density targets.\n\nDuring the host-authorized action turn, apply only the accepted candidate at the owning route or\ncontent source. Preserve truthful product claims, existing metadata composition, canonical output,\nand repository conventions. Do not edit generated artifacts.\n\nLeave the resulting repository diff uncommitted. Provider state remains read-only.\n";
|
|
44344
45535
|
//#endregion
|
|
44345
45536
|
//#region src/cli/preset/opencode/skills/metadata-improvement/references/executor.md
|
|
44346
45537
|
var executor_default$3 = "# Executor Starters for Metadata Improvement\n\nThese snippets are starting points, not an inventory. Discover owned query/page evidence and a\ncurrent SERP before selecting a candidate.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console query page CTR position\", limit: 8 }),\n tools.executor.search({ query: \"current SERP titles snippets intent\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst owned = (await search({ query: \"tools.<exact-owned-performance-path>\", limit: 1 })).items[0];\nreturn owned.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"PAGE\", \"QUERY\"],\n});\n~~~\n\nRecord dates, query and page filters, market, device, task status, errors, and cost. CTR and position\ndescribe observed performance; they do not prove a particular title caused it. Do not mutate\nprovider metadata or request recrawls.\n";
|
|
44347
45538
|
//#endregion
|
|
44348
45539
|
//#region src/cli/preset/opencode/skills/schema/SKILL.md
|
|
44349
|
-
var SKILL_default$
|
|
45540
|
+
var SKILL_default$3 = "---\nname: schema\ndescription: Research and apply JSON-LD types justified by visible page content.\n---\n\n# Schema\n\nUse the rendered page, existing JSON-LD, route kind, and owning source as the primary evidence. Read\n[Executor Starters](references/executor.md) when owned search appearance or current eligibility\nevidence is material.\n\nPropose only types and properties that describe visible content. Schema does not repair missing\ncontent, guarantee a rich result, or justify unsupported ratings, prices, authorship, FAQs, or\norganization claims.\n\nDuring the host-authorized action turn, apply only accepted changes through the repository's\nexisting JSON-LD composition primitives. Do not edit rendered or generated output. Leave the diff\nuncommitted and provider state unchanged.\n";
|
|
44350
45541
|
//#endregion
|
|
44351
45542
|
//#region src/cli/preset/opencode/skills/schema/references/executor.md
|
|
44352
45543
|
var executor_default$2 = "# Executor Starters for Schema\n\nThese snippets are starting points, not an inventory. Discover owned search-appearance, URL\ninspection, rich-result, or documentation tools when they can change the eligibility assessment.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console search appearance page\", limit: 8 }),\n tools.executor.search({ query: \"URL inspection rich results structured data eligibility\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst appearance = (await search({ query: \"tools.<exact-search-appearance-path>\", limit: 1 })).items[0];\nreturn appearance.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"SEARCH_APPEARANCE\", \"PAGE\"],\n});\n~~~\n\nTreat visible page content and current primary requirements as eligibility authority. Record dates,\npage filters, result types, task status, errors, and cost. Do not request indexing, submit schemas,\nor update provider projects.\n";
|
|
44353
45544
|
//#endregion
|
|
44354
45545
|
//#region src/cli/preset/opencode/skills/serp-analysis/SKILL.md
|
|
44355
|
-
var SKILL_default$
|
|
45546
|
+
var SKILL_default$2 = "---\nname: serp-analysis\ndescription: Compare a target page with current results, intent, formats, features, and title patterns.\n---\n\n# SERP Analysis\n\nTreat each supplied query, market, language, device, and target page as one analysis unit. Read\n[Executor Starters](references/executor.md) before fetching current result evidence.\n\nIdentify dominant intent, page format, result features, recurring domains, title framing, and the\nevidence a useful page must satisfy. Compare the target against those patterns without turning\ncompetitor wording into product truth. Mark mixed or unstable intent rather than forcing a single\nconclusion.\n\nReturn the structured state requested by the workflow. A current SERP is observed evidence for its\nmarket and time, not a universal ranking rule.\n";
|
|
44356
45547
|
//#endregion
|
|
44357
45548
|
//#region src/cli/preset/opencode/skills/serp-analysis/references/executor.md
|
|
44358
45549
|
var executor_default$1 = "# Executor Starters for SERP Analysis\n\nThese snippets are starting points, not an inventory. Discover a current organic SERP capability\nand resolve its exact signature before use.\n\n~~~ts\nreturn tools.executor.search({\n query: \"OpenSEO DataForSEO current organic SERP features intent\",\n limit: 8,\n});\n~~~\n\nIn a later turn:\n\n~~~ts\nconst serp = (await search({ query: \"tools.<exact-serp-path>\", limit: 1 })).items[0];\nreturn serp.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n query,\n market,\n language,\n device,\n depth: 20,\n});\n~~~\n\nReuse a matching cached OpenSEO SERP when it satisfies the requested freshness. Record query,\nmarket, language, device, collection time, depth, features, task status, errors, and cost. Fetch\nindividual result pages only when their content is material to the decision.\n";
|
|
44359
45550
|
//#endregion
|
|
44360
45551
|
//#region src/cli/preset/opencode/skills/site-architecture/SKILL.md
|
|
44361
|
-
var SKILL_default = "---\nname: site-architecture\ndescription: Plan clear route ownership, hierarchy, collections, and contextual navigation.\n---\n\n# Site Architecture\n\nUse PageGraph's deterministic route nodes and edges as the primary model. Read\n[Executor Starters](references/executor.md) when owned search or crawl evidence can resolve an\nownership or discoverability question.\n\nAssign one clear intent owner, identify useful collections and parent-child relationships, and\nprefer consolidation when multiple pages compete for the same need. Propose new routes only when a\ndistinct supported intent lacks an adequate owner. Treat URL changes as consequential and preserve\nredirect and canonical requirements in the recommendation.\n\nReturn the structured state requested by the workflow. This planning workflow does not move routes,\nedit navigation, or submit sitemaps.\n";
|
|
45552
|
+
var SKILL_default$1 = "---\nname: site-architecture\ndescription: Plan clear route ownership, hierarchy, collections, and contextual navigation.\n---\n\n# Site Architecture\n\nUse PageGraph's deterministic route nodes and edges as the primary model. Read\n[Executor Starters](references/executor.md) when owned search or crawl evidence can resolve an\nownership or discoverability question.\n\nAssign one clear intent owner, identify useful collections and parent-child relationships, and\nprefer consolidation when multiple pages compete for the same need. Propose new routes only when a\ndistinct supported intent lacks an adequate owner. Treat URL changes as consequential and preserve\nredirect and canonical requirements in the recommendation.\n\nReturn the structured state requested by the workflow. This planning workflow does not move routes,\nedit navigation, or submit sitemaps.\n";
|
|
44362
45553
|
//#endregion
|
|
44363
45554
|
//#region src/cli/preset/opencode/skills/site-architecture/references/executor.md
|
|
44364
|
-
var executor_default = "# Executor Starters for Site Architecture\n\nThese snippets are starting points, not an inventory. Discover owned page/query, crawl, sitemap,\nand URL evidence only when the deterministic graph does not answer the question.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"
|
|
45555
|
+
var executor_default = "# Executor Starters for Site Architecture\n\nThese snippets are starting points, not an inventory. Discover owned page/query, crawl, sitemap,\nand URL evidence only when the deterministic graph does not answer the question.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Search Console page performance\", limit: 8 }),\n tools.executor.search({ query: \"Bing Webmaster crawl\", limit: 8 }),\n]);\n~~~\n\nInspect a selected tool's signature before executing it. This is Code Mode's local schema lookup;\nit does not call the provider:\n\n~~~ts\nreturn await search({ query: \"tools.<exact-path-returned-by-executor>\", limit: 1 });\n~~~\n\nIn a later turn, call that exact path with the required fields from its returned signature. Property,\nproject, URL, date, and dimension arguments differ between integrations. Do not copy guessed fields\nfrom a recipe. Prefer page/query performance for demand and ownership questions; use URL inspection\nwhen the question actually concerns crawl, canonical, or indexing state.\n\nKeep submitted, crawled, indexed, ranking, and clicked evidence distinct. Record reporting dates,\nfilters, canonical state, task status, errors, and cost. Do not request indexing, submit sitemaps,\nor update provider projects.\n";
|
|
44365
45556
|
//#endregion
|
|
44366
45557
|
//#region src/cli/preset/opencode/preset.ts
|
|
44367
45558
|
const skill = (name, definition, executor) => ({
|
|
@@ -44372,17 +45563,17 @@ const opencodePreset = {
|
|
|
44372
45563
|
".pagegraph/opencode/opencode.jsonc": opencode_default,
|
|
44373
45564
|
".pagegraph/opencode/AGENTS.md": AGENTS_default,
|
|
44374
45565
|
".pagegraph/opencode/agents/seo.md": seo_default,
|
|
44375
|
-
...skill("keyword-research", SKILL_default$
|
|
44376
|
-
...skill("competitive-landscape", SKILL_default$
|
|
44377
|
-
...skill("authority-research", SKILL_default$
|
|
44378
|
-
...skill("serp-analysis", SKILL_default$
|
|
44379
|
-
...skill("content-analysis", SKILL_default$
|
|
44380
|
-
...skill("ai-search", SKILL_default$
|
|
44381
|
-
...skill("site-architecture", SKILL_default, executor_default),
|
|
44382
|
-
...skill("content-improvement", SKILL_default$
|
|
44383
|
-
...skill("metadata-improvement", SKILL_default$
|
|
44384
|
-
...skill("schema", SKILL_default$
|
|
44385
|
-
...skill("internal-linking", SKILL_default$
|
|
45566
|
+
...skill("keyword-research", SKILL_default$5, executor_default$4),
|
|
45567
|
+
...skill("competitive-landscape", SKILL_default$9, executor_default$8),
|
|
45568
|
+
...skill("authority-research", SKILL_default$10, executor_default$9),
|
|
45569
|
+
...skill("serp-analysis", SKILL_default$2, executor_default$1),
|
|
45570
|
+
...skill("content-analysis", SKILL_default$8, executor_default$7),
|
|
45571
|
+
...skill("ai-search", SKILL_default$11, executor_default$10),
|
|
45572
|
+
...skill("site-architecture", SKILL_default$1, executor_default),
|
|
45573
|
+
...skill("content-improvement", SKILL_default$7, executor_default$6),
|
|
45574
|
+
...skill("metadata-improvement", SKILL_default$4, executor_default$3),
|
|
45575
|
+
...skill("schema", SKILL_default$3, executor_default$2),
|
|
45576
|
+
...skill("internal-linking", SKILL_default$6, executor_default$5)
|
|
44386
45577
|
};
|
|
44387
45578
|
//#endregion
|
|
44388
45579
|
//#region src/cli/commands/init.ts
|
|
@@ -44448,277 +45639,6 @@ const initCommand = make$9("init", {
|
|
|
44448
45639
|
else yield* printText(`${dryRun ? "Would create" : "Created"} ${created.length} file(s). Kept ${existing.length} existing file(s).`);
|
|
44449
45640
|
})));
|
|
44450
45641
|
//#endregion
|
|
44451
|
-
//#region src/audit/crawl.ts
|
|
44452
|
-
/** Anchors that are never HTML documents; skipped so they do not consume the budget. */
|
|
44453
|
-
const NON_PAGE = /\.(?:avif|css|csv|gif|ico|jpe?g|js|json|map|mp4|pdf|png|rss|svg|txt|webm|webp|woff2?|xml|zip)$/i;
|
|
44454
|
-
/** The graph keys pages by path, so dedupe crawled URLs the same way. */
|
|
44455
|
-
const pageKey = (url) => normalizePath(url);
|
|
44456
|
-
/** Run `run` over `items` with at most `limit` in flight, preserving order. */
|
|
44457
|
-
const mapLimit = async (items, limit, run) => {
|
|
44458
|
-
const results = Array.from({ length: items.length });
|
|
44459
|
-
let index = 0;
|
|
44460
|
-
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
44461
|
-
for (;;) {
|
|
44462
|
-
const current = index++;
|
|
44463
|
-
if (current >= items.length) return;
|
|
44464
|
-
results[current] = await run(items[current]);
|
|
44465
|
-
}
|
|
44466
|
-
});
|
|
44467
|
-
await Promise.all(workers);
|
|
44468
|
-
return results.map((result, at) => {
|
|
44469
|
-
if (result === void 0) throw new Error(`Crawl worker ${at} produced no result`);
|
|
44470
|
-
return result;
|
|
44471
|
-
});
|
|
44472
|
-
};
|
|
44473
|
-
/**
|
|
44474
|
-
* Crawl `seed`'s origin breadth-first and return one {@link RenderedPage} per
|
|
44475
|
-
* successfully fetched HTML page. The seed must be an absolute `http(s)` URL;
|
|
44476
|
-
* private and reserved destinations are rejected unless `allowPrivate` is set.
|
|
44477
|
-
*/
|
|
44478
|
-
const crawlRenderedPages = async (seed, options) => {
|
|
44479
|
-
let seedUrl;
|
|
44480
|
-
try {
|
|
44481
|
-
seedUrl = new URL(seed);
|
|
44482
|
-
} catch {
|
|
44483
|
-
throw new Error(`Not a valid URL: ${seed}`);
|
|
44484
|
-
}
|
|
44485
|
-
if (seedUrl.protocol !== "https:" && seedUrl.protocol !== "http:") throw new Error(`Unsupported URL protocol: ${seedUrl.protocol}`);
|
|
44486
|
-
if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new Error(`Crawl limit must be a positive integer: ${options.limit}`);
|
|
44487
|
-
const origin = seedUrl.origin;
|
|
44488
|
-
const probeOptions = {
|
|
44489
|
-
allowPrivate: options.allowPrivate,
|
|
44490
|
-
timeoutMs: options.timeoutMs,
|
|
44491
|
-
maxBodyBytes: options.maxBodyBytes,
|
|
44492
|
-
captureAnchors: true
|
|
44493
|
-
};
|
|
44494
|
-
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
44495
|
-
const visited = /* @__PURE__ */ new Set([pageKey(seedUrl)]);
|
|
44496
|
-
/** Final paths already rendered, so a redirect cannot add the same page twice. */
|
|
44497
|
-
const rendered = /* @__PURE__ */ new Set();
|
|
44498
|
-
const pages = [];
|
|
44499
|
-
const failures = [];
|
|
44500
|
-
const truncatedBodies = [];
|
|
44501
|
-
let attempts = 0;
|
|
44502
|
-
let truncated = false;
|
|
44503
|
-
let root = pageKey(seedUrl);
|
|
44504
|
-
let frontier = [{
|
|
44505
|
-
url: seedUrl,
|
|
44506
|
-
depth: 0
|
|
44507
|
-
}];
|
|
44508
|
-
const probePage = async (item) => {
|
|
44509
|
-
const request = {
|
|
44510
|
-
kind: "page-html",
|
|
44511
|
-
method: "GET",
|
|
44512
|
-
accept: "text/html, */*;q=0.1",
|
|
44513
|
-
url: item.url
|
|
44514
|
-
};
|
|
44515
|
-
const probe = await probeHttp(request, probeOptions);
|
|
44516
|
-
if (!probe.ok || probe.finalUrl === null) return {
|
|
44517
|
-
item,
|
|
44518
|
-
page: null,
|
|
44519
|
-
failure: {
|
|
44520
|
-
url: item.url.href,
|
|
44521
|
-
error: probe.error ?? `HTTP ${probe.status ?? "no response"}`
|
|
44522
|
-
},
|
|
44523
|
-
links: [],
|
|
44524
|
-
bodyTruncated: false
|
|
44525
|
-
};
|
|
44526
|
-
if (new URL(probe.finalUrl).origin !== origin) return {
|
|
44527
|
-
item,
|
|
44528
|
-
page: null,
|
|
44529
|
-
failure: {
|
|
44530
|
-
url: item.url.href,
|
|
44531
|
-
error: `Redirected off-origin to ${probe.finalUrl}`
|
|
44532
|
-
},
|
|
44533
|
-
links: [],
|
|
44534
|
-
bodyTruncated: false
|
|
44535
|
-
};
|
|
44536
|
-
const anchors = probe.anchors ?? [];
|
|
44537
|
-
const links = anchors.flatMap((anchor) => {
|
|
44538
|
-
if (!anchor.internal) return [];
|
|
44539
|
-
let target;
|
|
44540
|
-
try {
|
|
44541
|
-
target = new URL(anchor.href);
|
|
44542
|
-
} catch {
|
|
44543
|
-
return [];
|
|
44544
|
-
}
|
|
44545
|
-
if (target.origin !== origin) return [];
|
|
44546
|
-
if (NON_PAGE.test(target.pathname)) return [];
|
|
44547
|
-
return [target];
|
|
44548
|
-
});
|
|
44549
|
-
return {
|
|
44550
|
-
item,
|
|
44551
|
-
page: {
|
|
44552
|
-
url: probe.finalUrl,
|
|
44553
|
-
anchors
|
|
44554
|
-
},
|
|
44555
|
-
failure: null,
|
|
44556
|
-
links,
|
|
44557
|
-
bodyTruncated: probe.bodyTruncated
|
|
44558
|
-
};
|
|
44559
|
-
};
|
|
44560
|
-
while (frontier.length > 0) {
|
|
44561
|
-
const remaining = options.limit - attempts;
|
|
44562
|
-
if (remaining <= 0) {
|
|
44563
|
-
truncated = true;
|
|
44564
|
-
break;
|
|
44565
|
-
}
|
|
44566
|
-
const batch = frontier.length > remaining ? frontier.slice(0, remaining) : frontier;
|
|
44567
|
-
if (frontier.length > batch.length) truncated = true;
|
|
44568
|
-
frontier = [];
|
|
44569
|
-
attempts += batch.length;
|
|
44570
|
-
const probed = await mapLimit(batch, concurrency, probePage);
|
|
44571
|
-
const discovered = [];
|
|
44572
|
-
for (const result of probed) {
|
|
44573
|
-
if (result.failure !== null) failures.push(result.failure);
|
|
44574
|
-
if (result.page === null) continue;
|
|
44575
|
-
const finalKey = pageKey(new URL(result.page.url));
|
|
44576
|
-
if (result.item.depth === 0) root = finalKey;
|
|
44577
|
-
if (rendered.has(finalKey)) continue;
|
|
44578
|
-
rendered.add(finalKey);
|
|
44579
|
-
visited.add(finalKey);
|
|
44580
|
-
pages.push(result.page);
|
|
44581
|
-
if (result.bodyTruncated) truncatedBodies.push(result.page.url);
|
|
44582
|
-
for (const link of result.links) {
|
|
44583
|
-
const key = pageKey(link);
|
|
44584
|
-
if (visited.has(key)) continue;
|
|
44585
|
-
visited.add(key);
|
|
44586
|
-
discovered.push({
|
|
44587
|
-
url: link,
|
|
44588
|
-
depth: result.item.depth + 1
|
|
44589
|
-
});
|
|
44590
|
-
}
|
|
44591
|
-
}
|
|
44592
|
-
frontier = discovered;
|
|
44593
|
-
}
|
|
44594
|
-
return {
|
|
44595
|
-
origin,
|
|
44596
|
-
seed: seedUrl.href,
|
|
44597
|
-
root,
|
|
44598
|
-
pages,
|
|
44599
|
-
failures,
|
|
44600
|
-
truncatedBodies,
|
|
44601
|
-
truncated,
|
|
44602
|
-
limit: options.limit
|
|
44603
|
-
};
|
|
44604
|
-
};
|
|
44605
|
-
//#endregion
|
|
44606
|
-
//#region src/core/link-candidates.ts
|
|
44607
|
-
/**
|
|
44608
|
-
* Canonical path key: query and hash stripped, trailing slashes removed, "/"
|
|
44609
|
-
* preserved. This is the same path key the graph and the rendered-link core
|
|
44610
|
-
* use, so a served anchor like `/blog/a?ref=nav` still matches the graph pair
|
|
44611
|
-
* `/blog/a`.
|
|
44612
|
-
*/
|
|
44613
|
-
const normalize = (path) => {
|
|
44614
|
-
const trimmed = (path.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "");
|
|
44615
|
-
return trimmed === "" ? "/" : trimmed;
|
|
44616
|
-
};
|
|
44617
|
-
/** Directionless pair key, so an edge in either direction means "connected". */
|
|
44618
|
-
const undirectedEdgeKey = (from, to) => {
|
|
44619
|
-
const [a, b] = [normalize(from), normalize(to)].sort();
|
|
44620
|
-
return `${a}\u0000${b}`;
|
|
44621
|
-
};
|
|
44622
|
-
/** First path segment, or undefined for the root page. */
|
|
44623
|
-
const topSegment = (path) => {
|
|
44624
|
-
const segment = path.split("/").filter(Boolean)[0];
|
|
44625
|
-
return segment === void 0 || segment === "" ? void 0 : segment;
|
|
44626
|
-
};
|
|
44627
|
-
/** A page with no nested segment (`/`, `/pricing`, `/blog`) is root-level. */
|
|
44628
|
-
const isRootLevel = (path) => path.split("/").filter(Boolean).length <= 1;
|
|
44629
|
-
/**
|
|
44630
|
-
* The cluster a pair shares, or undefined when they share none. Section-first:
|
|
44631
|
-
* a shared top-level section wins. The kind fallback is local to root-level
|
|
44632
|
-
* pages, so it never pairs two nested pages from different sections.
|
|
44633
|
-
*/
|
|
44634
|
-
const clusterOf = (a, b) => {
|
|
44635
|
-
const aSegment = topSegment(a.path);
|
|
44636
|
-
const bSegment = topSegment(b.path);
|
|
44637
|
-
if (aSegment !== void 0 && aSegment === bSegment) return {
|
|
44638
|
-
key: aSegment,
|
|
44639
|
-
reason: `same top-level section "/${aSegment}"`
|
|
44640
|
-
};
|
|
44641
|
-
if (isRootLevel(a.path) && isRootLevel(b.path) && a.kind === b.kind) return {
|
|
44642
|
-
key: `kind:${a.kind}`,
|
|
44643
|
-
reason: `same kind "${a.kind}"`
|
|
44644
|
-
};
|
|
44645
|
-
};
|
|
44646
|
-
/** A `--cluster` filter matches a section by name, or a kind via its bare label. */
|
|
44647
|
-
const matchesClusterFilter = (cluster, filter) => {
|
|
44648
|
-
const normalized = filter.replace(/^\/+/, "").toLowerCase();
|
|
44649
|
-
const key = cluster.toLowerCase();
|
|
44650
|
-
return key === normalized || key === `kind:${normalized}`;
|
|
44651
|
-
};
|
|
44652
|
-
/**
|
|
44653
|
-
* Enumerate reviewable contextual-link candidates from the declared graph.
|
|
44654
|
-
*
|
|
44655
|
-
* Excluded: self-pairs, pages that are not sitemap-eligible, pairs already
|
|
44656
|
-
* declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}
|
|
44657
|
-
* is supplied — pairs already rendered as an anchor. Queries, hashes, and
|
|
44658
|
-
* trailing slashes are normalized so both sides compare by the graph's path key.
|
|
44659
|
-
*/
|
|
44660
|
-
const generateLinkCandidates = (graph, options = {}) => {
|
|
44661
|
-
const limit = options.limit ?? 50;
|
|
44662
|
-
const filters = options.clusters ?? [];
|
|
44663
|
-
const rendered = new Set((options.renderedEdges ?? []).map((edge) => undirectedEdgeKey(edge.from, edge.to)));
|
|
44664
|
-
const declared = new Set(graph.edges.filter((edge) => edge.type === "related").map((edge) => undirectedEdgeKey(edge.from, edge.to)));
|
|
44665
|
-
const eligible = [...graph.nodes.values()].filter(isSitemapEligible);
|
|
44666
|
-
const all = [];
|
|
44667
|
-
for (let i = 0; i < eligible.length; i++) for (let j = i + 1; j < eligible.length; j++) {
|
|
44668
|
-
const a = eligible[i];
|
|
44669
|
-
const b = eligible[j];
|
|
44670
|
-
const cluster = clusterOf(a, b);
|
|
44671
|
-
if (cluster === void 0) continue;
|
|
44672
|
-
if (filters.length > 0 && !filters.some((filter) => matchesClusterFilter(cluster.key, filter))) continue;
|
|
44673
|
-
const key = undirectedEdgeKey(a.path, b.path);
|
|
44674
|
-
if (declared.has(key) || rendered.has(key)) continue;
|
|
44675
|
-
all.push({
|
|
44676
|
-
source: a.path,
|
|
44677
|
-
destination: b.path,
|
|
44678
|
-
cluster: cluster.key,
|
|
44679
|
-
reason: cluster.reason
|
|
44680
|
-
});
|
|
44681
|
-
all.push({
|
|
44682
|
-
source: b.path,
|
|
44683
|
-
destination: a.path,
|
|
44684
|
-
cluster: cluster.key,
|
|
44685
|
-
reason: cluster.reason
|
|
44686
|
-
});
|
|
44687
|
-
}
|
|
44688
|
-
const compare = (x, y) => x.cluster < y.cluster ? -1 : x.cluster > y.cluster ? 1 : x.source < y.source ? -1 : x.source > y.source ? 1 : x.destination < y.destination ? -1 : x.destination > y.destination ? 1 : 0;
|
|
44689
|
-
all.sort(compare);
|
|
44690
|
-
const counts = /* @__PURE__ */ new Map();
|
|
44691
|
-
for (const pair of all) counts.set(pair.cluster, (counts.get(pair.cluster) ?? 0) + 1);
|
|
44692
|
-
const candidates = all.slice(0, limit);
|
|
44693
|
-
return {
|
|
44694
|
-
candidates,
|
|
44695
|
-
total: all.length,
|
|
44696
|
-
truncated: all.length > candidates.length,
|
|
44697
|
-
clusters: [...counts.entries()].map(([key, count]) => ({
|
|
44698
|
-
key,
|
|
44699
|
-
candidates: count
|
|
44700
|
-
}))
|
|
44701
|
-
};
|
|
44702
|
-
};
|
|
44703
|
-
/**
|
|
44704
|
-
* Decode a rendered-edge dump supplied to the CLI: either a bare array of
|
|
44705
|
-
* `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).
|
|
44706
|
-
* Throws on any other shape — this is user input, not a provider payload.
|
|
44707
|
-
*/
|
|
44708
|
-
const decodeRenderedEdges = (input) => {
|
|
44709
|
-
const array = Array.isArray(input) ? input : input !== null && typeof input === "object" && Array.isArray(input.edges) ? input.edges : input !== null && typeof input === "object" && Array.isArray(input.internalEdges) ? input.internalEdges : void 0;
|
|
44710
|
-
if (array === void 0) throw new Error("expected an array of { from, to } edges, or an object with an `edges` array");
|
|
44711
|
-
return array.map((item) => {
|
|
44712
|
-
if (item === null || typeof item !== "object") throw new Error("each rendered edge must be an object with string `from` and `to`");
|
|
44713
|
-
const { from, to } = item;
|
|
44714
|
-
if (typeof from !== "string" || typeof to !== "string") throw new Error("each rendered edge must have string `from` and `to`");
|
|
44715
|
-
return {
|
|
44716
|
-
from,
|
|
44717
|
-
to
|
|
44718
|
-
};
|
|
44719
|
-
});
|
|
44720
|
-
};
|
|
44721
|
-
//#endregion
|
|
44722
45642
|
//#region src/cli/commands/links.ts
|
|
44723
45643
|
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
44724
45644
|
const positive = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
@@ -44744,6 +45664,7 @@ const analyzeDeclared = (crawlOrigin, renderedEdges, assertCoverage, crawlState)
|
|
|
44744
45664
|
if (assertCoverage) {
|
|
44745
45665
|
if (crawlState.truncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage on a truncated crawl; re-run with a higher --limit." });
|
|
44746
45666
|
if (crawlState.failures > 0) return yield* new SeoCliError({ message: `Refusing to assert rendered coverage: ${crawlState.failures} page(s) failed to fetch, so their anchors are missing.` });
|
|
45667
|
+
if (crawlState.discoveryFailures > 0 || crawlState.sitemapTruncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage: sitemap or robots discovery was incomplete." });
|
|
44747
45668
|
if (crawlState.bodyTruncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage when a crawled body was truncated; raise --max-body-bytes." });
|
|
44748
45669
|
}
|
|
44749
45670
|
const warnings = [];
|
|
@@ -44785,6 +45706,11 @@ const analyzeDeclared = (crawlOrigin, renderedEdges, assertCoverage, crawlState)
|
|
|
44785
45706
|
}
|
|
44786
45707
|
return yield* scoped(gen(function* () {
|
|
44787
45708
|
const graph = yield* acquireGraph(config);
|
|
45709
|
+
if (assertCoverage && crawlState.nonHtml.length > 0) {
|
|
45710
|
+
const declaredPaths = new Set([...graph.nodes.values()].filter(isSitemapEligible).map((node) => normalizePath(new URL(node.path, crawlOrigin))));
|
|
45711
|
+
const missed = crawlState.nonHtml.find((item) => declaredPaths.has(normalizePath(new URL(item.url))) || declaredPaths.has(normalizePath(new URL(item.finalUrl))));
|
|
45712
|
+
if (missed !== void 0) return yield* new SeoCliError({ message: `Refusing to assert rendered coverage: declared page ${missed.url} served non-HTML content.` });
|
|
45713
|
+
}
|
|
44788
45714
|
return {
|
|
44789
45715
|
declaredEdges: graph.edges.filter((edge) => edge.type === "related").map((edge) => ({
|
|
44790
45716
|
from: edge.from,
|
|
@@ -44878,8 +45804,13 @@ const linksVerifyCommand = make$9("verify", {
|
|
|
44878
45804
|
limit: artifact.crawl.limit,
|
|
44879
45805
|
truncated: artifact.crawl.truncated,
|
|
44880
45806
|
failures: artifact.crawl.failures,
|
|
45807
|
+
...artifact.crawl.nonHtml === void 0 ? {} : { nonHtml: artifact.crawl.nonHtml },
|
|
44881
45808
|
bodyTruncated: artifact.crawl.bodyTruncated,
|
|
44882
|
-
truncatedPages: artifact.crawl.truncatedPages
|
|
45809
|
+
truncatedPages: artifact.crawl.truncatedPages,
|
|
45810
|
+
...artifact.crawl.discoveryFailures === void 0 ? {} : { discoveryFailures: artifact.crawl.discoveryFailures },
|
|
45811
|
+
...artifact.crawl.skipped === void 0 ? {} : { skipped: artifact.crawl.skipped },
|
|
45812
|
+
...artifact.crawl.sitemapTruncated === void 0 ? {} : { sitemapTruncated: artifact.crawl.sitemapTruncated },
|
|
45813
|
+
...artifact.crawl.sitemapUsed === void 0 ? {} : { sitemapUsed: artifact.crawl.sitemapUsed }
|
|
44883
45814
|
};
|
|
44884
45815
|
} else {
|
|
44885
45816
|
const checkedLimit = yield* positive("limit", options.limit);
|
|
@@ -44904,8 +45835,13 @@ const linksVerifyCommand = make$9("verify", {
|
|
|
44904
45835
|
limit: crawl.limit,
|
|
44905
45836
|
truncated: crawl.truncated,
|
|
44906
45837
|
failures: crawl.failures,
|
|
45838
|
+
nonHtml: crawl.nonHtml,
|
|
44907
45839
|
bodyTruncated: crawl.truncatedBodies.length > 0,
|
|
44908
|
-
truncatedPages: crawl.truncatedBodies
|
|
45840
|
+
truncatedPages: crawl.truncatedBodies,
|
|
45841
|
+
discoveryFailures: crawl.discoveryFailures,
|
|
45842
|
+
skipped: crawl.skipped,
|
|
45843
|
+
sitemapTruncated: crawl.sitemapTruncated,
|
|
45844
|
+
sitemapUsed: crawl.sitemapUsed
|
|
44909
45845
|
};
|
|
44910
45846
|
if (emitPath !== void 0) {
|
|
44911
45847
|
const artifact = {
|
|
@@ -44920,7 +45856,12 @@ const linksVerifyCommand = make$9("verify", {
|
|
|
44920
45856
|
truncated: crawl.truncated,
|
|
44921
45857
|
bodyTruncated: crawl.truncatedBodies.length > 0,
|
|
44922
45858
|
truncatedPages: crawl.truncatedBodies,
|
|
44923
|
-
failures: crawl.failures
|
|
45859
|
+
failures: crawl.failures,
|
|
45860
|
+
nonHtml: crawl.nonHtml,
|
|
45861
|
+
discoveryFailures: crawl.discoveryFailures,
|
|
45862
|
+
skipped: crawl.skipped,
|
|
45863
|
+
sitemapTruncated: crawl.sitemapTruncated,
|
|
45864
|
+
sitemapUsed: crawl.sitemapUsed
|
|
44924
45865
|
},
|
|
44925
45866
|
nodes: graph.nodes,
|
|
44926
45867
|
edges: graph.internalEdges
|
|
@@ -44930,10 +45871,15 @@ const linksVerifyCommand = make$9("verify", {
|
|
|
44930
45871
|
}
|
|
44931
45872
|
}
|
|
44932
45873
|
if (crawlReport.truncatedPages.length > 0) warnings.push(`${crawlReport.truncatedPages.length} page body/bodies exceeded --max-body-bytes; anchors past the cutoff are missing.`);
|
|
45874
|
+
if ((crawlReport.discoveryFailures?.length ?? 0) > 0 || crawlReport.sitemapTruncated === true) warnings.push("Sitemap or robots discovery was incomplete; rendered coverage cannot be asserted.");
|
|
45875
|
+
if ((crawlReport.nonHtml?.length ?? 0) > 0) warnings.push(`${crawlReport.nonHtml.length} link-only URL(s) served non-HTML and were skipped.`);
|
|
44933
45876
|
const analysis = yield* analyzeDeclared(origin, renderedEdges, options.assertCoverage, {
|
|
44934
45877
|
truncated: crawlReport.truncated,
|
|
44935
45878
|
bodyTruncated: crawlReport.bodyTruncated,
|
|
44936
|
-
failures: crawlReport.failures.length
|
|
45879
|
+
failures: crawlReport.failures.length,
|
|
45880
|
+
discoveryFailures: crawlReport.discoveryFailures?.length ?? 0,
|
|
45881
|
+
sitemapTruncated: crawlReport.sitemapTruncated ?? false,
|
|
45882
|
+
nonHtml: crawlReport.nonHtml ?? []
|
|
44937
45883
|
});
|
|
44938
45884
|
warnings.push(...analysis.warnings);
|
|
44939
45885
|
const diff = analysis.declaredEdges === void 0 ? void 0 : diffLinkGraph(analysis.declaredEdges, graph);
|
|
@@ -44971,6 +45917,10 @@ const linksVerifyCommand = make$9("verify", {
|
|
|
44971
45917
|
const candidatesLimitFlag = Int("limit").pipe(withDescription$2("Maximum candidates to output after ordering"), withDefault(50));
|
|
44972
45918
|
const clusterFlag = String$2("cluster").pipe(withDescription$2("Restrict to a cluster: a top-level section or a kind (repeatable)"), between(0, 64));
|
|
44973
45919
|
const renderedFlag = String$2("rendered").pipe(withDescription$2("JSON file of already-rendered edges to exclude (array or { edges })"), optional$1);
|
|
45920
|
+
const siteFlag = String$2("site").pipe(withDescription$2("Opt in to same-origin served-content suggestions"), optional$1);
|
|
45921
|
+
const pageLimitFlag = Int("page-limit").pipe(withDescription$2("Maximum declared pages to probe for content (default 25)"), withDefault(25));
|
|
45922
|
+
const targetFlag = String$2("target").pipe(withDescription$2("Prioritize an exact target page path (repeatable)"), between(0, 64));
|
|
45923
|
+
const sourceFlag = String$2("source").pipe(withDescription$2("Prioritize an exact source page path (repeatable)"), between(0, 64));
|
|
44974
45924
|
/** Read already-rendered edges from an optional JSON file; absent input is no exclusions. */
|
|
44975
45925
|
const readRenderedEdges = (file) => file === void 0 ? succeed$2([]) : try_({
|
|
44976
45926
|
try: () => decodeRenderedEdges(JSON.parse(readFileSync(file, "utf8"))),
|
|
@@ -44980,7 +45930,14 @@ const linksCandidatesCommand = make$9("candidates", {
|
|
|
44980
45930
|
json: jsonFlag,
|
|
44981
45931
|
limit: candidatesLimitFlag,
|
|
44982
45932
|
cluster: clusterFlag,
|
|
44983
|
-
rendered: renderedFlag
|
|
45933
|
+
rendered: renderedFlag,
|
|
45934
|
+
site: siteFlag,
|
|
45935
|
+
pageLimit: pageLimitFlag,
|
|
45936
|
+
target: targetFlag,
|
|
45937
|
+
source: sourceFlag,
|
|
45938
|
+
allowPrivate,
|
|
45939
|
+
requestTimeoutMs,
|
|
45940
|
+
maxBodyBytes
|
|
44984
45941
|
}).pipe(withDescription$1("Propose reviewable contextual links from the declared graph, clustered and not already connected"), withExamples([
|
|
44985
45942
|
{
|
|
44986
45943
|
command: "pagegraph links candidates",
|
|
@@ -44993,12 +45950,149 @@ const linksCandidatesCommand = make$9("candidates", {
|
|
|
44993
45950
|
{
|
|
44994
45951
|
command: "pagegraph links candidates --rendered rendered.json --json | jq",
|
|
44995
45952
|
description: "Exclude anchors already served, and emit versioned JSON"
|
|
45953
|
+
},
|
|
45954
|
+
{
|
|
45955
|
+
command: "pagegraph links candidates --site https://example.com --json",
|
|
45956
|
+
description: "Rank verifiable sentence-and-anchor suggestions from served pages"
|
|
45957
|
+
},
|
|
45958
|
+
{
|
|
45959
|
+
command: "pagegraph links candidates --site https://example.com --cluster blog --page-limit 25 --json",
|
|
45960
|
+
description: "Focus a bounded probe on an editorial section when the site-wide sample is sparse"
|
|
45961
|
+
},
|
|
45962
|
+
{
|
|
45963
|
+
command: "pagegraph links candidates --site https://example.com --target /blog/weak --json",
|
|
45964
|
+
description: "Probe a weak target and its candidate source pages first"
|
|
44996
45965
|
}
|
|
44997
45966
|
]), withHandler(fn("SeoCli.linksCandidates")(function* (options) {
|
|
44998
45967
|
const limit = yield* positive("limit", options.limit);
|
|
45968
|
+
const pageLimit = yield* positive("page-limit", options.pageLimit);
|
|
45969
|
+
if (isSome(options.site) && (!Number.isSafeInteger(options.maxBodyBytes) || options.maxBodyBytes < 1 || options.maxBodyBytes > 1e7)) return yield* new SeoCliError({ message: "--max-body-bytes must be between 1 and 10000000 in site mode" });
|
|
44999
45970
|
const config = yield* loadSeoConfig;
|
|
45000
45971
|
const graph = yield* scoped(acquireGraph(config));
|
|
45001
45972
|
const renderedEdges = yield* readRenderedEdges(getOrUndefined$1(options.rendered));
|
|
45973
|
+
const site = getOrUndefined$1(options.site);
|
|
45974
|
+
if (site !== void 0) {
|
|
45975
|
+
const report = yield* tryPromise({
|
|
45976
|
+
try: async () => {
|
|
45977
|
+
const target = new URL(site);
|
|
45978
|
+
const configured = new URL(config.origin);
|
|
45979
|
+
if (!["http:", "https:"].includes(target.protocol) || target.origin !== configured.origin) throw new Error(`--site origin must match seo.config.ts (${configured.origin})`);
|
|
45980
|
+
const requestedTargets = new Set(options.target);
|
|
45981
|
+
const requestedSources = new Set(options.source);
|
|
45982
|
+
const nodes = [...graph.nodes.values()].filter(isSitemapEligible);
|
|
45983
|
+
const selected = selectSuggestionPages(graph, {
|
|
45984
|
+
pageLimit,
|
|
45985
|
+
targets: requestedTargets,
|
|
45986
|
+
sources: requestedSources,
|
|
45987
|
+
clusters: options.cluster,
|
|
45988
|
+
renderedEdges
|
|
45989
|
+
});
|
|
45990
|
+
const selectedPaths = new Set(selected.map((node) => node.path));
|
|
45991
|
+
const skipped = nodes.filter((node) => !selectedPaths.has(node.path)).map((node) => ({
|
|
45992
|
+
path: node.path,
|
|
45993
|
+
reason: "page limit or target filter"
|
|
45994
|
+
}));
|
|
45995
|
+
const robots = await probeHttp({
|
|
45996
|
+
kind: "robots",
|
|
45997
|
+
method: "GET",
|
|
45998
|
+
accept: "text/plain",
|
|
45999
|
+
url: new URL("/robots.txt", configured)
|
|
46000
|
+
}, {
|
|
46001
|
+
sameOrigin: configured.origin,
|
|
46002
|
+
allowPrivate: options.allowPrivate,
|
|
46003
|
+
timeoutMs: options.requestTimeoutMs,
|
|
46004
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
46005
|
+
captureBody: true
|
|
46006
|
+
});
|
|
46007
|
+
if (!robots.ok && robots.status !== 404 || robots.bodyTruncated) throw new Error("Could not establish complete robots policy for the site");
|
|
46008
|
+
const rules = robots.ok ? robotsRules(robots.body ?? "").rules : [];
|
|
46009
|
+
const pages = [];
|
|
46010
|
+
const observed = [];
|
|
46011
|
+
for (const node of selected) {
|
|
46012
|
+
if (!allowedByRobots(node.path, rules)) {
|
|
46013
|
+
skipped.push({
|
|
46014
|
+
path: node.path,
|
|
46015
|
+
reason: "robots disallow"
|
|
46016
|
+
});
|
|
46017
|
+
continue;
|
|
46018
|
+
}
|
|
46019
|
+
const url = new URL(node.path, configured);
|
|
46020
|
+
const probe = await probeHttp({
|
|
46021
|
+
kind: "page-html",
|
|
46022
|
+
method: "GET",
|
|
46023
|
+
accept: "text/html",
|
|
46024
|
+
url
|
|
46025
|
+
}, {
|
|
46026
|
+
sameOrigin: configured.origin,
|
|
46027
|
+
allowPrivate: options.allowPrivate,
|
|
46028
|
+
timeoutMs: options.requestTimeoutMs,
|
|
46029
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
46030
|
+
captureAnchors: true,
|
|
46031
|
+
captureBody: true,
|
|
46032
|
+
allowUrl: (next) => allowedByRobots(`${next.pathname}${next.search}`, rules)
|
|
46033
|
+
});
|
|
46034
|
+
const contentType = probe.responseHeaders["content-type"];
|
|
46035
|
+
if (!probe.ok || probe.bodyTruncated || contentType !== void 0 && !/text\/html|application\/xhtml\+xml/i.test(contentType) || !probe.body || !probe.finalUrl || new URL(probe.finalUrl).pathname.replace(/\/+$/, "") !== node.path.replace(/\/+$/, "")) {
|
|
46036
|
+
skipped.push({
|
|
46037
|
+
path: node.path,
|
|
46038
|
+
reason: probe.error ?? (probe.bodyTruncated ? "body truncated" : "unreadable or redirected page")
|
|
46039
|
+
});
|
|
46040
|
+
continue;
|
|
46041
|
+
}
|
|
46042
|
+
const sentences = extractPageSentences(probe.body);
|
|
46043
|
+
if (sentences.length === 0) {
|
|
46044
|
+
skipped.push({
|
|
46045
|
+
path: node.path,
|
|
46046
|
+
reason: "no readable main content"
|
|
46047
|
+
});
|
|
46048
|
+
continue;
|
|
46049
|
+
}
|
|
46050
|
+
pages.push({
|
|
46051
|
+
path: node.path,
|
|
46052
|
+
sentences
|
|
46053
|
+
});
|
|
46054
|
+
for (const anchor of probe.anchors ?? []) {
|
|
46055
|
+
if (!anchor.internal) continue;
|
|
46056
|
+
observed.push({
|
|
46057
|
+
from: node.path,
|
|
46058
|
+
to: new URL(anchor.href).pathname.replace(/\/+$/, "") || "/",
|
|
46059
|
+
region: anchor.region
|
|
46060
|
+
});
|
|
46061
|
+
}
|
|
46062
|
+
}
|
|
46063
|
+
const readablePaths = new Set(pages.map((page) => page.path));
|
|
46064
|
+
const scopedGraph = {
|
|
46065
|
+
...graph,
|
|
46066
|
+
nodes: new Map([...graph.nodes].filter(([path]) => readablePaths.has(path))),
|
|
46067
|
+
edges: graph.edges.filter((edge) => readablePaths.has(edge.from) && readablePaths.has(edge.to))
|
|
46068
|
+
};
|
|
46069
|
+
const pairs = generateLinkCandidates(scopedGraph, {
|
|
46070
|
+
clusters: options.cluster,
|
|
46071
|
+
limit: Number.MAX_SAFE_INTEGER,
|
|
46072
|
+
renderedEdges: [...renderedEdges, ...observed]
|
|
46073
|
+
}).candidates.filter((pair) => (requestedTargets.size === 0 || requestedTargets.has(pair.destination)) && (requestedSources.size === 0 || requestedSources.has(pair.source)));
|
|
46074
|
+
const inbound = /* @__PURE__ */ new Map();
|
|
46075
|
+
for (const edge of [...graph.edges.filter((edge) => edge.type === "related"), ...observed.filter((edge) => edge.region === "body")]) inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1);
|
|
46076
|
+
const ranked = rankLinkSuggestions(pairs, pages, inbound, limit);
|
|
46077
|
+
return {
|
|
46078
|
+
kind: "links-candidates",
|
|
46079
|
+
schemaVersion: 2,
|
|
46080
|
+
origin: configured.origin,
|
|
46081
|
+
limit,
|
|
46082
|
+
pageLimit,
|
|
46083
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
46084
|
+
total: ranked.total,
|
|
46085
|
+
truncated: ranked.total > ranked.candidates.length || skipped.some((item) => item.reason === "page limit or target filter"),
|
|
46086
|
+
skipped,
|
|
46087
|
+
candidates: ranked.candidates
|
|
46088
|
+
};
|
|
46089
|
+
},
|
|
46090
|
+
catch: (cause) => new SeoCliError({ message: `Could not produce site suggestions: ${messageOf(cause)}` })
|
|
46091
|
+
});
|
|
46092
|
+
if (options.json) yield* printJson(report);
|
|
46093
|
+
else yield* printText(`${report.total} content-backed suggestion(s) · ${report.skipped.length} page(s) skipped\n\n${report.candidates.map((item) => `${item.source} → ${item.destination} (score ${item.score})\n ${item.sentence}\n Anchor: ${item.anchor}\n Target: ${item.targetSentence}\n ${item.reason}`).join("\n\n")}`);
|
|
46094
|
+
return;
|
|
46095
|
+
}
|
|
45002
46096
|
const result = generateLinkCandidates(graph, {
|
|
45003
46097
|
limit,
|
|
45004
46098
|
clusters: options.cluster,
|
|
@@ -45029,6 +46123,7 @@ const writeFlags = {
|
|
|
45029
46123
|
dryRun: dryRunFlag$1,
|
|
45030
46124
|
allowDirty: allowDirtyFlag$1
|
|
45031
46125
|
};
|
|
46126
|
+
const allowPrivateFlag = Boolean$1("allow-private").pipe(withDescription$2("Allow localhost/private site probes for suggestion freshness (local development only)"), withDefault(false));
|
|
45032
46127
|
/** File-writing content, metadata, schema, and link workflows. */
|
|
45033
46128
|
const improveCommandGroup = make$9("improve").pipe(withDescription$1("Apply page-backed SEO improvements through the configured OpenCode agent"), withSubcommands([
|
|
45034
46129
|
workflowCommand({
|
|
@@ -45075,6 +46170,8 @@ const improveCommandGroup = make$9("improve").pipe(withDescription$1("Apply page
|
|
|
45075
46170
|
description: "Add contextual internal links and update PageGraph declarations when appropriate",
|
|
45076
46171
|
flags: {
|
|
45077
46172
|
...baseFlags,
|
|
46173
|
+
suggestions: suggestionsFlag,
|
|
46174
|
+
allowPrivate: allowPrivateFlag,
|
|
45078
46175
|
dryRun: writeFlags.dryRun,
|
|
45079
46176
|
allowDirty: writeFlags.allowDirty
|
|
45080
46177
|
},
|
|
@@ -45134,6 +46231,51 @@ const sitemapCommand = make$9("sitemap", {
|
|
|
45134
46231
|
}));
|
|
45135
46232
|
})));
|
|
45136
46233
|
//#endregion
|
|
46234
|
+
//#region src/cli/commands/skills.ts
|
|
46235
|
+
const skills = { core: "---\nname: core\ndescription: Use when adding Pagegraph to a TanStack Start app or changing its SEO wiring — staticData.seo declarations, seoHead in route head(), typed related links, content collections, the seoRouteConfig coverage gate, seo.config.ts, sitemap and robots routes — or when a `pagegraph check`, coverage, or rendered-head failure needs diagnosing.\n---\n\n# Pagegraph and TanStack Start\n\nEach route declares its SEO policy once in `staticData.seo`; Pagegraph derives the sitemap, robots, breadcrumbs, JSON-LD, related links, and `pagegraph check` from those declarations. TanStack Start still owns routing and the document shell. This skill ships inside the Pagegraph CLI that printed it, so it matches that version. Run the app's installed binary (`bunx pagegraph` from the app root) so the guidance and the types agree.\n\nBefore editing, read the app's `package.json`, `src/routes/`, `src/routeTree.gen.ts`, any existing `seo.config.ts`, and `vite.config.ts`. Adapt the paths below to the app's layout.\n\n## Wire it up\n\n**1. Bind site identity and typed paths once.** Route files never repeat the origin or brand.\n\n```ts\n// src/lib/seo.ts\nimport { createSeo } from \"pagegraph/react\";\nimport type { FileRouteTypes } from \"../routeTree.gen\";\n\ndeclare module \"pagegraph\" {\n interface Register {\n paths: FileRouteTypes[\"fullPaths\"];\n kinds: \"page\" | \"article\" | \"hub\";\n }\n}\n\nexport const { seoHead } = createSeo({\n origin: \"https://example.com\",\n site: {\n name: \"Example\",\n logo: \"/logo.png\",\n publisherLogo: \"/publisher.png\",\n defaultImage: \"/og.png\",\n defaultAuthor: { name: \"Example Team\" },\n },\n organization: {\n description: \"What the company does.\",\n sameAs: [],\n contactPoint: { contactType: \"support\", email: \"support@example.com\" },\n },\n website: { searchPath: \"/search?q={search_term_string}\" },\n});\n```\n\nThe `Register` augmentation is what types `staticData.seo` on TanStack routes and checks `related` and `redirectTo` against real paths, so a renamed route becomes a compile error. `kinds` is the app's own taxonomy; nothing branches on it.\n\n**2. Declare policy on the route, content in `head()`.**\n\n```tsx\n// src/routes/(marketing)/pricing.tsx\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { seoHead } from \"../../lib/seo\";\n\nexport const Route = createFileRoute(\"/(marketing)/pricing\")({\n staticData: {\n seo: {\n kind: \"page\",\n crumb: \"Pricing\",\n sitemap: { priority: 0.9, changeFrequency: \"monthly\" },\n related: [\"/features\"],\n link: { title: \"Pricing\", description: \"Simple volume pricing.\" },\n },\n },\n head: (ctx) =>\n seoHead(ctx, {\n title: \"Pricing — Example\",\n description: \"Simple volume pricing.\",\n }),\n component: PricingPage,\n});\n```\n\n- `staticData.seo` is static crawl policy and graph edges: `kind`, `crumb`, `sitemap` (or `false`), `robots`, `related`, `link`, `redirectTo`.\n- `seoHead(ctx, instance)` takes the full `title` (no suffix is added) and `description`, and optionally `article`, `faqs`, `service`, `itemList`, `jsonLd`, `canonicalPath`, and `breadcrumbs`. It returns `meta` and a canonical `links` entry for TanStack to render; the document shell must keep `<HeadContent />` and `<Scripts />`.\n- `link` is how other pages render a card for this route. Add it to every route that appears in someone's `related`.\n- Declare `related` only for links the page actually renders in its body.\n- `crumb` accepts `(match) => string` for dynamic segments, such as a title from `loaderData`.\n\n**3. Export one graph loader.** The CLI, sitemap, and robots all read it.\n\n```ts\n// src/lib/seo/graph.ts\nimport { buildSeoGraph } from \"pagegraph\";\nimport { routeTree } from \"../../routeTree.gen\";\nimport { posts } from \"../content/posts\"; // the app's own content source\n\nexport const loadSeoGraph = async () =>\n buildSeoGraph({\n routeTree,\n collections: [\n {\n route: \"/blog/$slug\",\n source: \"blog\",\n instances: posts.map((post) => ({\n path: `/blog/${post.slug}`,\n title: post.title,\n description: post.description,\n publishedAt: post.date,\n })),\n },\n ],\n });\n```\n\nA param route such as `/blog/$slug` never enters the sitemap itself; list its pages as a collection. Instances inherit the param route's `kind` and `sitemap`, so declare those on `/blog/$slug`. Omit `collections` when every public page is a static route.\n\n**4. Point the CLI at the loader** with `seo.config.ts` at the app root:\n\n```ts\n// seo.config.ts\nimport { defineSeoConfig, viteGraphLoader } from \"pagegraph/config\";\n\nexport default defineSeoConfig({\n origin: \"https://example.com\",\n disallow: [\"/dashboard\", \"/api\"],\n loadGraph: viteGraphLoader({\n root: import.meta.dirname,\n entry: \"/src/lib/seo/graph.ts\",\n exportName: \"loadSeoGraph\",\n }),\n});\n```\n\n`entry` is root-relative and evaluated in a headless Vite server, so aliases and content plugins resolve as they do in the app. Optional fields: `contentSignal`, `coverage` (`[{ path, minInbound }]`, contextual-link minimums enforced by `check`), `directives`, and `transform`.\n\n**5. Serve the projections.** In Start server routes, return `renderSitemap(graph, { origin, indexable })` and `renderRobots(graph, { origin, indexable, disallow, contentSignal })` from `pagegraph`. On preview deployments, pass `indexable: false` so robots.txt disallows everything; the sitemap body does not change.\n\n**6. Optionally gate coverage at build time.**\n\n```ts\n// vite.config.ts\nimport { fileURLToPath } from \"node:url\";\nimport { tanstackStart } from \"@tanstack/react-start/plugin/vite\";\nimport { defineConfig } from \"vite\";\nimport { seoRouteConfig } from \"pagegraph/vite\";\n\nexport default defineConfig({\n plugins: [\n tanstackStart(),\n // Runs its own route parse; it does not replace the Start plugin.\n seoRouteConfig({\n outputPath: fileURLToPath(new URL(\"./src/lib/route-config.ts\", import.meta.url)),\n publicGroups: [\"(marketing)\"],\n enforceCoverageIn: [\"(marketing)\"],\n alwaysDisallow: [\"/dashboard\", \"/api\"],\n }),\n ],\n});\n```\n\nThis plugin fails dev startup and `vite build` when a page leaf in an enforced group has neither `staticData` nor `head`. It writes `robotsExclusions` and `reservedSegments` to `outputPath`. Feed `robotsExclusions` into `seo.config.ts` `disallow` and `renderRobots` instead of repeating the list.\n\n## Rules that bite\n\n- `robots` values must be lowercase (`\"noindex, follow\"`). The sitemap projection matches `noindex` literally, so `check` rejects mixed case.\n- A route with `robots` containing `noindex` or with `redirectTo` must not declare a positive `sitemap`. Use `sitemap: false` or omit it.\n- A `related` target route without `link` fails `check`. Collection instances are exempt because their card uses the instance `title` and `description`.\n- Coverage enforcement sees only route files inside the named group folders. `route.tsx` layouts, `.ts` server routes, and ungrouped routes are not checked. List ungrouped public routes in `extraPublicRoutes`.\n- `seoRouteConfig` throws if the directory of `outputPath` does not exist. Point it outside `src/routes` so the route watcher does not loop.\n- The graph knows route declarations and collection instances, not `head()` output. Editorial title and description checks cover instances only. A route's rendered title is verified only by inspecting HTML.\n- Keep indexable pages server-rendered or prerendered. An `ssr: false` route's head is not in the served HTML.\n\n## Verify\n\n| Changed | Run | Proves |\n| --- | --- | --- |\n| Declarations, collections, `seo.config.ts` | `pagegraph check` | No structural violations (exit 1 otherwise); editorial findings are reported but do not fail |\n| Graph shape | `pagegraph graph`, `pagegraph inspect /path` | Nodes, edges, and a page's sitemap status |\n| Sitemap or robots wiring | `pagegraph sitemap`, `pagegraph robots` | The projections from the declared graph |\n| `head()`, `seoHead` input, document shell | `pagegraph inspect <full-url> --live` against a running server | The served `<head>` and JSON-LD |\n| Rendered links vs `related` | `pagegraph links verify <url>` (add `--assert-coverage` for `coverage` rules) | What a crawler receives, not what was declared |\n| Coverage gate | `vite build` | Every enforced page leaf declares SEO |\n\nA passing `check` says nothing about served HTML. Verify head changes with `inspect --live` or a rendered-head test. Add `--json` when a script consumes the output. Stdout carries the data and stderr carries status. Check the exit code as well as the output.\n" };
|
|
46236
|
+
const nameArgument = String$1("name").pipe(withDescription("Bundled skill name from `pagegraph skills list`"));
|
|
46237
|
+
const listCommand = make$9("list", { json: jsonFlag }).pipe(withDescription$1("List skills embedded in this Pagegraph CLI"), withExamples([{
|
|
46238
|
+
command: "pagegraph skills list",
|
|
46239
|
+
description: "List embedded skills"
|
|
46240
|
+
}, {
|
|
46241
|
+
command: "pagegraph skills list --json",
|
|
46242
|
+
description: "List skill names as JSON"
|
|
46243
|
+
}]), withHandler(fnUntraced(function* ({ json }) {
|
|
46244
|
+
const names = Object.keys(skills);
|
|
46245
|
+
if (json) return yield* printJson({
|
|
46246
|
+
schemaVersion: 1,
|
|
46247
|
+
skills: names
|
|
46248
|
+
});
|
|
46249
|
+
return yield* printText(names.join("\n"));
|
|
46250
|
+
})));
|
|
46251
|
+
const getCommand = make$9("get", {
|
|
46252
|
+
name: nameArgument,
|
|
46253
|
+
json: jsonFlag
|
|
46254
|
+
}).pipe(withDescription$1("Print a skill embedded in this Pagegraph CLI"), withExamples([{
|
|
46255
|
+
command: "pagegraph skills get core",
|
|
46256
|
+
description: "Read Pagegraph integration guidance"
|
|
46257
|
+
}, {
|
|
46258
|
+
command: "pagegraph skills get core --json",
|
|
46259
|
+
description: "Read the skill as JSON"
|
|
46260
|
+
}]), withHandler(fnUntraced(function* ({ name, json }) {
|
|
46261
|
+
if (!Object.hasOwn(skills, name)) return yield* new SeoCliError({ message: `Unknown skill "${name}". Run \`pagegraph skills list\` to see available names.` });
|
|
46262
|
+
const content = skills[name];
|
|
46263
|
+
if (json) return yield* printJson({
|
|
46264
|
+
schemaVersion: 1,
|
|
46265
|
+
name,
|
|
46266
|
+
content
|
|
46267
|
+
});
|
|
46268
|
+
return yield* printText(content.trimEnd());
|
|
46269
|
+
})));
|
|
46270
|
+
/** Serve Pagegraph's version-matched agent guidance from the installed CLI. */
|
|
46271
|
+
const skillsCommand = make$9("skills").pipe(withDescription$1("List and read skills embedded in Pagegraph"), withExamples([{
|
|
46272
|
+
command: "pagegraph skills list",
|
|
46273
|
+
description: "Discover embedded skills"
|
|
46274
|
+
}, {
|
|
46275
|
+
command: "pagegraph skills get core",
|
|
46276
|
+
description: "Read the integration skill"
|
|
46277
|
+
}]), withSubcommands([listCommand, getCommand]));
|
|
46278
|
+
//#endregion
|
|
45137
46279
|
//#region src/cli/cli.ts
|
|
45138
46280
|
/**
|
|
45139
46281
|
* Root `pagegraph` command. Every subcommand reads the same SEO graph that render
|
|
@@ -45154,6 +46296,10 @@ const cli = make$9("pagegraph").pipe(withDescription$1("Inspect, audit, research
|
|
|
45154
46296
|
command: "pagegraph graph",
|
|
45155
46297
|
description: "Print the SEO graph as a tree"
|
|
45156
46298
|
},
|
|
46299
|
+
{
|
|
46300
|
+
command: "pagegraph skills get core",
|
|
46301
|
+
description: "Read bundled agent guidance"
|
|
46302
|
+
},
|
|
45157
46303
|
{
|
|
45158
46304
|
command: "pagegraph sitemap",
|
|
45159
46305
|
description: "Render sitemap.xml"
|
|
@@ -45175,6 +46321,7 @@ const cli = make$9("pagegraph").pipe(withDescription$1("Inspect, audit, research
|
|
|
45175
46321
|
researchCommandGroup,
|
|
45176
46322
|
checkCommand,
|
|
45177
46323
|
sitemapCommand,
|
|
46324
|
+
skillsCommand,
|
|
45178
46325
|
robotsCommand
|
|
45179
46326
|
]));
|
|
45180
46327
|
//#endregion
|