@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,330 @@
1
+ export interface NavigationItem {
2
+ name: string;
3
+ url: string;
4
+ }
5
+
6
+ export interface SeoSettings {
7
+ siteRepresents: "person" | "organization";
8
+ separator: string;
9
+ defaultDescription: string;
10
+ personName: string;
11
+ personDescription: string;
12
+ personImageUrl: string;
13
+ personJobTitle: string;
14
+ personUrl: string;
15
+ orgName: string;
16
+ orgLogoUrl: string;
17
+ socials: string[];
18
+ /** URL to editorial/publishing principles page. */
19
+ publishingPrinciples: string;
20
+ /** Copyright year (e.g. 2026). Applied to WebPage and Article nodes. */
21
+ copyrightYear: number | null;
22
+ /** License URL (e.g. Creative Commons). Applied to WebPage and Article nodes. */
23
+ licenseUrl: string;
24
+ /** Blog section URL (e.g. "https://example.com/blog/"). Enables Blog entity. */
25
+ blogUrl: string;
26
+ /** Blog name (defaults to "Blog" if blogUrl is set). */
27
+ blogName: string;
28
+ /** Main navigation items for SiteNavigationElement schema. */
29
+ navigationItems: NavigationItem[];
30
+ /**
31
+ * Segment → display label map used by breadcrumb path derivation.
32
+ * Keys are path segments (`"blog"`), not full paths.
33
+ */
34
+ breadcrumbLabels: Record<string, string>;
35
+ /**
36
+ * Per-`pageType` rule map that overrides path derivation.
37
+ * Each rule is an ordered list of crumbs; `{title}` in a label is
38
+ * replaced with `page.title`; an omitted `href` resolves to the
39
+ * current page URL (canonical).
40
+ */
41
+ breadcrumbRules: Record<string, BreadcrumbRule>;
42
+ /**
43
+ * Absolute URL of an NLWeb endpoint. When set, the plugin contributes
44
+ * a `<link rel="nlweb" href="...">` tag on every rendered page so
45
+ * conversational agents can discover the site's chat surface.
46
+ */
47
+ nlwebEndpoint: string;
48
+ }
49
+
50
+ export interface BreadcrumbRuleCrumb {
51
+ label: string;
52
+ href?: string;
53
+ }
54
+
55
+ export type BreadcrumbRule = BreadcrumbRuleCrumb[];
56
+
57
+ const SOCIAL_KEYS = [
58
+ "socialTwitter",
59
+ "socialFacebook",
60
+ "socialLinkedIn",
61
+ "socialInstagram",
62
+ "socialYouTube",
63
+ "socialGitHub",
64
+ "socialBluesky",
65
+ "socialMastodon",
66
+ "socialWikipedia",
67
+ ] as const;
68
+
69
+ /**
70
+ * Load settings from a key-value map (works with both plugin KV and direct DB queries).
71
+ */
72
+ export function parseSettings(map: Map<string, string>): SeoSettings {
73
+ const socials: string[] = [];
74
+ for (const key of SOCIAL_KEYS) {
75
+ const val = map.get(key);
76
+ if (val) socials.push(val);
77
+ }
78
+
79
+ const copyrightYearRaw = map.get("copyrightYear");
80
+ const copyrightYear = copyrightYearRaw ? parseInt(copyrightYearRaw, 10) || null : null;
81
+
82
+ return {
83
+ siteRepresents: (map.get("siteRepresents") as "person" | "organization") || "person",
84
+ separator: map.get("separator") || " — ",
85
+ defaultDescription: map.get("defaultDescription") || "",
86
+ personName: map.get("personName") || "",
87
+ personDescription: map.get("personDescription") || "",
88
+ personImageUrl: map.get("personImageUrl") || "",
89
+ personJobTitle: map.get("personJobTitle") || "",
90
+ personUrl: map.get("personUrl") || "",
91
+ orgName: map.get("orgName") || "",
92
+ orgLogoUrl: map.get("orgLogoUrl") || "",
93
+ socials,
94
+ publishingPrinciples: map.get("publishingPrinciples") || "",
95
+ copyrightYear,
96
+ licenseUrl: map.get("licenseUrl") || "",
97
+ blogUrl: map.get("blogUrl") || "",
98
+ blogName: map.get("blogName") || "",
99
+ navigationItems: parseJsonArray<NavigationItem>(map.get("navigationItems")),
100
+ breadcrumbLabels: parseJsonRecord<string>(map.get("breadcrumbLabels")),
101
+ breadcrumbRules: parseJsonRecord<BreadcrumbRule>(map.get("breadcrumbRules")),
102
+ nlwebEndpoint: map.get("nlwebEndpoint") || "",
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Parse a JSON-serialized record from KV. Returns an empty object on any
108
+ * parse failure — bad settings should degrade silently to "no override"
109
+ * rather than crash the page:metadata hook.
110
+ */
111
+ function parseJsonRecord<V>(raw: string | undefined): Record<string, V> {
112
+ if (!raw) return {};
113
+ try {
114
+ const parsed: unknown = JSON.parse(raw);
115
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
116
+ return parsed as Record<string, V>;
117
+ }
118
+ } catch {
119
+ // fall through
120
+ }
121
+ return {};
122
+ }
123
+
124
+ function parseJsonArray<V>(raw: string | undefined): V[] {
125
+ if (!raw) return [];
126
+ try {
127
+ const parsed: unknown = JSON.parse(raw);
128
+ if (Array.isArray(parsed)) {
129
+ return parsed as V[];
130
+ }
131
+ } catch {
132
+ // fall through
133
+ }
134
+ return [];
135
+ }
136
+
137
+ /**
138
+ * Load settings from plugin KV (used by the page:metadata hook for logged-in users).
139
+ */
140
+ export async function loadSettings(kv: {
141
+ list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
142
+ }): Promise<SeoSettings> {
143
+ const entries = await kv.list("settings:");
144
+ const map = new Map<string, string>();
145
+ for (const { key, value } of entries) {
146
+ if (typeof value === "string") {
147
+ map.set(key.replace("settings:", ""), value);
148
+ }
149
+ }
150
+ return parseSettings(map);
151
+ }
152
+
153
+ export const settingsSchema = {
154
+ siteRepresents: {
155
+ type: "select" as const,
156
+ label: "Site represents",
157
+ description: "Does this site represent a person or an organization?",
158
+ options: [
159
+ { value: "person", label: "Person" },
160
+ { value: "organization", label: "Organization" },
161
+ ],
162
+ default: "person",
163
+ },
164
+ separator: {
165
+ type: "select" as const,
166
+ label: "Title separator",
167
+ description: "Character used between page title and site name in <title> tags",
168
+ options: [
169
+ { value: " — ", label: "— (em dash)" },
170
+ { value: " | ", label: "| (pipe)" },
171
+ { value: " - ", label: "- (hyphen)" },
172
+ { value: " · ", label: "· (dot)" },
173
+ ],
174
+ default: " — ",
175
+ },
176
+ defaultDescription: {
177
+ type: "string" as const,
178
+ label: "Default meta description",
179
+ description: "Fallback description for pages without their own",
180
+ multiline: true,
181
+ },
182
+ personName: {
183
+ type: "string" as const,
184
+ label: "Person name",
185
+ description: "Full name of the person this site represents",
186
+ },
187
+ personDescription: {
188
+ type: "string" as const,
189
+ label: "Person bio",
190
+ description: "Short biography (max 250 characters for schema.org)",
191
+ multiline: true,
192
+ },
193
+ personImageUrl: {
194
+ type: "string" as const,
195
+ label: "Person image URL",
196
+ description: "URL to the person's photo",
197
+ },
198
+ personJobTitle: {
199
+ type: "string" as const,
200
+ label: "Person job title",
201
+ description: "Job title for schema.org Person",
202
+ },
203
+ personUrl: {
204
+ type: "string" as const,
205
+ label: "Person URL",
206
+ description: "URL to the person's about page or personal website",
207
+ },
208
+ orgName: {
209
+ type: "string" as const,
210
+ label: "Organization name",
211
+ description: "Name of the organization (if site represents an organization)",
212
+ },
213
+ orgLogoUrl: {
214
+ type: "string" as const,
215
+ label: "Organization logo URL",
216
+ description: "URL to the organization's logo",
217
+ },
218
+ socialTwitter: {
219
+ type: "string" as const,
220
+ label: "X (Twitter) URL",
221
+ description: "Full profile URL (e.g. https://x.com/username)",
222
+ },
223
+ socialFacebook: {
224
+ type: "string" as const,
225
+ label: "Facebook URL",
226
+ description: "Full profile URL",
227
+ },
228
+ socialLinkedIn: {
229
+ type: "string" as const,
230
+ label: "LinkedIn URL",
231
+ description: "Full profile URL",
232
+ },
233
+ socialInstagram: {
234
+ type: "string" as const,
235
+ label: "Instagram URL",
236
+ description: "Full profile URL",
237
+ },
238
+ socialYouTube: {
239
+ type: "string" as const,
240
+ label: "YouTube URL",
241
+ description: "Full channel URL",
242
+ },
243
+ socialGitHub: {
244
+ type: "string" as const,
245
+ label: "GitHub URL",
246
+ description: "Full profile URL",
247
+ },
248
+ socialBluesky: {
249
+ type: "string" as const,
250
+ label: "Bluesky URL",
251
+ description: "Full profile URL",
252
+ },
253
+ socialMastodon: {
254
+ type: "string" as const,
255
+ label: "Mastodon URL",
256
+ description: "Full profile URL",
257
+ },
258
+ socialWikipedia: {
259
+ type: "string" as const,
260
+ label: "Wikipedia URL",
261
+ description: "Full article URL",
262
+ },
263
+ publishingPrinciples: {
264
+ type: "string" as const,
265
+ label: "Publishing principles URL",
266
+ description: "URL to your editorial policy or publishing principles page",
267
+ },
268
+ copyrightYear: {
269
+ type: "string" as const,
270
+ label: "Copyright year",
271
+ description: "Year copyright was first asserted (e.g. 2026)",
272
+ },
273
+ licenseUrl: {
274
+ type: "string" as const,
275
+ label: "License URL",
276
+ description: "URL to content license (e.g. https://creativecommons.org/licenses/by/4.0/)",
277
+ },
278
+ blogUrl: {
279
+ type: "string" as const,
280
+ label: "Blog URL",
281
+ description:
282
+ "Full URL of the blog section (e.g. https://example.com/blog/). Enables Blog schema entity.",
283
+ },
284
+ blogName: {
285
+ type: "string" as const,
286
+ label: "Blog name",
287
+ description: 'Name for the Blog schema entity (defaults to "Blog")',
288
+ },
289
+ navigationItems: {
290
+ type: "string" as const,
291
+ label: "Navigation items (JSON)",
292
+ description: "JSON array of {name, url} objects for SiteNavigationElement schema",
293
+ multiline: true,
294
+ },
295
+ indexnowEnabled: {
296
+ type: "select" as const,
297
+ label: "IndexNow submission",
298
+ description:
299
+ "Submit published/unpublished URLs to IndexNow (Bing, Yandex, Seznam, Naver, Yep). A key is generated automatically on first use.",
300
+ options: [
301
+ { value: "false", label: "Disabled" },
302
+ { value: "true", label: "Enabled" },
303
+ ],
304
+ default: "false",
305
+ },
306
+ llmsTxtEnabled: {
307
+ type: "select" as const,
308
+ label: "llms.txt",
309
+ description:
310
+ "Expose an llms.txt index of published content on the plugin's llms/txt route. Wire a public /llms.txt Astro route to serve it.",
311
+ options: [
312
+ { value: "true", label: "Enabled" },
313
+ { value: "false", label: "Disabled" },
314
+ ],
315
+ default: "true",
316
+ },
317
+ llmsTxtDescription: {
318
+ type: "string" as const,
319
+ label: "llms.txt site description",
320
+ description:
321
+ "Optional blurb rendered as the blockquote at the top of llms.txt. Falls back to the default meta description.",
322
+ multiline: true,
323
+ },
324
+ nlwebEndpoint: {
325
+ type: "string" as const,
326
+ label: "NLWeb endpoint URL",
327
+ description:
328
+ 'Absolute URL of your NLWeb conversational endpoint. When set, the plugin emits <link rel="nlweb" href="..."> on every page.',
329
+ },
330
+ };
package/src/terms.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { PublicPageContext, TaxonomyTerm } from "dineway";
2
+
3
+ export interface PageTerms {
4
+ keywords: string[];
5
+ articleSection: string | undefined;
6
+ }
7
+
8
+ const EMPTY_TERMS: PageTerms = { keywords: [], articleSection: undefined };
9
+ const CATEGORY_TAXONOMY_RE = /^categor/i;
10
+
11
+ function termLabel(term: TaxonomyTerm): string | null {
12
+ return term.label || term.slug || null;
13
+ }
14
+
15
+ /**
16
+ * Fetch taxonomy-derived terms for the page's backing content entry.
17
+ */
18
+ export async function fetchPageTerms(page: PublicPageContext): Promise<PageTerms> {
19
+ if (page.kind !== "content" || !page.content) return EMPTY_TERMS;
20
+
21
+ try {
22
+ const { getEntryTerms } = await import("dineway");
23
+ const terms = await getEntryTerms(page.content.collection, page.content.id);
24
+
25
+ const keywords = terms.map(termLabel).filter((label): label is string => Boolean(label));
26
+ const categoryTerm = terms.find((term) => CATEGORY_TAXONOMY_RE.test(term.name));
27
+ const articleSection = categoryTerm ? termLabel(categoryTerm) : undefined;
28
+
29
+ return { keywords, articleSection: articleSection || undefined };
30
+ } catch {
31
+ return EMPTY_TERMS;
32
+ }
33
+ }
package/src/titles.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { PublicPageContext } from "dineway";
2
+
3
+ import type { SeoSettings } from "./settings.js";
4
+
5
+ /**
6
+ * Extract the raw page title (without site name suffix).
7
+ * Base.astro constructs: `${title} — ${siteTitle}`
8
+ */
9
+ function extractRawTitle(page: PublicPageContext, settings: SeoSettings): string {
10
+ const fullTitle = page.title || "";
11
+ const siteName = page.siteName || "";
12
+ const sep = settings.separator;
13
+
14
+ if (siteName && fullTitle.includes(sep + siteName)) {
15
+ return (fullTitle.split(sep + siteName)[0] ?? "").trim();
16
+ }
17
+ if (siteName && fullTitle.endsWith(siteName)) {
18
+ return fullTitle.slice(0, -siteName.length).trim();
19
+ }
20
+ return fullTitle;
21
+ }
22
+
23
+ /**
24
+ * Detect pagination from URL query string.
25
+ */
26
+ function getPageNumber(url: string): number | null {
27
+ try {
28
+ const u = new URL(url, "https://placeholder.local");
29
+ const page = u.searchParams.get("page");
30
+ if (page && Number(page) > 1) return Number(page);
31
+ } catch {
32
+ // ignore
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Generate the OG title for a page.
39
+ * og:title should be just the page title, not including site name.
40
+ */
41
+ export function generateOgTitle(page: PublicPageContext, settings: SeoSettings): string {
42
+ const path = page.path || "/";
43
+ const pageNum = getPageNumber(page.url);
44
+ let title: string;
45
+
46
+ if (path === "/") {
47
+ title = page.siteName || extractRawTitle(page, settings);
48
+ } else if (path === "/404") {
49
+ title = "Page not found";
50
+ } else {
51
+ title = extractRawTitle(page, settings) || page.siteName || "";
52
+ }
53
+
54
+ if (pageNum) {
55
+ title += ` - Page ${pageNum}`;
56
+ }
57
+
58
+ return title;
59
+ }
package/src/urls.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { I18nConfig } from "dineway";
2
+
3
+ /**
4
+ * Build an absolute page URL for a `(locale, slug)` pair inside a
5
+ * collection, honoring Astro's i18n locale-prefix routing rules.
6
+ *
7
+ * This helper is shared between `hreflang.ts` and (indirectly) the
8
+ * canonical module — the URL it produces for a given triple is the
9
+ * canonical URL of that page. The hreflang invariant ("hreflang URL ==
10
+ * canonical URL of target") depends on this being consistent.
11
+ *
12
+ * Supported URL patterns: anything containing `{slug}` as the only
13
+ * placeholder (e.g. `"/{slug}"`, `"/blog/{slug}"`). Patterns without
14
+ * `{slug}`, or with additional placeholders that would remain
15
+ * unsubstituted, return `null` — the caller should skip them.
16
+ */
17
+ export interface BuildPageUrlInput {
18
+ locale: string;
19
+ slug: string;
20
+ /** `ctx.site.url` — absolute origin. Trailing slash is tolerated. */
21
+ siteUrl: string;
22
+ cfg: I18nConfig;
23
+ /** e.g. `"/{slug}"` or `"/blog/{slug}"`. */
24
+ urlPattern: string;
25
+ }
26
+
27
+ const UNSUBSTITUTED_PLACEHOLDER_RE = /\{[^}]+\}/;
28
+ const MULTI_SLASH_RE = /\/+/g;
29
+ const TRAILING_SLASHES_RE = /\/+$/;
30
+
31
+ export function buildPageUrl(input: BuildPageUrlInput): string | null {
32
+ const { locale, slug, siteUrl, cfg, urlPattern } = input;
33
+
34
+ if (!urlPattern.includes("{slug}")) return null;
35
+
36
+ // Substitute {slug} into the pattern. Leave other placeholders in
37
+ // place — they'll be caught by the unsubstituted check below.
38
+ let path = urlPattern.replace("{slug}", slug);
39
+
40
+ // Reject patterns that still carry unsubstituted placeholders; we
41
+ // don't know what to fill them with.
42
+ if (UNSUBSTITUTED_PLACEHOLDER_RE.test(path)) return null;
43
+
44
+ // Astro i18n locale prefixing: prefix with `/{locale}` unless this
45
+ // is the default locale AND `prefixDefaultLocale === false`.
46
+ const shouldPrefix = locale !== cfg.defaultLocale || cfg.prefixDefaultLocale === true;
47
+ if (shouldPrefix) {
48
+ if (!path.startsWith("/")) path = "/" + path;
49
+ path = `/${locale}${path}`;
50
+ }
51
+
52
+ // Normalize: ensure leading slash, lowercase, collapse duplicate
53
+ // slashes, enforce trailing slash. This matches canonical.ts's
54
+ // normalization rules — any change here must update canonical.ts
55
+ // (and vice versa) or the hreflang/canonical equality invariant
56
+ // will break.
57
+ if (!path.startsWith("/")) path = "/" + path;
58
+ path = path.toLowerCase().replace(MULTI_SLASH_RE, "/");
59
+ if (!path.endsWith("/")) path += "/";
60
+
61
+ // Strip trailing slash from siteUrl before concatenation.
62
+ const origin = siteUrl.replace(TRAILING_SLASHES_RE, "");
63
+
64
+ // Defensive: verify the result is a parseable absolute URL.
65
+ try {
66
+ const url = new URL(`${origin}${path}`);
67
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
68
+ return url.toString();
69
+ } catch {
70
+ return null;
71
+ }
72
+ }