@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.
- package/Bundle.js +127 -0
- package/BundleDiscovery.js +233 -0
- package/BundleFetch.js +348 -0
- package/BundleHash.js +107 -0
- package/BundleManifest.js +183 -0
- package/BundleResolver.js +167 -0
- package/LICENSE +21 -0
- package/PlatformOverrides.js +48 -0
- package/README.md +65 -0
- package/index.d.ts +885 -0
- package/index.js +9 -0
- package/package.json +55 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -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
|
+
[](https://www.npmjs.com/package/@tsdoctor/bundle)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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)
|