@tsdoctor/seo 0.1.0

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/Attribution.js ADDED
@@ -0,0 +1,99 @@
1
+ import { licenseExpressionOf } from "@effected/package-json";
2
+ import { SpdxExpression } from "@effected/spdx";
3
+ import { Option } from "effect";
4
+
5
+ //#region src/Attribution.ts
6
+ /** The license half of the facts, or nothing when there is no SPDX to read. */
7
+ function licenseFacts(license) {
8
+ if (license == null) return {
9
+ licenseIds: [],
10
+ licenseUrls: []
11
+ };
12
+ const parsed = Option.getOrUndefined(licenseExpressionOf(license));
13
+ if (parsed === void 0) return {
14
+ licenseIds: [],
15
+ licenseUrls: []
16
+ };
17
+ const entries = SpdxExpression.licensesOf(parsed);
18
+ const licenseIds = entries.map((entry) => entry.id);
19
+ const licenseUrls = entries.map((entry) => Option.getOrUndefined(entry.referenceUrl)).filter((url) => url !== void 0);
20
+ const primary = Option.getOrUndefined(SpdxExpression.primaryLicense(parsed));
21
+ if (primary === void 0) return {
22
+ licenseIds,
23
+ licenseUrls
24
+ };
25
+ const referenceUrl = Option.getOrUndefined(primary.referenceUrl);
26
+ return {
27
+ licenseIds,
28
+ licenseUrls,
29
+ primaryLicenseId: primary.id,
30
+ ...referenceUrl !== void 0 ? { licenseUrl: referenceUrl } : {}
31
+ };
32
+ }
33
+ /**
34
+ * Where this package lives, preferring the precise answer.
35
+ *
36
+ * @remarks
37
+ * `browseUrl` ignores `directory`, so on a monorepo every member reports the
38
+ * repository root — and that URL is exactly what a crawler uses to tell two
39
+ * packages apart. `directoryUrl` is the monorepo-aware form and returns `None`
40
+ * rather than fabricating a path convention for a host it does not recognize
41
+ * (a self-hosted forge, say).
42
+ *
43
+ * The fallback to `browseUrl` on that `None` is deliberate: schema.org's
44
+ * `codeRepository` denotes the REPOSITORY, not the subdirectory, so the root
45
+ * is a *true* location for the package — merely one that does not distinguish
46
+ * it from its siblings. That is precision loss, not a correctness bug, and it
47
+ * beats omitting the field.
48
+ */
49
+ const repositoryUrlOf = (repository) => Option.getOrUndefined(Option.orElse(repository.directoryUrl, () => repository.browseUrl));
50
+ /**
51
+ * Derive the attribution facts a documentation page can credit from a decoded
52
+ * package manifest.
53
+ *
54
+ * @remarks
55
+ * Total: a manifest carrying none of these fields yields empty arrays and no
56
+ * optional properties, never a failure. Per-field degradation is the contract —
57
+ * an unparseable license drops only the license facts, an unrecognized
58
+ * repository reference drops only the repository URL.
59
+ *
60
+ * @param manifest - the decoded manifest to read
61
+ * @returns the facts, with every underivable field absent
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * import { PackageManifest } from "@effected/package-json";
66
+ * import { attributionFacts } from "@tsdoctor/seo";
67
+ * import { Effect } from "effect";
68
+ *
69
+ * const program = Effect.gen(function* () {
70
+ * const manifest = yield* PackageManifest.decode({
71
+ * name: "@scope/pkg",
72
+ * version: "1.0.0",
73
+ * license: "MIT",
74
+ * repository: { url: "github:owner/repo", directory: "packages/pkg" },
75
+ * });
76
+ * const facts = attributionFacts(manifest);
77
+ * console.log(facts.primaryLicenseId, facts.repositoryUrl);
78
+ * // => "MIT" "https://github.com/owner/repo/tree/HEAD/packages/pkg"
79
+ * });
80
+ * ```
81
+ *
82
+ * @public
83
+ */
84
+ function attributionFacts(manifest) {
85
+ const author = manifest.author;
86
+ const repositoryUrl = manifest.repository === void 0 ? void 0 : repositoryUrlOf(manifest.repository);
87
+ return {
88
+ ...author !== void 0 ? { authorName: author.name } : {},
89
+ ...author?.url !== void 0 ? { authorUrl: author.url } : {},
90
+ maintainerNames: (manifest.maintainers ?? []).map((person) => person.name),
91
+ ...repositoryUrl !== void 0 ? { repositoryUrl } : {},
92
+ ...manifest.homepage !== void 0 ? { homepage: manifest.homepage } : {},
93
+ ...licenseFacts(manifest.license),
94
+ keywords: [...manifest.keywords ?? []]
95
+ };
96
+ }
97
+
98
+ //#endregion
99
+ export { attributionFacts };
package/Canonical.js ADDED
@@ -0,0 +1,99 @@
1
+ //#region src/Canonical.ts
2
+ /**
3
+ * URL derivation for a documentation page: the site URL prefix, the canonical
4
+ * page URL, absolute image URLs and image MIME mapping.
5
+ *
6
+ * @remarks
7
+ * `imageMimeType`, `resolveUrl` and `deriveSiteUrl` moved here verbatim from
8
+ * the RSPress adapter's `og-resolver.ts`. `resolveUrl` was `resolveOgUrl`
9
+ * there; canonical links resolve through the same function, so the
10
+ * OG-specific name no longer fits. Everything here is total and synchronous —
11
+ * no Effect, no filesystem.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ /**
16
+ * MIME type mappings for common image formats, used for `og:image:type`.
17
+ */
18
+ const IMAGE_MIME_TYPES = {
19
+ jpg: "image/jpeg",
20
+ jpeg: "image/jpeg",
21
+ png: "image/png",
22
+ gif: "image/gif",
23
+ webp: "image/webp",
24
+ svg: "image/svg+xml"
25
+ };
26
+ /**
27
+ * The `og:image:type` value for a detected image format, or `undefined` for a
28
+ * format with no mapping.
29
+ *
30
+ * @public
31
+ */
32
+ function imageMimeType(type) {
33
+ if (type == null) return void 0;
34
+ return IMAGE_MIME_TYPES[type.toLowerCase()];
35
+ }
36
+ /**
37
+ * Turn a configured URL into an absolute one.
38
+ *
39
+ * @returns The absolute URL, or `undefined` when the input is neither an
40
+ * absolute `http(s)` URL nor a site-root-relative path. A bare relative path
41
+ * is deliberately rejected rather than guessed at — there is no base to
42
+ * resolve it against that would not silently produce a broken link.
43
+ *
44
+ * @public
45
+ */
46
+ function resolveUrl(siteUrl, url) {
47
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
48
+ if (url.startsWith("/")) return `${siteUrl}${url}`;
49
+ }
50
+ /**
51
+ * Derive the site URL prefix from the framework's own config.
52
+ *
53
+ * @remarks
54
+ * Replaces the RSPress plugin's former `siteUrl` option. RSPress already knows
55
+ * where a site is deployed — {@link https://rspress.rs/api/config/config-basic#siteorigin | `siteOrigin`}
56
+ * plus `base` — so asking for it a second time invited the two to disagree, and
57
+ * a plugin-level answer that contradicted the site's own would silently emit
58
+ * canonical and `og:url` tags pointing at a host the site is not served from.
59
+ *
60
+ * RSPress concatenates as `siteOrigin + base + routePath`, and **this follows
61
+ * its documented fallback exactly**: with no `siteOrigin`, RSPress uses
62
+ * `base + routePath`. So an unset origin yields a ROOT-RELATIVE prefix rather
63
+ * than nothing.
64
+ *
65
+ * That fallback is what makes the tags inspectable in `rspress dev`, where the
66
+ * site is served from `localhost` and no configured origin could be correct
67
+ * anyway. A root-relative `/images/og.png` resolves against the page's own
68
+ * origin in the browser; it is a *relative* path (`images/og.png`, no leading
69
+ * slash) that has no base to resolve against, and this never emits one.
70
+ *
71
+ * @returns The prefix to put in front of a route that already begins with `/`.
72
+ * `""` when the site declares neither `siteOrigin` nor a non-root `base`, which
73
+ * leaves every URL root-relative. Never has a trailing slash, since every
74
+ * caller appends a route starting with `/`.
75
+ *
76
+ * @public
77
+ */
78
+ function deriveSiteUrl(siteOrigin, base) {
79
+ const origin = (siteOrigin ?? "").trim().replace(/\/+$/, "");
80
+ const path = (base ?? "/").trim();
81
+ return `${origin}${path === "" || path === "/" ? "" : `/${path.replace(/^\/+/, "").replace(/\/+$/, "")}`}`;
82
+ }
83
+ /**
84
+ * The canonical URL for a page.
85
+ *
86
+ * @remarks
87
+ * With no configured origin the prefix is `""`, so the result is
88
+ * root-relative (`/api/class/foo`) rather than absent. That matches RSPress's
89
+ * own documented `base + routePath` fallback and keeps the tag inspectable
90
+ * under a dev server, where no configured origin could be correct.
91
+ *
92
+ * @public
93
+ */
94
+ function canonicalUrl(siteUrl, pageRoute) {
95
+ return `${siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl}${pageRoute}`;
96
+ }
97
+
98
+ //#endregion
99
+ export { canonicalUrl, deriveSiteUrl, imageMimeType, resolveUrl };
package/HeadTag.js ADDED
@@ -0,0 +1,69 @@
1
+ //#region src/HeadTag.ts
2
+ /**
3
+ * Escape a JSON string so it cannot terminate the `<script>` element that
4
+ * carries it.
5
+ *
6
+ * @remarks
7
+ * Every string in a JSON-LD graph originates in author-written TSDoc, so a
8
+ * summary containing the literal `<\/script>` would close the element early and
9
+ * inject markup into the page. `JSON.stringify` does not escape it.
10
+ *
11
+ * Escaping both angle brackets as `<` / `>` is valid JSON that
12
+ * parses back to the original characters, so the graph a consumer reads is
13
+ * unchanged while the element becomes unclosable from inside.
14
+ *
15
+ * `&` is escaped for the same reason at a different layer: XHTML parses
16
+ * script content as ordinary element content, where a bare `&` is a
17
+ * well-formedness error. An HTML-parsed page tolerates it; an XHTML-served one
18
+ * does not, and nothing in a docs pipeline guarantees which a consumer serves.
19
+ *
20
+ * The escape is idempotent — no escape sequence it emits contains `<`, `>` or
21
+ * `&` — so a body that arrives already escaped by an upstream serializer
22
+ * survives a second pass unchanged.
23
+ *
24
+ * @public
25
+ */
26
+ function escapeScriptBody(json) {
27
+ return json.replaceAll("<", "\\u003C").replaceAll(">", "\\u003E").replaceAll("&", "\\u0026");
28
+ }
29
+ /** An Open Graph style `<meta property=… content=…>`. @public */
30
+ function meta(property, content) {
31
+ return {
32
+ tag: "meta",
33
+ attrs: {
34
+ property,
35
+ content
36
+ }
37
+ };
38
+ }
39
+ /** A Twitter/standard style `<meta name=… content=…>`. @public */
40
+ function metaNamed(name, content) {
41
+ return {
42
+ tag: "meta",
43
+ attrs: {
44
+ name,
45
+ content
46
+ }
47
+ };
48
+ }
49
+ /** A `<link rel=… href=…>`. @public */
50
+ function link(rel, href) {
51
+ return {
52
+ tag: "link",
53
+ attrs: {
54
+ rel,
55
+ href
56
+ }
57
+ };
58
+ }
59
+ /** A `<script type="application/ld+json">` carrying an escaped body. @public */
60
+ function jsonLd(json) {
61
+ return {
62
+ tag: "script",
63
+ attrs: { type: "application/ld+json" },
64
+ body: escapeScriptBody(json)
65
+ };
66
+ }
67
+
68
+ //#endregion
69
+ export { escapeScriptBody, jsonLd, link, meta, metaNamed };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/OpenGraph.js ADDED
@@ -0,0 +1,156 @@
1
+ import { canonicalUrl } from "./Canonical.js";
2
+ import { meta, metaNamed } from "./HeadTag.js";
3
+ import { Schema } from "effect";
4
+
5
+ //#region src/OpenGraph.ts
6
+ /**
7
+ * Open Graph and Twitter card vocabulary: the metadata schemas, the
8
+ * page-metadata assembly and the two tag emitters.
9
+ *
10
+ * @remarks
11
+ * The schemas and {@link createPageMetadata} / {@link ogAltText} moved here
12
+ * verbatim from the RSPress adapter (`schemas/opengraph.ts` and
13
+ * `og-resolver.ts`). {@link openGraphTags} is the tag-emission logic that was
14
+ * inlined in the adapter's `generateFrontmatter`, lifted unchanged — same tag
15
+ * order, same conditional emission of each optional image sub-tag — so that a
16
+ * second adapter can reach the vocabulary rather than reimplement it.
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+ /**
21
+ * Structured Open Graph image metadata (alternative to a plain URL string).
22
+ *
23
+ * @public
24
+ */
25
+ const OpenGraphImageMetadata = Schema.Struct({
26
+ /** Absolute URL of the image. */
27
+ url: Schema.String,
28
+ /** HTTPS URL of the image (for secure contexts). */
29
+ secureUrl: Schema.optional(Schema.String),
30
+ /** MIME type of the image (e.g. `"image/png"`). */
31
+ type: Schema.optional(Schema.String),
32
+ /** Image width in pixels. */
33
+ width: Schema.optional(Schema.Number),
34
+ /** Image height in pixels. */
35
+ height: Schema.optional(Schema.Number),
36
+ /** Alt text for the image. */
37
+ alt: Schema.optional(Schema.String)
38
+ });
39
+ /**
40
+ * Open Graph image: either a plain URL string or structured `OpenGraphImageMetadata`.
41
+ *
42
+ * @public
43
+ */
44
+ const OpenGraphImageConfig = Schema.Union([Schema.String, OpenGraphImageMetadata]);
45
+ /**
46
+ * Resolved Open Graph metadata for one documentation page.
47
+ *
48
+ * @public
49
+ */
50
+ const OpenGraphMetadata = Schema.Struct({
51
+ /** Canonical site base URL. */
52
+ siteUrl: Schema.String,
53
+ /** Page route path (e.g. `/api/classes/myclass`). */
54
+ pageRoute: Schema.String,
55
+ /** Page description for the `og:description` tag. */
56
+ description: Schema.String,
57
+ /** ISO 8601 date string for `article:published_time`. */
58
+ publishedTime: Schema.String,
59
+ /** ISO 8601 date string for `article:modified_time`. */
60
+ modifiedTime: Schema.String,
61
+ /** Article section label (e.g. `"API"`). */
62
+ section: Schema.String,
63
+ /** Article tag keywords. */
64
+ tags: Schema.mutable(Schema.Array(Schema.String)),
65
+ /** Optional structured image metadata. */
66
+ ogImage: Schema.optional(OpenGraphImageMetadata),
67
+ /** Open Graph object type (e.g. `"article"`). */
68
+ ogType: Schema.String
69
+ });
70
+ /**
71
+ * Descriptive alt text for a package's (or one API's) OG image.
72
+ *
73
+ * @public
74
+ */
75
+ function ogAltText(packageName, apiName) {
76
+ return apiName ? `${apiName} - ${packageName} API Documentation` : `${packageName} API Documentation`;
77
+ }
78
+ /**
79
+ * Assemble the complete Open Graph metadata for one documentation page.
80
+ *
81
+ * @public
82
+ */
83
+ function createPageMetadata(options) {
84
+ return {
85
+ siteUrl: options.siteUrl,
86
+ pageRoute: options.pageRoute,
87
+ description: options.description,
88
+ publishedTime: options.publishedTime,
89
+ modifiedTime: options.modifiedTime,
90
+ section: options.section,
91
+ tags: [
92
+ "TypeScript",
93
+ "API",
94
+ options.packageName
95
+ ],
96
+ ...options.ogImage != null ? { ogImage: options.ogImage } : {},
97
+ ogType: "article"
98
+ };
99
+ }
100
+ /**
101
+ * The Open Graph block for a page.
102
+ *
103
+ * @remarks
104
+ * Each optional image sub-tag is emitted only when the resolved image actually
105
+ * carries it — an `og:image:width` with no width is a tag a crawler reads as a
106
+ * declared-but-empty dimension rather than an absent one.
107
+ *
108
+ * @public
109
+ */
110
+ function openGraphTags(metadata) {
111
+ const tags = [
112
+ meta("og:url", canonicalUrl(metadata.siteUrl, metadata.pageRoute)),
113
+ meta("og:type", metadata.ogType),
114
+ meta("og:description", metadata.description)
115
+ ];
116
+ const image = metadata.ogImage;
117
+ if (image) {
118
+ tags.push(meta("og:image", image.url));
119
+ if (image.secureUrl) tags.push(meta("og:image:secure_url", image.secureUrl));
120
+ if (image.type) tags.push(meta("og:image:type", image.type));
121
+ if (image.width) tags.push(meta("og:image:width", String(image.width)));
122
+ if (image.height) tags.push(meta("og:image:height", String(image.height)));
123
+ if (image.alt) tags.push(meta("og:image:alt", image.alt));
124
+ }
125
+ tags.push(meta("article:published_time", metadata.publishedTime));
126
+ tags.push(meta("article:modified_time", metadata.modifiedTime));
127
+ tags.push(meta("article:section", metadata.section));
128
+ for (const tag of metadata.tags) tags.push(meta("article:tag", tag));
129
+ return tags;
130
+ }
131
+ /**
132
+ * Twitter card tags derived from the same metadata as the Open Graph block.
133
+ *
134
+ * @remarks
135
+ * Twitter reads most `og:` tags directly, so only what it does not infer is
136
+ * emitted here. The card type is a function of whether an image exists —
137
+ * `summary_large_image` with one, `summary` without — because declaring the
138
+ * large card with no image renders as a broken preview rather than degrading
139
+ * to the small one.
140
+ *
141
+ * Twitter's tags use `name`, not `property`.
142
+ *
143
+ * @public
144
+ */
145
+ function twitterTags(metadata, site) {
146
+ const tags = [metaNamed("twitter:card", metadata.ogImage ? "summary_large_image" : "summary"), metaNamed("twitter:description", metadata.description)];
147
+ if (site != null && site !== "") tags.push(metaNamed("twitter:site", site));
148
+ if (metadata.ogImage) {
149
+ tags.push(metaNamed("twitter:image", metadata.ogImage.url));
150
+ if (metadata.ogImage.alt) tags.push(metaNamed("twitter:image:alt", metadata.ogImage.alt));
151
+ }
152
+ return tags;
153
+ }
154
+
155
+ //#endregion
156
+ export { OpenGraphImageConfig, OpenGraphImageMetadata, OpenGraphMetadata, createPageMetadata, ogAltText, openGraphTags, twitterTags };
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @tsdoctor/seo
2
+
3
+ [![npm](https://img.shields.io/npm/v/@tsdoctor%2Fseo?label=npm&color=cb3837)](https://www.npmjs.com/package/@tsdoctor/seo)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ Framework-neutral `<head>` metadata for static TypeScript API documentation: schema.org JSON-LD, Open Graph and Twitter card vocabulary, canonical URLs, and package attribution derived from a `package.json` manifest. The package decides which tags a documentation page gets; a framework adapter only renders them into whatever its framework calls a head entry.
9
+
10
+ ## The one seam
11
+
12
+ `headTags(input)` returns a flat array of neutral `HeadTag` values — a canonical `<link>`, the Open Graph block, the Twitter card block, then a JSON-LD `<script>`. A `HeadTag` is deliberately dumb (`{ tag, attrs, body? }`), so RSPress renders one into a frontmatter `head` pair and VitePress renders the same value into a `transformHead` entry. Composition living here is what keeps two adapters from disagreeing about which tags a page gets.
13
+
14
+ ## What you get
15
+
16
+ - **`headTags(input: SeoPageInput)`** — every `<head>` tag for one page, in a fixed order so a diff over generated pages stays readable.
17
+ - **`HeadTag`, `meta`, `metaNamed`, `link`, `jsonLd`, `escapeScriptBody`** — the neutral tag vocabulary. `escapeScriptBody` is idempotent, so a body already escaped by an upstream serializer survives a second pass unchanged.
18
+ - **`deriveSiteUrl`, `canonicalUrl`, `resolveUrl`, `imageMimeType`** — URL derivation. With no configured origin the prefix is `""`, so URLs stay root-relative and the tags are still emitted rather than dropped.
19
+ - **`OpenGraphImageConfig`, `OpenGraphImageMetadata`, `OpenGraphMetadata`, `createPageMetadata`, `openGraphTags`, `twitterTags`, `ogAltText`** — the Open Graph and Twitter card vocabulary, as Effect Schemas plus the emitters over them.
20
+ - **`attributionFacts(manifest)`** — author, maintainers, repository URL, homepage, SPDX license ids and per-license canonical URLs, and keywords, derived from an `@effected/package-json` `PackageManifest`. Total and synchronous: a manifest carrying none of these yields empty arrays and no optional properties.
21
+ - **`packageContext`, `derive`, `deriveScriptBody`** — the schema.org graph. `packageContext` is derived once per package; `derive` assembles a page's `@graph` (a `SoftwareSourceCode`, a `TechArticle` and an `APIReference`, linked by `isPartOf` and `mainEntity`, plus a `Person` per credited human).
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install @tsdoctor/seo
27
+ # or
28
+ pnpm add @tsdoctor/seo
29
+ ```
30
+
31
+ This is an ESM-only package. `effect`, `@effected/package-json`, `@effected/schema-org` and `@effected/spdx` are peer dependencies. It is pure — no filesystem, no network, no native dependencies.
32
+
33
+ ## Quick start
34
+
35
+ ```ts
36
+ import { deriveSiteUrl, headTags } from "@tsdoctor/seo";
37
+
38
+ const siteUrl = deriveSiteUrl("https://docs.example.com", "/");
39
+ // "https://docs.example.com"
40
+
41
+ const tags = headTags({
42
+ siteUrl,
43
+ pageRoute: "/api/class/pipeline",
44
+ description: "Composes transformation steps.",
45
+ publishedTime: "2026-01-15T12:00:00.000Z",
46
+ modifiedTime: "2026-01-17T10:30:00.000Z",
47
+ section: "Classes",
48
+ packageName: "my-library",
49
+ });
50
+
51
+ console.log(tags[0]);
52
+ // { tag: "link", attrs: { rel: "canonical", href: "https://docs.example.com/api/class/pipeline" } }
53
+ ```
54
+
55
+ Structured data is a separate step because the package-level nodes are worth deriving once and reusing across every page of a package. `manifest` below is the documented package's `package.json` decoded through `@effected/package-json`'s `PackageManifest`, which is what gives attribution typed `Person`, `Repository` and SPDX license values to work from:
56
+
57
+ ```ts
58
+ import { attributionFacts, deriveScriptBody, packageContext } from "@tsdoctor/seo";
59
+
60
+ const pkg = packageContext({
61
+ siteUrl,
62
+ baseRoute: "/api",
63
+ packageName: "my-library",
64
+ version: manifest.version,
65
+ description: manifest.description,
66
+ attribution: attributionFacts(manifest),
67
+ });
68
+
69
+ const body = deriveScriptBody(pkg, {
70
+ pageRoute: "/api/class/pipeline",
71
+ symbolName: "Pipeline",
72
+ description: "Composes transformation steps.",
73
+ section: "Classes",
74
+ publishedTime: "2026-01-15T12:00:00.000Z",
75
+ modifiedTime: "2026-01-17T10:30:00.000Z",
76
+ });
77
+ // a Result: Success carries the serialized @graph, Failure a StructuredDataError
78
+ ```
79
+
80
+ Pass the serialized graph back as `headTags`'s `structuredData` and it becomes the page's JSON-LD `<script>`.
81
+
82
+ ## Two postures worth knowing
83
+
84
+ **Degrade, never fail.** Nothing here should be able to stop a documentation build. A graph that cannot be assembled fails with a typed `StructuredDataError` rather than throwing, so a caller reports it as a diagnostic and renders the page without that tag. Identity mistakes — a malformed `@id`, a duplicate, a colliding term — all land on that one error channel.
85
+
86
+ **Absent rather than guessed.** Attribution omits a field it cannot derive instead of inventing one, because every value ends up in markup a crawler reads as authoritative. License URLs come from the SPDX catalog's own per-entry reference URL, never from concatenating an id onto a URL prefix — a `LicenseRef` has no such page and drops out of the array rather than appearing as a fabricated link. `licenseIds` and `licenseUrls` are therefore not index-aligned.
87
+
88
+ ## Provenance
89
+
90
+ Added in phase 4 of the tsdoctor consolidation. It absorbed the Open Graph resolver and schema definitions that lived in `rspress-plugin-api-extractor`, and the schema.org vocabulary underneath it comes from `@effected/schema-org`, whose offline conformance validator gates this package's fixtures in CI.
91
+
92
+ ## License
93
+
94
+ [MIT](LICENSE)
package/Seo.js ADDED
@@ -0,0 +1,49 @@
1
+ import { canonicalUrl } from "./Canonical.js";
2
+ import { jsonLd, link } from "./HeadTag.js";
3
+ import { createPageMetadata, openGraphTags, twitterTags } from "./OpenGraph.js";
4
+
5
+ //#region src/Seo.ts
6
+ /**
7
+ * The one seam an adapter consumes: everything a documentation page needs in
8
+ * its `<head>`, as a flat array of neutral {@link HeadTag}s.
9
+ *
10
+ * @remarks
11
+ * Composition lives here so that every adapter emits the same tags in the same
12
+ * order. An adapter's job is to render a `HeadTag` into whatever its framework
13
+ * calls a head entry — never to decide which tags a page gets.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ /**
18
+ * Every `<head>` tag for one documentation page.
19
+ *
20
+ * @remarks
21
+ * The order — canonical link, Open Graph block, Twitter block, then the
22
+ * JSON-LD script — is fixed. It carries no semantics: a crawler reads the
23
+ * tags as a set. It is fixed so that a page's emitted head is stable
24
+ * build-to-build and a diff over generated pages stays readable.
25
+ *
26
+ * @public
27
+ */
28
+ function headTags(input) {
29
+ const metadata = createPageMetadata({
30
+ siteUrl: input.siteUrl,
31
+ pageRoute: input.pageRoute,
32
+ description: input.description,
33
+ publishedTime: input.publishedTime,
34
+ modifiedTime: input.modifiedTime,
35
+ section: input.section,
36
+ packageName: input.packageName,
37
+ ...input.ogImage != null ? { ogImage: input.ogImage } : {}
38
+ });
39
+ const tags = [
40
+ link("canonical", canonicalUrl(input.siteUrl, input.pageRoute)),
41
+ ...openGraphTags(metadata),
42
+ ...twitterTags(metadata, input.twitterSite)
43
+ ];
44
+ if (input.structuredData != null && input.structuredData !== "") tags.push(jsonLd(input.structuredData));
45
+ return tags;
46
+ }
47
+
48
+ //#endregion
49
+ export { headTags };