@tsdoctor/bundle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ import { HashMap, Option } from "effect";
2
+
3
+ //#region src/BundleResolver.ts
4
+ /** MIME types inferred from image file extensions. */
5
+ const IMAGE_MIME_BY_EXTENSION = {
6
+ avif: "image/avif",
7
+ gif: "image/gif",
8
+ jpeg: "image/jpeg",
9
+ jpg: "image/jpeg",
10
+ png: "image/png",
11
+ svg: "image/svg+xml",
12
+ webp: "image/webp"
13
+ };
14
+ /** The lowercased file extension of an image path or URL, query/fragment stripped. */
15
+ function imageExtension(pathOrUrl) {
16
+ const withoutQuery = pathOrUrl.split(/[?#]/, 1)[0];
17
+ const lastSegment = withoutQuery.slice(withoutQuery.lastIndexOf("/") + 1);
18
+ const dot = lastSegment.lastIndexOf(".");
19
+ if (dot <= 0) return;
20
+ return lastSegment.slice(dot + 1).toLowerCase();
21
+ }
22
+ /** A key-sorted plain record from a HashMap, for deterministic downstream hashing. */
23
+ function sortedRecord(map) {
24
+ const entries = [...HashMap.entries(map)].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
25
+ return Object.fromEntries(entries);
26
+ }
27
+ /** The highest-ranked defined candidate, with its source. */
28
+ function pick(candidates) {
29
+ for (const [value, source] of candidates) if (value !== void 0) return {
30
+ value,
31
+ source
32
+ };
33
+ }
34
+ /**
35
+ * Resolve a bundle's layers into a {@link ResolvedBundle}, pure.
36
+ *
37
+ * @remarks
38
+ * Highest tier wins per FIELD: `manifest.platform` → `manifest.leaf` →
39
+ * `manifest.project` → `packageJson` → `apiModel` → `inferred`. The project
40
+ * tier participates only in the fields it carries site/project identity for
41
+ * (tagline); the display `name` chain deliberately skips it — a project name
42
+ * outranking every leaf's own name would render each package in a monorepo
43
+ * under the same title, and the spec's `og:title` derivation reads
44
+ * `leaf name/tagline ← package name`. Inference (image `alt` and MIME
45
+ * `type`) runs on the RESOLVED tagline/description, so a tagline change at
46
+ * any tier propagates into inferred alt text.
47
+ *
48
+ * @public
49
+ */
50
+ function resolveBundle(input) {
51
+ const platform = input.platform;
52
+ const leaf = input.manifest;
53
+ const project = leaf?.project;
54
+ const packageJson = input.packageJson;
55
+ const name = pick([
56
+ [platform?.name, "manifest.platform"],
57
+ [leaf?.name, "manifest.leaf"],
58
+ [packageJson?.name, "packageJson"],
59
+ [input.apiModel.name, "apiModel"]
60
+ ]);
61
+ const tagline = pick([
62
+ [platform?.tagline, "manifest.platform"],
63
+ [leaf?.tagline, "manifest.leaf"],
64
+ [project?.tagline, "manifest.project"]
65
+ ]);
66
+ const description = pick([
67
+ [platform?.description, "manifest.platform"],
68
+ [leaf?.description, "manifest.leaf"],
69
+ [packageJson?.description, "packageJson"]
70
+ ]);
71
+ const version = packageJson?.version !== void 0 ? {
72
+ value: String(packageJson.version),
73
+ source: "packageJson"
74
+ } : void 0;
75
+ const projectIdentity = project !== void 0 ? {
76
+ value: project,
77
+ source: "manifest.project"
78
+ } : void 0;
79
+ const sbom = pick([[platform?.sbom, "manifest.platform"], [leaf?.sbom, "manifest.leaf"]]);
80
+ const registries = pick([[platform?.registries, "manifest.platform"], [leaf?.registries, "manifest.leaf"]]);
81
+ const openGraphRaw = pick([[platform?.openGraph, "manifest.platform"], [leaf?.openGraph, "manifest.leaf"]]);
82
+ const inferredAlt = () => {
83
+ if (tagline !== void 0) return {
84
+ value: tagline.value,
85
+ source: "inferred"
86
+ };
87
+ if (description !== void 0) return {
88
+ value: description.value,
89
+ source: "inferred"
90
+ };
91
+ return {
92
+ value: `${name.value} API documentation`,
93
+ source: "inferred"
94
+ };
95
+ };
96
+ const openGraph = openGraphRaw !== void 0 ? {
97
+ value: {
98
+ images: (openGraphRaw.value.images ?? []).map((image) => {
99
+ const extension = imageExtension(image.path ?? image.url ?? "");
100
+ const inferredType = extension !== void 0 ? IMAGE_MIME_BY_EXTENSION[extension] : void 0;
101
+ const type = image.type !== void 0 ? {
102
+ value: image.type,
103
+ source: openGraphRaw.source
104
+ } : inferredType !== void 0 ? {
105
+ value: inferredType,
106
+ source: "inferred"
107
+ } : void 0;
108
+ const alt = image.alt !== void 0 ? {
109
+ value: image.alt,
110
+ source: openGraphRaw.source
111
+ } : inferredAlt();
112
+ return {
113
+ ...image.path !== void 0 ? { path: image.path } : {},
114
+ ...image.url !== void 0 ? { url: image.url } : {},
115
+ ...type !== void 0 ? { type } : {},
116
+ ...image.width !== void 0 ? { width: image.width } : {},
117
+ ...image.height !== void 0 ? { height: image.height } : {},
118
+ alt
119
+ };
120
+ }),
121
+ ...openGraphRaw.value.themeColor !== void 0 ? { themeColor: openGraphRaw.value.themeColor } : {}
122
+ },
123
+ source: openGraphRaw.source
124
+ } : void 0;
125
+ const dependencies = packageJson !== void 0 ? {
126
+ value: sortedRecord(packageJson.dependencies),
127
+ source: "packageJson"
128
+ } : void 0;
129
+ const peerDependencies = packageJson !== void 0 ? {
130
+ value: sortedRecord(packageJson.peerDependencies),
131
+ source: "packageJson"
132
+ } : void 0;
133
+ const compilerOptions = input.tsconfig !== void 0 ? {
134
+ value: input.tsconfig.compilerOptions,
135
+ source: "tsconfig"
136
+ } : void 0;
137
+ return {
138
+ name,
139
+ ...version !== void 0 ? { version } : {},
140
+ ...tagline !== void 0 ? { tagline } : {},
141
+ ...description !== void 0 ? { description } : {},
142
+ ...projectIdentity !== void 0 ? { project: projectIdentity } : {},
143
+ ...openGraph !== void 0 ? { openGraph } : {},
144
+ ...sbom !== void 0 ? { sbom } : {},
145
+ ...registries !== void 0 ? { registries } : {},
146
+ ...dependencies !== void 0 ? { dependencies } : {},
147
+ ...peerDependencies !== void 0 ? { peerDependencies } : {},
148
+ ...compilerOptions !== void 0 ? { compilerOptions } : {}
149
+ };
150
+ }
151
+ /**
152
+ * Resolve a read {@link Bundle}, unwrapping its `Option` layers.
153
+ *
154
+ * @public
155
+ */
156
+ function resolveBundleFrom(bundle, platform) {
157
+ return resolveBundle({
158
+ apiModel: bundle.apiModel,
159
+ ...Option.isSome(bundle.packageJson) ? { packageJson: bundle.packageJson.value } : {},
160
+ ...Option.isSome(bundle.tsconfig) ? { tsconfig: bundle.tsconfig.value } : {},
161
+ ...Option.isSome(bundle.manifest) ? { manifest: bundle.manifest.value } : {},
162
+ ...platform !== void 0 ? { platform } : {}
163
+ });
164
+ }
165
+
166
+ //#endregion
167
+ export { resolveBundle, resolveBundleFrom };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,48 @@
1
+ import { BundleManifestError, OpenGraphConfig, RegistryRef, SbomRef } from "./BundleManifest.js";
2
+ import { Effect, Schema } from "effect";
3
+
4
+ //#region src/PlatformOverrides.ts
5
+ /**
6
+ * The `manifest.platform` tier: a data-override object a consumer passes
7
+ * through platform options (e.g. `ApiExtractorPlugin(options)`), sitting at
8
+ * the TOP of the tier ranking.
9
+ *
10
+ * @remarks
11
+ * Same field surface as the authored manifest tiers — name, tagline,
12
+ * description, openGraph, sbom, registries — with no `spec` field (it is not
13
+ * a file with an independent version) and no `project` block (it is a single
14
+ * tier, not a flattened hierarchy). Lets a user with ONLY an api.json declare
15
+ * identity/OG/registries declaratively; the resolver does the merging.
16
+ *
17
+ * @public
18
+ */
19
+ const PlatformOverrides = Schema.Struct({
20
+ /** Human display name override. */
21
+ name: Schema.optionalKey(Schema.String),
22
+ /** Tagline override. */
23
+ tagline: Schema.optionalKey(Schema.String),
24
+ /** Description override. */
25
+ description: Schema.optionalKey(Schema.String),
26
+ /** Open Graph override. */
27
+ openGraph: Schema.optionalKey(OpenGraphConfig),
28
+ /** SBOM pointer override. */
29
+ sbom: Schema.optionalKey(SbomRef),
30
+ /** Registries override. */
31
+ registries: Schema.optionalKey(Schema.Array(RegistryRef))
32
+ });
33
+ /**
34
+ * Decode an unknown value into a {@link (PlatformOverrides:type)}.
35
+ *
36
+ * @remarks
37
+ * For adapters decoding raw platform options. Failures share
38
+ * {@link BundleManifestError} — the platform tier is manifest data by another
39
+ * route, and a caller handles both boundaries with one tag.
40
+ *
41
+ * @public
42
+ */
43
+ function decodePlatformOverrides(input) {
44
+ return Schema.decodeUnknownEffect(PlatformOverrides)(input).pipe(Effect.mapError((cause) => new BundleManifestError({ cause })));
45
+ }
46
+
47
+ //#endregion
48
+ export { PlatformOverrides, decodePlatformOverrides };
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @tsdoctor/bundle
2
+
3
+ [![npm](https://img.shields.io/npm/v/@tsdoctor%2Fbundle?label=npm&color=cb3837)](https://www.npmjs.com/package/@tsdoctor/bundle)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ The tsdoctor bundle spec. A bundle is the folder of files that describes one documented package — an API Extractor `.api.json` model plus optional overlays — and this package owns everything about reading one: discovery on disk, the versioned `tsdoctor.json` sidecar manifest, resolution of manifest data across override tiers with per-field provenance, and canonical hashing of the inputs for change detection.
9
+
10
+ ## The layered bundle
11
+
12
+ A bundle is one required file plus optional overlays. Each layer enriches the result; none of the optional layers gates it — a folder holding only an `.api.json` still resolves.
13
+
14
+ | Layer | File | Required | Supplies |
15
+ | --- | --- | --- | --- |
16
+ | 0 | `<name>.api.json` | yes | package name, the API itself |
17
+ | 1 | `package.json` | no | version, description, dependencies and peers |
18
+ | 2 | `tsconfig.json` | no | compiler options for the rendering environment |
19
+ | 3 | `tsdoctor.json` | no | display identity, Open Graph, SBOM pointer, registries |
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pnpm add @tsdoctor/bundle effect @effected/glob @effected/package-json @effected/tsconfig-json @effected/walker
25
+ ```
26
+
27
+ This is an ESM-only package. `effect` and the `@effected/*` packages are peer dependencies.
28
+
29
+ ## Quick start
30
+
31
+ ```ts
32
+ import { NodeFileSystem } from "@effect/platform-node";
33
+ import { Effect, Layer, Path } from "effect";
34
+ import { fingerprintResolvedBundle, loadBundle, resolveBundleFrom } from "@tsdoctor/bundle";
35
+
36
+ const program = Effect.gen(function* () {
37
+ const bundle = yield* loadBundle("./lib/models/kitchensink");
38
+ const resolved = resolveBundleFrom(bundle, {
39
+ // The manifest.platform tier: declarative overrides that outrank every file.
40
+ tagline: "Every API Extractor feature in one module",
41
+ });
42
+ console.log(resolved.name); // { value: "...", source: "manifest.leaf" | "packageJson" | ... }
43
+ console.log(fingerprintResolvedBundle(resolved)); // per-field SHA-256 fingerprints
44
+ });
45
+
46
+ program.pipe(Effect.provide(Layer.mergeAll(NodeFileSystem.layer, Path.layer)), Effect.runPromise);
47
+ ```
48
+
49
+ Every resolved field carries `{ value, source }`, so "did the user override this or did we derive it?" is a rank comparison, not a heuristic. The fingerprints feed a snapshot store: unchanged inputs mean generation can be skipped at the granularity of exactly the surfaces a changed field invalidates.
50
+
51
+ ## API surface
52
+
53
+ - `discoverBundle(dir)` / `discoverBundles(parentDir)` — resolve folder(s) into `BundleDescriptor`s (model file selection, name/version parsing, overlay detection).
54
+ - `fetchNpmBundle` / `fetchGitHubReleaseBundle` — fetch a published bundle from any npm-protocol registry (via `@effected/npm`'s verified tarball pipeline) or from a GitHub release's `*.npm.meta.tgz` asset (via `@effected/github`), through a durable XDG cache (`@effected/store` + `@effected/xdg`). npm versions are immutable, so cache hits skip the network; `refresh: true` refetches.
55
+ - `readBundle(descriptor)` / `loadBundle(dir)` / `loadBundles(parentDir)` — read the four layers into typed structures.
56
+ - `BundleManifest`, `decodeBundleManifest` — the `tsdoctor.json` spec-1 schema; unknown fields and unknown registry types degrade gracefully instead of rejecting.
57
+ - `PlatformOverrides`, `decodePlatformOverrides` — the top-ranked data-override tier a consumer passes through platform options.
58
+ - `resolveBundle` / `resolveBundleFrom` — the pure six-tier resolver producing a `ResolvedBundle` of `Provenanced` fields, with the documented inference rules (Open Graph alt-text chain, MIME from extension).
59
+ - `hashLayerText`, `hashJsonValue`, `canonicalJson`, `fingerprintResolvedBundle` — canonical-normalization hashing for coarse (per-layer) and fine (per-field) change detection.
60
+
61
+ All filesystem-touching functions keep `FileSystem` and `Path` in the Effect `R` channel; provide a platform layer (for example `@effect/platform-node`) once at the application boundary.
62
+
63
+ ## License
64
+
65
+ [MIT](LICENSE)