@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,175 @@
1
+ import {
2
+ assembleGraph,
3
+ buildBreadcrumbList,
4
+ buildImageObject,
5
+ buildPiece,
6
+ buildSiteNavigationElement,
7
+ makeIds,
8
+ type GraphEntity,
9
+ } from "@jdevalk/seo-graph-core";
10
+ import type { PublicPageContext } from "dineway";
11
+ import type { Blog } from "schema-dts";
12
+
13
+ import type { SeoSettings } from "../settings.js";
14
+ import { buildArticle } from "./article.js";
15
+ import { buildBreadcrumbs } from "./breadcrumb.js";
16
+ import { buildSiteEntity, getSiteEntityId } from "./organization.js";
17
+ import { buildAuthorPerson } from "./person.js";
18
+ import { buildWebPage } from "./webpage.js";
19
+ import { buildWebSite } from "./website.js";
20
+
21
+ const TRAILING_SLASH_RE = /\/$/;
22
+
23
+ /**
24
+ * Build the complete JSON-LD schema graph for a page.
25
+ * Outputs a @graph array with distinct, linked nodes.
26
+ *
27
+ * Individual piece builders delegate to `@jdevalk/seo-graph-core`'s
28
+ * typed builders (`buildWebSite`, `buildWebPage`, `buildArticle`,
29
+ * `buildPiece<Organization>`, `buildPiece<Person>`) with thin
30
+ * Dineway-specific wrappers that map Dineway's page/settings model
31
+ * onto core's input types. This keeps both Dineway and joost.blog on
32
+ * the same code path for graph construction, not just the ID scheme.
33
+ */
34
+ export function buildSchemaGraph(
35
+ page: PublicPageContext,
36
+ settings: SeoSettings,
37
+ siteUrl: string,
38
+ siteName: string,
39
+ canonical: string | null,
40
+ ogTitle: string,
41
+ description: string | null,
42
+ locale: string,
43
+ keywords?: string[],
44
+ articleSection?: string,
45
+ ): Record<string, unknown> | null {
46
+ // No schema for 404 pages
47
+ if (page.path === "/404") return null;
48
+
49
+ const ids = makeIds({
50
+ siteUrl,
51
+ personUrl: settings.personUrl || undefined,
52
+ });
53
+
54
+ // Compute breadcrumbs early so the WebPage node can carry a
55
+ // back-reference (`breadcrumb: { "@id": ... }`) when present.
56
+ const crumbs = buildBreadcrumbs(page, settings, siteUrl);
57
+ const hasBreadcrumbs = crumbs !== null && crumbs.length > 1;
58
+ const pageUrl = canonical || page.url;
59
+ const siteEntityId = getSiteEntityId(settings, ids);
60
+
61
+ // Derive Blog @id when blog settings are configured.
62
+ const blogId = settings.blogUrl
63
+ ? `${settings.blogUrl.replace(TRAILING_SLASH_RE, "")}/#blog`
64
+ : null;
65
+
66
+ const pieces: GraphEntity[] = [];
67
+
68
+ // 1. Site entity (Organization or Person) - always present
69
+ pieces.push(buildSiteEntity(settings, siteUrl, siteName, locale, ids) as GraphEntity);
70
+
71
+ // 2. WebSite - always present
72
+ const hasNavigation = settings.navigationItems.length > 0;
73
+ pieces.push(
74
+ buildWebSite(
75
+ settings,
76
+ siteUrl,
77
+ siteName,
78
+ settings.defaultDescription || null,
79
+ locale,
80
+ ids,
81
+ hasNavigation,
82
+ ) as GraphEntity,
83
+ );
84
+
85
+ // 3. SiteNavigationElement - when navigation items are configured
86
+ if (hasNavigation) {
87
+ pieces.push(
88
+ buildSiteNavigationElement(
89
+ {
90
+ name: "Main navigation",
91
+ isPartOf: { "@id": ids.website },
92
+ items: settings.navigationItems,
93
+ },
94
+ ids,
95
+ ) as GraphEntity,
96
+ );
97
+ }
98
+
99
+ // 4. Blog entity - when blog URL is configured
100
+ if (blogId && settings.blogUrl) {
101
+ pieces.push(
102
+ buildPiece<Blog>({
103
+ "@type": "Blog",
104
+ "@id": blogId,
105
+ name: settings.blogName || "Blog",
106
+ url: settings.blogUrl,
107
+ publisher: { "@id": siteEntityId },
108
+ inLanguage: locale,
109
+ }) as GraphEntity,
110
+ );
111
+ }
112
+
113
+ // 5. WebPage - always present
114
+ pieces.push(
115
+ buildWebPage(
116
+ page,
117
+ settings,
118
+ canonical,
119
+ ogTitle,
120
+ description,
121
+ locale,
122
+ ids,
123
+ hasBreadcrumbs,
124
+ siteEntityId,
125
+ ) as GraphEntity,
126
+ );
127
+
128
+ // 6. BreadcrumbList - when derived/ruled with >1 crumb
129
+ if (hasBreadcrumbs) {
130
+ pieces.push(buildBreadcrumbList({ url: pageUrl, items: crumbs }, ids) as GraphEntity);
131
+ }
132
+
133
+ // 7. Primary ImageObject - when the page has an image
134
+ if (page.image) {
135
+ pieces.push(
136
+ buildImageObject(
137
+ {
138
+ pageUrl,
139
+ url: page.image,
140
+ width: 1200,
141
+ height: 675,
142
+ inLanguage: locale,
143
+ },
144
+ ids,
145
+ ) as GraphEntity,
146
+ );
147
+ }
148
+
149
+ // 8. Article + Author Person - for content pages with article meta
150
+ if (page.kind === "content" && page.articleMeta?.publishedTime) {
151
+ const article = buildArticle(
152
+ page,
153
+ settings,
154
+ siteName,
155
+ canonical,
156
+ ogTitle,
157
+ description,
158
+ locale,
159
+ ids,
160
+ blogId,
161
+ keywords,
162
+ articleSection,
163
+ );
164
+ if (article) pieces.push(article as GraphEntity);
165
+
166
+ // For Organization sites the author Person is a distinct entity
167
+ // from the site entity, so emit it separately. For Person sites
168
+ // the site entity already is this Person — assembleGraph's
169
+ // first-wins dedupe keeps the richer site-entity version.
170
+ const author = buildAuthorPerson(settings, siteName, ids);
171
+ if (author) pieces.push(author as GraphEntity);
172
+ }
173
+
174
+ return assembleGraph(pieces, { warnOnDanglingReferences: true });
175
+ }
@@ -0,0 +1,133 @@
1
+ import { buildPiece } from "@jdevalk/seo-graph-core";
2
+ import type { IdFactory } from "@jdevalk/seo-graph-core";
3
+ import type { Organization, Person } from "schema-dts";
4
+
5
+ import type { SeoSettings } from "../settings.js";
6
+
7
+ const TRAILING_SLASH_RE = /\/$/;
8
+ const NON_ALPHANUMERIC_RE = /[^a-z0-9]+/g;
9
+ const EDGE_DASH_RE = /(^-|-$)/g;
10
+
11
+ /**
12
+ * Build the Organization or Person node representing the site entity.
13
+ * Every page includes an Organization or Person node.
14
+ */
15
+ export function buildSiteEntity(
16
+ settings: SeoSettings,
17
+ siteUrl: string,
18
+ siteName: string,
19
+ locale: string,
20
+ ids: IdFactory,
21
+ ): Record<string, unknown> {
22
+ const baseUrl = siteUrl.replace(TRAILING_SLASH_RE, "");
23
+
24
+ if (settings.siteRepresents === "organization") {
25
+ return buildOrganizationEntity(settings, baseUrl, siteName, locale, ids);
26
+ }
27
+
28
+ return buildPersonEntity(settings, baseUrl, siteName, locale, ids);
29
+ }
30
+
31
+ function buildOrganizationEntity(
32
+ settings: SeoSettings,
33
+ baseUrl: string,
34
+ siteName: string,
35
+ locale: string,
36
+ ids: IdFactory,
37
+ ): Record<string, unknown> {
38
+ const orgLogoId = `${baseUrl}/#/schema.org/ImageObject/logo`;
39
+
40
+ const piece = buildPiece<Organization>({
41
+ "@type": "Organization",
42
+ "@id": ids.organization(orgSlug(settings)),
43
+ name: settings.orgName || siteName,
44
+ url: baseUrl,
45
+ });
46
+
47
+ if (settings.orgLogoUrl) {
48
+ piece.logo = {
49
+ "@type": "ImageObject",
50
+ "@id": orgLogoId,
51
+ url: settings.orgLogoUrl,
52
+ contentUrl: settings.orgLogoUrl,
53
+ inLanguage: locale,
54
+ };
55
+ piece.image = { "@id": orgLogoId };
56
+ }
57
+
58
+ if (settings.socials.length > 0) {
59
+ piece.sameAs = settings.socials;
60
+ }
61
+
62
+ if (settings.publishingPrinciples) {
63
+ piece.publishingPrinciples = settings.publishingPrinciples;
64
+ }
65
+
66
+ return piece;
67
+ }
68
+
69
+ function buildPersonEntity(
70
+ settings: SeoSettings,
71
+ baseUrl: string,
72
+ siteName: string,
73
+ locale: string,
74
+ ids: IdFactory,
75
+ ): Record<string, unknown> {
76
+ const name = settings.personName || siteName;
77
+
78
+ const piece = buildPiece<Person>({
79
+ "@type": "Person",
80
+ "@id": ids.person,
81
+ name,
82
+ url: baseUrl,
83
+ });
84
+
85
+ if (settings.personDescription) {
86
+ piece.description = settings.personDescription.slice(0, 250);
87
+ }
88
+
89
+ if (settings.personJobTitle) {
90
+ piece.jobTitle = settings.personJobTitle;
91
+ }
92
+
93
+ if (settings.personImageUrl) {
94
+ piece.image = {
95
+ "@type": "ImageObject",
96
+ "@id": ids.personImage,
97
+ url: settings.personImageUrl,
98
+ contentUrl: settings.personImageUrl,
99
+ inLanguage: locale,
100
+ caption: name,
101
+ };
102
+ }
103
+
104
+ if (settings.socials.length > 0) {
105
+ piece.sameAs = settings.socials;
106
+ }
107
+
108
+ if (settings.publishingPrinciples) {
109
+ piece.publishingPrinciples = settings.publishingPrinciples;
110
+ }
111
+
112
+ return piece;
113
+ }
114
+
115
+ /**
116
+ * Get the @id reference for the site entity — either the site-wide
117
+ * Person or an Organization. Used by other pieces (Article, WebSite)
118
+ * that need to reference the publisher.
119
+ */
120
+ export function getSiteEntityId(settings: SeoSettings, ids: IdFactory): string {
121
+ if (settings.siteRepresents === "organization") {
122
+ return ids.organization(orgSlug(settings));
123
+ }
124
+ return ids.person;
125
+ }
126
+
127
+ /**
128
+ * Slugify the organization name for use as the Organization @id slug.
129
+ */
130
+ function orgSlug(settings: SeoSettings): string {
131
+ const name = settings.orgName || "site";
132
+ return name.toLowerCase().replace(NON_ALPHANUMERIC_RE, "-").replace(EDGE_DASH_RE, "") || "site";
133
+ }
@@ -0,0 +1,54 @@
1
+ import { buildPiece } from "@jdevalk/seo-graph-core";
2
+ import type { IdFactory } from "@jdevalk/seo-graph-core";
3
+ import type { Person } from "schema-dts";
4
+
5
+ import type { SeoSettings } from "../settings.js";
6
+
7
+ /**
8
+ * Build the author Person schema node. Returned as a separate piece
9
+ * only when the site represents an Organization — for Person sites
10
+ * the site entity already covers this node.
11
+ *
12
+ * Required fields are @type, @id, name. Names like "admin" are
13
+ * rejected per schema.org best practice.
14
+ */
15
+ export function buildAuthorPerson(
16
+ settings: SeoSettings,
17
+ siteName: string,
18
+ ids: IdFactory,
19
+ ): Record<string, unknown> | null {
20
+ const name = settings.personName || siteName;
21
+
22
+ // Per spec: reject "admin" or similar invalid author names
23
+ if (!name || name.toLowerCase() === "admin") return null;
24
+
25
+ const piece = buildPiece<Person>({
26
+ "@type": "Person",
27
+ "@id": ids.person,
28
+ name,
29
+ });
30
+
31
+ if (settings.personDescription) {
32
+ piece.description = settings.personDescription.slice(0, 250);
33
+ }
34
+
35
+ if (settings.personUrl) {
36
+ piece.url = settings.personUrl;
37
+ }
38
+
39
+ if (settings.personImageUrl) {
40
+ piece.image = {
41
+ "@type": "ImageObject",
42
+ "@id": ids.personImage,
43
+ url: settings.personImageUrl,
44
+ contentUrl: settings.personImageUrl,
45
+ caption: name,
46
+ };
47
+ }
48
+
49
+ if (settings.socials.length > 0) {
50
+ piece.sameAs = settings.socials;
51
+ }
52
+
53
+ return piece;
54
+ }
@@ -0,0 +1,84 @@
1
+ import { buildWebPage as coreBuildWebPage, type WebPageType } from "@jdevalk/seo-graph-core";
2
+ import type { IdFactory, Reference } from "@jdevalk/seo-graph-core";
3
+ import type { PublicPageContext } from "dineway";
4
+
5
+ import type { SeoSettings } from "../settings.js";
6
+
7
+ const COLLECTION_PAGE_PATHS = new Set(["/", "/posts", "/posts/", "/videos", "/videos/"]);
8
+
9
+ const PROFILE_PAGE_PATHS = new Set(["/about", "/about/"]);
10
+
11
+ /**
12
+ * Determine the WebPage @type based on the page path.
13
+ */
14
+ function getWebPageType(page: PublicPageContext): WebPageType {
15
+ const path = page.path || "/";
16
+
17
+ if (PROFILE_PAGE_PATHS.has(path)) return "ProfilePage";
18
+ if (COLLECTION_PAGE_PATHS.has(path)) return "CollectionPage";
19
+ if (path.startsWith("/categories/")) return "CollectionPage";
20
+ if (path.startsWith("/tags/")) return "CollectionPage";
21
+
22
+ return "WebPage";
23
+ }
24
+
25
+ /**
26
+ * Build the WebPage schema node.
27
+ * Every page includes a WebPage node.
28
+ *
29
+ * When `hasBreadcrumbs` is true, a `breadcrumb` back-reference is
30
+ * added pointing at the BreadcrumbList entity. Callers are
31
+ * responsible for emitting the matching BreadcrumbList piece.
32
+ */
33
+ export function buildWebPage(
34
+ page: PublicPageContext,
35
+ settings: SeoSettings,
36
+ canonical: string | null,
37
+ ogTitle: string,
38
+ description: string | null,
39
+ locale: string,
40
+ ids: IdFactory,
41
+ hasBreadcrumbs: boolean,
42
+ siteEntityId: string,
43
+ ): Record<string, unknown> {
44
+ const pageUrl = canonical || page.url;
45
+ const type = getWebPageType(page);
46
+
47
+ // Homepage and ProfilePage carry an `about` reference to the site entity.
48
+ const isAboutPage = type === "ProfilePage" || (page.path || "/") === "/";
49
+ const about: Reference | undefined = isAboutPage ? { "@id": siteEntityId } : undefined;
50
+
51
+ // Primary image reference when the page has an image.
52
+ const primaryImage: Reference | undefined = page.image
53
+ ? { "@id": ids.primaryImage(pageUrl) }
54
+ : undefined;
55
+
56
+ // Copyright fields from settings.
57
+ const copyrightHolder: Reference | undefined = settings.copyrightYear
58
+ ? { "@id": siteEntityId }
59
+ : undefined;
60
+
61
+ return coreBuildWebPage(
62
+ {
63
+ url: pageUrl,
64
+ name: ogTitle,
65
+ isPartOf: { "@id": ids.website },
66
+ inLanguage: locale,
67
+ description: description || undefined,
68
+ breadcrumb: hasBreadcrumbs ? { "@id": ids.breadcrumb(pageUrl) } : undefined,
69
+ datePublished: page.articleMeta?.publishedTime
70
+ ? new Date(page.articleMeta.publishedTime)
71
+ : undefined,
72
+ dateModified: page.articleMeta?.modifiedTime
73
+ ? new Date(page.articleMeta.modifiedTime)
74
+ : undefined,
75
+ about,
76
+ primaryImage,
77
+ copyrightHolder,
78
+ copyrightYear: settings.copyrightYear || undefined,
79
+ license: settings.licenseUrl || undefined,
80
+ },
81
+ ids,
82
+ type,
83
+ );
84
+ }
@@ -0,0 +1,52 @@
1
+ import { buildWebSite as coreBuildWebSite } from "@jdevalk/seo-graph-core";
2
+ import type { IdFactory } from "@jdevalk/seo-graph-core";
3
+
4
+ import type { SeoSettings } from "../settings.js";
5
+ import { getSiteEntityId } from "./organization.js";
6
+
7
+ const TRAILING_SLASH_RE = /\/$/;
8
+
9
+ /**
10
+ * Build the WebSite schema node.
11
+ * Every page includes a WebSite node with SearchAction.
12
+ */
13
+ export function buildWebSite(
14
+ settings: SeoSettings,
15
+ siteUrl: string,
16
+ siteName: string,
17
+ siteDescription: string | null,
18
+ locale: string,
19
+ ids: IdFactory,
20
+ hasNavigation: boolean,
21
+ ): Record<string, unknown> {
22
+ const baseUrl = siteUrl.replace(TRAILING_SLASH_RE, "");
23
+
24
+ const piece = coreBuildWebSite(
25
+ {
26
+ url: `${baseUrl}/`,
27
+ name: siteName,
28
+ publisher: { "@id": getSiteEntityId(settings, ids) },
29
+ inLanguage: locale,
30
+ description: siteDescription || undefined,
31
+ hasPart: hasNavigation ? { "@id": ids.navigation } : undefined,
32
+ },
33
+ ids,
34
+ );
35
+
36
+ // SearchAction with Google's query-input extension — not in schema-dts
37
+ // types, so set directly on the result.
38
+ piece.potentialAction = {
39
+ "@type": "SearchAction",
40
+ target: {
41
+ "@type": "EntryPoint",
42
+ urlTemplate: `${baseUrl}/search?q={search_term_string}`,
43
+ },
44
+ "query-input": {
45
+ "@type": "PropertyValueSpecification",
46
+ valueRequired: "http://schema.org/True",
47
+ valueName: "search_term_string",
48
+ },
49
+ };
50
+
51
+ return piece;
52
+ }