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.
@@ -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