@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.
- package/README.md +227 -0
- package/package.json +49 -0
- package/src/admin-redirects.tsx +317 -0
- package/src/admin.tsx +529 -0
- package/src/canonical.ts +46 -0
- package/src/descriptions.ts +17 -0
- package/src/fuzzy.ts +112 -0
- package/src/hreflang.ts +103 -0
- package/src/index.ts +98 -0
- package/src/indexnow.ts +139 -0
- package/src/llms.ts +151 -0
- package/src/metadata.ts +93 -0
- package/src/opengraph.ts +327 -0
- package/src/robots.ts +29 -0
- package/src/schema/article.ts +70 -0
- package/src/schema/breadcrumb.ts +158 -0
- package/src/schema/endpoints.ts +69 -0
- package/src/schema/index.ts +175 -0
- package/src/schema/organization.ts +133 -0
- package/src/schema/person.ts +54 -0
- package/src/schema/webpage.ts +84 -0
- package/src/schema/website.ts +52 -0
- package/src/settings.ts +330 -0
- package/src/terms.ts +33 -0
- package/src/titles.ts +59 -0
- package/src/urls.ts +72 -0
package/src/hreflang.ts
ADDED
|
@@ -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;
|
package/src/indexnow.ts
ADDED
|
@@ -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
|
+
}
|
package/src/metadata.ts
ADDED
|
@@ -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
|
+
}
|