rspress-plugin-api-extractor 0.8.8 → 0.9.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.
Files changed (53) hide show
  1. package/README.md +11 -11
  2. package/api-extracted-package.js +1 -1
  3. package/build-program.js +3 -3
  4. package/build-stages.js +30 -31
  5. package/config-helpers.js +61 -21
  6. package/errors.js +1 -7
  7. package/frontmatter.js +149 -0
  8. package/index.d.ts +11 -2
  9. package/layers/ConfigServiceLive.js +53 -39
  10. package/layers/TypeRegistryServiceLive.js +11 -6
  11. package/markdown/helpers.js +40 -69
  12. package/markdown/index.js +2 -2
  13. package/markdown/page-generators/class-page.js +40 -42
  14. package/markdown/page-generators/enum-page.js +15 -15
  15. package/markdown/page-generators/function-page.js +18 -20
  16. package/markdown/page-generators/interface-page.js +41 -43
  17. package/markdown/page-generators/namespace-page.js +22 -24
  18. package/markdown/page-generators/type-alias-page.js +14 -16
  19. package/markdown/page-generators/variable-page.js +14 -16
  20. package/markdown/prose-linker.js +22 -0
  21. package/model-loader.js +59 -113
  22. package/package.json +14 -8
  23. package/plugin.js +1 -5
  24. package/runtime/components/EnumMembersTable/index.css +18 -18
  25. package/runtime/components/EnumMembersTable/index.module.js +3 -3
  26. package/runtime/components/ExampleBlock/index.css +2 -2
  27. package/runtime/components/ExampleBlock/index.module.js +2 -2
  28. package/runtime/components/MemberSignature/index.css +6 -6
  29. package/runtime/components/MemberSignature/index.module.js +4 -4
  30. package/runtime/components/ParametersTable/index.css +19 -19
  31. package/runtime/components/ParametersTable/index.module.js +3 -3
  32. package/runtime/components/SignatureBlock/index.css +6 -6
  33. package/runtime/components/SignatureBlock/index.module.js +4 -4
  34. package/runtime/components/SignatureCode/index.css +9 -9
  35. package/runtime/components/SignatureCode/index.module.js +3 -3
  36. package/runtime/components/SignatureToolbar/index.css +18 -18
  37. package/runtime/components/SignatureToolbar/index.module.js +7 -7
  38. package/runtime/components/buttons/index.css +5 -5
  39. package/runtime/components/buttons/index.module.js +2 -2
  40. package/shiki-transformer.js +3 -3
  41. package/sync-node-fs.js +80 -0
  42. package/tsdoc-metadata.json +1 -1
  43. package/twoslash-transformer.js +1 -1
  44. package/content-hash.js +0 -79
  45. package/formatter.js +0 -69
  46. package/layers/SnapshotServiceLive.js +0 -92
  47. package/loader.js +0 -200
  48. package/markdown/cross-linker.js +0 -157
  49. package/migrations/001_create_snapshots.js +0 -25
  50. package/multi-entry-resolver.js +0 -70
  51. package/route-collisions.js +0 -44
  52. package/services/SnapshotService.js +0 -7
  53. package/synthetic-bases.js +0 -74
package/README.md CHANGED
@@ -56,17 +56,17 @@ The plugin reads your `.api.json` model and writes one MDX page per public API i
56
56
 
57
57
  ## Documentation
58
58
 
59
- - [Getting started](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/01-getting-started.md) — Install, minimal config, first build.
60
- - [Configuration](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/02-configuration.md) — Full plugin-options reference.
61
- - [Config helpers](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/03-config-helpers.md) — `api.fromDir` and `apis.fromDir` for discovering config from package folders.
62
- - [Single package](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/04-single-package.md) — The single-API recipe.
63
- - [Multi-package](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/05-multi-package.md) — The multi-API portal recipe.
64
- - [Versioned](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/06-versioned.md) — Documenting major versions side by side.
65
- - [i18n](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/07-i18n.md) — Internationalized documentation.
66
- - [Multi-entry points](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/08-multi-entry-points.md) — Deduplication, "Available from" and route collisions.
67
- - [LLMs](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/09-llms.md) — Per-package `llms*.txt` files and assistant actions.
68
- - [Runtime components](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/10-runtime-components.md) — The runtime components and live `with-api` code blocks.
69
- - [Troubleshooting](https://github.com/spencerbeggs/rspress-plugin-api-extractor/blob/main/docs/11-troubleshooting.md) — Route collisions, forgotten exports, Twoslash errors and stale caches.
59
+ - [Getting started](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/01-getting-started.md) — Install, minimal config, first build.
60
+ - [Configuration](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/02-configuration.md) — Full plugin-options reference.
61
+ - [Config helpers](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/03-config-helpers.md) — `api.fromDir` and `apis.fromDir` for discovering config from package folders.
62
+ - [Single package](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/04-single-package.md) — The single-API recipe.
63
+ - [Multi-package](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/05-multi-package.md) — The multi-API portal recipe.
64
+ - [Versioned](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/06-versioned.md) — Documenting major versions side by side.
65
+ - [i18n](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/07-i18n.md) — Internationalized documentation.
66
+ - [Multi-entry points](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/08-multi-entry-points.md) — Deduplication, "Available from" and route collisions.
67
+ - [LLMs](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/09-llms.md) — Per-package `llms*.txt` files and assistant actions.
68
+ - [Runtime components](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/10-runtime-components.md) — The runtime components and live `with-api` code blocks.
69
+ - [Troubleshooting](https://github.com/spencerbeggs/tsdoctor/blob/main/docs/11-troubleshooting.md) — Route collisions, forgotten exports, Twoslash errors and stale caches.
70
70
 
71
71
  ## License
72
72
 
@@ -1,5 +1,5 @@
1
1
  import { ApiItemKind, ApiModel, ExcerptTokenKind } from "@microsoft/api-extractor-model";
2
- import { VirtualPackage } from "type-registry-effect";
2
+ import { VirtualPackage } from "@tsdoctor/registry";
3
3
 
4
4
  //#region src/api-extracted-package.ts
5
5
  /**
package/build-program.js CHANGED
@@ -1,11 +1,11 @@
1
- import { markdownCrossLinker } from "./markdown/cross-linker.js";
1
+ import { setProseLinker } from "./markdown/prose-linker.js";
2
2
  import "./markdown/index.js";
3
- import { SnapshotService } from "./services/SnapshotService.js";
4
3
  import { buildPipelineForApi, cleanupAndCommit, prepareWorkItems, writeMetadata } from "./build-stages.js";
5
4
  import { withPhase } from "./observability/spans.js";
6
5
  import { TwoslashManager } from "./twoslash-transformer.js";
7
6
  import { VfsRegistry } from "./vfs-registry.js";
8
7
  import path from "node:path";
8
+ import { SnapshotService } from "@tsdoctor/snapshot";
9
9
  import { Effect, FileSystem } from "effect";
10
10
 
11
11
  //#region src/build-program.ts
@@ -44,7 +44,7 @@ function generateApiDocs(apiConfig, buildContext, fileContextMap) {
44
44
  baseRoute,
45
45
  packageName
46
46
  })), thresholds);
47
- markdownCrossLinker.setRoutes(crossLinkData.routes);
47
+ setProseLinker(crossLinkData.routes);
48
48
  const apiScope = baseRoute.replace(/^\//, "").split("/")[0] || packageName;
49
49
  shikiCrossLinker.reinitialize(crossLinkData.routes, crossLinkData.kinds, apiScope);
50
50
  TwoslashManager.addTypeRoutes(crossLinkData.routes);
package/build-stages.js CHANGED
@@ -1,9 +1,8 @@
1
- import { hashContent, hashFrontmatter } from "./content-hash.js";
1
+ import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
2
2
  import { PluginEvent } from "./observability/events.js";
3
3
  import { emit } from "./observability/EventBus.js";
4
4
  import { BuildMetrics } from "./layers/build-metrics.js";
5
5
  import "./layers/ObservabilityLive.js";
6
- import { ApiParser } from "./loader.js";
7
6
  import { generateFrontmatter } from "./markdown/helpers.js";
8
7
  import { ClassPageGenerator } from "./markdown/page-generators/class-page.js";
9
8
  import { EnumPageGenerator } from "./markdown/page-generators/enum-page.js";
@@ -14,15 +13,12 @@ import { NamespacePageGenerator } from "./markdown/page-generators/namespace-pag
14
13
  import { TypeAliasPageGenerator } from "./markdown/page-generators/type-alias-page.js";
15
14
  import { VariablePageGenerator } from "./markdown/page-generators/variable-page.js";
16
15
  import "./markdown/index.js";
17
- import { resolveEntryPoints } from "./multi-entry-resolver.js";
18
16
  import { OpenGraphResolver } from "./og-resolver.js";
19
- import { detectRouteCollisions, formatRouteCollisionError } from "./route-collisions.js";
20
- import { SnapshotService } from "./services/SnapshotService.js";
21
- import { BASE_CLASS_ANCHOR, detectSyntheticBases } from "./synthetic-bases.js";
22
17
  import path from "node:path";
18
+ import { SnapshotService, hashContent, hashFrontmatter } from "@tsdoctor/snapshot";
23
19
  import { Effect, FileSystem, Metric, Stream } from "effect";
24
20
  import { ApiItemKind } from "@microsoft/api-extractor-model";
25
- import matter from "gray-matter";
21
+ import { ApiItems, EntryPoints, Routes, SyntheticBases } from "@tsdoctor/model";
26
22
 
27
23
  //#region src/build-stages.ts
28
24
  /**
@@ -63,60 +59,60 @@ function setBuildStagesEventEmitter(fn, buildId = "") {
63
59
  currentBuildId = buildId;
64
60
  }
65
61
  /**
66
- * Sanitize a display name to create a valid HTML ID.
67
- * Mirrors the logic in MarkdownCrossLinker.sanitizeId().
68
- */
69
- function sanitizeId(displayName) {
70
- return displayName.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
71
- }
72
- /**
73
62
  * Prepare the flat list of WorkItems to process and the cross-link data maps.
74
63
  *
75
64
  * This function:
76
65
  * 1. Categorizes API items from the model
77
- * 2. Builds cross-link routes and kinds maps (replicating MarkdownCrossLinker.initialize())
66
+ * 2. Builds cross-link routes and kinds maps for the prose and Shiki cross-linkers
78
67
  * 3. Extracts namespace members and adds their routes (with collision detection)
79
68
  * 4. Flattens all items into a single WorkItem[]
80
69
  *
81
- * NOTE: This function does NOT call the markdownCrossLinker singleton. The caller
70
+ * NOTE: This function does NOT install the prose linker. The caller
82
71
  * is responsible for passing the returned crossLinkData to the cross-linker and
83
72
  * Shiki cross-linker as needed.
84
73
  */
85
74
  function prepareWorkItems(input) {
86
75
  const { apiPackage, categories, baseRoute } = input;
87
- const resolvedItems = resolveEntryPoints(apiPackage);
88
- const syntheticBases = detectSyntheticBases(resolvedItems.map((r) => r.item));
76
+ const resolvedItems = EntryPoints.resolve(apiPackage);
77
+ const syntheticBases = SyntheticBases.detect(resolvedItems.map((r) => r.item));
89
78
  const docItems = syntheticBases.bases.size ? resolvedItems.filter((r) => !syntheticBases.bases.has(r.item)) : resolvedItems;
90
79
  const resolvedLookup = /* @__PURE__ */ new Map();
91
80
  for (const resolved of docItems) {
92
81
  const key = `${resolved.item.displayName}::${resolved.item.kind}`;
93
82
  resolvedLookup.set(key, resolved);
94
83
  }
95
- const items = ApiParser.categorizeApiItems(docItems, categories);
96
- const namespaceMembers = ApiParser.extractNamespaceMembers(docItems);
84
+ const { items, uncategorized } = ApiItems.categorize(docItems, categories);
85
+ for (const skipped of uncategorized) emitEvent(PluginEvent.ItemSkipped({
86
+ ctx: { buildId: currentBuildId },
87
+ item: skipped.displayName,
88
+ kind: String(skipped.kind),
89
+ reason: "uncategorized",
90
+ level: "warn"
91
+ }));
92
+ const namespaceMembers = ApiItems.namespaceMembers(docItems);
97
93
  const candidates = [];
98
- for (const [categoryKey, categoryConfig] of Object.entries(categories)) for (const item of items[categoryKey] || []) candidates.push({
94
+ for (const [categoryKey, categoryConfig] of Object.entries(categories)) for (const item of items[categoryKey] || []) candidates.push(new Routes.RouteCandidate({
99
95
  id: `${item.displayName}::${item.kind}`,
100
96
  displayName: item.displayName,
101
97
  folder: categoryConfig.folderName,
102
98
  baseName: item.displayName.toLowerCase(),
103
99
  kind: String(item.kind),
104
100
  canonicalRef: item.canonicalReference?.toString() ?? item.displayName
105
- });
101
+ }));
106
102
  for (const nsMember of namespaceMembers) {
107
103
  const nsCategoryEntry = Object.entries(categories).find(([, config]) => config.itemKinds?.includes(nsMember.item.kind));
108
104
  if (!nsCategoryEntry) continue;
109
105
  const [, nsCategoryConfig] = nsCategoryEntry;
110
- candidates.push({
106
+ candidates.push(new Routes.RouteCandidate({
111
107
  id: nsMember.qualifiedName,
112
108
  displayName: nsMember.qualifiedName,
113
109
  folder: nsCategoryConfig.folderName,
114
110
  baseName: nsMember.qualifiedName.toLowerCase(),
115
111
  kind: String(nsMember.item.kind),
116
112
  canonicalRef: nsMember.item.canonicalReference?.toString() ?? nsMember.qualifiedName
117
- });
113
+ }));
118
114
  }
119
- const collisions = detectRouteCollisions(candidates);
115
+ const collisions = Routes.detectCollisions(candidates);
120
116
  if (collisions.length > 0) {
121
117
  try {
122
118
  for (const collision of collisions) emitEvent(PluginEvent.RouteCollisionDetected({
@@ -128,7 +124,10 @@ function prepareWorkItems(input) {
128
124
  items: collision.items.map((item) => `${item.displayName} (${item.kind}) [${item.canonicalRef}]`)
129
125
  }));
130
126
  } catch {}
131
- throw new Error(formatRouteCollisionError(collisions, baseRoute));
127
+ throw new Routes.RouteCollisionError({
128
+ baseRoute,
129
+ collisions
130
+ });
132
131
  }
133
132
  const routes = /* @__PURE__ */ new Map();
134
133
  const kinds = /* @__PURE__ */ new Map();
@@ -148,7 +147,7 @@ function prepareWorkItems(input) {
148
147
  const itemWithMembers = item;
149
148
  for (const member of itemWithMembers.members) {
150
149
  const memberName = member.displayName;
151
- const memberId = sanitizeId(memberName);
150
+ const memberId = Routes.sanitizeId(memberName);
152
151
  const fullMemberName = `${item.displayName}.${memberName}`;
153
152
  const memberRoute = `${itemRoute}#${memberId}`;
154
153
  routes.set(fullMemberName, memberRoute);
@@ -181,7 +180,7 @@ function prepareWorkItems(input) {
181
180
  const owner = syntheticBase.ownerClasses[0];
182
181
  const ownerRoute = owner ? routes.get(owner.displayName) : void 0;
183
182
  if (!ownerRoute) continue;
184
- routes.set(baseName, `${ownerRoute}#${BASE_CLASS_ANCHOR}`);
183
+ routes.set(baseName, `${ownerRoute}#${SyntheticBases.BASE_CLASS_ANCHOR}`);
185
184
  kinds.set(baseName, baseItem.kind);
186
185
  }
187
186
  const workItems = [];
@@ -339,7 +338,7 @@ function generateSinglePage(workItem, ctx) {
339
338
  durationMs: Math.round(performance.now() - pageGenStart),
340
339
  level: "debug"
341
340
  }));
342
- const parsed = matter(page.content);
341
+ const parsed = parseFrontmatter(page.content);
343
342
  const bodyContent = normalizeMarkdownSpacing(parsed.content);
344
343
  const frontmatterData = parsed.data;
345
344
  const relativePathWithExt = `${page.routePath.replace(baseRoute, "").replace(/^\//, "")}.mdx`;
@@ -354,7 +353,7 @@ function generateSinglePage(workItem, ctx) {
354
353
  if (yield* fileSystem.exists(absolutePath).pipe(Effect.orElseSucceed(() => false))) {
355
354
  const existingContent = yield* fileSystem.readFileString(absolutePath).pipe(Effect.orElseSucceed(() => null));
356
355
  if (existingContent !== null) {
357
- const { data: existingFrontmatter, content: existingBody } = matter(existingContent);
356
+ const { data: existingFrontmatter, content: existingBody } = parseFrontmatter(existingContent);
358
357
  const normalizedExistingBody = normalizeMarkdownSpacing(existingBody);
359
358
  const existingContentHash = hashContent(normalizedExistingBody);
360
359
  const existingFrontmatterHash = hashFrontmatter(existingFrontmatter);
@@ -440,7 +439,7 @@ function writeSingleFile(result, ctx) {
440
439
  routePath
441
440
  };
442
441
  }
443
- let finalContent = matter.stringify(bodyContent, frontmatter);
442
+ let finalContent = stringifyFrontmatter(bodyContent, frontmatter);
444
443
  if (ogResolver && siteUrl && packageName) {
445
444
  const ogImageMetadata = yield* Effect.promise(() => ogResolver.resolve(ogImage, packageName, apiName));
446
445
  const ogMetadataOptions = {
package/config-helpers.js CHANGED
@@ -1,19 +1,21 @@
1
- import { normalizeBaseRoute, unscopedName } from "./path-derivation.js";
1
+ import { normalizeBaseRoute } from "./path-derivation.js";
2
+ import { SyncDiscoveryLayer } from "./sync-node-fs.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
5
+ import { Effect, Result } from "effect";
6
+ import { discoverBundle } from "@tsdoctor/bundle";
4
7
 
5
8
  //#region src/config-helpers.ts
6
9
  const PREFIX = "[rspress-plugin-api-extractor]";
7
- function discoverModel(dir, packageName) {
8
- const apiJsonFiles = fs.readdirSync(dir).filter((f) => f.endsWith(".api.json"));
9
- if (apiJsonFiles.length === 1) return path.join(dir, apiJsonFiles[0]);
10
- if (apiJsonFiles.length === 0) throw new Error(`${PREFIX} api.fromDir: no *.api.json model found in ${dir}. Pass an explicit \`model\` to override.`);
11
- const unscoped = unscopedName(packageName);
12
- const preferred = apiJsonFiles.find((f) => f === `${unscoped}.api.json`);
13
- if (preferred) return path.join(dir, preferred);
14
- throw new Error(`${PREFIX} api.fromDir: multiple *.api.json files in ${dir} (${apiJsonFiles.join(", ")}) and none match "${unscoped}.api.json". Pass an explicit \`model\`.`);
15
- }
16
- function discoverDir(dir) {
10
+ /**
11
+ * The adapter's strictness gate over `@tsdoctor/bundle`'s layer-0-only
12
+ * discovery: the plugin's public contract requires a `package.json` with a
13
+ * `name` (the bundle spec does not), so the gate runs FIRST and keeps the
14
+ * historical error messages. Returning the parsed name/version also lets the
15
+ * bundle discovery skip its api.json name fallback entirely (the model file
16
+ * is never parsed here, exactly as before).
17
+ */
18
+ function requirePackageJson(dir) {
17
19
  let stat;
18
20
  try {
19
21
  stat = fs.statSync(dir);
@@ -29,13 +31,34 @@ function discoverDir(dir) {
29
31
  }
30
32
  if (!pkg.name) throw new Error(`${PREFIX} api.fromDir: package.json in ${dir} has no "name" field`);
31
33
  return {
32
- dir,
33
- dirname: path.basename(dir),
34
- packageName: pkg.name,
35
- version: pkg.version ?? "",
36
- modelPath: discoverModel(dir, pkg.name)
34
+ name: pkg.name,
35
+ ...pkg.version !== void 0 ? { version: pkg.version } : {}
37
36
  };
38
37
  }
38
+ /**
39
+ * Run `@tsdoctor/bundle`'s `discoverBundle` synchronously (over the sync
40
+ * `FileSystem` bridge — see `sync-node-fs.ts`) and translate its typed
41
+ * failures into the plugin's historical error messages.
42
+ */
43
+ function discoverDescriptor(dir, name, version) {
44
+ const result = Effect.runSync(Effect.result(discoverBundle(dir, { overrides: {
45
+ name,
46
+ ...version !== void 0 ? { version } : {}
47
+ } }).pipe(Effect.provide(SyncDiscoveryLayer))));
48
+ if (Result.isSuccess(result)) return result.success;
49
+ const error = result.failure;
50
+ if (error._tag === "BundleDiscoveryError") switch (error.reason) {
51
+ case "noApiModel": throw new Error(`${PREFIX} api.fromDir: no *.api.json model found in ${dir}. Pass an explicit \`model\` to override.`);
52
+ case "ambiguousApiModel": {
53
+ const facts = (error.detail ?? "multiple *.api.json files").split(";")[0];
54
+ throw new Error(`${PREFIX} api.fromDir: ${facts} in ${dir}. Pass an explicit \`model\`.`);
55
+ }
56
+ case "notFound": throw new Error(`${PREFIX} api.fromDir: directory not found: ${dir}`);
57
+ case "notADirectory": throw new Error(`${PREFIX} api.fromDir: not a directory: ${dir}`);
58
+ default: throw new Error(`${PREFIX} api.fromDir: ${error.message}`);
59
+ }
60
+ throw new Error(`${PREFIX} api.fromDir: ${String(error)}`);
61
+ }
39
62
  function resolveBaseRoute(baseRoute, info) {
40
63
  const interpolated = (typeof baseRoute === "function" ? baseRoute(info) : baseRoute).replace(/\{dirname\}/g, info.dirname).replace(/\{packageName\}/g, info.packageName);
41
64
  return normalizeBaseRoute(interpolated);
@@ -46,21 +69,36 @@ function resolveBaseRoute(baseRoute, info) {
46
69
  * `ApiExtractorPlugin.api.fromDir`; the returned config can be passed to the
47
70
  * single-API `api:` option or used as an element of the multi-API `apis:` array.
48
71
  *
72
+ * Discovery (model-file selection, unscoped-name disambiguation, tsconfig
73
+ * detection) delegates to `@tsdoctor/bundle`'s `discoverBundle`; the
74
+ * RSPress-specific concerns — `baseRoute` templating and `MultiApiConfig`
75
+ * assembly — stay here, as does the plugin's stricter contract that the
76
+ * folder carry a named `package.json` (the bundle spec itself accepts
77
+ * layer-0-only folders).
78
+ *
49
79
  * `baseRoute` is intentionally left unset unless overridden, so the plugin
50
80
  * applies its own context-aware default (`/api` under `api:`,
51
81
  * `/{packageName}/api` under `apis:`). See {@link BaseRoute}.
52
82
  */
53
83
  function fromDir(dir, overrides = {}) {
54
84
  const { baseRoute, cwd, ...rest } = overrides;
55
- const info = discoverDir(path.resolve(cwd ?? process.cwd(), dir));
56
- const tsconfigPath = path.join(info.dir, "tsconfig.json");
85
+ const abs = path.resolve(cwd ?? process.cwd(), dir);
86
+ const pkg = requirePackageJson(abs);
87
+ const descriptor = discoverDescriptor(abs, pkg.name, pkg.version);
88
+ const info = {
89
+ dir: descriptor.dir,
90
+ dirname: descriptor.dirname,
91
+ packageName: descriptor.name,
92
+ version: descriptor.version ?? "",
93
+ modelPath: descriptor.modelPath
94
+ };
57
95
  return {
58
96
  packageName: info.packageName,
59
97
  name: info.packageName,
60
98
  model: info.modelPath,
61
99
  packageJson: path.join(info.dir, "package.json"),
62
100
  ...baseRoute !== void 0 ? { baseRoute: resolveBaseRoute(baseRoute, info) } : {},
63
- ...fs.existsSync(tsconfigPath) ? { tsconfig: tsconfigPath } : {},
101
+ ...descriptor.tsconfigPath !== void 0 ? { tsconfig: descriptor.tsconfigPath } : {},
64
102
  ...rest
65
103
  };
66
104
  }
@@ -76,8 +114,10 @@ function isModelFolder(dir) {
76
114
  * Strictly scan a parent directory of package folders and build one
77
115
  * `MultiApiConfig` per subfolder. Exposed as `ApiExtractorPlugin.apis.fromDir`;
78
116
  * the returned array is intended for the multi-API `apis:` option. Every
79
- * non-dotfile subdirectory MUST be a valid model folder. `options` (minus
80
- * `cwd`) is applied as shared defaults to each `api.fromDir` call.
117
+ * non-dotfile subdirectory MUST be a valid model folder including a
118
+ * `package.json`, which is this adapter's stricter contract over the bundle
119
+ * spec's layer-0-only discovery. `options` (minus `cwd`) is applied as shared
120
+ * defaults to each `api.fromDir` call.
81
121
  */
82
122
  function fromParentDir(parentDir, options = {}) {
83
123
  const { cwd, ...rest } = options;
package/errors.js CHANGED
@@ -8,12 +8,6 @@ var ConfigValidationError = class extends ConfigValidationErrorBase {
8
8
  }
9
9
  };
10
10
  const ApiModelLoadErrorBase = Data.TaggedError("ApiModelLoadError");
11
- const SnapshotDbErrorBase = Data.TaggedError("SnapshotDbError");
12
- var SnapshotDbError = class extends SnapshotDbErrorBase {
13
- get message() {
14
- return `Snapshot DB error during '${this.operation}' at '${this.dbPath}': ${this.reason}`;
15
- }
16
- };
17
11
  const PathDerivationErrorBase = Data.TaggedError("PathDerivationError");
18
12
  const TypeRegistryErrorBase = Data.TaggedError("TypeRegistryError");
19
13
  var TypeRegistryError = class extends TypeRegistryErrorBase {
@@ -26,4 +20,4 @@ const TwoslashProcessingErrorBase = Data.TaggedError("TwoslashProcessingError");
26
20
  const PrettierFormatErrorBase = Data.TaggedError("PrettierFormatError");
27
21
 
28
22
  //#endregion
29
- export { ConfigValidationError, ConfigValidationErrorBase, SnapshotDbError, SnapshotDbErrorBase, TypeRegistryError, TypeRegistryErrorBase };
23
+ export { ConfigValidationError, ConfigValidationErrorBase, TypeRegistryError, TypeRegistryErrorBase };
package/frontmatter.js ADDED
@@ -0,0 +1,149 @@
1
+ import { Effect } from "effect";
2
+ import { Yaml, YamlStringifyOptions } from "@effected/yaml";
3
+
4
+ //#region src/frontmatter.ts
5
+ /**
6
+ * Stringify options shared by both emit sites.
7
+ *
8
+ * `lineWidth: 0` disables wrapping so long titles/descriptions/URLs stay on
9
+ * one line, and `defaultScalarStyle: "double-quoted"` quotes every string
10
+ * value. The forced quoting matters for downstream consumers: RSPress parses
11
+ * the emitted frontmatter with js-yaml (YAML 1.1-flavored), where an unquoted
12
+ * ISO timestamp such as `2024-01-15T12:00:00.000Z` decodes to a `Date` object
13
+ * instead of a string. Quoting every value keeps the decoded representation
14
+ * identical across YAML 1.1 and 1.2 parsers.
15
+ */
16
+ const STRINGIFY_OPTIONS = YamlStringifyOptions.make({
17
+ lineWidth: 0,
18
+ defaultScalarStyle: "double-quoted"
19
+ });
20
+ const OPEN_DELIMITER = "---";
21
+ const CLOSE_SEARCH = "\n---";
22
+ /**
23
+ * Split markdown source into frontmatter data and body content, preserving
24
+ * gray-matter's exact boundary semantics.
25
+ *
26
+ * @remarks
27
+ * This is a byte-for-byte port of the `gray-matter` split contract the
28
+ * snapshot system's hashes depend on (see `@tsdoctor/snapshot`
29
+ * `hashContent`/`hashFrontmatter` and the disk-fallback comparison in
30
+ * `build-stages.ts`), with `@effected/yaml` (`Yaml.parse`, YAML 1.2) as the
31
+ * YAML engine instead of js-yaml:
32
+ *
33
+ * - No opening `---` line at offset 0 → `data: {}` and the whole input as
34
+ * `content` (a leading BOM is stripped first, as gray-matter does).
35
+ * - The closing delimiter is the first `\n---` after the opening line
36
+ * (gray-matter uses a plain `indexOf`, so `\n----` also closes and the
37
+ * leftover `-` stays in the body — preserved deliberately).
38
+ * - Exactly one newline (`\n` or `\r\n`) immediately after the closing `---`
39
+ * is consumed; everything else is the body verbatim. A build's generated
40
+ * page (`---\n…\n---\n\n# Title`) therefore yields a body starting with a
41
+ * single `\n`, exactly as gray-matter returned it.
42
+ * - A block with no closing delimiter is all frontmatter and yields an empty
43
+ * body; an empty/blank block yields `data: {}`.
44
+ * - Invalid YAML throws (a defect), matching gray-matter's js-yaml throw.
45
+ *
46
+ * One deliberate delta: gray-matter treats text on the opening line
47
+ * (`---toml`) as an engine name and throws for unregistered engines; this
48
+ * split treats such input as "no frontmatter" instead. The plugin never emits
49
+ * or consumes language-tagged frontmatter.
50
+ *
51
+ * Representation parity with js-yaml is verified by characterization tests
52
+ * (`__test__/frontmatter.test.ts`) pinning hashes captured under gray-matter.
53
+ * The one input where the engines disagree — an *unquoted* ISO timestamp
54
+ * (js-yaml: `Date`, YAML 1.2: string) — is unreachable from this plugin's
55
+ * emitters, which always quote timestamp values, and hashes identically
56
+ * anyway because `hashFrontmatter` JSON-serializes (a `Date` serializes to
57
+ * the same ISO string).
58
+ *
59
+ * @param source - The markdown source, with or without a frontmatter block
60
+ * @returns The decoded frontmatter data and the body content
61
+ *
62
+ * @public
63
+ */
64
+ function parseFrontmatter(source) {
65
+ const text = source.charCodeAt(0) === 65279 ? source.slice(1) : source;
66
+ if (!text.startsWith(OPEN_DELIMITER)) return {
67
+ data: {},
68
+ content: text
69
+ };
70
+ const afterOpen = text.charAt(3);
71
+ if (text === OPEN_DELIMITER) return {
72
+ data: {},
73
+ content: ""
74
+ };
75
+ if (afterOpen !== "\n" && !(afterOpen === "\r" && text.charAt(4) === "\n")) return {
76
+ data: {},
77
+ content: text
78
+ };
79
+ const fmStart = afterOpen === "\r" ? 5 : 4;
80
+ const closeIndex = text.indexOf(CLOSE_SEARCH, fmStart - 1);
81
+ let frontmatterText;
82
+ let content;
83
+ if (closeIndex === -1) {
84
+ frontmatterText = text.slice(fmStart);
85
+ content = "";
86
+ } else {
87
+ frontmatterText = closeIndex < fmStart ? "" : text.slice(fmStart, closeIndex);
88
+ let bodyStart = closeIndex + 4;
89
+ if (text.charAt(bodyStart) === "\r" && text.charAt(bodyStart + 1) === "\n") bodyStart += 2;
90
+ else if (text.charAt(bodyStart) === "\n") bodyStart += 1;
91
+ content = text.slice(bodyStart);
92
+ }
93
+ if (frontmatterText.trim() === "") return {
94
+ data: {},
95
+ content
96
+ };
97
+ const value = Effect.runSync(Yaml.parse(frontmatterText));
98
+ return {
99
+ data: value == null ? {} : value,
100
+ content
101
+ };
102
+ }
103
+ /**
104
+ * Serialize frontmatter data and body content back into a markdown document,
105
+ * preserving gray-matter's `matter.stringify` contract.
106
+ *
107
+ * @remarks
108
+ * Emits `---\n<yaml>---\n<content>` with the body's trailing newline ensured,
109
+ * and returns the body unchanged (no fences) when `data` has no keys — both
110
+ * gray-matter behaviors the write path relied on. The YAML is emitted by
111
+ * `@effected/yaml` with every string value double-quoted (see
112
+ * `STRINGIFY_OPTIONS` for why); byte output differs from js-yaml's dump, but
113
+ * the decoded representation is identical, which is the invariant the
114
+ * snapshot hashes depend on. Unchanged pages are never rewritten, so the byte
115
+ * difference only ever lands in files that were being rewritten anyway.
116
+ *
117
+ * @param content - The body content
118
+ * @param data - The frontmatter data to serialize
119
+ * @returns The combined markdown document
120
+ *
121
+ * @public
122
+ */
123
+ function stringifyFrontmatter(content, data) {
124
+ const body = content.endsWith("\n") ? content : `${content}\n`;
125
+ if (Object.keys(data).length === 0) return body;
126
+ return `---\n${Effect.runSync(Yaml.stringify(data, STRINGIFY_OPTIONS))}---\n${body}`;
127
+ }
128
+ /**
129
+ * Serialize a data object to a YAML frontmatter block (fences included, plus
130
+ * the trailing blank line the page generators emit before the body).
131
+ *
132
+ * @remarks
133
+ * Used by `generateFrontmatter` (`markdown/helpers.ts`) as the emission half
134
+ * of the page generators' frontmatter. Every string value is double-quoted
135
+ * (see `STRINGIFY_OPTIONS`), so values that a YAML 1.1 consumer would
136
+ * otherwise coerce (timestamps, `yes`/`no`, numeric-looking strings) stay
137
+ * strings for RSPress's js-yaml parse.
138
+ *
139
+ * @param data - The frontmatter data to serialize
140
+ * @returns A `---`-fenced YAML block ending with a blank line
141
+ *
142
+ * @public
143
+ */
144
+ function emitFrontmatterBlock(data) {
145
+ return `---\n${Effect.runSync(Yaml.stringify(data, STRINGIFY_OPTIONS))}---\n\n`;
146
+ }
147
+
148
+ //#endregion
149
+ export { emitFrontmatterBlock, parseFrontmatter, stringifyFrontmatter };
package/index.d.ts CHANGED
@@ -874,6 +874,13 @@ type FromDirOptions = Omit<Partial<MultiApiConfig>, "baseRoute"> & {
874
874
  * `ApiExtractorPlugin.api.fromDir`; the returned config can be passed to the
875
875
  * single-API `api:` option or used as an element of the multi-API `apis:` array.
876
876
  *
877
+ * Discovery (model-file selection, unscoped-name disambiguation, tsconfig
878
+ * detection) delegates to `@tsdoctor/bundle`'s `discoverBundle`; the
879
+ * RSPress-specific concerns — `baseRoute` templating and `MultiApiConfig`
880
+ * assembly — stay here, as does the plugin's stricter contract that the
881
+ * folder carry a named `package.json` (the bundle spec itself accepts
882
+ * layer-0-only folders).
883
+ *
877
884
  * `baseRoute` is intentionally left unset unless overridden, so the plugin
878
885
  * applies its own context-aware default (`/api` under `api:`,
879
886
  * `/{packageName}/api` under `apis:`). See {@link BaseRoute}.
@@ -883,8 +890,10 @@ declare function fromDir(dir: string, overrides?: FromDirOptions): MultiApiConfi
883
890
  * Strictly scan a parent directory of package folders and build one
884
891
  * `MultiApiConfig` per subfolder. Exposed as `ApiExtractorPlugin.apis.fromDir`;
885
892
  * the returned array is intended for the multi-API `apis:` option. Every
886
- * non-dotfile subdirectory MUST be a valid model folder. `options` (minus
887
- * `cwd`) is applied as shared defaults to each `api.fromDir` call.
893
+ * non-dotfile subdirectory MUST be a valid model folder including a
894
+ * `package.json`, which is this adapter's stricter contract over the bundle
895
+ * spec's layer-0-only discovery. `options` (minus `cwd`) is applied as shared
896
+ * defaults to each `api.fromDir` call.
888
897
  */
889
898
  declare function fromParentDir(parentDir: string, options?: FromDirOptions): MultiApiConfig[];
890
899
  //#endregion