Skip to content

Commit 591a5e7

Browse files
jdevalkclaude
andcommitted
feat: schema map endpoint for agent/crawler discovery (v0.7.0)
Adds a new public plugin route `schema/map` that enumerates every published URL backed by schema markup, returning `{items: [{url, collection, updatedAt}]}`. Site owners wire it to `/schemamap.xml` at the site root via a small Astro endpoint (snippet in README). Per-URL schema endpoints (`/schema/<slug>.json`) are deferred pending an upstream helper to build PublicPageContext from a plugin route (emdash-cms/emdash#527) — avoids reimplementing core's page builder and the drift risk that would come with it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dd8e2c5 commit 591a5e7

6 files changed

Lines changed: 358 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.0] - 2026-04-13
9+
10+
### Added
11+
12+
- **Schema map (experimental).** New public plugin route `schema/map` returning the list of every published URL backed by schema markup (`{ items: [{ url, collection, updatedAt }] }`). Wire it to `/schemamap.xml` at your site root with the Astro snippet in the README so agents and crawlers can enumerate structured-data URLs without scraping HTML. Per-URL schema endpoints (`/schema/<slug>.json`) are deferred pending an upstream helper for building page contexts from plugin routes.
13+
814
## [0.6.0] - 2026-04-13
915

1016
### Added

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ An SEO plugin for [EmDash CMS](https://github.com/emdash-cms/emdash) that genera
2727
- **Breadcrumbs** — derived from the URL path by default, with segment label overrides (`/blog/` → "Blog") and per-`pageType` rule overrides both editable in the admin UI. `@id` scheme matches [joost.blog](https://joost.blog) via `@jdevalk/seo-graph-core`
2828
- **hreflang alternates** — for multilingual EmDash sites (Astro `i18n` + `translation_group`), one `<link rel="alternate" hreflang="…">` per published sibling plus an automatic `x-default`, with BCP 47 tag normalization (`fr-ca``fr-CA`). Zero cost on single-locale sites
2929
- **llms.txt** *(experimental)* — exposes an index of published content at the plugin's `llms/txt` route, following the small-form [llms.txt spec](https://llmstxt.org). Enabled by default; flip the setting to disable. Only the plain `llms.txt` file is supported; the `llms-full.txt` variant is not implemented
30+
- **Schema map** *(experimental)* — exposes a list of every published URL backed by schema markup at the plugin's `schema/map` route, ready to be wired to a `/schemamap.xml` Astro endpoint for agent/crawler discovery
3031
- **IndexNow** — on publish/unpublish transitions, submits the affected URL to [IndexNow](https://www.indexnow.org) so Bing, Yandex, Seznam, Naver, and Yep recrawl immediately. Opt-in via a single toggle in the settings UI; the key is generated and persisted automatically on first use
3132
- **Admin settings UI** — auto-generated from `settingsSchema` for configuring Person/Organization identity, social profiles, title separator, and default description
3233

@@ -186,6 +187,69 @@ Alternatively, import `buildLlmsTxt` from this plugin and assemble the
186187
body yourself from `getEmDashCollection()` results if you want full
187188
control over sectioning, ordering, or filtering.
188189

190+
## Schema map (experimental)
191+
192+
> **Experimental.** Shape and exposed API may change in a minor
193+
> release. Per-URL schema endpoints (`/schema/<slug>.json`) are not
194+
> implemented yet — they depend on a core helper being discussed
195+
> upstream.
196+
197+
Every published page on an EmDash site carries a JSON-LD schema graph
198+
in its `<head>`. Agents and crawlers that want to enumerate those pages
199+
without scraping every HTML document need a *schema map* — the same
200+
idea as `sitemap.xml`, but scoped to "URLs that have structured data."
201+
202+
The plugin exposes the raw list at `schema/map` as JSON:
203+
204+
```json
205+
{
206+
"items": [
207+
{ "url": "https://example.com/blog/hello/", "collection": "blog", "updatedAt": "2026-02-01T00:00:00Z" },
208+
{ "url": "https://example.com/about/", "collection": "pages", "updatedAt": "2026-01-03T00:00:00Z" }
209+
]
210+
}
211+
```
212+
213+
Wire it to `/schemamap.xml` at your site root with a small Astro
214+
endpoint. The route is public — no auth needed on the fetch:
215+
216+
```ts
217+
// src/pages/schemamap.xml.ts
218+
import type { APIRoute } from "astro";
219+
220+
interface SchemaMapEntry {
221+
url: string;
222+
collection: string;
223+
updatedAt: string;
224+
}
225+
226+
export const GET: APIRoute = async ({ request }) => {
227+
const origin = new URL(request.url).origin;
228+
const res = await fetch(`${origin}/_emdash/api/plugins/seo/schema/map`, {
229+
method: "POST",
230+
headers: { "Content-Type": "application/json" },
231+
body: "{}",
232+
});
233+
const { items } = (await res.json()) as { items: SchemaMapEntry[] };
234+
235+
const urls = items
236+
.map(
237+
({ url, updatedAt }) =>
238+
` <url><loc>${url}</loc><lastmod>${updatedAt}</lastmod></url>`,
239+
)
240+
.join("\n");
241+
242+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
243+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
244+
${urls}
245+
</urlset>`;
246+
247+
return new Response(xml, {
248+
headers: { "Content-Type": "application/xml; charset=utf-8" },
249+
});
250+
};
251+
```
252+
189253
## Requirements
190254

191255
Requires EmDash with support for running `page:metadata` hooks on public pages for anonymous visitors (fixed in [emdash-cms/emdash#119](https://github.com/emdash-cms/emdash/pull/119)).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@jdevalk/emdash-plugin-seo",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "SEO plugin for EmDash CMS — meta tags, Open Graph, canonical URLs, robots directives, and JSON-LD schema markup",
55
"type": "module",
66
"exports": {

src/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import {
77
handleIndexNowTransition,
88
} from "./indexnow.js";
99
import { generateLlmsTxt } from "./llms.js";
10+
import { listSchemaEntries } from "./schema/endpoints.js";
1011

1112
export function seoPlugin(): PluginDescriptor {
1213
return {
1314
id: "seo",
14-
version: "0.6.0",
15+
version: "0.7.0",
1516
format: "native",
1617
entrypoint: new URL("./index.ts", import.meta.url).pathname,
1718
adminEntry: new URL("./admin.tsx", import.meta.url).pathname,
@@ -25,7 +26,7 @@ export function seoPlugin(): PluginDescriptor {
2526
export function createPlugin() {
2627
return definePlugin({
2728
id: "seo",
28-
version: "0.6.0",
29+
version: "0.7.0",
2930
capabilities: ["read:content", "page:inject", "network:fetch"],
3031
allowedHosts: ["api.indexnow.org"],
3132

@@ -77,6 +78,13 @@ export function createPlugin() {
7778
return { enabled: body !== null, body: body ?? "" };
7879
},
7980
},
81+
"schema/map": {
82+
public: true,
83+
handler: async (ctx: RouteContext) => {
84+
const items = await listSchemaEntries(ctx);
85+
return { items };
86+
},
87+
},
8088
},
8189

8290
admin: {

src/schema/endpoints.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { PluginContext } from "emdash";
2+
import { buildPageUrl } from "../urls.js";
3+
4+
/**
5+
* One entry in the schema map — a URL that has a published content
6+
* record backing it, with the last-modified timestamp used by crawlers
7+
* and agents to decide whether to refetch.
8+
*/
9+
export interface SchemaMapEntry {
10+
/** Absolute URL of the live page. */
11+
url: string;
12+
/** Collection slug (e.g. "posts", "pages"). */
13+
collection: string;
14+
/** ISO-8601 last-modified timestamp (updatedAt, falling back to createdAt). */
15+
updatedAt: string;
16+
}
17+
18+
/**
19+
* Enumerate every published URL the site exposes — the data a
20+
* `schemamap.xml` endpoint needs.
21+
*
22+
* This mirrors `generateLlmsTxt`'s iteration: walk every collection
23+
* with a `urlPattern`, paginate through `ctx.content.list`, filter to
24+
* `status === "published"`, and project each item to a `(url,
25+
* collection, updatedAt)` triple.
26+
*
27+
* Returns an empty array (not null) when the site has no publishable
28+
* content — callers should treat that as "serve an empty but valid
29+
* schema map," not "404." Collections without a `urlPattern` are
30+
* silently skipped; they have no public URL to advertise.
31+
*/
32+
export async function listSchemaEntries(ctx: PluginContext): Promise<SchemaMapEntry[]> {
33+
if (!ctx.content) return [];
34+
const siteUrl = ctx.site.url;
35+
if (!siteUrl) return [];
36+
37+
const { SchemaRegistry, isI18nEnabled, getI18nConfig } = await import("emdash");
38+
const { getDb } = await import("emdash/runtime");
39+
const db = await getDb();
40+
const registry = new SchemaRegistry(db);
41+
const collections = await registry.listCollections();
42+
43+
const cfg =
44+
isI18nEnabled() && getI18nConfig()
45+
? getI18nConfig()!
46+
: { locales: ["en"], defaultLocale: "en", prefixDefaultLocale: false };
47+
48+
const entries: SchemaMapEntry[] = [];
49+
50+
for (const collection of collections) {
51+
if (!collection.urlPattern) continue;
52+
53+
let cursor: string | undefined;
54+
do {
55+
const page = await ctx.content.list(collection.slug, { limit: 100, cursor });
56+
for (const item of page.items) {
57+
const data = item.data as Record<string, unknown>;
58+
if (data.status !== "published") continue;
59+
const slug = typeof data.slug === "string" ? data.slug : null;
60+
if (!slug) continue;
61+
const locale =
62+
typeof data.locale === "string" && data.locale ? data.locale : cfg.defaultLocale;
63+
64+
const url = buildPageUrl({
65+
locale,
66+
slug,
67+
siteUrl,
68+
cfg,
69+
urlPattern: collection.urlPattern,
70+
});
71+
if (!url) continue;
72+
73+
const updatedAt =
74+
(typeof item.updatedAt === "string" && item.updatedAt) ||
75+
(typeof item.createdAt === "string" && item.createdAt) ||
76+
new Date(0).toISOString();
77+
78+
entries.push({ url, collection: collection.slug, updatedAt });
79+
}
80+
cursor = page.cursor;
81+
} while (cursor);
82+
}
83+
84+
return entries;
85+
}

0 commit comments

Comments
 (0)