pagegraph 0.6.0 → 0.7.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 +226 -5
- package/dist/audit.js +1 -1
- package/dist/checks-6noG_xTS.d.ts +313 -0
- package/dist/cli.js +1980 -609
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/index.d.ts +2 -75
- package/dist/index.js +47 -13
- package/dist/index.js.map +1 -1
- package/dist/{links-sGbLkl-7.js → links-BFJc-pJ9.js} +152 -18
- package/dist/links-BFJc-pJ9.js.map +1 -0
- package/package.json +1 -1
- package/dist/checks-BfsQtKga.d.ts +0 -163
- package/dist/links-sGbLkl-7.js.map +0 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ bun add -D lighthouse # only for `pagegraph audit` performance evidence
|
|
|
27
27
|
|
|
28
28
|
| Entry | Exports | Peers |
|
|
29
29
|
| ---------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
|
30
|
-
| `pagegraph` | `buildSeoGraph`, `renderSitemap`, `renderRobots`, `contentSignal`, `checkGraph`, `inspectHtml` | — |
|
|
30
|
+
| `pagegraph` | `buildSeoGraph`, `renderSitemap`, `renderRobots`, `contentSignal`, `checkGraph`, `checkRenderedCoverage`, `decodeRenderedEdgeArtifact`, `inspectHtml` | — |
|
|
31
31
|
| `pagegraph/react` | `createSeo` → `seoHead`, `Breadcrumbs`, JSON-LD generators | `react`, `@tanstack/react-router` |
|
|
32
32
|
| `pagegraph/vite` | `seoRouteConfig` coverage gate | `vite` |
|
|
33
33
|
| `pagegraph/config` | `defineSeoConfig`, `viteGraphLoader` | `vite` |
|
|
@@ -305,8 +305,11 @@ pagegraph graph # the graph as a tree · --format mermaid | json
|
|
|
305
305
|
pagegraph inspect /pricing # one node: policy, sitemap status, in/out edges
|
|
306
306
|
pagegraph inspect <url> --live # fetch a deployed page, validate its rendered <head>
|
|
307
307
|
pagegraph links verify <url> # crawl served HTML: depth, orphans, declared-vs-rendered
|
|
308
|
+
pagegraph links verify <url> --assert-coverage # also assert seo.config.ts coverage on served anchors
|
|
309
|
+
pagegraph links verify <url> --emit-rendered <file> # save the rendered edge set
|
|
308
310
|
pagegraph links candidates # propose contextual links from the declared graph
|
|
309
311
|
pagegraph links decide <file> # answer typed link questions with Jev (TYPESAFE_API_KEY)
|
|
312
|
+
pagegraph decide <family> [<file>] # answer a decision batch with Jev (TYPESAFE_API_KEY)
|
|
310
313
|
pagegraph sitemap # print sitemap.xml
|
|
311
314
|
pagegraph robots # print robots.txt
|
|
312
315
|
```
|
|
@@ -327,6 +330,61 @@ pagegraph links verify https://example.com
|
|
|
327
330
|
pagegraph links verify https://example.com --limit 25 --json | jq
|
|
328
331
|
```
|
|
329
332
|
|
|
333
|
+
`--emit-rendered <path>` writes the crawl's raw edge set as a versioned artifact,
|
|
334
|
+
and `--rendered <path>` replays one instead of crawling — so a single crawl can be
|
|
335
|
+
asserted repeatedly in CI without re-fetching:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
pagegraph links verify https://example.com --emit-rendered .seo/rendered.json
|
|
339
|
+
pagegraph links verify --rendered .seo/rendered.json --json | jq
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
The artifact preserves every anchor's `region` (`body` vs `nav`/`footer`/`header`)
|
|
343
|
+
and the provenance a gate needs to trust it:
|
|
344
|
+
|
|
345
|
+
```jsonc
|
|
346
|
+
{
|
|
347
|
+
"kind": "links-rendered",
|
|
348
|
+
"schemaVersion": 1,
|
|
349
|
+
"origin": "https://example.com",
|
|
350
|
+
"seed": "https://example.com/",
|
|
351
|
+
"crawl": {
|
|
352
|
+
"pages": 42,
|
|
353
|
+
"root": "/",
|
|
354
|
+
"limit": 100,
|
|
355
|
+
"truncated": false,
|
|
356
|
+
"bodyTruncated": false,
|
|
357
|
+
"truncatedPages": [],
|
|
358
|
+
"failures": [],
|
|
359
|
+
},
|
|
360
|
+
"nodes": ["/", "/pricing"],
|
|
361
|
+
"edges": [{ "from": "/", "to": "/pricing", "region": "body" }],
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
It is also a drop-in input for `pagegraph links candidates --rendered`.
|
|
366
|
+
|
|
367
|
+
### Assert coverage on the rendered graph
|
|
368
|
+
|
|
369
|
+
`pagegraph check` evaluates the `seo.config.ts` `coverage` rules against the
|
|
370
|
+
**declared** graph; `pagegraph links verify --assert-coverage` evaluates the same
|
|
371
|
+
rules against the anchors a crawler **actually receives**. Only same-origin
|
|
372
|
+
body-region anchors count — nav, header, and footer links are chrome — which is
|
|
373
|
+
the served analogue of `check` counting `related` edges alone. The rule's target
|
|
374
|
+
universe is still the declared graph's sitemap-eligible pages.
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
pagegraph links verify https://example.com --assert-coverage
|
|
378
|
+
pagegraph links verify --rendered .seo/rendered.json --assert-coverage --json | jq
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The command exits non-zero when any rule is unmet, and **refuses to assert** (also
|
|
382
|
+
non-zero) rather than reporting a false pass when the crawl outran `--limit`, a
|
|
383
|
+
page failed to fetch (its anchors are missing), a body hit `--max-body-bytes`, the
|
|
384
|
+
config declares no `coverage`, or the artifact's origin differs from
|
|
385
|
+
`seo.config.ts`. Under `--assert-coverage` the report gains a `coverage` block
|
|
386
|
+
(`{ rules, ok, violations }`); the default `--json` summary is unchanged.
|
|
387
|
+
|
|
330
388
|
### Propose contextual links
|
|
331
389
|
|
|
332
390
|
`pagegraph links candidates` reads the declared graph and proposes
|
|
@@ -351,13 +409,15 @@ pagegraph links candidates --decide
|
|
|
351
409
|
|
|
352
410
|
### Decide with Jev
|
|
353
411
|
|
|
354
|
-
`pagegraph links decide` answers
|
|
355
|
-
reason to link to the destination
|
|
356
|
-
|
|
412
|
+
`pagegraph links decide` answers four decisions per candidate — *does the source have a genuine
|
|
413
|
+
reason to link to the destination?*, *is descriptive anchor text already in the copy?*, *which
|
|
414
|
+
direction deserves the link?*, and *how relevant is it?* — and routes anything inside the confidence
|
|
415
|
+
band `(1−t, t)` to a `review` bucket instead of auto-applying. `--budget` (default 4) keeps only the
|
|
416
|
+
top-K candidates per source by relevance.
|
|
357
417
|
|
|
358
418
|
```bash
|
|
359
419
|
pagegraph links decide candidates.json --threshold 0.9
|
|
360
|
-
pagegraph links decide candidates.json --
|
|
420
|
+
pagegraph links decide candidates.json --budget 2 --json | jq
|
|
361
421
|
cat candidates.json | pagegraph links decide
|
|
362
422
|
```
|
|
363
423
|
|
|
@@ -367,6 +427,163 @@ is deliberately not a `seo.config.ts` field, so keys never live in the repo; `se
|
|
|
367
427
|
non-secret decision settings only. Without a key the command exits 1 with a clear message and an
|
|
368
428
|
empty stdout.
|
|
369
429
|
|
|
430
|
+
### The decision runner
|
|
431
|
+
|
|
432
|
+
`pagegraph decide <family>` generalizes Jev decisions over a batch. Each input answers every decision
|
|
433
|
+
for that input in one provider call; confident answers resolve and anything inside the confidence
|
|
434
|
+
band goes to a `review` bucket. Nothing is applied automatically — the report is the end product.
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
pagegraph decide <family> inputs.json --json | jq
|
|
438
|
+
cat inputs.json | pagegraph decide <family> --threshold 0.9
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
A batch is a JSON array, a `{ "inputs": [...] }` envelope, or JSONL (one input per line), from a file
|
|
442
|
+
argument or stdin. Flags are shared across families:
|
|
443
|
+
|
|
444
|
+
| Flag | Default | Meaning |
|
|
445
|
+
| ----------------- | ----------- | ------------------------------------------------------------- |
|
|
446
|
+
| `--json` | false | Emit the versioned report as JSON on stdout |
|
|
447
|
+
| `--model <id>` | `jev-latest` | TypeSafe System One model (`jev-latest`, `jev-preview`, …) |
|
|
448
|
+
| `--threshold <t>` | `0.7` | Confidence boundary; `(1−t, t)` is the review band |
|
|
449
|
+
| `--concurrency <n>` | `4` | In-flight decision calls |
|
|
450
|
+
| `--cache <dir>` | — | Reuse model answers by family + model + input hash |
|
|
451
|
+
| `--review-out <file>` | — | Write only the below-threshold records to JSON |
|
|
452
|
+
|
|
453
|
+
`pagegraph decide links` is the links family alias of `pagegraph links decide` (see
|
|
454
|
+
[Decide with Jev](#decide-with-jev)); it accepts the same candidate array.
|
|
455
|
+
|
|
456
|
+
#### `decide serp` — is our page the shape this SERP rewards?
|
|
457
|
+
|
|
458
|
+
Input: one saved results-page snapshot per input.
|
|
459
|
+
|
|
460
|
+
```json
|
|
461
|
+
{
|
|
462
|
+
"query": "best email api",
|
|
463
|
+
"ourPage": {
|
|
464
|
+
"url": "https://example.com/email-api",
|
|
465
|
+
"title": "Email API for developers",
|
|
466
|
+
"kind": "page",
|
|
467
|
+
"firstWords": "Send transactional email from your product."
|
|
468
|
+
},
|
|
469
|
+
"serpItems": [
|
|
470
|
+
{ "type": "organic", "rank": 1, "domain": "a.example", "title": "Best email APIs", "snippet": "Compare" }
|
|
471
|
+
]
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
| Verdict | Meaning |
|
|
476
|
+
| ---------- | ------------------------------------------------------------------ |
|
|
477
|
+
| `aligned` | Intent and format are both confidently positive. |
|
|
478
|
+
| `mismatch` | At least one of intent/format is confidently wrong. |
|
|
479
|
+
| `review` | Any answer (`ourFormatFit`, `intentMatch`, `titlePatternMatch`) is inside the band. |
|
|
480
|
+
|
|
481
|
+
#### `decide content` — does this page carry enough substance?
|
|
482
|
+
|
|
483
|
+
Input: one page per input, with the evidence the rubric reads.
|
|
484
|
+
|
|
485
|
+
```json
|
|
486
|
+
{
|
|
487
|
+
"url": "https://example.com/email-api",
|
|
488
|
+
"query": "best email api",
|
|
489
|
+
"title": "Transactional email API for product teams",
|
|
490
|
+
"h1": "Send transactional email",
|
|
491
|
+
"first150Words": "The API sends one message per call and reports delivery events.",
|
|
492
|
+
"headings": ["Quickstart", "Events", "Pricing"],
|
|
493
|
+
"wordCount": 1200,
|
|
494
|
+
"structuredData": ["Article", "FAQPage"],
|
|
495
|
+
"categoryLock": "email api",
|
|
496
|
+
"siblingIntents": ["email deliverability", "sms api"],
|
|
497
|
+
"competitorExcerpts": ["A competitor's overview."]
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
| Verdict | Meaning |
|
|
502
|
+
| -------- | --------------------------------------------------------------------------- |
|
|
503
|
+
| `pass` | The value rating is not `thin` and no rubric probability is confidently false. |
|
|
504
|
+
| `flag` | Value is `thin`, or a rubric probability (`originalValue`, `answersFirst`, …) is confidently false. |
|
|
505
|
+
| `review` | Any rubric or value probability is inside the band, or an evidence-dependent rubric (`distinctIntent`, `competitiveSubstance`) is confidently positive without `siblingIntents`/`competitorExcerpts`. |
|
|
506
|
+
|
|
507
|
+
`wordCount` must be a non-negative integer.
|
|
508
|
+
|
|
509
|
+
`value` rates `thin | adequate | strong`.
|
|
510
|
+
|
|
511
|
+
#### `decide fit` — which existing page should target this query?
|
|
512
|
+
|
|
513
|
+
Input: a query plus the site's candidate pages. Candidate ids become the provider labels, so the
|
|
514
|
+
family needs at least two candidates with unique ids of at most 255 characters.
|
|
515
|
+
|
|
516
|
+
```json
|
|
517
|
+
{
|
|
518
|
+
"query": "best email api",
|
|
519
|
+
"candidates": [
|
|
520
|
+
{ "id": "/email-api", "title": "Email API", "excerpt": "Send transactional email." },
|
|
521
|
+
{ "id": "/pricing", "title": "Pricing", "excerpt": "Volume pricing." }
|
|
522
|
+
]
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
| Verdict | Meaning |
|
|
527
|
+
| -------------- | -------------------------------------------------------- |
|
|
528
|
+
| `map` | A page is confidently best; update it. |
|
|
529
|
+
| `cannibalized` | Multiple pages already target the query. |
|
|
530
|
+
| `gap` | No page can serve the query; a new page is needed. |
|
|
531
|
+
| `review` | The conflict or the best page is inside the confidence band. |
|
|
532
|
+
|
|
533
|
+
#### `decide meta` — rank supplied title/description candidates
|
|
534
|
+
|
|
535
|
+
Input: a page plus the candidates to rank. Drafting is out of scope — only the provided candidates
|
|
536
|
+
are judged. Needs at least two candidates with unique ids of at most 255 characters.
|
|
537
|
+
|
|
538
|
+
```json
|
|
539
|
+
{
|
|
540
|
+
"url": "https://example.com/email-api",
|
|
541
|
+
"intent": "choose an email api",
|
|
542
|
+
"categoryLock": "email api",
|
|
543
|
+
"candidates": [
|
|
544
|
+
{ "id": "a", "title": "Email API", "description": "Send transactional email." },
|
|
545
|
+
{ "id": "b", "title": "Email pricing", "description": "Volume pricing." }
|
|
546
|
+
]
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
| Verdict | Meaning |
|
|
551
|
+
| ------------- | ------------------------------------------------------------------------- |
|
|
552
|
+
| `choose:<id>` | A candidate is confidently best with clear intent, length, category, and clickbait checks. |
|
|
553
|
+
| `review` | A concern or an uncertain pick; a human decides. |
|
|
554
|
+
|
|
555
|
+
#### `decide authority` — is this a real link opportunity?
|
|
556
|
+
|
|
557
|
+
Input: one link target per input.
|
|
558
|
+
|
|
559
|
+
```json
|
|
560
|
+
{
|
|
561
|
+
"domain": "blog.example",
|
|
562
|
+
"url": "https://blog.example/email-guide",
|
|
563
|
+
"anchor": "transactional email API",
|
|
564
|
+
"context": "We cover how a transactional email API works end to end.",
|
|
565
|
+
"targetPath": "/email-api",
|
|
566
|
+
"rd": 120,
|
|
567
|
+
"signals": ["editorial", "no-outbound-links"]
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
| Verdict | Meaning |
|
|
572
|
+
| -------- | ------------------------------------------------------------------------ |
|
|
573
|
+
| `accept` | Legitimate, outreach-worthy, and a confident non-`none` fit. |
|
|
574
|
+
| `spam` | Spam is confidently high. |
|
|
575
|
+
| `review` | Any probability is inside the band, or the fit is `none`/uncertain. |
|
|
576
|
+
|
|
577
|
+
`fit` classifies `directory | partner | editorial | community | none`.
|
|
578
|
+
|
|
579
|
+
#### `decide links` — direction, relevance, and budget
|
|
580
|
+
|
|
581
|
+
`pagegraph decide links` shares the links decisions (see [Decide with Jev](#decide-with-jev)) and
|
|
582
|
+
adds `--budget <n>` (default 4): keep the top-K candidates per outbound page by `relevance`
|
|
583
|
+
(`irrelevant | useful | essential`). `direction` classifies `a_to_b | b_to_a | both`; a record also
|
|
584
|
+
carries the resulting `from`/`to` endpoints, so a `b_to_a` recommendation is reported — and
|
|
585
|
+
budgeted — against the page the model actually chose to link *from*.
|
|
586
|
+
|
|
370
587
|
### Contextual-link coverage
|
|
371
588
|
|
|
372
589
|
Declare a contextual-link coverage policy in `seo.config.ts` (a `coverage` array
|
|
@@ -380,6 +597,10 @@ rule that matches no sitemap-eligible page is itself a violation.
|
|
|
380
597
|
pagegraph check --require-inbound "/pricing=2" --require-inbound "/features/*=1"
|
|
381
598
|
```
|
|
382
599
|
|
|
600
|
+
`pagegraph check` asserts the policy on the declared graph. To prove the links are
|
|
601
|
+
actually served, assert the same policy on the rendered graph — see
|
|
602
|
+
[Assert coverage on the rendered graph](#assert-coverage-on-the-rendered-graph).
|
|
603
|
+
|
|
383
604
|
### Audit any website
|
|
384
605
|
|
|
385
606
|
`pagegraph audit` is framework-independent and does not need `seo.config.ts`. It
|
package/dist/audit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as extractAnchors } from "./links-BFJc-pJ9.js";
|
|
2
2
|
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import * as Context from "effect/Context";
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { AnyRoute, AnyRouteMatch } from "@tanstack/react-router";
|
|
2
|
+
//#region src/core/links.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Pure link-graph utilities: read anchors out of rendered HTML, model the
|
|
5
|
+
* rendered graph, compute homepage depth, and diff it against the declared
|
|
6
|
+
* graph. Effect- and framework-free so it runs in the CLI, a Worker, or a test
|
|
7
|
+
* on the same data.
|
|
8
|
+
*
|
|
9
|
+
* This is the "served HTML" half of the SEO graph: the declared graph describes
|
|
10
|
+
* intent, this describes what a crawler actually receives.
|
|
11
|
+
*/
|
|
12
|
+
/** Where an anchor sits in the document, coarsely. */
|
|
13
|
+
type AnchorRegion = "nav" | "footer" | "header" | "body";
|
|
14
|
+
interface Anchor {
|
|
15
|
+
readonly href: string;
|
|
16
|
+
readonly text: string;
|
|
17
|
+
readonly region: AnchorRegion;
|
|
18
|
+
/** True when the resolved target shares the page's origin. */
|
|
19
|
+
readonly internal: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface LinkEdge {
|
|
22
|
+
readonly from: string;
|
|
23
|
+
readonly to: string;
|
|
24
|
+
readonly region: AnchorRegion;
|
|
25
|
+
}
|
|
26
|
+
interface RenderedGraph {
|
|
27
|
+
/** Every same-origin page path the crawl rendered, in first-seen order. */
|
|
28
|
+
readonly nodes: ReadonlyArray<string>;
|
|
29
|
+
readonly edges: ReadonlyArray<LinkEdge>;
|
|
30
|
+
readonly internalEdges: ReadonlyArray<LinkEdge>;
|
|
31
|
+
/** Same-origin body-region edges: the contextual surface. */
|
|
32
|
+
readonly contextualEdges: ReadonlyArray<LinkEdge>;
|
|
33
|
+
/** Same-origin pages with no incoming internal edge ("reachable only via nav" is not implied). */
|
|
34
|
+
readonly orphans: ReadonlyArray<string>;
|
|
35
|
+
readonly depthByPath: ReadonlyMap<string, number>;
|
|
36
|
+
readonly maxDepth: number | null;
|
|
37
|
+
}
|
|
38
|
+
/** Normalize a same-origin URL to the graph's path key (no query/hash; "/" preserved). */
|
|
39
|
+
declare const normalizePath: (url: URL) => string;
|
|
40
|
+
/**
|
|
41
|
+
* Extract every anchor in document order, tagging each with the region tag it
|
|
42
|
+
* sits inside. Anchor text keeps only its letters and a coarse entity decode;
|
|
43
|
+
* this is a classifier input, not a renderer.
|
|
44
|
+
*/
|
|
45
|
+
declare const extractAnchors: (html: string, baseUrl: string) => ReadonlyArray<Anchor>;
|
|
46
|
+
interface RenderedPage {
|
|
47
|
+
readonly url: string;
|
|
48
|
+
readonly anchors: ReadonlyArray<Anchor>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the rendered graph from crawled pages. `origin` fixes the graph's
|
|
52
|
+
* same-origin boundary — pages and anchors on another origin are dropped, not
|
|
53
|
+
* silently folded in — and `root` is the normalized path the depth BFS starts
|
|
54
|
+
* from (the seed's final path when the homepage redirected). Every page path is
|
|
55
|
+
* keyed the way the declared graph keys its routes.
|
|
56
|
+
*/
|
|
57
|
+
declare const buildRenderedGraph: (origin: string, pages: ReadonlyArray<RenderedPage>, root?: string) => RenderedGraph;
|
|
58
|
+
/**
|
|
59
|
+
* Rebuild a {@link RenderedGraph} from a rendered-edge set alone — the artifact
|
|
60
|
+
* `pagegraph links verify --emit-rendered` writes. Path keys are normalized the
|
|
61
|
+
* same way as a crawl, and `nodes` carries the rendered page paths the artifact
|
|
62
|
+
* recorded, so an edge-less page (a redirect target with no internal links) stays
|
|
63
|
+
* a node and its orphan/depth report survives replay. Without `nodes` the node set
|
|
64
|
+
* falls back to the edge endpoints.
|
|
65
|
+
*/
|
|
66
|
+
declare const renderedGraphFromEdges: (edges: ReadonlyArray<LinkEdge>, root?: string, nodes?: ReadonlyArray<string>) => RenderedGraph;
|
|
67
|
+
interface SimpleEdge {
|
|
68
|
+
readonly from: string;
|
|
69
|
+
readonly to: string;
|
|
70
|
+
}
|
|
71
|
+
interface LinkGraphDiff {
|
|
72
|
+
/** Declared internal edges with no matching rendered anchor. */
|
|
73
|
+
readonly declaredNotRendered: ReadonlyArray<SimpleEdge>;
|
|
74
|
+
/** Rendered contextual edges that no declared edge accounts for. */
|
|
75
|
+
readonly renderedNotDeclared: ReadonlyArray<SimpleEdge>;
|
|
76
|
+
readonly declaredCount: number;
|
|
77
|
+
readonly renderedContextualCount: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Diff rendered contextual edges against declared `related`/`crumb` edges. Both
|
|
81
|
+
* sides are path-keyed; the diff is directional and reports each direction
|
|
82
|
+
* separately because they call for different fixes.
|
|
83
|
+
*/
|
|
84
|
+
declare const diffLinkGraph: (declared: ReadonlyArray<SimpleEdge>, rendered: RenderedGraph) => LinkGraphDiff;
|
|
85
|
+
/** A page the crawl could not render; its outgoing anchors are absent. */
|
|
86
|
+
interface RenderedEdgeFailure {
|
|
87
|
+
readonly url: string;
|
|
88
|
+
readonly error: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Provenance for a rendered-edge artifact: enough to trust the edges and to
|
|
92
|
+
* refuse a gate that would assert on an incomplete crawl. `root` is the seed's
|
|
93
|
+
* normalized final path (its post-redirect path), so depth and orphans rebuild
|
|
94
|
+
* exactly as the crawl computed them.
|
|
95
|
+
*/
|
|
96
|
+
interface RenderedEdgeArtifactCrawl {
|
|
97
|
+
/** Pages whose HTML was rendered into `edges`. */
|
|
98
|
+
readonly pages: number;
|
|
99
|
+
/** Normalized final path of the seed — the depth BFS root. */
|
|
100
|
+
readonly root: string;
|
|
101
|
+
/** The crawl's page budget (`--limit`). */
|
|
102
|
+
readonly limit: number;
|
|
103
|
+
/** True when discovery outran `limit`; a gate must not assert on this. */
|
|
104
|
+
readonly truncated: boolean;
|
|
105
|
+
/** True when any captured body was cut at `--max-body-bytes`. */
|
|
106
|
+
readonly bodyTruncated: boolean;
|
|
107
|
+
/** URLs whose captured body was cut; anchors past the cutoff are missing. */
|
|
108
|
+
readonly truncatedPages: ReadonlyArray<string>;
|
|
109
|
+
/** Pages that failed to fetch; their outgoing anchors are missing. */
|
|
110
|
+
readonly failures: ReadonlyArray<RenderedEdgeFailure>;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The stable, `decodeRenderedEdges`-compatible artifact `pagegraph links verify
|
|
114
|
+
* --emit-rendered` writes and `--rendered` reads. It carries the raw served edges
|
|
115
|
+
* (with `region`), the rendered page paths, and the provenance a coverage gate
|
|
116
|
+
* needs to decide whether the crawl is trustworthy, so one crawl can be asserted
|
|
117
|
+
* repeatedly without re-fetching.
|
|
118
|
+
*/
|
|
119
|
+
interface RenderedEdgeArtifact {
|
|
120
|
+
readonly kind: "links-rendered";
|
|
121
|
+
readonly schemaVersion: 1;
|
|
122
|
+
readonly origin: string;
|
|
123
|
+
readonly seed: string;
|
|
124
|
+
readonly crawl: RenderedEdgeArtifactCrawl;
|
|
125
|
+
/** Normalized paths of every rendered page — replay's node set. */
|
|
126
|
+
readonly nodes: ReadonlyArray<string>;
|
|
127
|
+
readonly edges: ReadonlyArray<LinkEdge>;
|
|
128
|
+
}
|
|
129
|
+
/** The rendered-edge artifact schema this package writes and accepts. */
|
|
130
|
+
declare const RENDERED_EDGE_ARTIFACT_SCHEMA_VERSION: 1;
|
|
131
|
+
/**
|
|
132
|
+
* Decode a rendered-edge artifact from user input. Throws on any other shape —
|
|
133
|
+
* this is user input, not a provider payload. `root`, `bodyTruncated`,
|
|
134
|
+
* `truncatedPages`, `failures`, and `nodes` are optional so a hand-written edge
|
|
135
|
+
* dump that only carries provenance can still be replayed.
|
|
136
|
+
*/
|
|
137
|
+
declare const decodeRenderedEdgeArtifact: (input: unknown) => RenderedEdgeArtifact;
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/core/declare.d.ts
|
|
140
|
+
/**
|
|
141
|
+
* Consumer-augmented registry.
|
|
142
|
+
*
|
|
143
|
+
* ```ts
|
|
144
|
+
* declare module "pagegraph" {
|
|
145
|
+
* interface Register {
|
|
146
|
+
* paths: FileRouteTypes["fullPaths"];
|
|
147
|
+
* kinds: "page" | "article" | "hub";
|
|
148
|
+
* }
|
|
149
|
+
* }
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
interface Register {}
|
|
153
|
+
/** Every public full path in the consumer's route tree (type-only; erased at runtime). */
|
|
154
|
+
type PublicPath = Register extends {
|
|
155
|
+
paths: infer P extends string;
|
|
156
|
+
} ? P : string;
|
|
157
|
+
/** The consumer's page taxonomy. A label: nothing in the core branches on it. */
|
|
158
|
+
type SeoKind = Register extends {
|
|
159
|
+
kinds: infer K extends string;
|
|
160
|
+
} ? K : string;
|
|
161
|
+
interface SitemapPolicy {
|
|
162
|
+
priority: number;
|
|
163
|
+
changeFrequency: "weekly" | "monthly";
|
|
164
|
+
}
|
|
165
|
+
interface RouteSeo {
|
|
166
|
+
kind: SeoKind;
|
|
167
|
+
/** Breadcrumb label; fn form reads the match (e.g. loaderData frontmatter title). */
|
|
168
|
+
crumb?: string | ((match: AnyRouteMatch) => string) | undefined;
|
|
169
|
+
/** false = excluded from sitemap. Absent on non-indexable kinds is fine. */
|
|
170
|
+
sitemap?: SitemapPolicy | false | undefined;
|
|
171
|
+
/** meta robots value, e.g. "noindex, follow" on a hub page. */
|
|
172
|
+
robots?: string | undefined;
|
|
173
|
+
/** Typed cross-link edges. */
|
|
174
|
+
related?: ReadonlyArray<PublicPath> | undefined;
|
|
175
|
+
/** How OTHER pages render a card for this page. */
|
|
176
|
+
link?: {
|
|
177
|
+
title: string;
|
|
178
|
+
description: string;
|
|
179
|
+
} | undefined;
|
|
180
|
+
/** This route is a redirect/alias. */
|
|
181
|
+
redirectTo?: PublicPath | undefined;
|
|
182
|
+
}
|
|
183
|
+
declare module "@tanstack/react-router" {
|
|
184
|
+
interface StaticDataRouteOption {
|
|
185
|
+
seo?: RouteSeo | undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/core/graph.d.ts
|
|
190
|
+
/**
|
|
191
|
+
* Where a node came from: `"route"` for a structural route declaration, or the
|
|
192
|
+
* `source` label of the collection that produced the instance (e.g. "blog").
|
|
193
|
+
*/
|
|
194
|
+
type SeoSource = string;
|
|
195
|
+
interface SeoNode {
|
|
196
|
+
/** Canonical path, no origin (e.g. "/pricing", "/blog/my-post"). */
|
|
197
|
+
path: string;
|
|
198
|
+
kind: SeoKind;
|
|
199
|
+
source: SeoSource;
|
|
200
|
+
/** Route-declared policy, or synthesized (kind + inherited sitemap) for instances. */
|
|
201
|
+
policy: RouteSeo;
|
|
202
|
+
instance?: {
|
|
203
|
+
title: string;
|
|
204
|
+
description?: string | undefined;
|
|
205
|
+
publishedAt?: string | undefined;
|
|
206
|
+
modifiedAt?: string | undefined;
|
|
207
|
+
} | undefined;
|
|
208
|
+
}
|
|
209
|
+
type SeoEdgeType = "crumb-parent" | "related" | "redirect" | "collection-member";
|
|
210
|
+
interface SeoEdge {
|
|
211
|
+
from: string;
|
|
212
|
+
to: string;
|
|
213
|
+
type: SeoEdgeType;
|
|
214
|
+
}
|
|
215
|
+
interface SeoGraph {
|
|
216
|
+
nodes: Map<string, SeoNode>;
|
|
217
|
+
edges: Array<SeoEdge>;
|
|
218
|
+
/** Exact-path ownership conflicts encountered while assembling graph sources. */
|
|
219
|
+
collisions?: ReadonlyArray<{
|
|
220
|
+
path: string;
|
|
221
|
+
sources: ReadonlyArray<SeoSource>;
|
|
222
|
+
}> | undefined;
|
|
223
|
+
}
|
|
224
|
+
/** One concrete page produced by a collection. */
|
|
225
|
+
interface SeoInstance {
|
|
226
|
+
/** Canonical path of the page (e.g. "/blog/my-post"). */
|
|
227
|
+
readonly path: string;
|
|
228
|
+
readonly title: string;
|
|
229
|
+
readonly description?: string | undefined;
|
|
230
|
+
readonly publishedAt?: string | undefined;
|
|
231
|
+
readonly modifiedAt?: string | undefined;
|
|
232
|
+
}
|
|
233
|
+
interface SeoCollection {
|
|
234
|
+
/**
|
|
235
|
+
* The param route these instances render through (e.g. "/blog/$slug"). Instances
|
|
236
|
+
* inherit this route's declared policy (kind + sitemap) — the graph reads it from
|
|
237
|
+
* the structural node, so declarations stay the single source of truth.
|
|
238
|
+
*/
|
|
239
|
+
readonly route: PublicPath;
|
|
240
|
+
/** The `source` stamped on every node this collection produces (e.g. "blog"). */
|
|
241
|
+
readonly source: SeoSource;
|
|
242
|
+
readonly instances: ReadonlyArray<SeoInstance>;
|
|
243
|
+
/** Edges the collection declares between its instances and the rest of the graph. */
|
|
244
|
+
readonly edges?: ReadonlyArray<SeoEdge> | undefined;
|
|
245
|
+
}
|
|
246
|
+
interface BuildSeoGraphInput {
|
|
247
|
+
readonly routeTree: AnyRoute;
|
|
248
|
+
readonly collections?: ReadonlyArray<SeoCollection> | undefined;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Build the SEO graph from route declarations and content collections.
|
|
252
|
+
*
|
|
253
|
+
* Synchronous: the caller materializes its collections before calling, so there is
|
|
254
|
+
* no async work here. Callers own the origin.
|
|
255
|
+
*/
|
|
256
|
+
declare function buildSeoGraph(input: BuildSeoGraphInput): SeoGraph;
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/core/checks.d.ts
|
|
259
|
+
type Severity = "structural" | "editorial";
|
|
260
|
+
interface Violation {
|
|
261
|
+
severity: Severity;
|
|
262
|
+
rule: string;
|
|
263
|
+
path?: string | undefined;
|
|
264
|
+
message: string;
|
|
265
|
+
fix?: string | undefined;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* A contextual-link coverage rule: every sitemap-eligible page matching the
|
|
269
|
+
* `path` glob must have at least `minInbound` incoming contextual links. Unlike
|
|
270
|
+
* the static rules, these come from project policy (a CLI flag or
|
|
271
|
+
* `seo.config.ts`), because "which pages are money pages" is app knowledge.
|
|
272
|
+
*
|
|
273
|
+
* The same rule shape is evaluated twice: `checkCoverage` counts declared
|
|
274
|
+
* `related` edges, and `checkRenderedCoverage` counts the contextual anchors a
|
|
275
|
+
* crawl actually served. The target universe is always the declared graph's
|
|
276
|
+
* sitemap-eligible pages — the rendered pass only swaps the edge source.
|
|
277
|
+
*/
|
|
278
|
+
interface CoverageRule {
|
|
279
|
+
/** Path glob: `*` matches within a segment, `**` matches across segments. */
|
|
280
|
+
readonly path: string;
|
|
281
|
+
/** Minimum incoming contextual links the matched page needs. */
|
|
282
|
+
readonly minInbound: number;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Run every static rule against the graph, then any caller-supplied
|
|
286
|
+
* {@link CoverageRule}s, and return the flat list of violations. With no
|
|
287
|
+
* `coverage` option the result is exactly the static rule set.
|
|
288
|
+
*/
|
|
289
|
+
declare function checkGraph(graph: SeoGraph, options?: {
|
|
290
|
+
readonly coverage?: ReadonlyArray<CoverageRule> | undefined;
|
|
291
|
+
}): Array<Violation>;
|
|
292
|
+
/**
|
|
293
|
+
* Enforce contextual-link coverage against the *declared* graph: only `related`
|
|
294
|
+
* edges count — breadcrumb ancestry is navigation, not context.
|
|
295
|
+
*/
|
|
296
|
+
declare function checkCoverage(graph: SeoGraph, rules: ReadonlyArray<CoverageRule>): Array<Violation>;
|
|
297
|
+
/**
|
|
298
|
+
* Enforce contextual-link coverage against the *rendered* graph: only same-origin
|
|
299
|
+
* body-region anchors count. Nav, header, and footer anchors are site chrome, so
|
|
300
|
+
* a page linked solely from them has no contextual inbound link — the same
|
|
301
|
+
* distinction the declared pass draws by counting `related` edges alone.
|
|
302
|
+
*
|
|
303
|
+
* The target universe is the declared graph's sitemap-eligible pages: the globs
|
|
304
|
+
* in `seo.config.ts` are written against declared paths, and the crawl is what
|
|
305
|
+
* supplies the served edges. Pass the crawl's `internalEdges` (each edge carries
|
|
306
|
+
* its `region`).
|
|
307
|
+
*/
|
|
308
|
+
declare function checkRenderedCoverage(graph: SeoGraph, renderedEdges: ReadonlyArray<LinkEdge>, rules: ReadonlyArray<CoverageRule>): Array<Violation>;
|
|
309
|
+
/** Structural violations fail `pagegraph check`; editorial-only stays green. */
|
|
310
|
+
declare const hasStructuralViolations: (violations: ReadonlyArray<Violation>) => boolean;
|
|
311
|
+
//#endregion
|
|
312
|
+
export { RenderedGraph as A, AnchorRegion as C, RenderedEdgeArtifact as D, RENDERED_EDGE_ARTIFACT_SCHEMA_VERSION as E, diffLinkGraph as F, extractAnchors as I, normalizePath as L, SimpleEdge as M, buildRenderedGraph as N, RenderedEdgeArtifactCrawl as O, decodeRenderedEdgeArtifact as P, renderedGraphFromEdges as R, Anchor as S, LinkGraphDiff as T, PublicPath as _, checkGraph as a, SeoKind as b, BuildSeoGraphInput as c, SeoEdgeType as d, SeoGraph as f, buildSeoGraph as g, SeoSource as h, checkCoverage as i, RenderedPage as j, RenderedEdgeFailure as k, SeoCollection as l, SeoNode as m, Severity as n, checkRenderedCoverage as o, SeoInstance as p, Violation as r, hasStructuralViolations as s, CoverageRule as t, SeoEdge as u, Register as v, LinkEdge as w, SitemapPolicy as x, RouteSeo as y };
|
|
313
|
+
//# sourceMappingURL=checks-6noG_xTS.d.ts.map
|