rspress-plugin-api-extractor 0.12.0 → 0.13.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/README.md CHANGED
@@ -54,6 +54,7 @@ The plugin reads your `.api.json` model and writes one MDX page per public API i
54
54
  - Drives single-package sites, multi-package portals, RSPress multiVersion and i18n from one plugin.
55
55
  - Handles multi-entry-point packages: it deduplicates re-exports and notes which entry points each item is available from.
56
56
  - Writes per-package `llms*.txt` files and in-page actions for pointing an assistant at one package's docs.
57
+ - Gives every generated page complete `<head>` metadata: a canonical link, Open Graph and Twitter card tags, and a schema.org JSON-LD graph describing the package, the page and the documented symbol.
57
58
 
58
59
  ## Documentation
59
60
 
package/build-program.js CHANGED
@@ -10,6 +10,7 @@ import { ShikiCrossLinker } from "./shiki-transformer.js";
10
10
  import { VfsRegistry } from "./vfs-registry.js";
11
11
  import path from "node:path";
12
12
  import { Effect, FileSystem } from "effect";
13
+ import { attributionFacts, packageContext } from "@tsdoctor/seo";
13
14
  import { SnapshotService } from "@tsdoctor/snapshot";
14
15
 
15
16
  //#region src/build-program.ts
@@ -35,8 +36,16 @@ function generateApiDocs(apiConfig, fileContextMap) {
35
36
  const pageConcurrency = yield* PageConcurrency;
36
37
  const suppressExampleErrors = yield* SuppressExampleErrors;
37
38
  const snapshotSvc = yield* SnapshotService;
38
- const { apiPackage, packageName, apiName, outputDir, baseRoute, categories, source, packageJson, llmsPlugin, siteUrl, ogImage } = apiConfig;
39
+ const { apiPackage, packageName, apiName, outputDir, baseRoute, categories, source, packageJson, llmsPlugin, siteUrl, ogImage, manifest } = apiConfig;
39
40
  const phaseCtx = { packageName };
41
+ const structuredDataPkg = manifest != null && siteUrl != null ? packageContext({
42
+ siteUrl,
43
+ baseRoute,
44
+ packageName,
45
+ ...manifest.version != null ? { version: manifest.version.toString() } : {},
46
+ ...manifest.description != null ? { description: manifest.description } : {},
47
+ attribution: attributionFacts(manifest)
48
+ }) : void 0;
40
49
  const resolvedOutputDir = path.resolve(process.cwd(), outputDir);
41
50
  const buildTime = (/* @__PURE__ */ new Date()).toISOString();
42
51
  const allSnapshots = yield* snapshotSvc.getAllForDirectory(resolvedOutputDir).pipe(Effect.orDie);
@@ -81,7 +90,8 @@ function generateApiDocs(apiConfig, fileContextMap) {
81
90
  ...llmsPlugin != null ? { llmsPlugin } : {},
82
91
  ...apiConfig.docsRoot != null ? { docsRoot: apiConfig.docsRoot } : {},
83
92
  ...siteUrl != null ? { siteUrl } : {},
84
- ...ogImage != null ? { ogImage } : {}
93
+ ...ogImage != null ? { ogImage } : {},
94
+ ...structuredDataPkg != null ? { structuredDataPkg } : {}
85
95
  }));
86
96
  const changedCount = fileResults.filter((r) => r.status !== "unchanged").length;
87
97
  yield* Effect.logDebug(`Generated ${changedCount} pages`);
package/build-stages.js CHANGED
@@ -12,10 +12,10 @@ import { InterfacePageGenerator } from "./markdown/page-generators/interface-pag
12
12
  import { NamespacePageGenerator } from "./markdown/page-generators/namespace-page.js";
13
13
  import { TypeAliasPageGenerator } from "./markdown/page-generators/type-alias-page.js";
14
14
  import { VariablePageGenerator } from "./markdown/page-generators/variable-page.js";
15
- import { createPageMetadata } from "./og-resolver.js";
16
15
  import { OgService } from "./services/OgService.js";
17
16
  import path from "node:path";
18
17
  import { Effect, FileSystem, Metric, Option, Stream } from "effect";
18
+ import { deriveScriptBody, headTags } from "@tsdoctor/seo";
19
19
  import { SnapshotService, hashContent, hashFrontmatter } from "@tsdoctor/snapshot";
20
20
  import { ApiItemKind } from "@microsoft/api-extractor-model";
21
21
  import { ApiItems, EntryPoints, Routes, SyntheticBases } from "@tsdoctor/model";
@@ -328,7 +328,79 @@ function generateSinglePage(workItem, ctx) {
328
328
  const frontmatterData = parsed.data;
329
329
  const relativePathWithExt = `${page.routePath.replace(baseRoute, "").replace(/^\//, "")}.mdx`;
330
330
  const contentHash = hashContent(bodyContent);
331
- const frontmatterHash = hashFrontmatter(frontmatterData);
331
+ const description = frontmatterData.description;
332
+ const seoEnabled = ctx.siteUrl != null && packageName !== "";
333
+ let ogImageMetadata;
334
+ let structuredData;
335
+ if (seoEnabled) {
336
+ const siteUrl = ctx.siteUrl;
337
+ const ogSvc = yield* OgService;
338
+ const ogImageResult = yield* Effect.result(ogSvc.resolveImage({
339
+ config: ctx.ogImage,
340
+ siteUrl,
341
+ docsRoot: ctx.docsRoot,
342
+ packageName,
343
+ ...apiName != null ? { apiName } : {}
344
+ }));
345
+ if (ogImageResult._tag === "Failure") {
346
+ const failure = ogImageResult.failure;
347
+ yield* emit(PluginEvent.ConfigValidationWarning({
348
+ ctx: {
349
+ buildId,
350
+ packageName
351
+ },
352
+ field: failure.field,
353
+ value: failure.value,
354
+ reason: failure.message,
355
+ level: "warn"
356
+ }));
357
+ } else if (Option.isSome(ogImageResult.success)) ogImageMetadata = ogImageResult.success.value;
358
+ if (ctx.structuredDataPkg != null) {
359
+ const graphResult = deriveScriptBody(ctx.structuredDataPkg, {
360
+ pageRoute: page.routePath,
361
+ symbolName: item.displayName,
362
+ description,
363
+ section: categoryConfig.displayName,
364
+ publishedTime: buildTime,
365
+ modifiedTime: buildTime
366
+ });
367
+ if (graphResult._tag === "Failure") yield* emit(PluginEvent.ConfigValidationWarning({
368
+ ctx: {
369
+ buildId,
370
+ packageName,
371
+ route: page.routePath
372
+ },
373
+ field: "structuredData",
374
+ value: ctx.structuredDataPkg.id,
375
+ reason: `schema.org document assembly failed: ${graphResult.failure._tag}`,
376
+ level: "warn"
377
+ }));
378
+ else structuredData = graphResult.success;
379
+ }
380
+ }
381
+ /**
382
+ * The final frontmatter for a given pair of timestamps.
383
+ *
384
+ * @remarks
385
+ * Called twice: once with the build time to compute the hash, and once
386
+ * with the resolved timestamps to write. That is sound only because
387
+ * `hashFrontmatter` strips every timestamp it can reach — the meta-pair
388
+ * form and the JSON-LD `datePublished`/`dateModified` keys alike — so the
389
+ * two calls hash identically. Without that stripping the hash would
390
+ * depend on the timestamps the hash itself decides.
391
+ */
392
+ const finalFrontmatter = (published, modified) => seoEnabled ? generateFrontmatter(item.displayName, description, categoryConfig.singularName, apiName, headTags({
393
+ siteUrl: ctx.siteUrl,
394
+ pageRoute: page.routePath,
395
+ description,
396
+ publishedTime: published,
397
+ modifiedTime: modified,
398
+ section: categoryConfig.displayName,
399
+ packageName,
400
+ ...ogImageMetadata != null ? { ogImage: ogImageMetadata } : {},
401
+ ...structuredData != null ? { structuredData } : {}
402
+ })) : stringifyFrontmatter("", frontmatterData);
403
+ const frontmatterHash = hashFrontmatter(parseFrontmatter(finalFrontmatter(buildTime, buildTime)).data);
332
404
  let publishedTime;
333
405
  let modifiedTime;
334
406
  let isUnchanged = false;
@@ -368,7 +440,7 @@ function generateSinglePage(workItem, ctx) {
368
440
  }
369
441
  return {
370
442
  workItem,
371
- content: page.content,
443
+ content: seoEnabled ? finalFrontmatter(publishedTime, modifiedTime) + bodyContent : page.content,
372
444
  bodyContent,
373
445
  frontmatter: frontmatterData,
374
446
  contentHash,
@@ -387,9 +459,9 @@ function generateSinglePage(workItem, ctx) {
387
459
  function writeSingleFile(result, ctx) {
388
460
  return Effect.gen(function* () {
389
461
  const fileSystem = yield* FileSystem.FileSystem;
390
- const { buildId, resolvedOutputDir, buildTime, siteUrl, docsRoot, ogImage, packageName, apiName } = ctx;
391
- const { workItem, bodyContent, frontmatter, contentHash, frontmatterHash, publishedTime, modifiedTime, isUnchanged, routePath, relativePathWithExt } = result;
392
- const { item, categoryKey, categoryConfig, namespaceMember } = workItem;
462
+ const { buildId, resolvedOutputDir, buildTime, packageName } = ctx;
463
+ const { workItem, contentHash, frontmatterHash, publishedTime, modifiedTime, isUnchanged, routePath, relativePathWithExt } = result;
464
+ const { item, categoryKey, namespaceMember } = workItem;
393
465
  const absolutePath = path.join(resolvedOutputDir, relativePathWithExt);
394
466
  const label = namespaceMember ? namespaceMember.qualifiedName : item.displayName;
395
467
  const snapshot = {
@@ -424,43 +496,7 @@ function writeSingleFile(result, ctx) {
424
496
  routePath
425
497
  };
426
498
  }
427
- let finalContent = stringifyFrontmatter(bodyContent, frontmatter);
428
- if (siteUrl != null && packageName) {
429
- const ogSvc = yield* OgService;
430
- const ogImageResult = yield* Effect.result(ogSvc.resolveImage({
431
- config: ogImage,
432
- siteUrl,
433
- docsRoot,
434
- packageName,
435
- ...apiName != null ? { apiName } : {}
436
- }));
437
- if (ogImageResult._tag === "Failure") {
438
- const failure = ogImageResult.failure;
439
- yield* emit(PluginEvent.ConfigValidationWarning({
440
- ctx: {
441
- buildId,
442
- packageName
443
- },
444
- field: failure.field,
445
- value: failure.value,
446
- reason: failure.message,
447
- level: "warn"
448
- }));
449
- }
450
- const ogImageMetadata = ogImageResult._tag === "Success" && Option.isSome(ogImageResult.success) ? ogImageResult.success.value : void 0;
451
- const ogMetadataOptions = {
452
- siteUrl,
453
- pageRoute: routePath,
454
- description: frontmatter.description,
455
- publishedTime,
456
- modifiedTime,
457
- section: categoryConfig.displayName,
458
- packageName
459
- };
460
- if (ogImageMetadata) ogMetadataOptions.ogImage = ogImageMetadata;
461
- const ogMetadata = createPageMetadata(ogMetadataOptions);
462
- finalContent = generateFrontmatter(item.displayName, frontmatter.description, categoryConfig.singularName, apiName, ogMetadata) + bodyContent;
463
- }
499
+ const finalContent = result.content;
464
500
  const fileExisted = yield* fileSystem.exists(absolutePath).pipe(Effect.orElseSucceed(() => false));
465
501
  const dirPath = path.dirname(absolutePath);
466
502
  yield* fileSystem.makeDirectory(dirPath, { recursive: true }).pipe(Effect.orDie);
@@ -793,7 +829,11 @@ function buildPipelineForApi(input) {
793
829
  buildTime: input.buildTime,
794
830
  resolvedOutputDir: input.resolvedOutputDir,
795
831
  ...input.suppressExampleErrors != null ? { suppressExampleErrors: input.suppressExampleErrors } : {},
796
- ...input.llmsPlugin != null ? { llmsPlugin: input.llmsPlugin } : {}
832
+ ...input.llmsPlugin != null ? { llmsPlugin: input.llmsPlugin } : {},
833
+ ...input.docsRoot !== void 0 ? { docsRoot: input.docsRoot } : {},
834
+ ...input.siteUrl != null ? { siteUrl: input.siteUrl } : {},
835
+ ...input.ogImage != null ? { ogImage: input.ogImage } : {},
836
+ ...input.structuredDataPkg != null ? { structuredDataPkg: input.structuredDataPkg } : {}
797
837
  };
798
838
  const writeCtx = {
799
839
  buildId: input.buildId,
@@ -803,7 +843,8 @@ function buildPipelineForApi(input) {
803
843
  ...input.siteUrl != null ? { siteUrl: input.siteUrl } : {},
804
844
  ...input.ogImage != null ? { ogImage: input.ogImage } : {},
805
845
  ...input.packageName != null ? { packageName: input.packageName } : {},
806
- ...input.apiName != null ? { apiName: input.apiName } : {}
846
+ ...input.apiName != null ? { apiName: input.apiName } : {},
847
+ ...input.structuredDataPkg != null ? { structuredDataPkg: input.structuredDataPkg } : {}
807
848
  };
808
849
  return Stream.fromIterable(input.workItems).pipe(Stream.mapEffect((workItem) => generateSinglePage(workItem, generateCtx), { concurrency: input.pageConcurrency }), Stream.filter((result) => result !== null), Stream.mapEffect((result) => writeSingleFile(result, writeCtx), { concurrency: input.pageConcurrency }), Stream.runFold(() => [], (acc, result) => [...acc, result]));
809
850
  }
package/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { OpenGraphImageConfig, OpenGraphImageMetadata, OpenGraphMetadata } from "@tsdoctor/seo";
1
2
  import { ApiItemKind, ApiModel } from "@microsoft/api-extractor-model";
2
3
  import { Schema } from "effect";
3
4
  import "typescript";
@@ -854,90 +855,6 @@ declare const ApiExtractorPlugin: typeof ApiExtractorPluginImpl & {
854
855
  };
855
856
  };
856
857
  //#endregion
857
- //#region src/schemas/opengraph.d.ts
858
- /**
859
- * Structured Open Graph image metadata (alternative to a plain URL string).
860
- *
861
- * @public
862
- */
863
- declare const OpenGraphImageMetadata: Schema.Struct<{
864
- /** Absolute URL of the image. */
865
- readonly url: Schema.String;
866
- /** HTTPS URL of the image (for secure contexts). */
867
- readonly secureUrl: Schema.optional<Schema.String>;
868
- /** MIME type of the image (e.g. `"image/png"`). */
869
- readonly type: Schema.optional<Schema.String>;
870
- /** Image width in pixels. */
871
- readonly width: Schema.optional<Schema.Number>;
872
- /** Image height in pixels. */
873
- readonly height: Schema.optional<Schema.Number>;
874
- /** Alt text for the image. */
875
- readonly alt: Schema.optional<Schema.String>;
876
- }>;
877
- /** @public */
878
- type OpenGraphImageMetadata = typeof OpenGraphImageMetadata.Type;
879
- /**
880
- * Open Graph image: either a plain URL string or structured `OpenGraphImageMetadata`.
881
- *
882
- * @public
883
- */
884
- declare const OpenGraphImageConfig: Schema.Union<readonly [Schema.String, Schema.Struct<{
885
- /** Absolute URL of the image. */
886
- readonly url: Schema.String;
887
- /** HTTPS URL of the image (for secure contexts). */
888
- readonly secureUrl: Schema.optional<Schema.String>;
889
- /** MIME type of the image (e.g. `"image/png"`). */
890
- readonly type: Schema.optional<Schema.String>;
891
- /** Image width in pixels. */
892
- readonly width: Schema.optional<Schema.Number>;
893
- /** Image height in pixels. */
894
- readonly height: Schema.optional<Schema.Number>;
895
- /** Alt text for the image. */
896
- readonly alt: Schema.optional<Schema.String>;
897
- }>]>;
898
- /** @public */
899
- type OpenGraphImageConfig = typeof OpenGraphImageConfig.Type;
900
- /**
901
- * Resolved Open Graph metadata emitted into each generated page's frontmatter.
902
- *
903
- * @public
904
- */
905
- declare const OpenGraphMetadata: Schema.Struct<{
906
- /** Canonical site base URL. */
907
- readonly siteUrl: Schema.String;
908
- /** Page route path (e.g. `/api/classes/myclass`). */
909
- readonly pageRoute: Schema.String;
910
- /** Page description for the `og:description` tag. */
911
- readonly description: Schema.String;
912
- /** ISO 8601 date string for `article:published_time`. */
913
- readonly publishedTime: Schema.String;
914
- /** ISO 8601 date string for `article:modified_time`. */
915
- readonly modifiedTime: Schema.String;
916
- /** Article section label (e.g. `"API"`). */
917
- readonly section: Schema.String;
918
- /** Article tag keywords. */
919
- readonly tags: Schema.mutable<Schema.$Array<Schema.String>>;
920
- /** Optional structured image metadata. */
921
- readonly ogImage: Schema.optional<Schema.Struct<{
922
- /** Absolute URL of the image. */
923
- readonly url: Schema.String;
924
- /** HTTPS URL of the image (for secure contexts). */
925
- readonly secureUrl: Schema.optional<Schema.String>;
926
- /** MIME type of the image (e.g. `"image/png"`). */
927
- readonly type: Schema.optional<Schema.String>;
928
- /** Image width in pixels. */
929
- readonly width: Schema.optional<Schema.Number>;
930
- /** Image height in pixels. */
931
- readonly height: Schema.optional<Schema.Number>;
932
- /** Alt text for the image. */
933
- readonly alt: Schema.optional<Schema.String>;
934
- }>>;
935
- /** Open Graph object type (e.g. `"article"`). */
936
- readonly ogType: Schema.String;
937
- }>;
938
- /** @public */
939
- type OpenGraphMetadata = typeof OpenGraphMetadata.Type;
940
- //#endregion
941
858
  //#region src/serve.d.ts
942
859
  /**
943
860
  * Which RSPress server {@link serve} runs.
@@ -3,7 +3,6 @@ import { BuildMetrics } from "./build-metrics.js";
3
3
  import { PluginEvent } from "../observability/events.js";
4
4
  import { emit, wantsLevel } from "../observability/EventBus.js";
5
5
  import { TypeReferenceExtractor } from "../type-reference-extractor.js";
6
- import { deriveSiteUrl } from "../og-resolver.js";
7
6
  import { withPhase } from "../observability/spans.js";
8
7
  import { normalizeThemeConfig } from "../markdown/shiki-utils.js";
9
8
  import { apiScopeOf, deriveOutputPaths, normalizeBaseRoute, unscopedName } from "../path-derivation.js";
@@ -20,7 +19,9 @@ import { mergeExternalTypes } from "./external-types.js";
20
19
  import { registerTypeEnvironments, resolveTsConfigTyped } from "./type-environment.js";
21
20
  import path from "node:path";
22
21
  import { Effect, Metric } from "effect";
22
+ import { deriveSiteUrl } from "@tsdoctor/seo";
23
23
  import { hashContent } from "@tsdoctor/snapshot";
24
+ import { PackageManifest } from "@effected/package-json";
24
25
 
25
26
  //#region src/layers/config-resolution.ts
26
27
  /**
@@ -61,6 +62,40 @@ function prependImportsToVfs(vfs, apiPackage, packageName, wantTrace) {
61
62
  return payloads;
62
63
  }
63
64
  /**
65
+ * Decode a loaded package.json into a typed {@link PackageManifest}, degrading
66
+ * to `undefined` when it does not satisfy the codec.
67
+ *
68
+ * @remarks
69
+ * `PackageManifest` is presence-lenient but shape-strict — the private
70
+ * workspace-root shape decodes fine, but one malformed field (a `version` of
71
+ * `"1.0"`, an `author` that is neither a string nor an object) fails the whole
72
+ * decode. That is the right strictness for the SEO layer, which needs real
73
+ * `Person` / `Repository` values rather than the discovery tier's raw unions,
74
+ * but it must never fail a docs build: the failure is surfaced as a
75
+ * `ConfigValidationWarning` (which reaches `issues.json`) and the manifest is
76
+ * simply absent. The same posture as the OG image path in `build-stages.ts`.
77
+ */
78
+ function decodeManifest(packageJson, buildId, packageName) {
79
+ return Effect.gen(function* () {
80
+ if (packageJson == null) return void 0;
81
+ const decoded = yield* Effect.result(PackageManifest.decode(packageJson));
82
+ if (decoded._tag === "Failure") {
83
+ yield* emit(PluginEvent.ConfigValidationWarning({
84
+ ctx: {
85
+ buildId,
86
+ packageName
87
+ },
88
+ field: "packageJson",
89
+ value: packageName,
90
+ reason: decoded.failure.message,
91
+ level: "warn"
92
+ }));
93
+ return;
94
+ }
95
+ return decoded.success;
96
+ });
97
+ }
98
+ /**
64
99
  * Validate plugin options and return an Effect that fails with ConfigValidationError.
65
100
  */
66
101
  function validateOptions(options, rspressConfig) {
@@ -195,6 +230,7 @@ const makeConfigService = Effect.gen(function* () {
195
230
  cause
196
231
  })
197
232
  }) : void 0;
233
+ const manifest = yield* decodeManifest(packageJson, buildId, api.packageName);
198
234
  yield* Effect.try({
199
235
  try: () => validateExternalPackages(api.externalPackages, packageJson),
200
236
  catch: (cause) => new ConfigValidationError({
@@ -222,6 +258,7 @@ const makeConfigService = Effect.gen(function* () {
222
258
  categories: resolvedCategories,
223
259
  ...resolvedSource != null ? { source: resolvedSource } : {},
224
260
  ...packageJson != null ? { packageJson } : {},
261
+ ...manifest != null ? { manifest } : {},
225
262
  ...resolvedLlms != null ? { llmsPlugin: resolvedLlms } : {},
226
263
  ...siteUrl != null ? { siteUrl } : {},
227
264
  ...resolvedOgImage != null ? { ogImage: resolvedOgImage } : {},
@@ -272,6 +309,7 @@ const makeConfigService = Effect.gen(function* () {
272
309
  cause
273
310
  })
274
311
  }) : void 0);
312
+ const manifest = yield* decodeManifest(packageJson, buildId, api.packageName);
275
313
  yield* Effect.try({
276
314
  try: () => validateExternalPackages(versionExternalPackages || api.externalPackages, packageJson),
277
315
  catch: (cause) => new ConfigValidationError({
@@ -302,6 +340,7 @@ const makeConfigService = Effect.gen(function* () {
302
340
  categories: resolvedCategories,
303
341
  ...resolvedSource != null ? { source: resolvedSource } : {},
304
342
  ...packageJson != null ? { packageJson } : {},
343
+ ...manifest != null ? { manifest } : {},
305
344
  ...resolvedLlms != null ? { llmsPlugin: resolvedLlms } : {},
306
345
  ...siteUrl != null ? { siteUrl } : {},
307
346
  ...resolvedOgImage != null ? { ogImage: resolvedOgImage } : {},
@@ -5,15 +5,6 @@ import { TypeReferenceExtractor } from "../type-reference-extractor.js";
5
5
 
6
6
  //#region src/markdown/helpers.ts
7
7
  /**
8
- * Helper utilities for generating markdown API documentation.
9
- *
10
- * This module provides shared utility functions used by the page generators
11
- * for common tasks like preparing Twoslash examples, generating frontmatter,
12
- * escaping special characters, and sanitizing IDs.
13
- *
14
- * @packageDocumentation
15
- */
16
- /**
17
8
  * Generate an "Available from" line for items exported from multiple entry points.
18
9
  * Returns empty string if only one entry point or none provided.
19
10
  */
@@ -118,7 +109,7 @@ function buildPageTitle(entityName, singularName, apiName) {
118
109
  * @param description - Page description for SEO
119
110
  * @param singularName - The category singular name (e.g., "Class")
120
111
  * @param apiName - Optional API/package display name
121
- * @param ogMetadata - Optional Open Graph metadata for social sharing
112
+ * @param tags - Optional neutral head tags to render into the `head` array
122
113
  * @returns YAML frontmatter string
123
114
  *
124
115
  * @example
@@ -136,30 +127,14 @@ function buildPageTitle(entityName, singularName, apiName) {
136
127
  * // ---
137
128
  * ```
138
129
  */
139
- function generateFrontmatter(entityName, description, singularName, apiName, ogMetadata) {
130
+ function generateFrontmatter(entityName, description, singularName, apiName, tags) {
140
131
  const title = buildPageTitle(entityName, singularName, apiName);
141
- const meta = (property, content) => ["meta", {
142
- property,
143
- content: cleanYamlValue(content)
144
- }];
145
- const headEntries = [];
146
- if (ogMetadata) {
147
- headEntries.push(meta("og:url", `${ogMetadata.siteUrl}${ogMetadata.pageRoute}`));
148
- headEntries.push(meta("og:type", ogMetadata.ogType));
149
- headEntries.push(meta("og:description", ogMetadata.description));
150
- if (ogMetadata.ogImage) {
151
- headEntries.push(meta("og:image", ogMetadata.ogImage.url));
152
- if (ogMetadata.ogImage.secureUrl) headEntries.push(meta("og:image:secure_url", ogMetadata.ogImage.secureUrl));
153
- if (ogMetadata.ogImage.type) headEntries.push(meta("og:image:type", ogMetadata.ogImage.type));
154
- if (ogMetadata.ogImage.width) headEntries.push(meta("og:image:width", String(ogMetadata.ogImage.width)));
155
- if (ogMetadata.ogImage.height) headEntries.push(meta("og:image:height", String(ogMetadata.ogImage.height)));
156
- if (ogMetadata.ogImage.alt) headEntries.push(meta("og:image:alt", ogMetadata.ogImage.alt));
157
- }
158
- headEntries.push(meta("article:published_time", ogMetadata.publishedTime));
159
- headEntries.push(meta("article:modified_time", ogMetadata.modifiedTime));
160
- headEntries.push(meta("article:section", ogMetadata.section));
161
- for (const tag of ogMetadata.tags) headEntries.push(meta("article:tag", tag));
162
- }
132
+ const headEntries = (tags ?? []).map((tag) => {
133
+ const attrs = {};
134
+ for (const [key, value] of Object.entries(tag.attrs)) attrs[key] = cleanYamlValue(value);
135
+ if (tag.body != null) attrs.children = cleanYamlValue(tag.body);
136
+ return [tag.tag, attrs];
137
+ });
163
138
  const data = {
164
139
  title: cleanYamlValue(title),
165
140
  description: cleanYamlValue(description)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rspress-plugin-api-extractor",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "description": "RSPress plugin for generating API documentation from TypeScript API Extractor models",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "@effected/jsonc": "^0.8.0",
44
44
  "@effected/markdown": "^0.7.0",
45
45
  "@effected/npm": "^0.12.1",
46
- "@effected/package-json": "^0.12.0",
46
+ "@effected/package-json": "^0.13.0",
47
47
  "@effected/semver": "^0.5.0",
48
48
  "@effected/store": "^0.5.0",
49
49
  "@effected/tsconfig-json": "^0.6.1",
@@ -53,9 +53,10 @@
53
53
  "@microsoft/api-extractor-model": "^7.33.11",
54
54
  "@shikijs/twoslash": "^4.4.3",
55
55
  "@tsdoctor/bundle": "0.2.0",
56
- "@tsdoctor/model": "0.3.0",
56
+ "@tsdoctor/model": "0.4.0",
57
57
  "@tsdoctor/registry": "0.2.1",
58
- "@tsdoctor/snapshot": "0.2.0",
58
+ "@tsdoctor/seo": "0.1.0",
59
+ "@tsdoctor/snapshot": "0.2.1",
59
60
  "@typescript/vfs": "^1.6.4",
60
61
  "clsx": "^2.1.1",
61
62
  "effect": "4.0.0-rc.109",
package/schemas/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { PerformanceConfig } from "./performance.js";
2
2
  import { ObservabilityConfig } from "./observability.js";
3
- import { OpenGraphImageConfig } from "./opengraph.js";
4
3
  import { Effect, Schema } from "effect";
4
+ import { OpenGraphImageConfig } from "@tsdoctor/seo";
5
5
  import { ApiItemKind } from "@microsoft/api-extractor-model";
6
6
 
7
7
  //#region src/schemas/config.ts
@@ -1,25 +1,10 @@
1
1
  import { PluginEvent } from "../observability/events.js";
2
2
  import { emit } from "../observability/EventBus.js";
3
- import { imageMimeType, ogAltText, resolveOgUrl } from "../og-resolver.js";
4
3
  import { Context, Data, Effect, FileSystem, Layer, Option, Path } from "effect";
4
+ import { imageMimeType, ogAltText, resolveUrl } from "@tsdoctor/seo";
5
5
  import { imageSize } from "image-size";
6
6
 
7
7
  //#region src/services/OgService.ts
8
- /**
9
- * Resolving an API's configured Open Graph image into page metadata.
10
- *
11
- * @remarks
12
- * Replaces the `OpenGraphResolver` class, which did synchronous `node:fs` from
13
- * inside `Effect.promise`, carried its own sync-island event emitter, and
14
- * returned `undefined` for all three of its failure modes — indistinguishable
15
- * from "no image was configured".
16
- *
17
- * This is also where phase 4's SEO work lands, which is why the contract is
18
- * wider than today's single caller needs: it names its failures instead of
19
- * erasing them.
20
- *
21
- * @packageDocumentation
22
- */
23
8
  const OgImageErrorBase = Data.TaggedError("OgImageError");
24
9
  /**
25
10
  * A configured OG image that could not be resolved.
@@ -129,7 +114,7 @@ const make = () => Effect.gen(function* () {
129
114
  return facts;
130
115
  });
131
116
  const resolveFromString = (imageUrl, request) => Effect.gen(function* () {
132
- const resolvedUrl = resolveOgUrl(request.siteUrl, imageUrl);
117
+ const resolvedUrl = resolveUrl(request.siteUrl, imageUrl);
133
118
  if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
134
119
  code: "invalid-url",
135
120
  field: "ogImage",
@@ -147,7 +132,7 @@ const make = () => Effect.gen(function* () {
147
132
  });
148
133
  const resolveFromMetadata = (metadata, request) => Effect.gen(function* () {
149
134
  const { url, secureUrl, type, width, height, alt } = metadata;
150
- const resolvedUrl = resolveOgUrl(request.siteUrl, url);
135
+ const resolvedUrl = resolveUrl(request.siteUrl, url);
151
136
  if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
152
137
  code: "invalid-url",
153
138
  field: "ogImage.url",
package/og-resolver.js DELETED
@@ -1,95 +0,0 @@
1
- //#region src/og-resolver.ts
2
- /**
3
- * MIME type mappings for common image formats, used for `og:image:type`.
4
- */
5
- const IMAGE_MIME_TYPES = {
6
- jpg: "image/jpeg",
7
- jpeg: "image/jpeg",
8
- png: "image/png",
9
- gif: "image/gif",
10
- webp: "image/webp",
11
- svg: "image/svg+xml"
12
- };
13
- /**
14
- * The `og:image:type` value for a detected image format, or `undefined` for a
15
- * format with no mapping.
16
- */
17
- function imageMimeType(type) {
18
- if (type == null) return void 0;
19
- return IMAGE_MIME_TYPES[type.toLowerCase()];
20
- }
21
- /**
22
- * Turn a configured image URL into an absolute one.
23
- *
24
- * @returns The absolute URL, or `undefined` when the input is neither an
25
- * absolute `http(s)` URL nor a site-root-relative path. A bare relative path
26
- * is deliberately rejected rather than guessed at — there is no base to
27
- * resolve it against that would not silently produce a broken link.
28
- */
29
- function resolveOgUrl(siteUrl, url) {
30
- if (url.startsWith("http://") || url.startsWith("https://")) return url;
31
- if (url.startsWith("/")) return `${siteUrl}${url}`;
32
- }
33
- /**
34
- * Derive the site URL prefix from RSPress's own config.
35
- *
36
- * @remarks
37
- * Replaces the plugin's former `siteUrl` option. RSPress already knows where a
38
- * site is deployed — {@link https://rspress.rs/api/config/config-basic#siteorigin | `siteOrigin`}
39
- * plus `base` — so asking for it a second time invited the two to disagree, and
40
- * a plugin-level answer that contradicted the site's own would silently emit
41
- * canonical and `og:url` tags pointing at a host the site is not served from.
42
- *
43
- * RSPress concatenates as `siteOrigin + base + routePath`, and **this follows
44
- * its documented fallback exactly**: with no `siteOrigin`, RSPress uses
45
- * `base + routePath`. So an unset origin yields a ROOT-RELATIVE prefix rather
46
- * than nothing.
47
- *
48
- * That fallback is what makes the tags inspectable in `rspress dev`, where the
49
- * site is served from `localhost` and no configured origin could be correct
50
- * anyway. A root-relative `/images/og.png` resolves against the page's own
51
- * origin in the browser; it is a *relative* path (`images/og.png`, no leading
52
- * slash) that has no base to resolve against, and this never emits one.
53
- *
54
- * @returns The prefix to put in front of a route that already begins with `/`.
55
- * `""` when the site declares neither `siteOrigin` nor a non-root `base`, which
56
- * leaves every URL root-relative. Never has a trailing slash, since every
57
- * caller appends a route starting with `/`.
58
- */
59
- function deriveSiteUrl(siteOrigin, base) {
60
- const origin = (siteOrigin ?? "").trim().replace(/\/+$/, "");
61
- const path = (base ?? "/").trim();
62
- return `${origin}${path === "" || path === "/" ? "" : `/${path.replace(/^\/+/, "").replace(/\/+$/, "")}`}`;
63
- }
64
- /** Descriptive alt text for a package's (or one API's) OG image. */
65
- function ogAltText(packageName, apiName) {
66
- return apiName ? `${apiName} - ${packageName} API Documentation` : `${packageName} API Documentation`;
67
- }
68
- /**
69
- * Assemble the complete Open Graph metadata for one documentation page.
70
- *
71
- * @remarks
72
- * Was `OpenGraphResolver.createPageMetadata`. It never touched the resolver's
73
- * instance state, so it is a free function now rather than a static on a class
74
- * that no longer exists.
75
- */
76
- function createPageMetadata(options) {
77
- return {
78
- siteUrl: options.siteUrl,
79
- pageRoute: options.pageRoute,
80
- description: options.description,
81
- publishedTime: options.publishedTime,
82
- modifiedTime: options.modifiedTime,
83
- section: options.section,
84
- tags: [
85
- "TypeScript",
86
- "API",
87
- options.packageName
88
- ],
89
- ...options.ogImage != null ? { ogImage: options.ogImage } : {},
90
- ogType: "article"
91
- };
92
- }
93
-
94
- //#endregion
95
- export { createPageMetadata, deriveSiteUrl, imageMimeType, ogAltText, resolveOgUrl };
@@ -1,56 +0,0 @@
1
- import { Schema } from "effect";
2
-
3
- //#region src/schemas/opengraph.ts
4
- /**
5
- * Structured Open Graph image metadata (alternative to a plain URL string).
6
- *
7
- * @public
8
- */
9
- const OpenGraphImageMetadata = Schema.Struct({
10
- /** Absolute URL of the image. */
11
- url: Schema.String,
12
- /** HTTPS URL of the image (for secure contexts). */
13
- secureUrl: Schema.optional(Schema.String),
14
- /** MIME type of the image (e.g. `"image/png"`). */
15
- type: Schema.optional(Schema.String),
16
- /** Image width in pixels. */
17
- width: Schema.optional(Schema.Number),
18
- /** Image height in pixels. */
19
- height: Schema.optional(Schema.Number),
20
- /** Alt text for the image. */
21
- alt: Schema.optional(Schema.String)
22
- });
23
- /**
24
- * Open Graph image: either a plain URL string or structured `OpenGraphImageMetadata`.
25
- *
26
- * @public
27
- */
28
- const OpenGraphImageConfig = Schema.Union([Schema.String, OpenGraphImageMetadata]);
29
- /**
30
- * Resolved Open Graph metadata emitted into each generated page's frontmatter.
31
- *
32
- * @public
33
- */
34
- const OpenGraphMetadata = Schema.Struct({
35
- /** Canonical site base URL. */
36
- siteUrl: Schema.String,
37
- /** Page route path (e.g. `/api/classes/myclass`). */
38
- pageRoute: Schema.String,
39
- /** Page description for the `og:description` tag. */
40
- description: Schema.String,
41
- /** ISO 8601 date string for `article:published_time`. */
42
- publishedTime: Schema.String,
43
- /** ISO 8601 date string for `article:modified_time`. */
44
- modifiedTime: Schema.String,
45
- /** Article section label (e.g. `"API"`). */
46
- section: Schema.String,
47
- /** Article tag keywords. */
48
- tags: Schema.mutable(Schema.Array(Schema.String)),
49
- /** Optional structured image metadata. */
50
- ogImage: Schema.optional(OpenGraphImageMetadata),
51
- /** Open Graph object type (e.g. `"article"`). */
52
- ogType: Schema.String
53
- });
54
-
55
- //#endregion
56
- export { OpenGraphImageConfig, OpenGraphImageMetadata };