zopia 0.3.0 โ†’ 0.5.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/CHANGELOG.md CHANGED
@@ -7,7 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-29
11
+
12
+ ### โœจ Added
13
+ - ๐Ÿง  **Exact IntelliSense for runtime tree consumption (S-95).** Every tree
14
+ generated with a manifest now also carries a types-only
15
+ **`.zopia-tree.d.ts`** declaration beside the manifest, and
16
+ `createApiDocs` / `flattenApiDocs` accept it as a type argument โ€” turning the
17
+ permissive runtime typing into **exact** typing:
18
+ ```ts
19
+ import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
20
+ import type { ApiDocsTree, ApiDocsFlat } from './api_docs/.zopia-tree';
21
+
22
+ const apiDocs = await createApiDocs<ApiDocsTree>('./api_docs');
23
+ apiDocs.users['{userId}'].get.pathShape; // literal "/users/{userId}", autocompleted
24
+ const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
25
+ endpoints.getUser; // exact key, same leaf object
26
+ ```
27
+ Segment and method keys autocomplete exactly (unknown keys are **compile
28
+ errors**, not `any`), every leaf is typed as the generated module's own
29
+ `makeApiConfig()` export (literal `method`/`pathShape`, exact Zod request /
30
+ response shapes), and the flat record's keys derive through the same shared
31
+ naming rules as the generator's export identifiers (camelize,
32
+ reserved-word guard, `2`/`3`โ€ฆ collision suffixes) โ€” parity with the runtime
33
+ keys is pinned by tests. The declaration is emitted in both layouts and in
34
+ every preset bucket root, follows the manifest lifecycle (pruned when
35
+ manifests are disabled, refreshed on regeneration, and a deleted declaration
36
+ reports `ZOPIA_WARN_STALE_TREE`), and is types-only: it imports nothing
37
+ beyond the tree itself (R-502 holds). `zopia generate` results report the
38
+ file with a new `kind: 'types'`. Conflicting paths that cannot share one
39
+ nested tree (below a method leaf, trailing-slash twins) render the
40
+ permissive intersection shape โ€” matching the runtime's typed
41
+ `ZOPIA_SPEC_INVALID` failure. The shared deterministic tree ordering
42
+ (path segments, then canonical method order) moved to
43
+ `src/conversions/api-docs-layout.ts` so the runtime resolver and the
44
+ declaration emitter enumerate identically.
45
+
46
+ ## [0.4.0] - 2026-09-29
47
+
48
+ ### ๐Ÿ”„ Changed
49
+ - ๐Ÿ“ฆ **Slimmer npm package โ€” practical docs only.** The published archive now
50
+ ships just the user-facing guides (`docs/07-api-docs.md`,
51
+ `docs/09-configuration.md`, `docs/10-usage.md`) next to `README.md` /
52
+ `CHANGELOG.md`; the development documentation (overview, targets, roadmap,
53
+ architecture, concepts, conversions, components, testing, standards, the
54
+ docs map, and the publish-workflow example) stays in the GitHub repository
55
+ and is linked from the README โ€” npm users installing the package get
56
+ usage/installation docs, not project management artifacts. The tarball
57
+ shrinks **239 KB โ†’ 182 KB packed (915 KB โ†’ 750 KB unpacked, 50 โ†’ 39
58
+ files)**; `npm` force-includes `README*` from any directory, so the docs map
59
+ is explicitly negated (`!docs/README.md`) in `files`. `package:check` and
60
+ the release contract now enforce the slim archive: a missing practical doc
61
+ fails, and any development doc leaking into the pack fails. The release
62
+ standard (R-192) and the docs map describe the split.
63
+
10
64
  ### โœจ Added
65
+ - ๐ŸŒณ **Runtime tree consumption โ€” `zopia/runtime` (S-94).** New opt-in subpath
66
+ export (`import { createApiDocs, flattenApiDocs } from 'zopia/runtime'`) that
67
+ turns a generated api-docs directory into the objects an application
68
+ consumes, with **zero changes to generation output**. `createApiDocs(dir)` โ€”
69
+ the entire consumer DX โ€” discovers the root `.zopia-manifest.json` plus every
70
+ one-level-deep preset bucket manifest (`multi-tag`/`multi-server`), merges
71
+ them (deduplicating by `${path}#${method}`, root manifest first), sorts
72
+ deterministically (path segments, then the canonical method order), and
73
+ returns one nested object keyed by the exact URL path segments with the
74
+ lowercase method as leaf key holding the endpoint module's `export default`
75
+ (loaded via `pathToFileURL`, Windows-safe). `flattenApiDocs(tree)` is the
76
+ flat freebie: a `Record<string, config>` keyed by `operationId`, deriving
77
+ missing names and collision suffixes through the **same shared rules the
78
+ generator uses for its export identifiers** (extracted to
79
+ `src/conversions/api-docs-names.ts`: camelize, reserved-word guard,
80
+ leading-numeric guard, `2`/`3`โ€ฆ suffixing โ€” `await` โ†’ `awaitEndpoint`).
81
+ Missing/garbled manifests, unsafe manifest paths, import failures, and
82
+ modules without default exports fail typed (`ZOPIA_DOCS_MISSING_MANIFEST`,
83
+ `ZOPIA_MANIFEST_INVALID`, `ZOPIA_DOCS_IMPORT_FAILED`); paths that cannot
84
+ share one nested tree (below a method leaf, trailing-slash twins) fail typed
85
+ `ZOPIA_SPEC_INVALID`. Documented in
86
+ [docs/07-api-docs.md โ†’ Runtime tree consumption](docs/07-api-docs.md).
11
87
  - ๐Ÿ“ฆ **npm publish pipeline** โ€” `.github/workflows/publish.yml` publishes on GitHub Release creation (or manually) with the pinned Bun toolchain, verifies the release tag matches `package.json`'s version, and runs `npm publish --provenance --access public`. Its only credential is the repository-secret `NODE_AUTH_TOKEN` (npm `NPM_TOKEN`) โ€” never handled in chat or commits; npm trusted-publishing (OIDC) is supported by the declared `id-token` permission.
12
88
 
13
89
  ### ๐Ÿ› Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  [![Runtime](https://img.shields.io/badge/Runtime-Bun%201.x-black.svg)](https://bun.sh/)
14
14
  [![Tests](https://img.shields.io/badge/Tests-vitest-10b981.svg)](https://vitest.dev/)
15
15
 
16
- โœ… **Status โ€” Phase 3 complete ยท v0.3.0 released**
16
+ โœ… **Status โ€” Phase 3 complete ยท v0.5.0 released**
17
17
 
18
18
  </div>
19
19
 
@@ -91,25 +91,62 @@ await openApiToApiDocs('swagger.json', {
91
91
  const { openapi } = await apiDocsToOpenApi('api_docs', { version: '3.1' });
92
92
  ```
93
93
 
94
+ ### ๐ŸŒณ Consuming the tree at runtime
95
+
96
+ When wiring endpoints dynamically beats importing generated files one by one,
97
+ import the opt-in runtime subpath and point it at any directory zopia ever
98
+ generated into (`directory`, `flat`, and `multi-tag` / `multi-server` splits
99
+ all resolve through their manifests):
100
+
101
+ ```ts
102
+ import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
103
+ import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
104
+
105
+ // Nested: URL path segments โ†’ lowercase method โ†’ the makeApiConfig object
106
+ const apiDocs = await createApiDocs<ApiDocsTree>('api_docs');
107
+ const getUser = apiDocs.users['{userId}'].get; // default export of that index.ts
108
+
109
+ // Flat freebie: keyed by operationId, same leaf objects, deterministic order
110
+ const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
111
+ endpoints.getUser === getUser; // โ†’ true
112
+ ```
113
+
114
+ Every generated tree also ships a types-only `.zopia-tree.d.ts` โ€” pass its
115
+ types as shown and IntelliSense becomes **exact**: keys autocomplete, and
116
+ misspelled segments, methods, or endpoint names are compile errors.
117
+
118
+ Generation output is untouched โ€” the resolver only reads manifests and imports
119
+ modules. See [docs/07-api-docs.md โ†’ Runtime tree consumption](docs/07-api-docs.md#-runtime-tree-consumption--createapidocs).
120
+
94
121
  ## ๐Ÿ“š Documentation
95
122
 
96
- Everything about the project โ€” targets, architecture, conversion rules,
97
- output format, configuration, testing, standards โ€” lives in [`docs/`](docs/).
123
+ The **practical guides ship inside the npm package** โ€” usage, configuration,
124
+ and the generated output format. The development documentation (project
125
+ targets, roadmap, architecture, testing strategy, engineering standards)
126
+ stays in the
127
+ [GitHub repository](https://github.com/komeilm76/zopia/tree/main/docs).
128
+
129
+ **๐Ÿ“ฆ Packed with the npm package** (relative links, in `docs/`):
98
130
 
99
131
  | ๐Ÿ“„ Document | Contents |
100
132
  | --- | --- |
101
- | ๐Ÿงญ [Overview](docs/01-overview.md) | What zopia is, the problem it solves, principles, non-goals |
102
- | ๐ŸŽฏ [Targets](docs/02-targets.md) | The explicit, testable targets of this project |
103
- | ๐Ÿ—บ๏ธ [Roadmap](docs/03-roadmap.md) | Phases, milestones, definition of done |
104
- | ๐Ÿ—๏ธ [Architecture](docs/04-architecture.md) | Modules, pipeline, internal model, error model, safety |
105
- | ๐Ÿงฉ [Concepts](docs/05-concepts.md) | Glossary โ€” Swagger 2.0, OpenAPI 3.x, JSON Schema, `$ref`, Zod v4, km-api |
106
- | ๐Ÿ”„ [Conversions](docs/06-conversions.md) | The four engines: algorithms, mapping tables, edge cases |
107
- | ๐Ÿ“„ [API docs format](docs/07-api-docs.md) | `directory` & `flat` layouts, `index.ts` contract, manifest |
108
- | ๐Ÿงฑ [Components](docs/08-components.md) | `insertComponents` / `useComponentAsReference`, `$ref` graphs |
109
- | โš™๏ธ [Configuration](docs/09-configuration.md) | Full option reference, defaults, validation rules |
110
133
  | ๐Ÿš€ [Usage](docs/10-usage.md) | Installation, programmatic API, CLI, end-to-end example |
111
- | ๐Ÿงช [Testing](docs/11-testing.md) | Vitest strategy, scenario matrix, fixtures, coverage gates |
112
- | ๐Ÿ“ [Standards](docs/12-standards.md) | Code, JSDoc, commits, changelog, releases, key decisions |
134
+ | โš™๏ธ [Configuration](docs/09-configuration.md) | Full option reference, defaults, validation rules |
135
+ | ๐Ÿ“„ [API docs format](docs/07-api-docs.md) | `directory` & `flat` layouts, `index.ts` contract, manifest, runtime consumption |
136
+
137
+ **๐Ÿ™ GitHub-only** (development docs, not packed):
138
+
139
+ | ๐Ÿ“„ Document | Contents |
140
+ | --- | --- |
141
+ | ๐Ÿงญ [Overview](https://github.com/komeilm76/zopia/blob/main/docs/01-overview.md) | What zopia is, the problem it solves, principles, non-goals |
142
+ | ๐ŸŽฏ [Targets](https://github.com/komeilm76/zopia/blob/main/docs/02-targets.md) | The explicit, testable targets of this project |
143
+ | ๐Ÿ—บ๏ธ [Roadmap](https://github.com/komeilm76/zopia/blob/main/docs/03-roadmap.md) | Phases, milestones, definition of done |
144
+ | ๐Ÿ—๏ธ [Architecture](https://github.com/komeilm76/zopia/blob/main/docs/04-architecture.md) | Modules, pipeline, internal model, error model, safety |
145
+ | ๐Ÿงฉ [Concepts](https://github.com/komeilm76/zopia/blob/main/docs/05-concepts.md) | Glossary โ€” Swagger 2.0, OpenAPI 3.x, JSON Schema, `$ref`, Zod v4, km-api |
146
+ | ๐Ÿ”„ [Conversions](https://github.com/komeilm76/zopia/blob/main/docs/06-conversions.md) | The four engines: algorithms, mapping tables, edge cases |
147
+ | ๐Ÿงฑ [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md) | `insertComponents` / `useComponentAsReference`, `$ref` graphs |
148
+ | ๐Ÿงช [Testing](https://github.com/komeilm76/zopia/blob/main/docs/11-testing.md) | Vitest strategy, scenario matrix, fixtures, coverage gates |
149
+ | ๐Ÿ“ [Standards](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md) | Code, JSDoc, commits, changelog, releases, key decisions |
113
150
 
114
151
  ## ๐Ÿ—๏ธ Project structure
115
152
 
@@ -139,7 +176,7 @@ zopia/
139
176
 
140
177
  ## ๐Ÿงช Development
141
178
 
142
- zopia is a **Bun-first** project (see [docs/12-standards.md](docs/12-standards.md)):
179
+ zopia is a **Bun-first** project (see [docs/12-standards.md](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md)):
143
180
 
144
181
  ```bash
145
182
  bun install --frozen-lockfile # ๐Ÿ“ฆ reproducible install from bun.lock
@@ -157,7 +194,7 @@ bun run release:check # ๐Ÿšข complete pinned-Bun prepublish gate
157
194
 
158
195
  ## ๐Ÿค Contributing
159
196
 
160
- Read [docs/12-standards.md](docs/12-standards.md) first โ€” it defines code style,
197
+ Read [docs/12-standards.md](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md) first โ€” it defines code style,
161
198
  JSDoc rules, the commit convention, and the changelog rule. Every feature ships
162
199
  **with** its documentation in the same commit.
163
200
 
@@ -106,6 +106,98 @@ templates, duplicate parameters, unsupported methods, and unsafe roots. Engine
106
106
  does not emit a facade module in v0.1.0: direct imports are the generated
107
107
  file-level API, and the manifest remains authoritative (D-06).
108
108
 
109
+ ## ๐ŸŒณ Runtime tree consumption โ€” `createApiDocs`
110
+
111
+ > ๐ŸŽฏ **S-94** โ€” *an opt-in runtime API, shipped in the `zopia` package itself,
112
+ > converts a generated tree directory into the nested / flat objects an
113
+ > application consumes. No generation output changes: the feature only reads
114
+ > and imports.*
115
+
116
+ ```ts
117
+ import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
118
+ import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
119
+
120
+ const apiDocs = await createApiDocs<ApiDocsTree>('api_docs'); // โ† the entire consumer DX
121
+ const endpoint = apiDocs.applicant['{applicantId}'].exame['{examId}'].get;
122
+ // โ†’ the endpoint module's makeApiConfig object (its default export), EXACTLY typed
123
+
124
+ const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs); // the flat freebie
125
+ endpoints.getExam; // same leaf object
126
+ ```
127
+
128
+ **Exact IntelliSense (S-95).** Called without a type argument, the helpers
129
+ return permissively typed results (every node is a branch โˆช config). Passing
130
+ the generated **`.zopia-tree.d.ts`** types โ€” written beside every retained
131
+ manifest, in both layouts and every preset bucket root โ€” makes the result
132
+ **exact**: segment and method keys autocomplete, unknown keys are compile
133
+ errors (not `any`), every leaf carries the generated module's own
134
+ `makeApiConfig()` type (literal `method`/`pathShape`, exact Zod request and
135
+ response shapes), and the flat record's keys are the derived endpoint names
136
+ (same shared rules as the generator's export identifiers, including collision
137
+ suffixes). The declaration is types-only โ€” it imports nothing beyond the tree
138
+ itself (R-502 holds) โ€” and follows the manifest lifecycle: refreshed on
139
+ regeneration, pruned when manifests are disabled, and a deleted declaration
140
+ reports `ZOPIA_WARN_STALE_TREE`. `zopia generate` results list it with
141
+ `kind: 'types'`.
142
+
143
+ **Manifest discovery & merge (B-conditions).** The resolver reads the root
144
+ `.zopia-manifest.json` **plus** every one-level-deep preset bucket manifest
145
+ (`multi-tag` / `multi-server` splits write one manifest per bucket directory),
146
+ merging all of them and deduplicating by `${path}#${method}` โ€” the root
147
+ manifest wins duplicates, buckets follow in sorted directory order. Any
148
+ directory that was ever a zopia output root is accepted, including a single
149
+ preset bucket root. Entries are then sorted deterministically: URL path
150
+ segments first, then the canonical method order
151
+ (`get, post, put, delete, head, options, patch, trace`).
152
+
153
+ **Nesting.** Branch keys are the URL path segments **exactly and in order**
154
+ (including literal `{param}` segments), the leaf key is the lowercase method,
155
+ and the leaf value is the endpoint module's **default export**, loaded with
156
+ dynamic `import()` through `pathToFileURL` (Windows-safe). Because which keys
157
+ exist depends on the source spec, the default `ApiDocsTree` return type is
158
+ permissive (branch โˆช config) โ€” pass the generated `.zopia-tree.d.ts` type for
159
+ exact keys (see below). A tree `/` path nests its methods at the root
160
+ (`apiDocs.get`). Paths that cannot coexist in one nested tree โ€” a path
161
+ continuing below another path's method leaf, or two paths differing only by a
162
+ trailing slash โ€” fail with a typed `ZOPIA_SPEC_INVALID` instead of silently
163
+ dropping an endpoint. Key insertion follows the deterministic sort, so the
164
+ same tree always enumerates keys in the same order (P-1).
165
+
166
+ **Errors** reuse the manifest conventions: a missing manifest โ†’
167
+ `ZOPIA_DOCS_MISSING_MANIFEST`; garbled JSON or unusable `apis[]` records โ†’
168
+ `ZOPIA_MANIFEST_INVALID`; a module that cannot be imported or has no default
169
+ export โ†’ `ZOPIA_DOCS_IMPORT_FAILED` naming the file. Reading is pure โ€” nothing
170
+ on disk is written.
171
+
172
+ **The flatten freebie.** `flattenApiDocs(apiDocs)` deep-walks the tree in its
173
+ deterministic leaf order and returns a flat `Record<string, config>` keyed by
174
+ each config's `operationId`. When a leaf has no usable `operationId`, or two
175
+ keys collide, the name is derived by the **same rules the generator uses for
176
+ its `export const` identifiers** (R-732: camelize, reserved-word guard,
177
+ leading-numeric guard, `2`/`3`โ€ฆ uniqueness suffix โ€” `await` โ†’ `awaitEndpoint`,
178
+ `get-a` + `getA` โ†’ `getA` + `getA2`), so the flat keys always match the
179
+ modules' named exports. The shared rules live in
180
+ `src/conversions/api-docs-names.ts` and both call-sites (generation and
181
+ runtime) use them.
182
+
183
+ **A small km-api hand-off** โ€” every leaf *is* a `makeApiConfig()` object, so
184
+ the km-api surface is available directly:
185
+
186
+ ```ts
187
+ import { createApiDocs } from 'zopia/runtime';
188
+
189
+ const apiDocs = await createApiDocs('api_docs');
190
+ const getUser = apiDocs.users['{userId}'].get;
191
+
192
+ getUser.method; // "GET"
193
+ getUser.pathShape; // "/users/{userId}"
194
+ getUser.request.params.parse({ userId: '22ccbc6a-โ€ฆ' }); // โœ… Zod-validated path params
195
+ getUser.response[200].parse({ id: 'u1', email: 'a@b.c' }); // โœ… Zod-validated response
196
+ ```
197
+
198
+ > ๐Ÿ’ก Generated webhook modules are not URL-path endpoints and stay outside the
199
+ > nested tree โ€” import them directly or reverse-convert the tree (engine โ‘ฃ).
200
+
109
201
  ## ๐Ÿงฌ Operation contract extraction
110
202
 
111
203
  Before rendering an endpoint, zopia normalizes each operation into an
@@ -177,7 +269,7 @@ With the default options (`insertComponents: false`), every `index.ts` imports
177
269
  **only** `zod` and `km-api` (R-502): each component use is inlined into its
178
270
  request/parameter/response expression (R-403). Cross-file imports appear
179
271
  **only** when `useComponentAsReference` is `true` โ€” see
180
- [Components](08-components.md).
272
+ [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md).
181
273
 
182
274
  ## ๐Ÿ“ฆ The manifest โ€” `.zopia-manifest.json`
183
275
 
@@ -333,5 +425,5 @@ Security requirements remain manifest-owned because km-api stores only `auth: 'Y
333
425
 
334
426
  ## ๐Ÿ”— Next
335
427
 
336
- - ๐Ÿงฑ What changes when components are emitted โ†’ [Components](08-components.md)
428
+ - ๐Ÿงฑ What changes when components are emitted โ†’ [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md)
337
429
  - โš™๏ธ Every option that shapes this output โ†’ [Configuration](09-configuration.md)
@@ -107,7 +107,7 @@ interface ZopiaReverseOptions {
107
107
 
108
108
  ## ๐Ÿ“„ `ZodToJsonSchemaOptions` โ€” engine โ‘ 
109
109
 
110
- See [Conversions โ†’ Engine โ‘ ](06-conversions.md)
110
+ See [Conversions โ†’ Engine โ‘ ](https://github.com/komeilm76/zopia/blob/main/docs/06-conversions.md)
111
111
  (`target`, `$schema`, `io`).
112
112
 
113
113
  ## ๐Ÿ“„ `JsonSchemaToZodOptions` โ€” engine โ‘ก
@@ -164,4 +164,4 @@ zopia diff <old-spec> <new-spec> (no options yet; `--config path` accep
164
164
  ## ๐Ÿ”— Next
165
165
 
166
166
  - ๐Ÿš€ How options are used end-to-end โ†’ [Usage](10-usage.md)
167
- - ๐Ÿ“‚ What each option changes in the tree โ†’ [API docs format](07-api-docs.md) ยท [Components](08-components.md)
167
+ - ๐Ÿ“‚ What each option changes in the tree โ†’ [API docs format](07-api-docs.md) ยท [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md)
package/docs/10-usage.md CHANGED
@@ -178,8 +178,37 @@ stderr, hint included) ยท `2` internal error (should never happen โ€” report it)
178
178
  | R-101 | ๐Ÿ“‚ **Import, don't re-type** โ€” your app imports the generated `index.ts` files; their Zod schemas *are* the validation | โ€” |
179
179
  | R-102 | ๐Ÿ”„ **Spec or generation options changed?** re-run `zopia generate` โ€” output is idempotent (P-1); manifest staleness warns on source/config/incomplete-tree drift and safely prunes only obsolete manifest-owned files (`ZOPIA_WARN_STALE_TREE`) | [07 โ†’ Regeneration](07-api-docs.md#-regeneration--manual-edits-phase-1-policy) |
180
180
  | R-103 | โœ๏ธ **Hand edits** โ€” Phase 1 overwrites them on regeneration (see the file banner); the merge-safe custom layer arrives in Phase 3 | [07 โ†’ Regeneration](07-api-docs.md#-regeneration--manual-edits-phase-1-policy) |
181
- | R-104 | ๐Ÿงช **km-api helpers** โ€” `makeFullPath`, `makeParams`, `convertResponseType`, โ€ฆ are available on every generated config for free | [Concepts โ†’ km-api](05-concepts.md#-km-api) |
181
+ | R-104 | ๐Ÿงช **km-api helpers** โ€” `makeFullPath`, `makeParams`, `convertResponseType`, โ€ฆ are available on every generated config for free | [Concepts โ†’ km-api](https://github.com/komeilm76/zopia/blob/main/docs/05-concepts.md#-km-api) |
182
182
  | R-105 | ๐Ÿšซ **No zopia import in app code** โ€” generated files depend only on `zod` + `km-api` (R-502) | โ€” |
183
+ | R-106 | ๐ŸŒณ **Runtime tree loading** โ€” when wiring endpoints dynamically beats importing files one by one, `createApiDocs()` from the opt-in `zopia/runtime` subpath turns the whole directory into one nested object (plus the `flattenApiDocs` flat record); generation output is untouched | [07 โ†’ Runtime tree consumption](07-api-docs.md#-runtime-tree-consumption--createapidocs) |
184
+
185
+ ### ๐ŸŒณ Consuming the tree at runtime
186
+
187
+ Import the dedicated subpath (the package root stays free of
188
+ filesystem-importing APIs) and point it at any directory zopia ever generated
189
+ into โ€” `directory`, `flat`, and split `multi-tag` / `multi-server` trees all
190
+ resolve through their manifests:
191
+
192
+ ```ts
193
+ import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
194
+ import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
195
+
196
+ // Nested: exact URL path segments, lowercase method leaf, default-export config
197
+ const apiDocs = await createApiDocs<ApiDocsTree>('api_docs');
198
+ const getUser = apiDocs.users['{userId}'].get; // the makeApiConfig object
199
+
200
+ // Flat: keyed by operationId (derived with the generator's own naming rules)
201
+ const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
202
+ endpoints.getUser === getUser; // โ†’ true
203
+ ```
204
+
205
+ The type import is optional โ€” without it the results stay permissively typed โ€”
206
+ but with it every key is exact: `apiDocs.users.` autocompletes `{userId}`,
207
+ `endpoints.` autocompletes the endpoint names, and misspelled keys are compile
208
+ errors.
209
+
210
+ Failures are typed `ZopiaError`s with `at`/`hint` โ€” see
211
+ [API docs โ†’ Runtime tree consumption](07-api-docs.md#-runtime-tree-consumption--createapidocs).
183
212
 
184
213
  ## ๐Ÿงฏ Error handling
185
214
 
@@ -200,9 +229,9 @@ try {
200
229
  ```
201
230
 
202
231
  The full error-code table lives in
203
- [Architecture โ†’ Error model](04-architecture.md#-error-model).
232
+ [Architecture โ†’ Error model](https://github.com/komeilm76/zopia/blob/main/docs/04-architecture.md#-error-model).
204
233
 
205
234
  ## ๐Ÿ”— Next
206
235
 
207
236
  - โš™๏ธ Every option โ†’ [Configuration](09-configuration.md)
208
- - ๐Ÿงช How all of this is tested โ†’ [Testing](11-testing.md)
237
+ - ๐Ÿงช How all of this is tested โ†’ [Testing](https://github.com/komeilm76/zopia/blob/main/docs/11-testing.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zopia",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Type-safe OpenAPI, JSON Schema, and Zod conversion toolkit.",
5
5
  "keywords": [
6
6
  "api-docs",
@@ -27,7 +27,10 @@
27
27
  "files": [
28
28
  "bin",
29
29
  "src",
30
- "docs",
30
+ "docs/07-api-docs.md",
31
+ "docs/09-configuration.md",
32
+ "docs/10-usage.md",
33
+ "!docs/README.md",
31
34
  "CHANGELOG.md",
32
35
  "LICENSE",
33
36
  "README.md"
@@ -72,6 +75,11 @@
72
75
  "types": "./src/index.ts",
73
76
  "import": "./src/index.ts",
74
77
  "default": "./src/index.ts"
78
+ },
79
+ "./runtime": {
80
+ "types": "./src/runtime.ts",
81
+ "import": "./src/runtime.ts",
82
+ "default": "./src/runtime.ts"
75
83
  }
76
84
  }
77
85
  }
@@ -1,11 +1,13 @@
1
1
  import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
3
  import { asZopiaError, ZopiaError } from '../errors';
4
+ import { endpointExportName } from './api-docs-names';
4
5
  import { buildOpenApiOperationIR } from './openapi-ir';
5
6
  import { deriveReusableParameterSchema, deriveReusableResponseSchema, extractOperationContracts, reusableDeclarations } from './openapi-contracts';
6
7
  import { jsonSchemaToZod } from './json-schema-to-zod';
7
8
  import { assertUniqueOperationIdsAcrossScopes, planApiDocsFiles, planWebhookDocsFiles, webhookRuntimePath, type ApiDocsFilePlan } from './api-docs-plan';
8
9
  import { isPortableApiDocsSegment, type ApiDocsMode } from './api-docs-layout';
10
+ import { renderApiDocsTreeTypes, ZOPIA_TREE_TYPES_FILE } from './api-docs-tree-types';
9
11
  import type { OpenApiDocument } from './openapi';
10
12
  import { createZopiaManifest, hashOpenApiDocument, writeZopiaManifest, ZOPIA_MANIFEST_FILE } from './manifest-writer';
11
13
  import { inspectZopiaManifestStaleness, removeObsoleteManifestFiles } from './manifest-staleness';
@@ -61,7 +63,7 @@ function collectComponentRefs(value: unknown, names = new Set<string>(), mapEntr
61
63
  return names;
62
64
  }
63
65
  function schemaCode(schema: unknown, name: string): string {
64
- const safeName = exportName(name);
66
+ const safeName = endpointExportName(name);
65
67
  const converted = jsonSchemaToZod(schema === undefined ? true : schema as any, { rootName: safeName });
66
68
  const source = converted.code.trimEnd();
67
69
  const direct = source.match(new RegExp(`^const ${safeName.replace(/[$]/g, '\\$&')} = ([\\s\\S]*);$`));
@@ -222,23 +224,16 @@ function resolveObject(value: unknown, source: OpenApiDocument): any {
222
224
  }
223
225
  return current;
224
226
  }
225
- function exportName(operationId: string): string {
226
- const parts = operationId.split(/[^A-Za-z0-9_$]+/).filter(Boolean);
227
- let name = parts.map((part, index) => index === 0 ? part : part[0].toUpperCase() + part.slice(1)).join('') || 'endpoint';
228
- if (!/^[A-Za-z_$]/.test(name)) name = `endpoint${name}`;
229
- if (['arguments', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'eval', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield'].includes(name)) name = `${name}Endpoint`;
230
- return name;
231
- }
232
227
  function componentExportName(componentName: string): string {
233
- const name = exportName(componentName);
228
+ const name = endpointExportName(componentName);
234
229
  return name.endsWith('Schema') ? name : `${name}Schema`;
235
230
  }
236
231
  function componentParameterExportName(componentName: string): string {
237
- const name = exportName(componentName);
232
+ const name = endpointExportName(componentName);
238
233
  return name.endsWith('Parameter') ? name : `${name}Parameter`;
239
234
  }
240
235
  function componentResponseExportName(componentName: string): string {
241
- const name = exportName(componentName);
236
+ const name = endpointExportName(componentName);
242
237
  return name.endsWith('Response') ? name : `${name}Response`;
243
238
  }
244
239
  function componentReaches(source: OpenApiDocument, from: string, target: string, seen = new Set<string>()): boolean {
@@ -352,7 +347,7 @@ function renderEndpoint(operation: any, source: OpenApiDocument, mode: ApiDocsMo
352
347
  const examplesValue = { ...(Object.keys(requestExamples).length ? { request: requestExamples } : {}), ...(Object.keys(responseExamples).length ? { response: responseExamples } : {}) };
353
348
  const examples = Object.keys(examplesValue).length ? `examples: JSON.parse(${JSON.stringify(stableDataJson(examplesValue))}),` : '';
354
349
  const opId = operation.operationId;
355
- const exportId = exportName(opId);
350
+ const exportId = endpointExportName(opId);
356
351
  const tags = ir.tags;
357
352
  const auth = ir.security !== undefined && ir.security.length > 0 && ir.security.every((requirement) => Object.keys(requirement as Record<string, unknown>).length > 0) ? 'YES' : 'NO';
358
353
  const sourceName = JSON.stringify(`${source.info.title} v${source.info.version}`).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
@@ -458,7 +453,11 @@ async function generateApiDocsFilesInternal(input: OpenApiDocument | string, opt
458
453
  reservedFiles.push(`${directory}/index.ts`, ...names.map((name) => `${directory}/${name}/index.ts`));
459
454
  }
460
455
  }
461
- if (retainManifest) reservedFiles.push(ZOPIA_MANIFEST_FILE);
456
+ if (retainManifest) {
457
+ reservedFiles.push(ZOPIA_MANIFEST_FILE);
458
+ // The exact-tree declaration is emitted beside the manifest and shares its lifecycle.
459
+ reservedFiles.push(ZOPIA_TREE_TYPES_FILE);
460
+ }
462
461
  const plans = avoidReservedFileCollisions(planApiDocsFiles(source, mode), reservedFiles);
463
462
  const webhookPlans = avoidReservedFileCollisions(planWebhookDocsFiles(source, mode), [...reservedFiles, ...plans.map((plan) => plan.file)]);
464
463
  // OpenAPI requires operationId to be unique document-wide; $ref aliases can make the
@@ -561,6 +560,14 @@ async function generateApiDocsFilesInternal(input: OpenApiDocument | string, opt
561
560
  if (manifest) {
562
561
  const manifestPath = await writeZopiaManifest(root, manifest);
563
562
  generated.push({ file: ZOPIA_MANIFEST_FILE, absolutePath: manifestPath, operationId: 'manifest' });
563
+ // Exact IntelliSense: the declaration mirrors the runtime tree/flat shapes (S-95).
564
+ const treeTypesPath = await writeGeneratedFile(root, ZOPIA_TREE_TYPES_FILE, renderApiDocsTreeTypes(plans.map((plan) => ({
565
+ file: plan.file,
566
+ path: plan.path,
567
+ method: plan.method,
568
+ operationId: plan.operationId,
569
+ }))), previouslyOwned);
570
+ generated.push({ file: ZOPIA_TREE_TYPES_FILE, absolutePath: treeTypesPath, operationId: 'tree-types' });
564
571
  }
565
572
  await removeObsoleteManifestFiles(root, previous.ownedFiles, generated.map(({ file }) => file));
566
573
  return generated;
@@ -4,6 +4,47 @@ import { OPENAPI_METHODS, type OpenApiMethod } from './openapi-to-api-docs';
4
4
  /** Filesystem layout used for generated endpoint modules. */
5
5
  export type ApiDocsMode = 'directory' | 'flat';
6
6
 
7
+ /**
8
+ * Split one OpenAPI path template into its literal segment keys.
9
+ *
10
+ * Empty segments are dropped, so the root path `/` has no segments and its
11
+ * methods nest directly at the tree root. This is the single segmentation rule
12
+ * shared by the runtime tree resolver and the generated `.zopia-tree.d.ts`.
13
+ *
14
+ * @param path OpenAPI path template beginning with `/`.
15
+ * @returns Path segments in order, including literal `{param}` segments.
16
+ */
17
+ export function apiDocsPathSegments(path: string): string[] {
18
+ return path.split('/').filter(Boolean);
19
+ }
20
+
21
+ /**
22
+ * Compare two endpoint entries by the canonical deterministic tree order:
23
+ * URL path segments lexically, shorter segment chains first, then the
24
+ * canonical method order (`get, post, put, delete, head, options, patch, trace`).
25
+ *
26
+ * The runtime tree resolver inserts keys in this order and the generated
27
+ * `.zopia-tree.d.ts` declares them in this order, so both surfaces enumerate
28
+ * identically (R-732/S-94).
29
+ *
30
+ * @param left Endpoint entry with an OpenAPI `path` and lowercase `method`.
31
+ * @param right Endpoint entry with an OpenAPI `path` and lowercase `method`.
32
+ * @returns Negative when `left` sorts first, positive when `right` does, zero when equal.
33
+ */
34
+ export function compareApiDocsEntries(left: { path: string; method: string }, right: { path: string; method: string }): number {
35
+ const leftSegments = apiDocsPathSegments(left.path);
36
+ const rightSegments = apiDocsPathSegments(right.path);
37
+ const depth = Math.min(leftSegments.length, rightSegments.length);
38
+ for (let index = 0; index < depth; index += 1) {
39
+ const order = leftSegments[index] < rightSegments[index] ? -1 : leftSegments[index] > rightSegments[index] ? 1 : 0;
40
+ if (order !== 0) return order;
41
+ }
42
+ const lengthOrder = leftSegments.length - rightSegments.length;
43
+ if (lengthOrder !== 0) return lengthOrder;
44
+ const methods = OPENAPI_METHODS as readonly string[];
45
+ return methods.indexOf(left.method) - methods.indexOf(right.method);
46
+ }
47
+
7
48
  /**
8
49
  * Return whether one generated-tree path segment is portable across supported filesystems.
9
50
  *
@@ -0,0 +1,54 @@
1
+ /**
2
+ * ๐Ÿ“› Shared endpoint identifier rules (R-732).
3
+ *
4
+ * The single source of truth for deriving safe export identifiers from
5
+ * operation IDs โ€” and for the numeric uniqueness suffix applied when two
6
+ * derived identifiers collide. Generation (Engine โ‘ข's `export const` names
7
+ * and the collectors' deterministic operation IDs) and the runtime tree
8
+ * resolver (`flattenApiDocs` record keys) call these helpers so both surfaces
9
+ * keep producing identical names by construction.
10
+ */
11
+
12
+ /** ECMAScript keywords and literals never emitted as generated identifiers. */
13
+ const RESERVED_IDENTIFIER_NAMES: ReadonlySet<string> = new Set([
14
+ 'arguments', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'eval', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield',
15
+ ]);
16
+
17
+ /**
18
+ * Derive the endpoint export identifier from one operation ID.
19
+ *
20
+ * Applies the fixed naming contract of R-732: split on non-identifier
21
+ * characters, camel-case the parts (camelize), fall back to `endpoint` when
22
+ * nothing remains, prefix `endpoint` when the result starts with a digit or
23
+ * other non-identifier character, and append `Endpoint` when the result is a
24
+ * reserved ECMAScript keyword or literal (e.g. `await` โ†’ `awaitEndpoint`).
25
+ *
26
+ * @param operationId Explicit or deterministically derived operation identifier.
27
+ * @returns Safe camel-case TypeScript identifier for the module export.
28
+ */
29
+ export function endpointExportName(operationId: string): string {
30
+ const parts = operationId.split(/[^A-Za-z0-9_$]+/).filter(Boolean);
31
+ let name = parts.map((part, index) => index === 0 ? part : part[0].toUpperCase() + part.slice(1)).join('') || 'endpoint';
32
+ if (!/^[A-Za-z_$]/.test(name)) name = `endpoint${name}`;
33
+ if (RESERVED_IDENTIFIER_NAMES.has(name)) name = `${name}Endpoint`;
34
+ return name;
35
+ }
36
+
37
+ /**
38
+ * Return the first free variant of a derived identifier, suffixing `2`, `3`, โ€ฆ
39
+ *
40
+ * This is the exact uniqueness rule the operation collectors apply when a
41
+ * derived operation ID collides (`getA` โ†’ `getA2` โ†’ `getA3`): the first
42
+ * candidate is the base itself, and every subsequent candidate appends the
43
+ * next integer directly to the base.
44
+ *
45
+ * @param base Identifier before any uniqueness suffix.
46
+ * @param used Already-claimed identifiers this derivation must not clobber.
47
+ * @returns `base` itself when free, otherwise the first suffixed variant.
48
+ */
49
+ export function uniqueEndpointName(base: string, used: ReadonlySet<string>): string {
50
+ let name = base;
51
+ let suffix = 1;
52
+ while (used.has(name)) name = `${base}${++suffix}`;
53
+ return name;
54
+ }