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 +1 -0
- package/build-program.js +12 -2
- package/build-stages.js +86 -45
- package/index.d.ts +1 -84
- package/layers/config-resolution.js +40 -1
- package/markdown/helpers.js +8 -33
- package/package.json +5 -4
- package/schemas/config.js +1 -1
- package/services/OgService.js +3 -18
- package/og-resolver.js +0 -95
- package/schemas/opengraph.js +0 -56
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
|
|
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,
|
|
391
|
-
const { workItem,
|
|
392
|
-
const { item, categoryKey,
|
|
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
|
-
|
|
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 } : {},
|
package/markdown/helpers.js
CHANGED
|
@@ -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
|
|
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,
|
|
130
|
+
function generateFrontmatter(entityName, description, singularName, apiName, tags) {
|
|
140
131
|
const title = buildPageTitle(entityName, singularName, apiName);
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
+
"@tsdoctor/model": "0.4.0",
|
|
57
57
|
"@tsdoctor/registry": "0.2.1",
|
|
58
|
-
"@tsdoctor/
|
|
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
|
package/services/OgService.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 };
|
package/schemas/opengraph.js
DELETED
|
@@ -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 };
|