pagegraph 0.5.0 → 0.5.1
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/dist/audit.d.ts +3 -3
- package/dist/audit.js +1928 -1
- package/dist/audit.js.map +1 -0
- package/dist/cli.js +33482 -35
- package/dist/cli.js.map +1 -1
- package/dist/index.js +449 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/audit-B96V1x3q.js +0 -1929
- package/dist/audit-B96V1x3q.js.map +0 -1
- package/dist/inspect-html-CHuoiO2s.js +0 -452
- package/dist/inspect-html-CHuoiO2s.js.map +0 -1
- package/dist/main-GFEobQTH.js +0 -750
- package/dist/main-GFEobQTH.js.map +0 -1
package/dist/main-GFEobQTH.js
DELETED
|
@@ -1,750 +0,0 @@
|
|
|
1
|
-
import { c as renderSitemap, i as hasStructuralViolations, n as inspectHtml, o as inspectNode, r as checkGraph, s as renderRobots, t as hasBlockingIssues } from "./inspect-html-CHuoiO2s.js";
|
|
2
|
-
import { _ as compareAuditReports, a as makeHttpScanner, b as AuditReport, c as writeAuditFiles, d as AuditLayer, f as AuditComparisonResult, o as renderAuditDiff, r as makeLighthouseScanner, s as renderAuditMarkdown, t as makeHostedScanner, w as Audit } from "./audit-B96V1x3q.js";
|
|
3
|
-
import * as fs from "node:fs";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { dirname, join, resolve } from "node:path";
|
|
6
|
-
import * as Data$1 from "effect/Data";
|
|
7
|
-
import * as Effect$1 from "effect/Effect";
|
|
8
|
-
import { Effect, FileSystem, Schema } from "effect";
|
|
9
|
-
import * as Option from "effect/Option";
|
|
10
|
-
import * as BunRuntime from "@effect/platform-bun/BunRuntime";
|
|
11
|
-
import * as BunServices from "@effect/platform-bun/BunServices";
|
|
12
|
-
import * as Console from "effect/Console";
|
|
13
|
-
import * as Logger from "effect/Logger";
|
|
14
|
-
import * as Runtime from "effect/Runtime";
|
|
15
|
-
import * as CliError from "effect/unstable/cli/CliError";
|
|
16
|
-
import * as Command from "effect/unstable/cli/Command";
|
|
17
|
-
import { pathToFileURL } from "node:url";
|
|
18
|
-
import * as Predicate from "effect/Predicate";
|
|
19
|
-
import * as Flag from "effect/unstable/cli/Flag";
|
|
20
|
-
import * as Argument from "effect/unstable/cli/Argument";
|
|
21
|
-
//#region package.json
|
|
22
|
-
var version = "0.5.0";
|
|
23
|
-
//#endregion
|
|
24
|
-
//#region src/cli/output.ts
|
|
25
|
-
/**
|
|
26
|
-
* Shared surfaces for the `pagegraph` CLI. The three output planes:
|
|
27
|
-
* - **data** → stdout via {@link printJson} / {@link printText}. Under `--json`
|
|
28
|
-
* this is the *only* thing on stdout: no ANSI, no status, valid JSON.
|
|
29
|
-
* - **status** → stderr via Effect leveled logging (`Effect.logInfo` /
|
|
30
|
-
* `Effect.logDebug`), routed off stdout by `Logger.LogToStderr` in `main.ts`
|
|
31
|
-
* and gated by the built-in `--log-level` flag.
|
|
32
|
-
* - **diagnostics** → stderr; expected failures surface as {@link SeoCliError},
|
|
33
|
-
* printed by the entrypoint with a non-zero exit.
|
|
34
|
-
*/
|
|
35
|
-
/** Expected, user-facing CLI failure — message to stderr, process exits non-zero. */
|
|
36
|
-
var SeoCliError = class extends Data$1.TaggedError("SeoCliError") {};
|
|
37
|
-
/** Machine-readable output. When set, stdout is exactly the JSON payload. */
|
|
38
|
-
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Emit the payload as JSON on stdout (no status, no color)"), Flag.withDefault(false));
|
|
39
|
-
/**
|
|
40
|
-
* Absolute origin the sitemap/robots projection is rendered under. It carries no
|
|
41
|
-
* static default: the fallback is the host's own `origin` from `seo.config.ts`,
|
|
42
|
-
* which is not known until the config is loaded. Resolve it with {@link originOf}.
|
|
43
|
-
*/
|
|
44
|
-
const originFlag = Flag.string("origin").pipe(Flag.withDescription("Absolute origin for URLs (default: `origin` from seo.config.ts)"), Flag.optional);
|
|
45
|
-
/** The `--origin` flag when given, else the origin the config declares. */
|
|
46
|
-
const originOf = (flag, configured) => Option.getOrElse(flag, () => configured);
|
|
47
|
-
/**
|
|
48
|
-
* `--indexable` (default true) / `--no-indexable`. A non-indexable host yields a
|
|
49
|
-
* disallow-all robots.txt with no Sitemap line — the preview posture.
|
|
50
|
-
*/
|
|
51
|
-
const indexableFlag = Flag.boolean("indexable").pipe(Flag.withDescription("Render as an indexable host; --no-indexable = disallow-all robots.txt"), Flag.withDefault(true));
|
|
52
|
-
/** Data plane: pretty-printed JSON on stdout. */
|
|
53
|
-
const printJson = (value) => Console.log(JSON.stringify(value, null, 2));
|
|
54
|
-
/** Data plane: a block of already-formatted text on stdout. */
|
|
55
|
-
const printText = (text) => Console.log(text);
|
|
56
|
-
//#endregion
|
|
57
|
-
//#region src/cli/load-config.ts
|
|
58
|
-
/**
|
|
59
|
-
* Config discovery and graph acquisition for the `pagegraph` CLI.
|
|
60
|
-
*
|
|
61
|
-
* The CLI knows how to *view* a graph; the host knows how to *produce* one. That
|
|
62
|
-
* seam is a `seo.config.ts` at the app root, found by walking up from the working
|
|
63
|
-
* directory — so `bun run pagegraph check` works from anywhere inside the app.
|
|
64
|
-
*
|
|
65
|
-
* The config is a TypeScript module the CLI imports directly, which is one of the
|
|
66
|
-
* reasons the `bin` runs under Bun (the other being the synchronous fd-1 flush in
|
|
67
|
-
* `main.ts`).
|
|
68
|
-
*/
|
|
69
|
-
const CONFIG_FILENAMES = [
|
|
70
|
-
"seo.config.ts",
|
|
71
|
-
"seo.config.js",
|
|
72
|
-
"seo.config.mjs"
|
|
73
|
-
];
|
|
74
|
-
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
75
|
-
/** First `seo.config.*` at or above `from`, or undefined at the filesystem root. */
|
|
76
|
-
const findConfigFile = (from) => {
|
|
77
|
-
let directory = resolve(from);
|
|
78
|
-
for (;;) {
|
|
79
|
-
for (const filename of CONFIG_FILENAMES) {
|
|
80
|
-
const candidate = join(directory, filename);
|
|
81
|
-
if (existsSync(candidate)) return candidate;
|
|
82
|
-
}
|
|
83
|
-
const parent = dirname(directory);
|
|
84
|
-
if (parent === directory) return void 0;
|
|
85
|
-
directory = parent;
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
const isStringArray = (value) => Array.isArray(value) && value.every(Predicate.isString);
|
|
89
|
-
/**
|
|
90
|
-
* `seo.config.ts` is the consumer's file and may be plain JS, so its types are
|
|
91
|
-
* a suggestion, not a guarantee. Check every field the commands actually read —
|
|
92
|
-
* an undefined `origin` would otherwise surface as "undefined/pricing" in a
|
|
93
|
-
* rendered sitemap rather than as an error here.
|
|
94
|
-
*/
|
|
95
|
-
const isSeoCliConfig = (value) => Predicate.isObject(value) && Predicate.isFunction(value["loadGraph"]) && Predicate.isString(value["origin"]) && isStringArray(value["disallow"]) && (value["contentSignal"] === void 0 || Predicate.isString(value["contentSignal"])) && (value["directives"] === void 0 || isStringArray(value["directives"])) && (value["transform"] === void 0 || Predicate.isFunction(value["transform"]));
|
|
96
|
-
/**
|
|
97
|
-
* Load the app's `seo.config.ts`. Cheap to run more than once per process: the
|
|
98
|
-
* ESM cache evaluates the config module exactly once.
|
|
99
|
-
*/
|
|
100
|
-
const loadSeoConfig = Effect$1.gen(function* () {
|
|
101
|
-
const cwd = process.cwd();
|
|
102
|
-
const configPath = findConfigFile(cwd);
|
|
103
|
-
if (configPath === void 0) return yield* new SeoCliError({ message: `No ${CONFIG_FILENAMES[0]} in ${cwd} or any parent directory. Create one that exports \`defineSeoConfig({ origin, disallow, loadGraph })\` from "pagegraph/config".` });
|
|
104
|
-
yield* Effect$1.logDebug(`Loading SEO config from ${configPath}`);
|
|
105
|
-
const module = yield* Effect$1.tryPromise({
|
|
106
|
-
try: () => import(pathToFileURL(configPath).href),
|
|
107
|
-
catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf(cause)}` })
|
|
108
|
-
});
|
|
109
|
-
if (!isSeoCliConfig(module.default)) return yield* new SeoCliError({ message: `${configPath} must default-export defineSeoConfig({ origin, disallow, loadGraph }).` });
|
|
110
|
-
return module.default;
|
|
111
|
-
});
|
|
112
|
-
/**
|
|
113
|
-
* The live SEO graph as a scoped resource: whatever the loader acquired (for
|
|
114
|
-
* {@link viteGraphLoader}, an in-process Vite server) is released when the
|
|
115
|
-
* surrounding `Effect.scoped` exits, on success or failure.
|
|
116
|
-
*/
|
|
117
|
-
const acquireGraph = (config) => Effect$1.gen(function* () {
|
|
118
|
-
yield* Effect$1.logDebug("Loading the SEO graph…");
|
|
119
|
-
const loaded = yield* Effect$1.acquireRelease(Effect$1.tryPromise({
|
|
120
|
-
try: () => config.loadGraph(),
|
|
121
|
-
catch: (cause) => new SeoCliError({ message: messageOf(cause) })
|
|
122
|
-
}), (acquired) => Effect$1.promise(() => acquired.dispose()).pipe(Effect$1.catchDefect((defect) => Effect$1.logWarning(`Could not dispose the SEO graph loader: ${messageOf(defect)}`))));
|
|
123
|
-
yield* Effect$1.logDebug(`Loaded SEO graph: ${loaded.graph.nodes.size} nodes, ${loaded.graph.edges.length} edges.`);
|
|
124
|
-
return loaded.graph;
|
|
125
|
-
});
|
|
126
|
-
//#endregion
|
|
127
|
-
//#region src/cli/render.ts
|
|
128
|
-
/** Count of edges pointing *at* each node — 0 means nothing links to it. */
|
|
129
|
-
const incomingCounts = (graph) => {
|
|
130
|
-
const counts = /* @__PURE__ */ new Map();
|
|
131
|
-
for (const node of graph.nodes.keys()) counts.set(node, 0);
|
|
132
|
-
for (const edge of graph.edges) counts.set(edge.to, (counts.get(edge.to) ?? 0) + 1);
|
|
133
|
-
return counts;
|
|
134
|
-
};
|
|
135
|
-
/** Paths nothing links to (zero incoming edges) — the "orphan" set. */
|
|
136
|
-
const orphanPaths = (graph) => {
|
|
137
|
-
const incoming = incomingCounts(graph);
|
|
138
|
-
return new Set([...graph.nodes.keys()].filter((path) => (incoming.get(path) ?? 0) === 0));
|
|
139
|
-
};
|
|
140
|
-
const byPath = (a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
141
|
-
/** A compact one-line summary of a node's policy — kind and the flags that matter. */
|
|
142
|
-
const nodeMarkers = (node) => {
|
|
143
|
-
const markers = [node.kind];
|
|
144
|
-
if (node.source !== "route") markers.push(node.source);
|
|
145
|
-
const sitemap = node.policy.sitemap;
|
|
146
|
-
if (sitemap) markers.push(`sitemap:${sitemap.priority.toFixed(1)}`);
|
|
147
|
-
if (node.policy.robots?.includes("noindex")) markers.push("noindex");
|
|
148
|
-
if (node.policy.redirectTo) markers.push(`→ ${node.policy.redirectTo}`);
|
|
149
|
-
return markers.join(" ");
|
|
150
|
-
};
|
|
151
|
-
/** Map each node to its parent = the longest strict path-prefix that is also a node. */
|
|
152
|
-
const parentOf = (path, nodePaths) => {
|
|
153
|
-
if (path === "/") return void 0;
|
|
154
|
-
const segments = path.split("/").filter(Boolean);
|
|
155
|
-
for (let depth = segments.length - 1; depth >= 1; depth--) {
|
|
156
|
-
const candidate = `/${segments.slice(0, depth).join("/")}`;
|
|
157
|
-
if (nodePaths.has(candidate)) return candidate;
|
|
158
|
-
}
|
|
159
|
-
return nodePaths.has("/") ? "/" : void 0;
|
|
160
|
-
};
|
|
161
|
-
/** Render the graph as an indented path hierarchy with per-node markers. */
|
|
162
|
-
const renderTree = (graph) => {
|
|
163
|
-
const nodePaths = new Set(graph.nodes.keys());
|
|
164
|
-
const children = /* @__PURE__ */ new Map();
|
|
165
|
-
const roots = [];
|
|
166
|
-
for (const path of [...nodePaths].sort()) {
|
|
167
|
-
const parent = parentOf(path, nodePaths);
|
|
168
|
-
if (parent === void 0) roots.push(path);
|
|
169
|
-
else {
|
|
170
|
-
const bucket = children.get(parent);
|
|
171
|
-
if (bucket) bucket.push(path);
|
|
172
|
-
else children.set(parent, [path]);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
const lines = [];
|
|
176
|
-
const walk = (path, depth) => {
|
|
177
|
-
const node = graph.nodes.get(path);
|
|
178
|
-
lines.push(`${" ".repeat(depth)}${path} · ${nodeMarkers(node)}`);
|
|
179
|
-
for (const child of children.get(path) ?? []) walk(child, depth + 1);
|
|
180
|
-
};
|
|
181
|
-
for (const root of roots) walk(root, 0);
|
|
182
|
-
return `${lines.join("\n")}\n\n${renderSummary(graph)}`;
|
|
183
|
-
};
|
|
184
|
-
/** One-line-per-node list of nodes nothing links to (no incoming graph edges). */
|
|
185
|
-
const renderOrphans = (graph) => {
|
|
186
|
-
const orphans = orphanPaths(graph);
|
|
187
|
-
const orphanNodes = [...graph.nodes.values()].filter((node) => orphans.has(node.path)).sort(byPath);
|
|
188
|
-
if (orphanNodes.length === 0) return "No orphan nodes — every node has an incoming edge.";
|
|
189
|
-
const lines = orphanNodes.map((node) => `${node.path} · ${nodeMarkers(node)}`);
|
|
190
|
-
return [
|
|
191
|
-
`Orphans — ${orphanNodes.length} node(s) with no incoming edge (reachable only via nav/sitemap):`,
|
|
192
|
-
"",
|
|
193
|
-
...lines
|
|
194
|
-
].join("\n");
|
|
195
|
-
};
|
|
196
|
-
const sanitizeId = (path) => `n_${path.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
197
|
-
/** Render the graph (or just its orphans) as a Mermaid `graph LR` diagram. */
|
|
198
|
-
const renderMermaid = (graph, orphansOnly) => {
|
|
199
|
-
const orphans = orphanPaths(graph);
|
|
200
|
-
const nodes = [...graph.nodes.values()].filter((node) => !orphansOnly || orphans.has(node.path)).sort(byPath);
|
|
201
|
-
const visible = new Set(nodes.map((node) => node.path));
|
|
202
|
-
const lines = ["graph LR"];
|
|
203
|
-
for (const node of nodes) lines.push(` ${sanitizeId(node.path)}["${node.path}"]`);
|
|
204
|
-
if (!orphansOnly) for (const edge of graph.edges) {
|
|
205
|
-
if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
|
|
206
|
-
lines.push(` ${sanitizeId(edge.from)} -->|${edge.type}| ${sanitizeId(edge.to)}`);
|
|
207
|
-
}
|
|
208
|
-
return lines.join("\n");
|
|
209
|
-
};
|
|
210
|
-
const renderSummary = (graph) => {
|
|
211
|
-
const bySource = /* @__PURE__ */ new Map();
|
|
212
|
-
for (const node of graph.nodes.values()) bySource.set(node.source, (bySource.get(node.source) ?? 0) + 1);
|
|
213
|
-
const byEdge = /* @__PURE__ */ new Map();
|
|
214
|
-
for (const edge of graph.edges) byEdge.set(edge.type, (byEdge.get(edge.type) ?? 0) + 1);
|
|
215
|
-
const sources = [...bySource.entries()].map(([source, count]) => `${source} ${count}`).join(", ");
|
|
216
|
-
const edges = [...byEdge.entries()].map(([type, count]) => `${type} ${count}`).join(", ");
|
|
217
|
-
return `${graph.nodes.size} nodes (${sources}) · ${graph.edges.length} edges (${edges})`;
|
|
218
|
-
};
|
|
219
|
-
/** Static `inspect <path>` report: the node's declaration, sitemap status, edges. */
|
|
220
|
-
const renderNodeReport = (report) => {
|
|
221
|
-
const { node } = report;
|
|
222
|
-
const lines = [
|
|
223
|
-
node.path,
|
|
224
|
-
` kind ${node.kind}`,
|
|
225
|
-
` source ${node.source}`,
|
|
226
|
-
` in sitemap ${report.inSitemap ? "yes" : "no"}`
|
|
227
|
-
];
|
|
228
|
-
if (node.policy.robots) lines.push(` robots ${node.policy.robots}`);
|
|
229
|
-
if (node.policy.redirectTo) lines.push(` redirect → ${node.policy.redirectTo}`);
|
|
230
|
-
if (node.policy.link) {
|
|
231
|
-
lines.push(` link.title ${node.policy.link.title}`);
|
|
232
|
-
lines.push(` link.desc ${node.policy.link.description}`);
|
|
233
|
-
}
|
|
234
|
-
if (node.instance) {
|
|
235
|
-
lines.push(` title ${node.instance.title}`);
|
|
236
|
-
if (node.instance.description) lines.push(` description ${node.instance.description}`);
|
|
237
|
-
if (node.instance.publishedAt) lines.push(` published ${node.instance.publishedAt}`);
|
|
238
|
-
if (node.instance.modifiedAt) lines.push(` modified ${node.instance.modifiedAt}`);
|
|
239
|
-
}
|
|
240
|
-
const edgeLine = (label, edges) => {
|
|
241
|
-
if (edges.length === 0) return;
|
|
242
|
-
lines.push(` ${label}`);
|
|
243
|
-
for (const edge of edges) {
|
|
244
|
-
const other = label === "outgoing" ? edge.to : edge.from;
|
|
245
|
-
lines.push(` ${edge.type.padEnd(13)} ${other}`);
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
edgeLine("outgoing", report.outgoing);
|
|
249
|
-
edgeLine("incoming", report.incoming);
|
|
250
|
-
return lines.join("\n");
|
|
251
|
-
};
|
|
252
|
-
/** Live `inspect <url> --live` report: fetched head tags + JSON-LD validation. */
|
|
253
|
-
const renderLiveReport = (report) => {
|
|
254
|
-
const lines = [
|
|
255
|
-
`${report.url} (HTTP ${report.status})`,
|
|
256
|
-
` title ${report.title ?? "—"}`,
|
|
257
|
-
` description ${report.description ?? "—"}`,
|
|
258
|
-
` canonical ${report.canonical ?? "—"}`,
|
|
259
|
-
` robots ${report.robots ?? "—"}`
|
|
260
|
-
];
|
|
261
|
-
const kv = (label, map) => {
|
|
262
|
-
const keys = Object.keys(map);
|
|
263
|
-
if (keys.length === 0) return;
|
|
264
|
-
lines.push(` ${label}`);
|
|
265
|
-
for (const key of keys) lines.push(` ${key.padEnd(18)} ${map[key]}`);
|
|
266
|
-
};
|
|
267
|
-
kv("open graph", report.og);
|
|
268
|
-
kv("twitter", report.twitter);
|
|
269
|
-
if (report.jsonLd.length > 0) {
|
|
270
|
-
lines.push(" json-ld");
|
|
271
|
-
for (const block of report.jsonLd) {
|
|
272
|
-
lines.push(` ${block.valid ? "✓" : "✗"} ${block.type}`);
|
|
273
|
-
for (const error of block.errors) lines.push(` ${error}`);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
if (report.issues.length > 0) {
|
|
277
|
-
lines.push("", ` ${report.issues.length} issue(s):`);
|
|
278
|
-
for (const issue of report.issues) lines.push(` ✗ ${issue}`);
|
|
279
|
-
} else lines.push("", " ✓ required tags present, JSON-LD valid");
|
|
280
|
-
return lines.join("\n");
|
|
281
|
-
};
|
|
282
|
-
/** `pagegraph check` report: violations grouped by severity, with a headline count. */
|
|
283
|
-
const renderViolations = (violations) => {
|
|
284
|
-
const structural = violations.filter((violation) => violation.severity === "structural");
|
|
285
|
-
const editorial = violations.filter((violation) => violation.severity === "editorial");
|
|
286
|
-
if (violations.length === 0) return "✓ No violations. The SEO graph is clean.";
|
|
287
|
-
const block = (title, group) => {
|
|
288
|
-
if (group.length === 0) return [];
|
|
289
|
-
const lines = [`${title} (${group.length}):`, ""];
|
|
290
|
-
for (const violation of group) {
|
|
291
|
-
const where = violation.path ? ` ${violation.path}` : "";
|
|
292
|
-
lines.push(` ✗ [${violation.rule}]${where}`);
|
|
293
|
-
lines.push(` ${violation.message}`);
|
|
294
|
-
if (violation.fix) lines.push(` fix: ${violation.fix}`);
|
|
295
|
-
}
|
|
296
|
-
lines.push("");
|
|
297
|
-
return lines;
|
|
298
|
-
};
|
|
299
|
-
return [
|
|
300
|
-
...block("Structural", structural),
|
|
301
|
-
...block("Editorial", editorial),
|
|
302
|
-
structural.length > 0 ? `${structural.length} structural, ${editorial.length} editorial — structural violations fail the check.` : `${editorial.length} editorial warning(s) — no structural violations.`
|
|
303
|
-
].join("\n");
|
|
304
|
-
};
|
|
305
|
-
//#endregion
|
|
306
|
-
//#region src/cli/commands/check.ts
|
|
307
|
-
const checkCommand = Command.make("check", { json: jsonFlag }).pipe(Command.withDescription("Check the SEO graph; exit 1 on any structural violation"), Command.withExamples([{
|
|
308
|
-
command: "pagegraph check",
|
|
309
|
-
description: "Run every rule and print the violations"
|
|
310
|
-
}, {
|
|
311
|
-
command: "pagegraph check --json",
|
|
312
|
-
description: "Violations as JSON (exit 1 iff structural)"
|
|
313
|
-
}]), Command.withHandler(Effect$1.fnUntraced(function* ({ json }) {
|
|
314
|
-
const config = yield* loadSeoConfig;
|
|
315
|
-
const graph = yield* Effect$1.scoped(acquireGraph(config));
|
|
316
|
-
const violations = checkGraph(graph);
|
|
317
|
-
const structural = violations.filter((violation) => violation.severity === "structural");
|
|
318
|
-
if (json) yield* printJson({
|
|
319
|
-
ok: structural.length === 0,
|
|
320
|
-
structural: structural.length,
|
|
321
|
-
editorial: violations.length - structural.length,
|
|
322
|
-
violations
|
|
323
|
-
});
|
|
324
|
-
else yield* printText(renderViolations(violations));
|
|
325
|
-
if (hasStructuralViolations(violations)) return yield* new SeoCliError({ message: `${structural.length} structural violation(s) — see the report above.` });
|
|
326
|
-
})));
|
|
327
|
-
//#endregion
|
|
328
|
-
//#region src/cli/commands/audit.ts
|
|
329
|
-
const urls = Argument.string("url").pipe(Argument.withDescription("One or more absolute http(s) URLs"), Argument.variadic({ min: 1 }));
|
|
330
|
-
const allowPrivate = Flag.boolean("allow-private").pipe(Flag.withDescription("Allow localhost and private addresses (local development only)"), Flag.withDefault(false));
|
|
331
|
-
const probeOnly = Flag.boolean("probe-only").pipe(Flag.withDescription("Run HTTP probes without Lighthouse"), Flag.withDefault(false));
|
|
332
|
-
const hosted = Flag.boolean("hosted").pipe(Flag.withDescription("Opt in to external agent-readiness scanners"), Flag.withDefault(false));
|
|
333
|
-
const formFactor = Flag.choice("form-factor", ["mobile", "desktop"]).pipe(Flag.withDescription("Lighthouse form factor"), Flag.withDefault("mobile"));
|
|
334
|
-
const runs = Flag.integer("runs").pipe(Flag.withDescription("Lighthouse runs per target"), Flag.withDefault(1));
|
|
335
|
-
const concurrency = Flag.integer("concurrency").pipe(Flag.withDescription("Maximum concurrent target/scanner pairs"), Flag.withDefault(4));
|
|
336
|
-
const requestTimeoutMs = Flag.integer("request-timeout-ms").pipe(Flag.withDescription("HTTP request timeout in milliseconds"), Flag.withDefault(15e3));
|
|
337
|
-
const scannerTimeoutMs = Flag.integer("scanner-timeout-ms").pipe(Flag.withDescription("Lighthouse and hosted scanner timeout in milliseconds"), Flag.withDefault(18e4));
|
|
338
|
-
const maxBodyBytes = Flag.integer("max-body-bytes").pipe(Flag.withDescription("Maximum captured response bytes"), Flag.withDefault(2e6));
|
|
339
|
-
const outputDir = Flag.string("output-dir").pipe(Flag.withDescription("Atomically write timestamped JSON and Markdown artifacts"), Flag.optional);
|
|
340
|
-
const positive = (name, value) => Number.isSafeInteger(value) && value > 0 ? Effect$1.succeed(value) : Effect$1.fail(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
341
|
-
const auditCommand = Command.make("audit", {
|
|
342
|
-
urls,
|
|
343
|
-
json: jsonFlag,
|
|
344
|
-
allowPrivate,
|
|
345
|
-
probeOnly,
|
|
346
|
-
hosted,
|
|
347
|
-
formFactor,
|
|
348
|
-
runs,
|
|
349
|
-
concurrency,
|
|
350
|
-
requestTimeoutMs,
|
|
351
|
-
scannerTimeoutMs,
|
|
352
|
-
maxBodyBytes,
|
|
353
|
-
outputDir
|
|
354
|
-
}).pipe(Command.withDescription("Audit any website without a TanStack app or seo.config.ts"), Command.withExamples([
|
|
355
|
-
{
|
|
356
|
-
command: "pagegraph audit https://example.com",
|
|
357
|
-
description: "HTTP and Lighthouse audit"
|
|
358
|
-
},
|
|
359
|
-
{
|
|
360
|
-
command: "pagegraph audit https://example.com --json",
|
|
361
|
-
description: "One JSON report on stdout"
|
|
362
|
-
},
|
|
363
|
-
{
|
|
364
|
-
command: "pagegraph audit http://localhost:3000 --allow-private --probe-only",
|
|
365
|
-
description: "Audit a local app"
|
|
366
|
-
}
|
|
367
|
-
]), Command.withHandler(Effect$1.fn("SeoCli.audit")(function* (options) {
|
|
368
|
-
const checkedRuns = yield* positive("runs", options.runs);
|
|
369
|
-
const checkedConcurrency = yield* positive("concurrency", options.concurrency);
|
|
370
|
-
const checkedRequestTimeout = yield* positive("request-timeout-ms", options.requestTimeoutMs);
|
|
371
|
-
const checkedScannerTimeout = yield* positive("scanner-timeout-ms", options.scannerTimeoutMs);
|
|
372
|
-
const checkedMaxBody = yield* positive("max-body-bytes", options.maxBodyBytes);
|
|
373
|
-
const scanners = [
|
|
374
|
-
makeHttpScanner({
|
|
375
|
-
allowPrivate: options.allowPrivate,
|
|
376
|
-
timeoutMs: checkedRequestTimeout,
|
|
377
|
-
maxBodyBytes: checkedMaxBody
|
|
378
|
-
}),
|
|
379
|
-
...!options.probeOnly ? [makeLighthouseScanner({
|
|
380
|
-
allowPrivate: options.allowPrivate,
|
|
381
|
-
timeoutMs: checkedScannerTimeout
|
|
382
|
-
})] : [],
|
|
383
|
-
...options.hosted ? [makeHostedScanner({
|
|
384
|
-
allowPrivate: options.allowPrivate,
|
|
385
|
-
timeoutMs: checkedScannerTimeout,
|
|
386
|
-
maxBodyBytes: checkedMaxBody,
|
|
387
|
-
origins: {
|
|
388
|
-
isitagentready: "https://isitagentready.com",
|
|
389
|
-
isAgentic: "https://is-agentic.com"
|
|
390
|
-
}
|
|
391
|
-
})] : []
|
|
392
|
-
];
|
|
393
|
-
const report = yield* Effect$1.gen(function* () {
|
|
394
|
-
return yield* (yield* Audit.Service).run({
|
|
395
|
-
targets: [...new Set(options.urls)],
|
|
396
|
-
options: {
|
|
397
|
-
concurrency: checkedConcurrency,
|
|
398
|
-
formFactors: [options.formFactor],
|
|
399
|
-
runs: checkedRuns,
|
|
400
|
-
allowPrivate: options.allowPrivate,
|
|
401
|
-
requestTimeoutMs: checkedRequestTimeout,
|
|
402
|
-
scannerTimeoutMs: checkedScannerTimeout,
|
|
403
|
-
maxBodyBytes: checkedMaxBody
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
}).pipe(Effect$1.provide(AuditLayer(scanners)), Effect$1.mapError((error) => new SeoCliError({ message: error.message })));
|
|
407
|
-
if (options.json) yield* printJson(report);
|
|
408
|
-
else yield* printText(renderAuditMarkdown(report));
|
|
409
|
-
if (Option.isSome(options.outputDir)) {
|
|
410
|
-
const directory = options.outputDir.value;
|
|
411
|
-
const artifacts = yield* Effect$1.tryPromise({
|
|
412
|
-
try: () => writeAuditFiles(report, directory),
|
|
413
|
-
catch: (cause) => new SeoCliError({ message: `Could not write audit artifacts: ${cause instanceof Error ? cause.message : String(cause)}` })
|
|
414
|
-
});
|
|
415
|
-
yield* Effect$1.logInfo(`Wrote ${artifacts.json} and ${artifacts.markdown}`);
|
|
416
|
-
}
|
|
417
|
-
const structural = report.findings.filter((finding) => finding.severity === "structural");
|
|
418
|
-
const http = report.results.filter((result) => result.scanner === "http");
|
|
419
|
-
if (structural.length > 0 || http.every((result) => result.status === "error")) return yield* new SeoCliError({ message: `${structural.length} structural finding(s); see the report above.` });
|
|
420
|
-
})));
|
|
421
|
-
//#endregion
|
|
422
|
-
//#region src/cli/commands/diff.ts
|
|
423
|
-
const before = Argument.string("before.json").pipe(Argument.withDescription("Earlier pagegraph audit JSON artifact"));
|
|
424
|
-
const after = Argument.string("after.json").pipe(Argument.withDescription("Later pagegraph audit JSON artifact"));
|
|
425
|
-
const readAuditReport = Effect.fn("SeoCli.readAuditReport")(function* (label, path) {
|
|
426
|
-
const contents = yield* (yield* FileSystem.FileSystem).readFileString(path).pipe(Effect.mapError((error) => new SeoCliError({ message: `Could not read ${label} report ${path}: ${error.message}` })));
|
|
427
|
-
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(AuditReport))(contents).pipe(Effect.mapError((error) => new SeoCliError({ message: `Invalid ${label} report ${path}: ${error.message}` })));
|
|
428
|
-
});
|
|
429
|
-
const diffCommand = Command.make("diff", {
|
|
430
|
-
before,
|
|
431
|
-
after,
|
|
432
|
-
json: jsonFlag
|
|
433
|
-
}).pipe(Command.withDescription("Compare two versioned pagegraph audit JSON artifacts for semantic regressions"), Command.withExamples([{
|
|
434
|
-
command: "pagegraph diff before.json after.json",
|
|
435
|
-
description: "Render a human-readable semantic comparison"
|
|
436
|
-
}, {
|
|
437
|
-
command: "pagegraph diff before.json after.json --json",
|
|
438
|
-
description: "Emit one versioned JSON diff on stdout"
|
|
439
|
-
}]), Command.withHandler(Effect.fn("SeoCli.diff")(function* (options) {
|
|
440
|
-
const beforeReport = yield* readAuditReport("before", options.before);
|
|
441
|
-
const afterReport = yield* readAuditReport("after", options.after);
|
|
442
|
-
const comparison = compareAuditReports(beforeReport, afterReport);
|
|
443
|
-
if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
|
|
444
|
-
if (options.json) yield* printJson(comparison.diff);
|
|
445
|
-
else yield* printText(renderAuditDiff(comparison.diff));
|
|
446
|
-
if (comparison.diff.outcome === "regressed") {
|
|
447
|
-
const summary = comparison.diff.summary;
|
|
448
|
-
return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
|
|
449
|
-
}
|
|
450
|
-
})));
|
|
451
|
-
//#endregion
|
|
452
|
-
//#region src/cli/serialize.ts
|
|
453
|
-
const serializeCrumb = (crumb) => {
|
|
454
|
-
if (crumb === void 0) return void 0;
|
|
455
|
-
return typeof crumb === "function" ? "(dynamic)" : crumb;
|
|
456
|
-
};
|
|
457
|
-
/** JSON-safe projection of one node (route policy `crumb` functions → sentinel). */
|
|
458
|
-
const serializeNode = (node) => ({
|
|
459
|
-
path: node.path,
|
|
460
|
-
kind: node.kind,
|
|
461
|
-
source: node.source,
|
|
462
|
-
policy: {
|
|
463
|
-
kind: node.policy.kind,
|
|
464
|
-
crumb: serializeCrumb(node.policy.crumb),
|
|
465
|
-
sitemap: node.policy.sitemap,
|
|
466
|
-
robots: node.policy.robots,
|
|
467
|
-
related: node.policy.related,
|
|
468
|
-
link: node.policy.link,
|
|
469
|
-
redirectTo: node.policy.redirectTo
|
|
470
|
-
},
|
|
471
|
-
instance: node.instance
|
|
472
|
-
});
|
|
473
|
-
/** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
|
|
474
|
-
const serializeGraph = (graph) => ({
|
|
475
|
-
nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
|
|
476
|
-
edges: graph.edges
|
|
477
|
-
});
|
|
478
|
-
//#endregion
|
|
479
|
-
//#region src/cli/commands/graph.ts
|
|
480
|
-
const formatFlag = Flag.choice("format", [
|
|
481
|
-
"tree",
|
|
482
|
-
"mermaid",
|
|
483
|
-
"json"
|
|
484
|
-
]).pipe(Flag.withDescription("Output format: tree (default), mermaid diagram, or json"), Flag.withDefault("tree"));
|
|
485
|
-
const orphansFlag = Flag.boolean("orphans").pipe(Flag.withDescription("Show only orphan nodes (nothing links to them)"), Flag.withDefault(false));
|
|
486
|
-
const graphCommand = Command.make("graph", {
|
|
487
|
-
format: formatFlag,
|
|
488
|
-
orphans: orphansFlag
|
|
489
|
-
}).pipe(Command.withDescription("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), Command.withExamples([
|
|
490
|
-
{
|
|
491
|
-
command: "pagegraph graph",
|
|
492
|
-
description: "The graph as an indented path tree"
|
|
493
|
-
},
|
|
494
|
-
{
|
|
495
|
-
command: "pagegraph graph --format mermaid",
|
|
496
|
-
description: "A Mermaid diagram of nodes and edges"
|
|
497
|
-
},
|
|
498
|
-
{
|
|
499
|
-
command: "pagegraph graph --orphans",
|
|
500
|
-
description: "Only nodes with no incoming edge"
|
|
501
|
-
},
|
|
502
|
-
{
|
|
503
|
-
command: "pagegraph graph --format json",
|
|
504
|
-
description: "The serialized graph on stdout"
|
|
505
|
-
}
|
|
506
|
-
]), Command.withHandler(Effect$1.fnUntraced(function* ({ format, orphans }) {
|
|
507
|
-
const config = yield* loadSeoConfig;
|
|
508
|
-
const graph = yield* Effect$1.scoped(acquireGraph(config));
|
|
509
|
-
if (format === "json") {
|
|
510
|
-
const serialized = serializeGraph(graph);
|
|
511
|
-
if (!orphans) return yield* printJson(serialized);
|
|
512
|
-
const orphanSet = orphanPaths(graph);
|
|
513
|
-
return yield* printJson({
|
|
514
|
-
nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
|
|
515
|
-
edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
if (format === "mermaid") return yield* printText(renderMermaid(graph, orphans));
|
|
519
|
-
return yield* printText(orphans ? renderOrphans(graph) : renderTree(graph));
|
|
520
|
-
})));
|
|
521
|
-
//#endregion
|
|
522
|
-
//#region src/cli/live-inspect.ts
|
|
523
|
-
/**
|
|
524
|
-
* `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
|
|
525
|
-
* validator in `../inspect-html`. The fetch is all that lives here — the
|
|
526
|
-
* validation is a library capability, not a CLI one, so it stays out of the
|
|
527
|
-
* Effect-bearing half of the package.
|
|
528
|
-
*/
|
|
529
|
-
/** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
|
|
530
|
-
const fetchAndInspect = (url) => Effect$1.tryPromise({
|
|
531
|
-
try: async () => {
|
|
532
|
-
const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
|
|
533
|
-
const html = await response.text();
|
|
534
|
-
return inspectHtml(url, response.status, html);
|
|
535
|
-
},
|
|
536
|
-
catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
|
|
537
|
-
});
|
|
538
|
-
//#endregion
|
|
539
|
-
//#region src/cli/commands/inspect.ts
|
|
540
|
-
const targetArg = Argument.string("target").pipe(Argument.withDescription("A route path (e.g. /pricing), or a full URL with --live"));
|
|
541
|
-
const liveFlag = Flag.boolean("live").pipe(Flag.withDescription("Fetch the URL and inspect its rendered <head> and JSON-LD"), Flag.withDefault(false));
|
|
542
|
-
const inspectCommand = Command.make("inspect", {
|
|
543
|
-
target: targetArg,
|
|
544
|
-
live: liveFlag,
|
|
545
|
-
json: jsonFlag
|
|
546
|
-
}).pipe(Command.withDescription("Inspect one page: its graph declaration, or its live <head> with --live"), Command.withExamples([
|
|
547
|
-
{
|
|
548
|
-
command: "pagegraph inspect /pricing",
|
|
549
|
-
description: "The graph node, policy, and edges for a path"
|
|
550
|
-
},
|
|
551
|
-
{
|
|
552
|
-
command: "pagegraph inspect https://example.com/pricing --live",
|
|
553
|
-
description: "Fetch the page and validate its head tags + JSON-LD"
|
|
554
|
-
},
|
|
555
|
-
{
|
|
556
|
-
command: "pagegraph inspect /blog/some-post --json",
|
|
557
|
-
description: "The node report as JSON"
|
|
558
|
-
}
|
|
559
|
-
]), Command.withHandler(Effect$1.fnUntraced(function* ({ target, live, json }) {
|
|
560
|
-
if (live) {
|
|
561
|
-
const report = yield* fetchAndInspect(target);
|
|
562
|
-
if (json) yield* printJson(report);
|
|
563
|
-
else yield* printText(renderLiveReport(report));
|
|
564
|
-
if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
const config = yield* loadSeoConfig;
|
|
568
|
-
const graph = yield* Effect$1.scoped(acquireGraph(config));
|
|
569
|
-
const report = inspectNode(graph, target);
|
|
570
|
-
if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
|
|
571
|
-
if (json) return yield* printJson({
|
|
572
|
-
node: serializeNode(report.node),
|
|
573
|
-
inSitemap: report.inSitemap,
|
|
574
|
-
incoming: report.incoming,
|
|
575
|
-
outgoing: report.outgoing
|
|
576
|
-
});
|
|
577
|
-
return yield* printText(renderNodeReport(report));
|
|
578
|
-
})));
|
|
579
|
-
//#endregion
|
|
580
|
-
//#region src/cli/commands/robots.ts
|
|
581
|
-
const robotsCommand = Command.make("robots", {
|
|
582
|
-
origin: originFlag,
|
|
583
|
-
indexable: indexableFlag
|
|
584
|
-
}).pipe(Command.withDescription("Render robots.txt from the graph (the exact server-route output)"), Command.withExamples([{
|
|
585
|
-
command: "pagegraph robots",
|
|
586
|
-
description: "The robots.txt, under the origin from seo.config.ts"
|
|
587
|
-
}, {
|
|
588
|
-
command: "pagegraph robots --origin https://preview.example.com --no-indexable",
|
|
589
|
-
description: "Disallow-all with no Sitemap line — the preview posture"
|
|
590
|
-
}]), Command.withHandler(Effect$1.fnUntraced(function* ({ origin, indexable }) {
|
|
591
|
-
const config = yield* loadSeoConfig;
|
|
592
|
-
const graph = yield* Effect$1.scoped(acquireGraph(config));
|
|
593
|
-
yield* printText(renderRobots(graph, {
|
|
594
|
-
origin: originOf(origin, config.origin),
|
|
595
|
-
indexable,
|
|
596
|
-
disallow: config.disallow,
|
|
597
|
-
contentSignal: config.contentSignal,
|
|
598
|
-
directives: config.directives,
|
|
599
|
-
transform: config.transform
|
|
600
|
-
}));
|
|
601
|
-
})));
|
|
602
|
-
//#endregion
|
|
603
|
-
//#region src/cli/commands/sitemap.ts
|
|
604
|
-
const sitemapCommand = Command.make("sitemap", {
|
|
605
|
-
origin: originFlag,
|
|
606
|
-
indexable: indexableFlag
|
|
607
|
-
}).pipe(Command.withDescription("Render sitemap.xml from the graph (the exact server-route output)"), Command.withExamples([{
|
|
608
|
-
command: "pagegraph sitemap",
|
|
609
|
-
description: "The sitemap XML, under the origin from seo.config.ts"
|
|
610
|
-
}, {
|
|
611
|
-
command: "pagegraph sitemap --origin https://preview.example.com --no-indexable",
|
|
612
|
-
description: "Sitemap body is host-independent; robots.txt is what gates crawling"
|
|
613
|
-
}]), Command.withHandler(Effect$1.fnUntraced(function* ({ origin, indexable }) {
|
|
614
|
-
const config = yield* loadSeoConfig;
|
|
615
|
-
const graph = yield* Effect$1.scoped(acquireGraph(config));
|
|
616
|
-
yield* printText(renderSitemap(graph, {
|
|
617
|
-
origin: originOf(origin, config.origin),
|
|
618
|
-
indexable
|
|
619
|
-
}));
|
|
620
|
-
})));
|
|
621
|
-
//#endregion
|
|
622
|
-
//#region src/cli/cli.ts
|
|
623
|
-
/**
|
|
624
|
-
* Root `pagegraph` command. Every subcommand reads the same SEO graph that render
|
|
625
|
-
* time, the sitemap/robots server routes, and the test suite read — the one the
|
|
626
|
-
* app's `seo.config.ts` loader produces. Route declarations are the single
|
|
627
|
-
* source of truth, and these are pure views over them.
|
|
628
|
-
*/
|
|
629
|
-
const cli = Command.make("pagegraph").pipe(Command.withDescription("Inspect and audit a TanStack Start SEO graph: sitemap, robots, cross-links, structured data, and link decisions."), Command.withExamples([
|
|
630
|
-
{
|
|
631
|
-
command: "pagegraph audit https://example.com",
|
|
632
|
-
description: "Audit any deployed website"
|
|
633
|
-
},
|
|
634
|
-
{
|
|
635
|
-
command: "pagegraph check",
|
|
636
|
-
description: "Fail (exit 1) on any structural SEO violation"
|
|
637
|
-
},
|
|
638
|
-
{
|
|
639
|
-
command: "pagegraph graph",
|
|
640
|
-
description: "Print the SEO graph as a tree"
|
|
641
|
-
},
|
|
642
|
-
{
|
|
643
|
-
command: "pagegraph sitemap",
|
|
644
|
-
description: "Render sitemap.xml"
|
|
645
|
-
}
|
|
646
|
-
]), Command.withSubcommands([
|
|
647
|
-
auditCommand,
|
|
648
|
-
diffCommand,
|
|
649
|
-
graphCommand,
|
|
650
|
-
inspectCommand,
|
|
651
|
-
checkCommand,
|
|
652
|
-
sitemapCommand,
|
|
653
|
-
robotsCommand
|
|
654
|
-
]));
|
|
655
|
-
//#endregion
|
|
656
|
-
//#region src/cli/main.ts
|
|
657
|
-
/**
|
|
658
|
-
* The `pagegraph` CLI program (Bun runtime), wiring the three output planes:
|
|
659
|
-
* - **data** → stdout, via `Console.log` in the command handlers.
|
|
660
|
-
* - **status** → stderr, via `Logger.LogToStderr(true)`: the built-in loggers
|
|
661
|
-
* call `console.error`, so `Effect.log*` never touches stdout. The built-in
|
|
662
|
-
* `--log-level` flag (from `Command.run`) gates them.
|
|
663
|
-
* - **diagnostics** → stderr: an expected `SeoCliError` prints `✗ <message>`.
|
|
664
|
-
*
|
|
665
|
-
* This module is what `bin.ts` dynamic-imports, and it is the only place Effect
|
|
666
|
-
* enters the package — which is what keeps Effect an *optional* peer dependency
|
|
667
|
-
* that no consumer of `pagegraph` or `pagegraph/react` ever installs.
|
|
668
|
-
*
|
|
669
|
-
* Two Bun-specific wrinkles are handled here:
|
|
670
|
-
* - `Command.run` writes its help to stdout, so a flag typo would dump the full
|
|
671
|
-
* help there. Under Bun, `Console.log` bypasses `process.stdout.write` (it
|
|
672
|
-
* calls `console.log` natively), so the buffer intercepts `console.log`.
|
|
673
|
-
* - A graph loader may leave handles alive after the command finishes (the Vite
|
|
674
|
-
* loader's worker threads do), and the default teardown only force-exits on a
|
|
675
|
-
* non-zero code, so a custom teardown exits on success too.
|
|
676
|
-
*/
|
|
677
|
-
/**
|
|
678
|
-
* Write one buffered line synchronously to fd 1, looping over partial writes and
|
|
679
|
-
* retrying `EAGAIN`. The forced `process.exit` needed to escape the Vite loader's
|
|
680
|
-
* lingering handles truncates async stdout to a slow pipe (`… | jq`) at the OS
|
|
681
|
-
* pipe-buffer boundary; a synchronous write blocks until every byte lands, so
|
|
682
|
-
* `process.exit` afterwards can't cut it off.
|
|
683
|
-
*/
|
|
684
|
-
const writeLineSync = (line) => {
|
|
685
|
-
const buffer = Buffer.from(`${line}\n`, "utf8");
|
|
686
|
-
let offset = 0;
|
|
687
|
-
while (offset < buffer.length) try {
|
|
688
|
-
offset += fs.writeSync(1, buffer, offset, buffer.length - offset);
|
|
689
|
-
} catch (cause) {
|
|
690
|
-
if (cause.code === "EAGAIN") continue;
|
|
691
|
-
throw cause;
|
|
692
|
-
}
|
|
693
|
-
};
|
|
694
|
-
const flushStdout = (state) => {
|
|
695
|
-
for (const args of state.buffer) writeLineSync(args.map((arg) => typeof arg === "string" ? arg : String(arg)).join(" "));
|
|
696
|
-
state.buffer.length = 0;
|
|
697
|
-
};
|
|
698
|
-
/**
|
|
699
|
-
* `Command.run` already prints the error itself to stderr (showHelp → Console.error);
|
|
700
|
-
* this adds only the "Try …" nudge, and `commandPath` already starts with "seo".
|
|
701
|
-
*/
|
|
702
|
-
const formatParseHint = (error) => {
|
|
703
|
-
return `Try: ${error.commandPath.length > 0 ? error.commandPath.join(" ") : "seo"} --help`;
|
|
704
|
-
};
|
|
705
|
-
/**
|
|
706
|
-
* Buffer stdout (`console.log`) so a parse error can suppress the help dump and
|
|
707
|
-
* print a one-line hint to stderr instead. Successful runs flush the buffer as
|
|
708
|
-
* their final act. The Vite loader temporarily re-points `console.log` to stderr
|
|
709
|
-
* while it runs (see `vite-graph-loader.ts`), nested inside this override, so its
|
|
710
|
-
* noise never enters the buffer.
|
|
711
|
-
*/
|
|
712
|
-
const withStdoutBuffer = (program) => Effect$1.acquireUseRelease(Effect$1.sync(() => {
|
|
713
|
-
const originalLog = console.log;
|
|
714
|
-
const buffer = [];
|
|
715
|
-
console.log = (...args) => buffer.push(args);
|
|
716
|
-
return {
|
|
717
|
-
originalLog,
|
|
718
|
-
buffer,
|
|
719
|
-
discard: false
|
|
720
|
-
};
|
|
721
|
-
}), (state) => program.pipe(Effect$1.tap(() => Effect$1.sync(() => flushStdout(state))), Effect$1.tapError((error) => {
|
|
722
|
-
if (CliError.isCliError(error) && error._tag === "ShowHelp" && error.errors.length > 0) {
|
|
723
|
-
state.discard = true;
|
|
724
|
-
return Console.error(formatParseHint(error));
|
|
725
|
-
}
|
|
726
|
-
return Effect$1.sync(() => flushStdout(state));
|
|
727
|
-
})), (state) => Effect$1.sync(() => {
|
|
728
|
-
console.log = state.originalLog;
|
|
729
|
-
if (!state.discard && state.buffer.length > 0) flushStdout(state);
|
|
730
|
-
else state.buffer.length = 0;
|
|
731
|
-
}));
|
|
732
|
-
const program = withStdoutBuffer(Command.run(cli, { version }).pipe(Effect$1.provideService(Logger.LogToStderr, true), Effect$1.provide(BunServices.layer), Effect$1.tapErrorTag("SeoCliError", (error) => Console.error(`✗ ${error.message}`))));
|
|
733
|
-
/**
|
|
734
|
-
* Run the CLI. An explicit call from `bin.ts` rather than an import-time side
|
|
735
|
-
* effect: the package declares `sideEffects: false`, so a body that only ran on
|
|
736
|
-
* import would be tree-shaken out of the built chunk.
|
|
737
|
-
*
|
|
738
|
-
* Force exit to escape the Vite loader's lingering worker handles. `flushStdout`
|
|
739
|
-
* already wrote the data plane synchronously, so exiting here can't truncate it.
|
|
740
|
-
*/
|
|
741
|
-
const run = () => {
|
|
742
|
-
BunRuntime.runMain(program, {
|
|
743
|
-
disableErrorReporting: true,
|
|
744
|
-
teardown: (exit, _onExit) => Runtime.defaultTeardown(exit, (code) => process.exit(code))
|
|
745
|
-
});
|
|
746
|
-
};
|
|
747
|
-
//#endregion
|
|
748
|
-
export { run };
|
|
749
|
-
|
|
750
|
-
//# sourceMappingURL=main-GFEobQTH.js.map
|