@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.
@@ -0,0 +1,169 @@
1
+ import { canonicalUrl } from "./Canonical.js";
2
+ import { Result } from "effect";
3
+ import { APIReference, JsonLdDocument, NodeRef, Person, SoftwareSourceCode, TechArticle } from "@effected/schema-org";
4
+
5
+ //#region src/StructuredData.ts
6
+ /**
7
+ * The package node's `@id`.
8
+ *
9
+ * @remarks
10
+ * A fragment on the package's own route rather than a bare URL, so the node
11
+ * is distinguishable from the page that happens to sit at that route. Every
12
+ * page in the API references this same id, which is what makes the package
13
+ * node deduplicate across a crawl.
14
+ */
15
+ const packageId = (input) => `${canonicalUrl(input.siteUrl, input.baseRoute)}#source`;
16
+ /** An author or maintainer's `@id`, scoped to the package that credits them. */
17
+ const personId = (input, name) => `${canonicalUrl(input.siteUrl, input.baseRoute)}#person-${encodeURIComponent(name)}`;
18
+ /**
19
+ * The people nodes a package credits, and refs to them.
20
+ *
21
+ * @remarks
22
+ * The author is a `Person` when the manifest named a human and an
23
+ * `Organization` when the name reads as a scope — but npm carries no such
24
+ * distinction, so guessing would be fabrication. Everyone is a `Person`, which
25
+ * is what the manifest field is documented to hold. An organization ends up
26
+ * modelled as a person with an organization's name, which is imprecise rather
27
+ * than wrong; inventing a type from a string's shape would be neither.
28
+ */
29
+ function peopleNodes(input) {
30
+ const nodes = [];
31
+ const authors = [];
32
+ const seen = /* @__PURE__ */ new Set();
33
+ const add = (name, url) => {
34
+ const id = personId(input, name);
35
+ if (seen.has(id)) return;
36
+ seen.add(id);
37
+ nodes.push(Person.make({
38
+ "@id": id,
39
+ name,
40
+ ...url !== void 0 ? { url } : {}
41
+ }));
42
+ authors.push(NodeRef.to(id));
43
+ };
44
+ if (input.attribution.authorName !== void 0) add(input.attribution.authorName, input.attribution.authorUrl);
45
+ for (const maintainer of input.attribution.maintainerNames) add(maintainer);
46
+ return {
47
+ nodes,
48
+ authors
49
+ };
50
+ }
51
+ /**
52
+ * The `SoftwareSourceCode` node for a documented package, plus the people it
53
+ * credits.
54
+ *
55
+ * @remarks
56
+ * Note `version`, not `softwareVersion` — the latter reads like the right name
57
+ * and is defined on `SoftwareApplication`, not here. It would serialize fine
58
+ * and be silently ignored; the conformance validator is what catches it.
59
+ *
60
+ * `license` carries the canonical SPDX page for EVERY license the expression
61
+ * names — schema.org's `license` accepts an array, and an `AND` expression has
62
+ * no single answer to give. The URLs come from each catalog entry's own
63
+ * `referenceUrl`, never from concatenating an id onto
64
+ * `https://spdx.org/licenses/`: that is the string-building the catalog exists
65
+ * to prevent, and it is wrong for a `LicenseRef`, which has no such page. A
66
+ * license outside the catalog drops out of the array rather than appearing as
67
+ * a fabricated URL.
68
+ *
69
+ * @param input - the per-API facts
70
+ * @returns the reusable context every page in the API derives against
71
+ *
72
+ * @public
73
+ */
74
+ function packageContext(input) {
75
+ const { nodes: people, authors } = peopleNodes(input);
76
+ const facts = input.attribution;
77
+ const pkg = SoftwareSourceCode.make({
78
+ "@id": packageId(input),
79
+ name: input.packageName,
80
+ url: canonicalUrl(input.siteUrl, input.baseRoute),
81
+ ...input.description !== void 0 ? { description: input.description } : {},
82
+ ...input.version !== void 0 ? { version: input.version } : {},
83
+ ...facts.repositoryUrl !== void 0 ? { codeRepository: facts.repositoryUrl } : {},
84
+ programmingLanguage: ["TypeScript"],
85
+ ...facts.licenseUrls.length > 0 ? { license: [...facts.licenseUrls] } : {},
86
+ ...authors.length > 0 ? { author: [...authors] } : {},
87
+ ...facts.keywords.length > 0 ? { keywords: [...facts.keywords] } : {},
88
+ ...facts.homepage !== void 0 ? { sameAs: [facts.homepage] } : {}
89
+ });
90
+ return {
91
+ id: packageId(input),
92
+ nodes: [pkg, ...people],
93
+ siteUrl: input.siteUrl,
94
+ ...input.version !== void 0 ? { version: input.version } : {}
95
+ };
96
+ }
97
+ /**
98
+ * Derive the schema.org graph for one documentation page.
99
+ *
100
+ * @remarks
101
+ * Three linked nodes plus the package's people: a `SoftwareSourceCode` for the
102
+ * package, a `TechArticle` for the page, and an `APIReference` for the symbol
103
+ * the page documents. The article `isPartOf` the package and its `mainEntity`
104
+ * is the symbol, so a crawler reading any one node can reach the other two.
105
+ *
106
+ * Serialize the result with `JsonLdDocument.toScriptBody()`, never with
107
+ * `JSON.stringify(graph.toJsonLd())` — `toScriptBody` is the only serializer
108
+ * that escapes the sequences that would close the surrounding `<script>`
109
+ * element, and it is idempotent, so an adapter layering its own escaping over
110
+ * it is a no-op rather than a double-escape.
111
+ *
112
+ * @param pkg - the per-API context from {@link packageContext}
113
+ * @param page - the per-page facts
114
+ * @returns the assembled graph, or the identity failure that stopped it
115
+ *
116
+ * @public
117
+ */
118
+ function derive(pkg, page) {
119
+ const pageUrl = canonicalUrl(pkg.siteUrl, page.pageRoute);
120
+ const articleId = `${pageUrl}#article`;
121
+ const symbolId = `${pageUrl}#symbol`;
122
+ const symbol = APIReference.make({
123
+ "@id": symbolId,
124
+ name: page.symbolName,
125
+ url: pageUrl,
126
+ description: page.description,
127
+ isPartOf: [NodeRef.to(pkg.id)],
128
+ ...pkg.version !== void 0 ? { assemblyVersion: pkg.version } : {},
129
+ programmingModel: "TypeScript"
130
+ });
131
+ const article = TechArticle.make({
132
+ "@id": articleId,
133
+ name: page.symbolName,
134
+ headline: page.symbolName,
135
+ url: pageUrl,
136
+ description: page.description,
137
+ articleSection: [page.section],
138
+ datePublished: page.publishedTime,
139
+ dateModified: page.modifiedTime,
140
+ isPartOf: [NodeRef.to(pkg.id)],
141
+ mainEntity: NodeRef.to(symbolId),
142
+ inLanguage: "en"
143
+ });
144
+ return JsonLdDocument.buildResult([
145
+ ...pkg.nodes,
146
+ article,
147
+ symbol
148
+ ]);
149
+ }
150
+ /**
151
+ * {@link derive}, serialized to the text an adapter embeds in a `<script>`.
152
+ *
153
+ * @remarks
154
+ * The convenience the adapter actually wants: a page's structured data as a
155
+ * string. Callers that need the graph itself — a conformance check in a test,
156
+ * say — use {@link derive}.
157
+ *
158
+ * @param pkg - the per-API context from {@link packageContext}
159
+ * @param page - the per-page facts
160
+ * @returns the script body, or the identity failure
161
+ *
162
+ * @public
163
+ */
164
+ function deriveScriptBody(pkg, page) {
165
+ return Result.map(derive(pkg, page), (graph) => graph.toScriptBody());
166
+ }
167
+
168
+ //#endregion
169
+ export { derive, deriveScriptBody, packageContext };