@jdevalk/seo-graph-core 0.6.0 → 0.6.2
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/AGENTS.md +323 -11
- package/README.md +17 -6
- package/dist/indexnow.d.ts +2 -2
- package/dist/indexnow.d.ts.map +1 -1
- package/dist/indexnow.js +11 -5
- package/dist/indexnow.js.map +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -24,6 +24,38 @@ Two packages:
|
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
27
|
+
## Contents
|
|
28
|
+
|
|
29
|
+
**Schema core** — concepts and builders for the JSON-LD graph:
|
|
30
|
+
|
|
31
|
+
- [Architecture](#architecture)
|
|
32
|
+
- [Installation](#installation)
|
|
33
|
+
- [The @id system](#the-id-system)
|
|
34
|
+
- [Piece builders reference](#piece-builders-reference)
|
|
35
|
+
|
|
36
|
+
**Recipes and patterns** — how to model real sites:
|
|
37
|
+
|
|
38
|
+
- [Site type recipes](#site-type-recipes)
|
|
39
|
+
- [Trust and credibility signals](#trust-and-credibility-signals)
|
|
40
|
+
- [Choosing the right Article subtype](#choosing-the-right-article-subtype)
|
|
41
|
+
- [Actions: telling agents what they can do](#actions-telling-agents-what-they-can-do)
|
|
42
|
+
- [Multi-type entities](#multi-type-entities)
|
|
43
|
+
- [Rich Organization patterns](#rich-organization-patterns)
|
|
44
|
+
- [Rich Person patterns](#rich-person-patterns)
|
|
45
|
+
- [Reference implementations](#reference-implementations)
|
|
46
|
+
|
|
47
|
+
**Astro integration** — runtime component and build-time checks:
|
|
48
|
+
|
|
49
|
+
- [Astro integration guide](#astro-integration-guide) — the `<Seo>` component, hreflang, schema endpoints
|
|
50
|
+
- [Build-time integration](#build-time-integration) — `seoGraph()` hook, H1 validation, metadata uniqueness, IndexNow, `llms.txt`
|
|
51
|
+
- [Complete integration example](#complete-integration-example)
|
|
52
|
+
- [Advanced patterns](#advanced-patterns)
|
|
53
|
+
- [Common mistakes](#common-mistakes)
|
|
54
|
+
- [Validating your output](#validating-your-output)
|
|
55
|
+
- [Repository structure](#repository-structure)
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
27
59
|
## Architecture
|
|
28
60
|
|
|
29
61
|
```
|
|
@@ -2484,10 +2516,89 @@ export const GET = createSchemaMap({
|
|
|
2484
2516
|
**Schema map entry options:**
|
|
2485
2517
|
| Field | Type | Default | Purpose |
|
|
2486
2518
|
|---|---|---|---|
|
|
2487
|
-
| `path` | `string` | — |
|
|
2488
|
-
| `lastModified` | `Date` | — | Last
|
|
2489
|
-
|
|
2490
|
-
|
|
2519
|
+
| `path` | `string` | — | Path of the schema endpoint. Leading slash optional. |
|
|
2520
|
+
| `lastModified` | `Date` | — | Last-modified date. Rendered as `YYYY-MM-DD`. |
|
|
2521
|
+
|
|
2522
|
+
**`createSchemaMap` options:**
|
|
2523
|
+
| Option | Type | Default | Purpose |
|
|
2524
|
+
|---|---|---|---|
|
|
2525
|
+
| `siteUrl` | `string` | — | Canonical origin. Trailing slash stripped. |
|
|
2526
|
+
| `entries` | `SchemaMapEntry[]` | — | One entry per schema endpoint. |
|
|
2527
|
+
| `cacheControl` | `string \| null` | `'max-age=300'` | Cache-Control header. `null` to omit. |
|
|
2528
|
+
|
|
2529
|
+
`<changefreq>` and `<priority>` are deliberately not emitted —
|
|
2530
|
+
Google and other major crawlers ignore them, and fabricating defaults
|
|
2531
|
+
is worse than omitting.
|
|
2532
|
+
|
|
2533
|
+
### API catalog (RFC 9727)
|
|
2534
|
+
|
|
2535
|
+
Serve a standards-compliant API catalog at `/.well-known/api-catalog`
|
|
2536
|
+
([RFC 9727](https://www.rfc-editor.org/rfc/rfc9727)) so agent crawlers
|
|
2537
|
+
can discover the site's APIs in one fetch:
|
|
2538
|
+
|
|
2539
|
+
```ts
|
|
2540
|
+
// src/pages/.well-known/api-catalog.ts
|
|
2541
|
+
import { createApiCatalog } from '@jdevalk/astro-seo-graph';
|
|
2542
|
+
|
|
2543
|
+
export const GET = createApiCatalog({
|
|
2544
|
+
siteUrl: 'https://example.com',
|
|
2545
|
+
schemaEndpoints: [
|
|
2546
|
+
{ path: '/schema/post.json', schemaType: 'BlogPosting', serviceDoc: '/seo-graph/' },
|
|
2547
|
+
{ path: '/schema/page.json', schemaType: 'WebPage', serviceDoc: '/seo-graph/' },
|
|
2548
|
+
],
|
|
2549
|
+
schemaMap: { path: '/schemamap.xml', serviceDoc: '/seo-graph/' },
|
|
2550
|
+
additional: [
|
|
2551
|
+
{ anchor: '/ask', serviceDoc: '/ask-docs/', type: 'https://schema.org/SearchAction' },
|
|
2552
|
+
],
|
|
2553
|
+
});
|
|
2554
|
+
```
|
|
2555
|
+
|
|
2556
|
+
Output is `application/linkset+json` ([RFC 9264](https://www.rfc-editor.org/rfc/rfc9264)).
|
|
2557
|
+
`schemaEndpoints` get auto-typed as `https://schema.org/<schemaType>`;
|
|
2558
|
+
`schemaMap` is emitted without a `type` (no standard one exists);
|
|
2559
|
+
`additional` accepts free-form `anchor`/`serviceDoc`/`type` (each
|
|
2560
|
+
either `string` or `string[]`). Relative paths are absolutized against
|
|
2561
|
+
`siteUrl`; absolute URLs pass through unchanged.
|
|
2562
|
+
|
|
2563
|
+
The package also exports a `CATALOG_PATH` constant
|
|
2564
|
+
(`'/.well-known/api-catalog'`) so callers can reference the canonical
|
|
2565
|
+
location without duplicating the string.
|
|
2566
|
+
|
|
2567
|
+
**Options:**
|
|
2568
|
+
| Option | Type | Default | Purpose |
|
|
2569
|
+
|---|---|---|---|
|
|
2570
|
+
| `siteUrl` | `string` | — | Canonical origin. Trailing slash stripped. |
|
|
2571
|
+
| `schemaEndpoints` | `ApiCatalogSchemaEndpointEntry[]` | — | Schema.org JSON endpoints with `path` + `schemaType`. |
|
|
2572
|
+
| `schemaMap` | `ApiCatalogSchemaMapEntry` | — | Path of the `createSchemaMap` route. |
|
|
2573
|
+
| `additional` | `ApiCatalogEntry[]` | — | Site-specific APIs not covered by the package factories. |
|
|
2574
|
+
| `cacheControl` | `string \| null` | `'max-age=300'` | Cache-Control header. `null` to omit. |
|
|
2575
|
+
| `contentType` | `string` | `'application/linkset+json'` | Override only with reason. |
|
|
2576
|
+
| `indent` | `number` | `2` | JSON indentation. `0` for compact. |
|
|
2577
|
+
|
|
2578
|
+
### Last-modified dates from git
|
|
2579
|
+
|
|
2580
|
+
`gitLastmod` reads the committer date of the most recent git commit
|
|
2581
|
+
that touched a file, with configurable `excludeCommits` (skip bulk
|
|
2582
|
+
imports / reformats / renames) and `depth` (how many commits to
|
|
2583
|
+
inspect). Use it to feed `dateModified` on JSON-LD pieces or
|
|
2584
|
+
`<lastmod>` in sitemaps without trusting filesystem `mtime` (which
|
|
2585
|
+
gets rewritten on every CI checkout):
|
|
2586
|
+
|
|
2587
|
+
```ts
|
|
2588
|
+
import { gitLastmod } from '@jdevalk/astro-seo-graph';
|
|
2589
|
+
|
|
2590
|
+
const last = gitLastmod(`src/content/blog/${entry.id}/index.md`, {
|
|
2591
|
+
excludeCommits: ['52130a9', '989dc47'],
|
|
2592
|
+
depth: 20,
|
|
2593
|
+
});
|
|
2594
|
+
```
|
|
2595
|
+
|
|
2596
|
+
Returns `null` when the file has no git history, git isn't on the
|
|
2597
|
+
PATH, or every commit in the inspected window is excluded — fall back
|
|
2598
|
+
to `publishDate` in that case. `excludeCommits` matches on the first 7
|
|
2599
|
+
characters of the SHA, so short hashes from `git log --oneline` work
|
|
2600
|
+
directly. Build-time only — shells out to the `git` binary via
|
|
2601
|
+
`execFileSync`.
|
|
2491
2602
|
|
|
2492
2603
|
### The `aggregate` function
|
|
2493
2604
|
|
|
@@ -2544,18 +2655,24 @@ const blog = defineCollection({
|
|
|
2544
2655
|
}
|
|
2545
2656
|
```
|
|
2546
2657
|
|
|
2547
|
-
### `
|
|
2658
|
+
### `buildSeoContext`
|
|
2548
2659
|
|
|
2549
|
-
Pure-TS function that powers `<Seo>` internally.
|
|
2550
|
-
|
|
2660
|
+
Pure-TS function that powers `<Seo>` internally. Returns a flat,
|
|
2661
|
+
render-ready normalization of `SeoProps` (resolved title, canonical, OG
|
|
2662
|
+
fields, hreflang entries, robots directives, twitter overrides). Use when
|
|
2663
|
+
you want to render the head yourself:
|
|
2551
2664
|
|
|
2552
2665
|
```ts
|
|
2553
|
-
import {
|
|
2666
|
+
import { buildSeoContext } from '@jdevalk/astro-seo-graph';
|
|
2554
2667
|
|
|
2555
|
-
const
|
|
2556
|
-
//
|
|
2668
|
+
const ctx = buildSeoContext(mySeoProps, Astro.url.href);
|
|
2669
|
+
// ctx.title, ctx.canonical, ctx.og.*, ctx.twitter, ctx.hreflangs, ...
|
|
2557
2670
|
```
|
|
2558
2671
|
|
|
2672
|
+
The companion constant `ROBOTS_EXTRAS` exposes the
|
|
2673
|
+
`max-snippet:-1, max-image-preview:large, max-video-preview:-1`
|
|
2674
|
+
directives that `<Seo>` always appends to the robots tag.
|
|
2675
|
+
|
|
2559
2676
|
### `buildAlternateLinks`
|
|
2560
2677
|
|
|
2561
2678
|
Pure helper for hreflang link generation. No Astro runtime needed — safe
|
|
@@ -2576,6 +2693,200 @@ const links = buildAlternateLinks({
|
|
|
2576
2693
|
|
|
2577
2694
|
---
|
|
2578
2695
|
|
|
2696
|
+
## Build-time integration
|
|
2697
|
+
|
|
2698
|
+
`@jdevalk/astro-seo-graph/integration` exports a default `seoGraph()`
|
|
2699
|
+
function that returns an Astro integration. It hooks `astro:build:done`
|
|
2700
|
+
to run cross-page SEO checks and optional post-build actions against the
|
|
2701
|
+
static HTML output. SSR pages aren't on disk at build time, so they're
|
|
2702
|
+
not checked.
|
|
2703
|
+
|
|
2704
|
+
```js
|
|
2705
|
+
// astro.config.mjs
|
|
2706
|
+
import { defineConfig } from 'astro/config';
|
|
2707
|
+
import seoGraph from '@jdevalk/astro-seo-graph/integration';
|
|
2708
|
+
|
|
2709
|
+
export default defineConfig({
|
|
2710
|
+
site: 'https://example.com',
|
|
2711
|
+
integrations: [
|
|
2712
|
+
seoGraph({
|
|
2713
|
+
// All options below are optional; listed with defaults.
|
|
2714
|
+
validateH1: true,
|
|
2715
|
+
validateUniqueMetadata: true,
|
|
2716
|
+
validateImageAlt: true,
|
|
2717
|
+
validateMetadataLength: true, // or { title: { max: 60 }, ... }
|
|
2718
|
+
validateInternalLinks: true, // or { skip: (href) => href.startsWith('/api/') }
|
|
2719
|
+
// indexNow: { ... },
|
|
2720
|
+
// llmsTxt: { ... },
|
|
2721
|
+
}),
|
|
2722
|
+
],
|
|
2723
|
+
});
|
|
2724
|
+
```
|
|
2725
|
+
|
|
2726
|
+
### Options
|
|
2727
|
+
|
|
2728
|
+
| Option | Default | Purpose |
|
|
2729
|
+
| ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2730
|
+
| `validateH1` | `true` | Warn when a page has zero or >1 `<h1>` elements. |
|
|
2731
|
+
| `validateUniqueMetadata` | `true` | Warn when two pages share the same `<title>` or meta description. |
|
|
2732
|
+
| `validateImageAlt` | `true` | Warn when a page has `<img>` tags missing an `alt` attribute. |
|
|
2733
|
+
| `validateMetadataLength` | `true` | Warn when `<title>` or description length falls outside SERP-friendly bounds (title 30–65, description 70–200 by default; overridable per-field). |
|
|
2734
|
+
| `validateInternalLinks` | `true` | Warn on trailing-slash mismatches and links to pages not in the build. Accepts `{ skip }` to exclude SSR-only routes. |
|
|
2735
|
+
| `indexNow` | — | Submit built URLs to IndexNow. Omit to disable. |
|
|
2736
|
+
| `llmsTxt` | — | Generate `llms.txt` at the root of the build output. Omit to disable. |
|
|
2737
|
+
|
|
2738
|
+
### H1 and metadata validation
|
|
2739
|
+
|
|
2740
|
+
`validateH1` flags pages with missing or duplicate `<h1>` elements — the
|
|
2741
|
+
most common on-page SEO/accessibility miss. `validateUniqueMetadata`
|
|
2742
|
+
flags `<title>` or `<meta name="description">` values that repeat across
|
|
2743
|
+
pages; duplicates hurt Google's ability to pick a canonical result and
|
|
2744
|
+
can only be spotted across the whole corpus. `validateImageAlt` flags
|
|
2745
|
+
`<img>` tags missing an `alt` attribute. WCAG-sanctioned decorative
|
|
2746
|
+
markers are respected: `alt=""` (the canonical pattern) and
|
|
2747
|
+
`role="presentation"`/`role="none"` (removes the image from the
|
|
2748
|
+
accessibility tree) are not flagged. Only a tag with neither triggers a
|
|
2749
|
+
warning.
|
|
2750
|
+
|
|
2751
|
+
`validateMetadataLength` flags `<title>` and `<meta name="description">`
|
|
2752
|
+
values outside the configured bounds. Pass `true` for the defaults
|
|
2753
|
+
(title 30–65, description 70–200) or an object to override per-field:
|
|
2754
|
+
|
|
2755
|
+
```ts
|
|
2756
|
+
validateMetadataLength: {
|
|
2757
|
+
title: { min: 40, max: 60 },
|
|
2758
|
+
description: { max: 160 },
|
|
2759
|
+
},
|
|
2760
|
+
```
|
|
2761
|
+
|
|
2762
|
+
`validateInternalLinks` scans `<a href>` values across every built page
|
|
2763
|
+
and flags two classes of issue: trailing-slash mismatches (e.g. linking
|
|
2764
|
+
to `/about-me` when the built page is `/about-me/` — "works" via
|
|
2765
|
+
redirect but wastes a round-trip on every click) and true 404s (links
|
|
2766
|
+
to paths not in the build). Only same-origin (via `config.site`) and
|
|
2767
|
+
root-relative links are checked; external URLs, `mailto:`, `tel:`, and
|
|
2768
|
+
fragment-only links are skipped.
|
|
2769
|
+
|
|
2770
|
+
Explicit redirects are honored by default: sources in
|
|
2771
|
+
`public/_redirects` (Netlify / Cloudflare Pages format) and literal
|
|
2772
|
+
keys in Astro's `redirects` config are treated as valid link targets.
|
|
2773
|
+
Dynamic rules (`*`, `:splat`, `[slug]` params) are skipped — use
|
|
2774
|
+
`skip` for those cases. Set `honorRedirects: false` to opt out (useful
|
|
2775
|
+
when auditing for redirect hops).
|
|
2776
|
+
|
|
2777
|
+
Pass `{ skip: (href) => boolean }` to exclude SSR-only routes or paths
|
|
2778
|
+
handled at the host/CDN layer.
|
|
2779
|
+
|
|
2780
|
+
The extractors, helpers, and the length resolver are exported for reuse:
|
|
2781
|
+
|
|
2782
|
+
```ts
|
|
2783
|
+
import {
|
|
2784
|
+
classifyInternalLink,
|
|
2785
|
+
collectAstroRedirectSources,
|
|
2786
|
+
countH1s,
|
|
2787
|
+
DEFAULT_METADATA_LENGTH_BOUNDS,
|
|
2788
|
+
extractAnchorHrefs,
|
|
2789
|
+
extractTitle,
|
|
2790
|
+
extractMetaDescription,
|
|
2791
|
+
findImagesWithoutAlt,
|
|
2792
|
+
htmlFileToPath,
|
|
2793
|
+
parseNetlifyRedirects,
|
|
2794
|
+
resolveMetadataLengthBounds,
|
|
2795
|
+
} from '@jdevalk/astro-seo-graph/integration';
|
|
2796
|
+
```
|
|
2797
|
+
|
|
2798
|
+
### IndexNow submission
|
|
2799
|
+
|
|
2800
|
+
```js
|
|
2801
|
+
seoGraph({
|
|
2802
|
+
indexNow: {
|
|
2803
|
+
key: process.env.INDEXNOW_KEY!, // 8–128 chars from [A-Za-z0-9-]
|
|
2804
|
+
host: 'example.com',
|
|
2805
|
+
siteUrl: 'https://example.com',
|
|
2806
|
+
// keyLocation?: defaults to https://<host>/<key>.txt
|
|
2807
|
+
// endpoint?: defaults to api.indexnow.org
|
|
2808
|
+
// filter?: (url) => boolean — composed on top of the built-in
|
|
2809
|
+
// /404 exclusion. Example skipping paginated archives:
|
|
2810
|
+
// filter: (url) => !/^\/blog\/\d+\/$/.test(new URL(url).pathname),
|
|
2811
|
+
},
|
|
2812
|
+
});
|
|
2813
|
+
```
|
|
2814
|
+
|
|
2815
|
+
Only URLs on `host` are submitted. `index.html` paths are rewritten to
|
|
2816
|
+
their trailing-slash form. `/404` (and `/404/`) are always excluded —
|
|
2817
|
+
search engines don't need to be notified about the 404 page and
|
|
2818
|
+
submitting it wastes daily IndexNow quota.
|
|
2819
|
+
|
|
2820
|
+
**Deploy the key file first.** IndexNow verifies ownership by fetching
|
|
2821
|
+
`https://<host>/<key>.txt` on every submission. Submissions sent before
|
|
2822
|
+
the key is reachable in production get rejected (HTTP 403) and the key
|
|
2823
|
+
is treated as invalid — you'll have to rotate it. Serve the key via
|
|
2824
|
+
`createIndexNowKeyRoute` (see below), deploy, confirm the `.txt` loads
|
|
2825
|
+
over HTTPS, _then_ enable `indexNow` in the integration.
|
|
2826
|
+
|
|
2827
|
+
```ts
|
|
2828
|
+
// src/pages/[your-key-here].txt.ts
|
|
2829
|
+
import { createIndexNowKeyRoute } from '@jdevalk/astro-seo-graph';
|
|
2830
|
+
|
|
2831
|
+
export const GET = createIndexNowKeyRoute({ key: 'your-key-here' });
|
|
2832
|
+
```
|
|
2833
|
+
|
|
2834
|
+
The filename (minus `.txt.ts`) must equal the key.
|
|
2835
|
+
|
|
2836
|
+
### llms.txt generation
|
|
2837
|
+
|
|
2838
|
+
Generates an [`llms.txt`](https://llmstxt.org) file — a markdown summary
|
|
2839
|
+
of the site that LLMs can use as a concise entry point.
|
|
2840
|
+
|
|
2841
|
+
```js
|
|
2842
|
+
seoGraph({
|
|
2843
|
+
llmsTxt: {
|
|
2844
|
+
title: 'Example Site',
|
|
2845
|
+
siteUrl: 'https://example.com',
|
|
2846
|
+
summary: 'A demo site about X, Y, and Z.',
|
|
2847
|
+
// details?: extra paragraphs between summary and sections
|
|
2848
|
+
// sections?: user-supplied sections (skips auto-collection)
|
|
2849
|
+
// filter?: (url) => boolean — drop URLs from auto-section
|
|
2850
|
+
// autoSectionName?: defaults to 'Pages'
|
|
2851
|
+
// outputPath?: defaults to 'llms.txt'
|
|
2852
|
+
},
|
|
2853
|
+
});
|
|
2854
|
+
```
|
|
2855
|
+
|
|
2856
|
+
By default, one "Pages" section is auto-generated from every built HTML
|
|
2857
|
+
file's `<title>` + meta description. Supply `sections` to take full
|
|
2858
|
+
control of the structure:
|
|
2859
|
+
|
|
2860
|
+
```js
|
|
2861
|
+
llmsTxt: {
|
|
2862
|
+
title: 'Example Site',
|
|
2863
|
+
siteUrl: 'https://example.com',
|
|
2864
|
+
sections: [
|
|
2865
|
+
{
|
|
2866
|
+
name: 'Docs',
|
|
2867
|
+
links: [
|
|
2868
|
+
{ url: 'https://example.com/docs/intro/', title: 'Intro', description: 'Start here' },
|
|
2869
|
+
],
|
|
2870
|
+
},
|
|
2871
|
+
{ name: 'Blog', links: [/* ... */] },
|
|
2872
|
+
],
|
|
2873
|
+
}
|
|
2874
|
+
```
|
|
2875
|
+
|
|
2876
|
+
The renderer is also exported for non-Astro contexts:
|
|
2877
|
+
|
|
2878
|
+
```ts
|
|
2879
|
+
import { renderLlmsTxt } from '@jdevalk/astro-seo-graph';
|
|
2880
|
+
|
|
2881
|
+
const markdown = renderLlmsTxt({
|
|
2882
|
+
title: 'Example',
|
|
2883
|
+
summary: 'A demo site.',
|
|
2884
|
+
sections: [{ name: 'Pages', links: [{ url: 'https://x/', title: 'Home' }] }],
|
|
2885
|
+
});
|
|
2886
|
+
```
|
|
2887
|
+
|
|
2888
|
+
---
|
|
2889
|
+
|
|
2579
2890
|
## Complete integration example
|
|
2580
2891
|
|
|
2581
2892
|
Here's how joost.blog wires everything together. Use this as a reference
|
|
@@ -3095,7 +3406,8 @@ seo-graph/
|
|
|
3095
3406
|
│ ├── alternates.ts # buildAlternateLinks
|
|
3096
3407
|
│ ├── content.ts # seoSchema, imageSchema
|
|
3097
3408
|
│ └── components/
|
|
3098
|
-
│
|
|
3409
|
+
│ ├── seo-props.ts # SeoProps interface
|
|
3410
|
+
│ └── seo-context.ts # buildSeoContext
|
|
3099
3411
|
├── AGENTS.md # This file
|
|
3100
3412
|
├── README.md # Project overview
|
|
3101
3413
|
└── pnpm-workspace.yaml
|
package/README.md
CHANGED
|
@@ -35,14 +35,19 @@ npm install @jdevalk/seo-graph-core
|
|
|
35
35
|
| `assembleGraph(pieces)` | Wraps pieces in a `{ @context, @graph }` envelope with first-wins deduplication by `@id`. |
|
|
36
36
|
| `deduplicateByGraphId(entities)` | The dedup engine on its own, in case you need custom assembly. |
|
|
37
37
|
|
|
38
|
+
Pass `{ warnOnDanglingReferences: true }` to `assembleGraph` to emit a
|
|
39
|
+
console warning when any `{ '@id': '...' }` reference doesn't resolve to
|
|
40
|
+
an entity in the graph — a cheap sanity check to catch typos and
|
|
41
|
+
missing pieces during development.
|
|
42
|
+
|
|
38
43
|
### IndexNow
|
|
39
44
|
|
|
40
|
-
| API
|
|
41
|
-
|
|
|
42
|
-
| `submitToIndexNow`
|
|
43
|
-
| `generateIndexNowKey`
|
|
44
|
-
| `validateIndexNowKey`
|
|
45
|
-
| `getIndexNowKeyFileContent`
|
|
45
|
+
| API | Purpose |
|
|
46
|
+
| --------------------------- | ------------------------------------------------------------------------------ |
|
|
47
|
+
| `submitToIndexNow` | POST URLs to the IndexNow aggregator. Filters by host, dedupes, chunks at 10k. |
|
|
48
|
+
| `generateIndexNowKey` | Generate a random hex key (Web Crypto). |
|
|
49
|
+
| `validateIndexNowKey` | Verify a key is 8–128 chars from `[A-Za-z0-9-]` (IndexNow spec allow-list). |
|
|
50
|
+
| `getIndexNowKeyFileContent` | Body to serve at `/<key>.txt` for host verification. |
|
|
46
51
|
|
|
47
52
|
### Piece builders
|
|
48
53
|
|
|
@@ -164,6 +169,12 @@ network errors — it returns one result per chunk with `ok`, `status`, and
|
|
|
164
169
|
`message`. Pass `endpoint` to override the default aggregator, `keyLocation`
|
|
165
170
|
to point at a non-default key-file path, or `fetch` for testing.
|
|
166
171
|
|
|
172
|
+
> **Deploy the key file before submitting.** IndexNow verifies host
|
|
173
|
+
> ownership by fetching `https://<host>/<key>.txt` (or `keyLocation`)
|
|
174
|
+
> on every call. Submissions sent before the file is publicly reachable
|
|
175
|
+
> are rejected with HTTP 403 and the key is marked invalid — rotate
|
|
176
|
+
> with `generateIndexNowKey()` if that happens.
|
|
177
|
+
|
|
167
178
|
## Why
|
|
168
179
|
|
|
169
180
|
Read more about [why this project exists](https://joost.blog/seo-graph/).
|
package/dist/indexnow.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare const INDEXNOW_MAX_URLS_PER_REQUEST = 10000;
|
|
|
5
5
|
export interface SubmitToIndexNowOptions {
|
|
6
6
|
/** Bare host, e.g. `example.com`. No scheme, no trailing slash. */
|
|
7
7
|
host: string;
|
|
8
|
-
/** 8–128
|
|
8
|
+
/** 8–128 character key from `[A-Za-z0-9-]`. Must match the key file served on the host. */
|
|
9
9
|
key: string;
|
|
10
10
|
/**
|
|
11
11
|
* Optional absolute URL where the key file is hosted. Defaults to
|
|
@@ -36,7 +36,7 @@ export interface IndexNowSubmitResult {
|
|
|
36
36
|
* per chunk; a single-chunk submission returns a one-element array.
|
|
37
37
|
*/
|
|
38
38
|
export declare function submitToIndexNow(options: SubmitToIndexNowOptions): Promise<IndexNowSubmitResult[]>;
|
|
39
|
-
/** Returns true when `key` is 8–128
|
|
39
|
+
/** Returns true when `key` is 8–128 characters drawn from `[A-Za-z0-9-]`. */
|
|
40
40
|
export declare function validateIndexNowKey(key: string): boolean;
|
|
41
41
|
/**
|
|
42
42
|
* Generate a cryptographically random IndexNow key of `length` hex
|
package/dist/indexnow.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"indexnow.d.ts","sourceRoot":"","sources":["../src/indexnow.ts"],"names":[],"mappings":"AAIA,2EAA2E;AAC3E,eAAO,MAAM,yBAAyB,sCAAsC,CAAC;AAE7E,2DAA2D;AAC3D,eAAO,MAAM,6BAA6B,QAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"indexnow.d.ts","sourceRoot":"","sources":["../src/indexnow.ts"],"names":[],"mappings":"AAIA,2EAA2E;AAC3E,eAAO,MAAM,yBAAyB,sCAAsC,CAAC;AAE7E,2DAA2D;AAC3D,eAAO,MAAM,6BAA6B,QAAS,CAAC;AAapD,MAAM,WAAW,uBAAuB;IACpC,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb,2FAA2F;IAC3F,GAAG,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACxB;AAED,MAAM,WAAW,oBAAoB;IACjC,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,EAAE,EAAE,OAAO,CAAC;IACZ,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,SAAS,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAClC,OAAO,EAAE,uBAAuB,GACjC,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAgDjC;AA0BD,6EAA6E;AAC7E,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAIxD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,SAAK,GAAG,MAAM,CAWvD;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO7D"}
|
package/dist/indexnow.js
CHANGED
|
@@ -8,7 +8,13 @@ export const INDEXNOW_MAX_URLS_PER_REQUEST = 10_000;
|
|
|
8
8
|
/** Key length constraints per the spec. */
|
|
9
9
|
const MIN_KEY_LENGTH = 8;
|
|
10
10
|
const MAX_KEY_LENGTH = 128;
|
|
11
|
-
|
|
11
|
+
// Per the IndexNow spec the key may contain [A-Za-z0-9-]. The wording at
|
|
12
|
+
// indexnow.org calls these "hexadecimal characters" but the explicit
|
|
13
|
+
// allow-list is the broader set, and Bing / Yandex both issue and accept
|
|
14
|
+
// non-hex keys (Yandex's own docs use a mixed-case example).
|
|
15
|
+
// Refs: https://www.indexnow.org/documentation
|
|
16
|
+
// https://yandex.com/support/webmaster/en/indexnow/key
|
|
17
|
+
const KEY_RE = /^[A-Za-z0-9-]+$/;
|
|
12
18
|
/**
|
|
13
19
|
* Submit URLs to IndexNow. Filters out URLs not on `host`, deduplicates,
|
|
14
20
|
* and chunks at {@link INDEXNOW_MAX_URLS_PER_REQUEST}. Returns one result
|
|
@@ -17,7 +23,7 @@ const HEX_KEY_RE = /^[a-f0-9]+$/i;
|
|
|
17
23
|
export async function submitToIndexNow(options) {
|
|
18
24
|
const { host, key, urls } = options;
|
|
19
25
|
if (!validateIndexNowKey(key)) {
|
|
20
|
-
throw new Error(`IndexNow key must be ${MIN_KEY_LENGTH}–${MAX_KEY_LENGTH}
|
|
26
|
+
throw new Error(`IndexNow key must be ${MIN_KEY_LENGTH}–${MAX_KEY_LENGTH} characters from [A-Za-z0-9-].`);
|
|
21
27
|
}
|
|
22
28
|
if (!host || host.includes('/') || host.includes(':')) {
|
|
23
29
|
throw new Error(`IndexNow host must be a bare host (got: ${host}).`);
|
|
@@ -86,13 +92,13 @@ function filterUrlsForHost(urls, host) {
|
|
|
86
92
|
}
|
|
87
93
|
return out;
|
|
88
94
|
}
|
|
89
|
-
/** Returns true when `key` is 8–128
|
|
95
|
+
/** Returns true when `key` is 8–128 characters drawn from `[A-Za-z0-9-]`. */
|
|
90
96
|
export function validateIndexNowKey(key) {
|
|
91
97
|
if (typeof key !== 'string')
|
|
92
98
|
return false;
|
|
93
99
|
if (key.length < MIN_KEY_LENGTH || key.length > MAX_KEY_LENGTH)
|
|
94
100
|
return false;
|
|
95
|
-
return
|
|
101
|
+
return KEY_RE.test(key);
|
|
96
102
|
}
|
|
97
103
|
/**
|
|
98
104
|
* Generate a cryptographically random IndexNow key of `length` hex
|
|
@@ -117,7 +123,7 @@ export function generateIndexNowKey(length = 32) {
|
|
|
117
123
|
*/
|
|
118
124
|
export function getIndexNowKeyFileContent(key) {
|
|
119
125
|
if (!validateIndexNowKey(key)) {
|
|
120
|
-
throw new Error(`IndexNow key must be ${MIN_KEY_LENGTH}–${MAX_KEY_LENGTH}
|
|
126
|
+
throw new Error(`IndexNow key must be ${MIN_KEY_LENGTH}–${MAX_KEY_LENGTH} characters from [A-Za-z0-9-].`);
|
|
121
127
|
}
|
|
122
128
|
return key;
|
|
123
129
|
}
|
package/dist/indexnow.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"indexnow.js","sourceRoot":"","sources":["../src/indexnow.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,oEAAoE;AACpE,6EAA6E;AAE7E,2EAA2E;AAC3E,MAAM,CAAC,MAAM,yBAAyB,GAAG,mCAAmC,CAAC;AAE7E,2DAA2D;AAC3D,MAAM,CAAC,MAAM,6BAA6B,GAAG,MAAM,CAAC;AAEpD,2CAA2C;AAC3C,MAAM,cAAc,GAAG,CAAC,CAAC;AACzB,MAAM,cAAc,GAAG,GAAG,CAAC;AAC3B,MAAM,
|
|
1
|
+
{"version":3,"file":"indexnow.js","sourceRoot":"","sources":["../src/indexnow.ts"],"names":[],"mappings":"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"}
|