@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 +99 -0
- package/Canonical.js +99 -0
- package/HeadTag.js +69 -0
- package/LICENSE +21 -0
- package/OpenGraph.js +156 -0
- package/README.md +94 -0
- package/Seo.js +49 -0
- package/StructuredData.js +169 -0
- package/index.d.ts +543 -0
- package/index.js +8 -0
- package/package.json +48 -0
- package/tsdoc-metadata.json +11 -0
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
|
+
[](https://www.npmjs.com/package/@tsdoctor/seo)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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 };
|