@dineway-ai/plugin-seo-graph 0.1.7

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.
@@ -0,0 +1,103 @@
1
+ import { buildAlternateLinks } from "@jdevalk/astro-seo-graph";
2
+ import type {
3
+ I18nConfig,
4
+ PageMetadataContribution,
5
+ PluginContext,
6
+ PublicPageContext,
7
+ } from "dineway";
8
+
9
+ import { buildPageUrl } from "./urls.js";
10
+
11
+ /**
12
+ * Thin Dineway adapter around `@jdevalk/astro-seo-graph`'s
13
+ * `buildAlternateLinks`.
14
+ *
15
+ * Sources translation data via Dineway's public API
16
+ * (`getTranslations`, `getI18nConfig`, `getCollectionInfo`), constructs
17
+ * per-locale absolute URLs with the shared `buildPageUrl` helper, and
18
+ * hands off to `buildAlternateLinks` for normalization, dedup, and
19
+ * x-default resolution.
20
+ *
21
+ * Returns `[]` under any of these conditions:
22
+ * - i18n is not enabled (single-locale site)
23
+ * - the current page is not a content entry
24
+ * - fewer than 2 published siblings exist (no alternates to link)
25
+ * - the current entry is not in its own translation group (data bug)
26
+ * - the collection has no `urlPattern` (cannot build URLs)
27
+ * - any transient failure (e.g. `getTranslations` throws)
28
+ */
29
+ export async function generateHreflang(
30
+ page: PublicPageContext,
31
+ ctx: PluginContext,
32
+ siteUrl: string,
33
+ ): Promise<PageMetadataContribution[]> {
34
+ // Dynamically import dineway to keep this module testable via
35
+ // `vi.mock("dineway", ...)` — the alternative is top-level imports
36
+ // that vitest then has to intercept, which works but makes mocks
37
+ // brittle. Dynamic import lets each test replace the module cleanly.
38
+ const { isI18nEnabled, getI18nConfig, getTranslations, getCollectionInfo } =
39
+ await import("dineway");
40
+
41
+ if (!isI18nEnabled()) return [];
42
+ if (page.kind !== "content" || !page.content) return [];
43
+
44
+ const cfg: I18nConfig | null = getI18nConfig();
45
+ if (!cfg) return [];
46
+
47
+ let result;
48
+ try {
49
+ result = await getTranslations(page.content.collection, page.content.id);
50
+ } catch (error) {
51
+ ctx.log.warn("hreflang: getTranslations failed", { error });
52
+ return [];
53
+ }
54
+ if (result.error !== undefined) return [];
55
+ if (result.translations.length < 2) return [];
56
+
57
+ // Data-integrity guard: the current entry must appear in its own
58
+ // translation group. If not, something is wrong upstream — don't
59
+ // emit partial or incorrect annotations.
60
+ const currentInGroup = result.translations.some((t) => t.id === page.content!.id);
61
+ if (!currentInGroup) return [];
62
+
63
+ let collection;
64
+ try {
65
+ collection = await getCollectionInfo(page.content.collection);
66
+ } catch (error) {
67
+ ctx.log.warn("hreflang: getCollectionInfo failed", { error });
68
+ return [];
69
+ }
70
+ if (!collection?.urlPattern) return [];
71
+ const urlPattern = collection.urlPattern;
72
+
73
+ const entries: Array<{ hreflang: string; href: string }> = [];
74
+ for (const t of result.translations) {
75
+ if (t.status !== "published") continue;
76
+ if (!t.slug) continue;
77
+ if (!cfg.locales.includes(t.locale)) continue;
78
+
79
+ const href = buildPageUrl({
80
+ locale: t.locale,
81
+ slug: t.slug,
82
+ siteUrl,
83
+ cfg,
84
+ urlPattern,
85
+ });
86
+ if (href === null) continue;
87
+
88
+ entries.push({ hreflang: t.locale, href });
89
+ }
90
+
91
+ const alternates = buildAlternateLinks({
92
+ entries,
93
+ defaultLocale: cfg.defaultLocale,
94
+ });
95
+
96
+ return alternates.map((a) => ({
97
+ kind: "link" as const,
98
+ rel: "alternate" as const,
99
+ href: a.href,
100
+ hreflang: a.hreflang,
101
+ key: `hreflang:${a.hreflang}`,
102
+ }));
103
+ }
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { definePlugin } from "dineway";
2
+ import type { PluginDescriptor, RouteContext } from "dineway";
3
+
4
+ import { getKeyFileBody, getOrCreateIndexNowKey, handleIndexNowTransition } from "./indexnow.js";
5
+ import { generateLlmsTxt } from "./llms.js";
6
+ import { metadataHandler } from "./metadata.js";
7
+ import { listSchemaEntries } from "./schema/endpoints.js";
8
+
9
+ export function seoGraphPlugin(): PluginDescriptor {
10
+ return {
11
+ id: "seo-graph",
12
+ version: "0.5.0",
13
+ format: "native",
14
+ entrypoint: new URL("./index.ts", import.meta.url).pathname,
15
+ adminEntry: new URL("./admin.tsx", import.meta.url).pathname,
16
+ adminPages: [
17
+ { path: "/settings", label: "SEO Graph", icon: "settings" },
18
+ { path: "/fuzzy-redirects", label: "Fuzzy Redirects", icon: "arrow-right" },
19
+ ],
20
+ options: {},
21
+ };
22
+ }
23
+
24
+ export function createPlugin() {
25
+ return definePlugin({
26
+ id: "seo-graph",
27
+ version: "0.5.0",
28
+ capabilities: ["content:read", "hooks.page-fragments:register", "network:request"],
29
+ allowedHosts: ["api.indexnow.org"],
30
+
31
+ hooks: {
32
+ "page:metadata": {
33
+ handler: metadataHandler,
34
+ priority: 10,
35
+ },
36
+ "content:afterPublish": {
37
+ handler: handleIndexNowTransition,
38
+ priority: 50,
39
+ },
40
+ "content:afterUnpublish": {
41
+ handler: handleIndexNowTransition,
42
+ priority: 50,
43
+ },
44
+ },
45
+
46
+ routes: {
47
+ settings: {
48
+ handler: async (ctx: RouteContext) => {
49
+ const entries = await ctx.kv.list("settings:");
50
+ const settings: Record<string, string> = {};
51
+ for (const { key, value } of entries) {
52
+ const k = key.replace("settings:", "");
53
+ settings[k] = typeof value === "string" ? value : String(value);
54
+ }
55
+ return { settings };
56
+ },
57
+ },
58
+ "settings/save": {
59
+ handler: async (ctx: RouteContext) => {
60
+ const { settings } = ctx.input as { settings: Record<string, string> };
61
+ for (const [key, value] of Object.entries(settings)) {
62
+ await ctx.kv.set(`settings:${key}`, value);
63
+ }
64
+ return { ok: true };
65
+ },
66
+ },
67
+ "indexnow/key": {
68
+ handler: async (ctx: RouteContext) => {
69
+ const key = await getOrCreateIndexNowKey(ctx);
70
+ return { key, keyFile: await getKeyFileBody(ctx) };
71
+ },
72
+ },
73
+ "llms/txt": {
74
+ public: true,
75
+ handler: async (ctx: RouteContext) => {
76
+ const body = await generateLlmsTxt(ctx);
77
+ return { enabled: body !== null, body: body ?? "" };
78
+ },
79
+ },
80
+ "schema/map": {
81
+ public: true,
82
+ handler: async (ctx: RouteContext) => {
83
+ const items = await listSchemaEntries(ctx);
84
+ return { items };
85
+ },
86
+ },
87
+ },
88
+
89
+ admin: {
90
+ pages: [
91
+ { path: "/settings", label: "SEO Graph", icon: "settings" },
92
+ { path: "/fuzzy-redirects", label: "Fuzzy Redirects", icon: "arrow-right" },
93
+ ],
94
+ },
95
+ });
96
+ }
97
+
98
+ export default createPlugin;
@@ -0,0 +1,139 @@
1
+ import {
2
+ generateIndexNowKey,
3
+ getIndexNowKeyFileContent,
4
+ submitToIndexNow,
5
+ validateIndexNowKey,
6
+ } from "@jdevalk/seo-graph-core";
7
+ import type { PluginContext } from "dineway";
8
+
9
+ import { buildPageUrl } from "./urls.js";
10
+
11
+ const KEY_KV = "indexnow:key";
12
+ const ENABLED_KV = "settings:indexnowEnabled";
13
+
14
+ /**
15
+ * Read or lazily generate the IndexNow key. The key is persisted in plugin
16
+ * KV so subsequent submissions (and the key-file route exposed on the
17
+ * Astro front-end) use the same value. Key rotation is a manual action:
18
+ * delete the KV entry and the next call will mint a new one.
19
+ */
20
+ export async function getOrCreateIndexNowKey(ctx: PluginContext): Promise<string> {
21
+ const existing = await ctx.kv.get(KEY_KV);
22
+ if (typeof existing === "string" && validateIndexNowKey(existing)) {
23
+ return existing;
24
+ }
25
+ const key = generateIndexNowKey(32);
26
+ await ctx.kv.set(KEY_KV, key);
27
+ return key;
28
+ }
29
+
30
+ /** True when the admin has opted in via the settings toggle. */
31
+ export async function isIndexNowEnabled(ctx: PluginContext): Promise<boolean> {
32
+ const raw = await ctx.kv.get(ENABLED_KV);
33
+ if (raw === true) return true;
34
+ if (typeof raw === "string") return raw === "true" || raw === "1";
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * Build the canonical URL for a published content item using the
40
+ * collection's `urlPattern`. Returns `null` when the collection has no
41
+ * pattern or the content lacks a slug (e.g. unpublished draft without a
42
+ * resolvable URL).
43
+ */
44
+ async function urlForContent(
45
+ content: Record<string, unknown>,
46
+ collection: string,
47
+ siteUrl: string,
48
+ ): Promise<string | null> {
49
+ const slug = typeof content.slug === "string" ? content.slug : null;
50
+ if (!slug) return null;
51
+
52
+ const { getCollectionInfo, getI18nConfig, isI18nEnabled } = await import("dineway");
53
+
54
+ let info;
55
+ try {
56
+ info = await getCollectionInfo(collection);
57
+ } catch {
58
+ return null;
59
+ }
60
+ if (!info?.urlPattern) return null;
61
+
62
+ const locale = typeof content.locale === "string" && content.locale ? content.locale : null;
63
+
64
+ // Non-i18n sites: fall back to a minimal pattern substitution that
65
+ // doesn't require an I18nConfig. buildPageUrl demands a cfg, so fake a
66
+ // single-locale config when i18n is disabled.
67
+ const cfg =
68
+ isI18nEnabled() && getI18nConfig()
69
+ ? getI18nConfig()!
70
+ : {
71
+ locales: [locale ?? "en"],
72
+ defaultLocale: locale ?? "en",
73
+ prefixDefaultLocale: false,
74
+ };
75
+
76
+ return buildPageUrl({
77
+ locale: locale ?? cfg.defaultLocale,
78
+ slug,
79
+ siteUrl,
80
+ cfg,
81
+ urlPattern: info.urlPattern,
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Handler for `content:afterPublish` and `content:afterUnpublish`.
87
+ * Submits the transitioned URL to IndexNow so participating engines
88
+ * recrawl and pick up the new state (including 410/404 for unpublished
89
+ * content). Fire-and-forget: never throws, logs errors on ctx.log.
90
+ */
91
+ export async function handleIndexNowTransition(
92
+ event: { content: Record<string, unknown>; collection: string },
93
+ ctx: PluginContext,
94
+ ): Promise<void> {
95
+ try {
96
+ if (!(await isIndexNowEnabled(ctx))) return;
97
+
98
+ const siteUrl = ctx.site.url;
99
+ if (!siteUrl) return;
100
+
101
+ const url = await urlForContent(event.content, event.collection, siteUrl);
102
+ if (!url) return;
103
+
104
+ let host: string;
105
+ try {
106
+ host = new URL(siteUrl).hostname;
107
+ } catch {
108
+ return;
109
+ }
110
+
111
+ const key = await getOrCreateIndexNowKey(ctx);
112
+
113
+ const results = await submitToIndexNow({
114
+ host,
115
+ key,
116
+ urls: [url],
117
+ });
118
+
119
+ for (const r of results) {
120
+ if (r.ok) {
121
+ ctx.log.info("IndexNow: submitted", { url, status: r.status });
122
+ } else {
123
+ ctx.log.warn("IndexNow: submission failed", {
124
+ url,
125
+ status: r.status,
126
+ message: r.message,
127
+ });
128
+ }
129
+ }
130
+ } catch (error) {
131
+ ctx.log.warn("IndexNow: transition handler error", { error });
132
+ }
133
+ }
134
+
135
+ /** Returns the plain-text body to serve at `/<key>.txt`. */
136
+ export async function getKeyFileBody(ctx: PluginContext): Promise<string> {
137
+ const key = await getOrCreateIndexNowKey(ctx);
138
+ return getIndexNowKeyFileContent(key);
139
+ }
package/src/llms.ts ADDED
@@ -0,0 +1,151 @@
1
+ import type { PluginContext } from "dineway";
2
+
3
+ import { buildPageUrl } from "./urls.js";
4
+
5
+ const ENABLED_KV = "settings:llmsTxtEnabled";
6
+ const TRAILING_NEWLINES_RE = /\n+$/;
7
+ const HUMANIZE_SEPARATOR_RE = /[-_]+/g;
8
+ const WORD_START_RE = /\b\w/g;
9
+
10
+ /**
11
+ * A single link entry in an `llms.txt` section.
12
+ *
13
+ * Spec: https://llmstxt.org/ uses a list item of form
14
+ * `- [name](url): optional description`.
15
+ */
16
+ export interface LlmsTxtEntry {
17
+ title: string;
18
+ url: string;
19
+ description?: string;
20
+ }
21
+
22
+ export interface LlmsTxtBuildOptions {
23
+ /** Heading at the top of the file. */
24
+ siteName: string;
25
+ /** Optional blockquote blurb immediately below the heading. */
26
+ siteDescription?: string;
27
+ /** Ordered map of section heading to entries. Empty sections are skipped. */
28
+ sections: Record<string, LlmsTxtEntry[]>;
29
+ }
30
+
31
+ /**
32
+ * Render a small-form `llms.txt` body from structured input.
33
+ *
34
+ * The `llms-full.txt` variant is intentionally out of scope for this plugin.
35
+ */
36
+ export function buildLlmsTxt(opts: LlmsTxtBuildOptions): string {
37
+ const lines: string[] = [];
38
+ lines.push(`# ${opts.siteName}`, "");
39
+ if (opts.siteDescription) {
40
+ lines.push(`> ${opts.siteDescription}`, "");
41
+ }
42
+ for (const [heading, entries] of Object.entries(opts.sections)) {
43
+ if (!entries.length) continue;
44
+ lines.push(`## ${heading}`, "");
45
+ for (const entry of entries) {
46
+ const desc = entry.description ? `: ${entry.description}` : "";
47
+ lines.push(`- [${entry.title}](${entry.url})${desc}`);
48
+ }
49
+ lines.push("");
50
+ }
51
+ return lines.join("\n").replace(TRAILING_NEWLINES_RE, "\n");
52
+ }
53
+
54
+ /**
55
+ * True when llms.txt generation is active. Enabled by default; flip
56
+ * `settings:llmsTxtEnabled` to `"false"` in the admin to turn it off.
57
+ */
58
+ export async function isLlmsTxtEnabled(ctx: PluginContext): Promise<boolean> {
59
+ const raw = await ctx.kv.get(ENABLED_KV);
60
+ if (raw === false) return false;
61
+ if (typeof raw === "string") return !(raw === "false" || raw === "0");
62
+ return true;
63
+ }
64
+
65
+ function humanize(slug: string): string {
66
+ return slug
67
+ .replace(HUMANIZE_SEPARATOR_RE, " ")
68
+ .replace(WORD_START_RE, (char) => char.toUpperCase());
69
+ }
70
+
71
+ /**
72
+ * Pull published entries across every collection with a `urlPattern`
73
+ * and assemble an `llms.txt` body.
74
+ *
75
+ * Returns `null` when the feature is disabled or content/site URL access is
76
+ * unavailable. Collections without a public URL pattern are skipped.
77
+ */
78
+ export async function generateLlmsTxt(ctx: PluginContext): Promise<string | null> {
79
+ if (!(await isLlmsTxtEnabled(ctx))) return null;
80
+ if (!ctx.content) return null;
81
+
82
+ const siteUrl = ctx.site.url;
83
+ if (!siteUrl) return null;
84
+
85
+ const { SchemaRegistry, isI18nEnabled, getI18nConfig } = await import("dineway");
86
+ const { getDb } = await import("dineway/runtime");
87
+ const db = await getDb();
88
+ const registry = new SchemaRegistry(db);
89
+ const collections = await registry.listCollections();
90
+
91
+ const cfg =
92
+ isI18nEnabled() && getI18nConfig()
93
+ ? getI18nConfig()!
94
+ : { locales: ["en"], defaultLocale: "en", prefixDefaultLocale: false };
95
+
96
+ const settings = await ctx.kv.list("settings:");
97
+ const get = (key: string): string => {
98
+ const hit = settings.find((entry) => entry.key === `settings:${key}`);
99
+ return typeof hit?.value === "string" ? hit.value : "";
100
+ };
101
+
102
+ const siteName = ctx.site.name || get("personName") || get("orgName") || "Site";
103
+ const siteDescription = get("llmsTxtDescription") || get("defaultDescription") || undefined;
104
+
105
+ const sections: Record<string, LlmsTxtEntry[]> = {};
106
+
107
+ for (const collection of collections) {
108
+ if (!collection.urlPattern) continue;
109
+
110
+ const entries: LlmsTxtEntry[] = [];
111
+ let cursor: string | undefined;
112
+ do {
113
+ const page = await ctx.content.list(collection.slug, {
114
+ limit: 100,
115
+ cursor,
116
+ where: { status: "published" },
117
+ });
118
+ for (const item of page.items) {
119
+ if (!item.slug) continue;
120
+ const locale = item.locale || cfg.defaultLocale;
121
+
122
+ const url = buildPageUrl({
123
+ locale,
124
+ slug: item.slug,
125
+ siteUrl,
126
+ cfg,
127
+ urlPattern: collection.urlPattern,
128
+ });
129
+ if (!url) continue;
130
+
131
+ const title =
132
+ (typeof item.data.title === "string" && item.data.title) ||
133
+ (typeof item.data.name === "string" && item.data.name) ||
134
+ item.slug;
135
+ const description =
136
+ (typeof item.data.description === "string" && item.data.description) ||
137
+ (typeof item.data.excerpt === "string" && item.data.excerpt) ||
138
+ undefined;
139
+
140
+ entries.push({ title, url, description });
141
+ }
142
+ cursor = page.cursor;
143
+ } while (cursor);
144
+
145
+ if (entries.length) {
146
+ sections[collection.label || humanize(collection.slug)] = entries;
147
+ }
148
+ }
149
+
150
+ return buildLlmsTxt({ siteName, siteDescription, sections });
151
+ }
@@ -0,0 +1,93 @@
1
+ import type { PageMetadataEvent, PageMetadataContribution, PluginContext } from "dineway";
2
+
3
+ import { generateCanonical } from "./canonical.js";
4
+ import { generateDescription } from "./descriptions.js";
5
+ import { generateHreflang } from "./hreflang.js";
6
+ import { generateOpengraph } from "./opengraph.js";
7
+ import { generateRobots } from "./robots.js";
8
+ import { buildSchemaGraph } from "./schema/index.js";
9
+ import { loadSettings } from "./settings.js";
10
+ import { fetchPageTerms } from "./terms.js";
11
+ import { generateOgTitle } from "./titles.js";
12
+
13
+ /**
14
+ * Main page:metadata hook handler.
15
+ * Orchestrates all SEO contribution modules.
16
+ */
17
+ export async function metadataHandler(
18
+ event: PageMetadataEvent,
19
+ ctx: PluginContext,
20
+ ): Promise<PageMetadataContribution[]> {
21
+ const { page } = event;
22
+ const settings = await loadSettings(ctx.kv);
23
+ const siteUrl = ctx.site.url;
24
+ const siteName = page.siteName || ctx.site.name;
25
+ const locale = page.locale || ctx.site.locale || "en";
26
+
27
+ const contributions: PageMetadataContribution[] = [];
28
+
29
+ // 1. OG title (page title without site name)
30
+ const ogTitle = generateOgTitle(page, settings);
31
+
32
+ // 2. Description
33
+ const description = generateDescription(page, settings);
34
+
35
+ // 3. Meta description
36
+ if (description) {
37
+ contributions.push({ kind: "meta", name: "description", content: description });
38
+ }
39
+
40
+ // 4. Robots
41
+ const robots = generateRobots(page);
42
+ if (robots) {
43
+ contributions.push({ kind: "meta", name: "robots", content: robots });
44
+ }
45
+
46
+ // 5. Canonical
47
+ const canonical = generateCanonical(page, siteUrl);
48
+ if (canonical) {
49
+ contributions.push({ kind: "link", rel: "canonical", href: canonical });
50
+ }
51
+
52
+ // 5b. hreflang alternates (multilingual content sites only)
53
+ // Short-circuits internally when i18n is disabled — zero cost on
54
+ // single-locale sites.
55
+ const hreflangContributions = await generateHreflang(page, ctx, siteUrl);
56
+ contributions.push(...hreflangContributions);
57
+
58
+ // 6. Open Graph + Twitter
59
+ const ogContributions = generateOpengraph(
60
+ page,
61
+ settings,
62
+ ogTitle,
63
+ description,
64
+ canonical,
65
+ locale,
66
+ );
67
+ contributions.push(...ogContributions);
68
+
69
+ // 7. JSON-LD Schema graph (replaces base "primary" JSON-LD)
70
+ const { keywords, articleSection } = await fetchPageTerms(page);
71
+ const schema = buildSchemaGraph(
72
+ page,
73
+ settings,
74
+ siteUrl,
75
+ siteName,
76
+ canonical,
77
+ ogTitle,
78
+ description,
79
+ locale,
80
+ keywords,
81
+ articleSection,
82
+ );
83
+ if (schema) {
84
+ contributions.push({ kind: "jsonld", id: "primary", graph: schema });
85
+ }
86
+
87
+ // 8. NLWeb discovery link
88
+ if (settings.nlwebEndpoint) {
89
+ contributions.push({ kind: "link", rel: "nlweb", href: settings.nlwebEndpoint });
90
+ }
91
+
92
+ return contributions;
93
+ }