docfy-core 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/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # docfy-core
2
+
3
+ Document Model puro (normalização de spec OpenAPI + gerador de exemplos +
4
+ transformer "Copy for AI") extraído do `docfy-ui`, sem dependências de
5
+ React/DOM. Consumido por `docfy-ui` e `docfy-mcp`.
6
+
7
+ ## API
8
+
9
+ ```ts
10
+ import { normalizeDocument, operationToAiText } from 'docfy-core';
11
+
12
+ const document = await normalizeDocument(rawOpenApiSpec);
13
+ const endpoint = document.tagGroups[0].endpoints[0];
14
+ const aiText = operationToAiText(endpoint);
15
+ ```
16
+
17
+ ## Scripts
18
+
19
+ - `npm run build` — compila para `dist/` (tipos + JS)
20
+ - `npm test` — roda a suíte (vitest)
21
+ - `npm run typecheck` — `tsc --noEmit`
@@ -0,0 +1,17 @@
1
+ export interface CapDepthOptions {
2
+ maxDepth?: number;
3
+ }
4
+ /**
5
+ * Walks a dereferenced OpenAPI schema (or any object graph) and produces a
6
+ * JSON-safe copy: cycles become `"[Circular]"` and anything past `maxDepth`
7
+ * becomes `"[Object]"`/`"[Array]"`.
8
+ *
9
+ * Needed because `SwaggerParser.dereference()` resolves `$ref` into real
10
+ * object identity — a recursive DTO (`User.parent: User`) becomes an actual
11
+ * cycle, which `JSON.stringify` cannot serialize. Cycle detection uses the
12
+ * current ancestor chain, not a global "seen" set: the same dereferenced
13
+ * object can legitimately appear in multiple sibling branches (e.g.
14
+ * `createdBy: User` and `updatedBy: User`) without being circular — only
15
+ * an object appearing in its own chain of parents is a real cycle.
16
+ */
17
+ export declare function capDepth(value: unknown, options?: CapDepthOptions): unknown;
@@ -0,0 +1,41 @@
1
+ const DEFAULT_MAX_DEPTH = 50;
2
+ /**
3
+ * Walks a dereferenced OpenAPI schema (or any object graph) and produces a
4
+ * JSON-safe copy: cycles become `"[Circular]"` and anything past `maxDepth`
5
+ * becomes `"[Object]"`/`"[Array]"`.
6
+ *
7
+ * Needed because `SwaggerParser.dereference()` resolves `$ref` into real
8
+ * object identity — a recursive DTO (`User.parent: User`) becomes an actual
9
+ * cycle, which `JSON.stringify` cannot serialize. Cycle detection uses the
10
+ * current ancestor chain, not a global "seen" set: the same dereferenced
11
+ * object can legitimately appear in multiple sibling branches (e.g.
12
+ * `createdBy: User` and `updatedBy: User`) without being circular — only
13
+ * an object appearing in its own chain of parents is a real cycle.
14
+ */
15
+ export function capDepth(value, options = {}) {
16
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
17
+ const ancestors = new Set();
18
+ function walk(node, depth) {
19
+ if (node === null || typeof node !== 'object')
20
+ return node;
21
+ if (ancestors.has(node))
22
+ return '[Circular]';
23
+ if (depth >= maxDepth)
24
+ return Array.isArray(node) ? '[Array]' : '[Object]';
25
+ ancestors.add(node);
26
+ try {
27
+ if (Array.isArray(node)) {
28
+ return node.map((item) => walk(item, depth + 1));
29
+ }
30
+ const result = {};
31
+ for (const [key, val] of Object.entries(node)) {
32
+ result[key] = walk(val, depth + 1);
33
+ }
34
+ return result;
35
+ }
36
+ finally {
37
+ ancestors.delete(node);
38
+ }
39
+ }
40
+ return walk(value, 0);
41
+ }
@@ -0,0 +1,22 @@
1
+ import type { JSONSchemaLike, ResponseInfo } from './types.js';
2
+ export declare const STATUS_TEXT: Record<string, string>;
3
+ /** Resolves `allOf`/`oneOf`/`anyOf` to a single concrete schema. Returns the variant count when a `oneOf`/`anyOf` union was found. */
4
+ export declare function resolveUnion(schema: JSONSchemaLike | undefined): {
5
+ schema: JSONSchemaLike | undefined;
6
+ unionSize?: number;
7
+ };
8
+ /** Text used wherever a schema cycle is detected, instead of expanding it again. */
9
+ export declare function circularMarker(schema: JSONSchemaLike): string;
10
+ export interface SchemaExampleResult {
11
+ /** The flattened example, JSON.stringify-safe (cycles already capped). */
12
+ example: unknown;
13
+ json: string;
14
+ unionSizes: number[];
15
+ }
16
+ export declare function buildSchemaExample(schema: JSONSchemaLike | undefined): SchemaExampleResult | undefined;
17
+ export declare function withUnionNotes(body: string, unionSizes: number[]): string;
18
+ /**
19
+ * Primary 2xx response: smallest numeric 2xx code declared. Non-numeric
20
+ * statuses (e.g. "default") are not considered.
21
+ */
22
+ export declare function pickPrimarySuccessResponse(responses: ResponseInfo[]): ResponseInfo | undefined;
@@ -0,0 +1,164 @@
1
+ import { capDepth } from './cap-depth.js';
2
+ export const STATUS_TEXT = {
3
+ '400': 'Bad Request',
4
+ '401': 'Unauthorized',
5
+ '403': 'Forbidden',
6
+ '404': 'Not Found',
7
+ '405': 'Method Not Allowed',
8
+ '409': 'Conflict',
9
+ '422': 'Unprocessable Entity',
10
+ '429': 'Too Many Requests',
11
+ '500': 'Internal Server Error',
12
+ '502': 'Bad Gateway',
13
+ '503': 'Service Unavailable',
14
+ };
15
+ /**
16
+ * Caches `schema` (with `allOf`) -> merged result by identity. Without this,
17
+ * every visit to the same `$ref`'d schema (e.g. a recursive `User.manager:
18
+ * User` where `User` itself uses `allOf`) would produce a freshly-allocated
19
+ * merged object, defeating the identity-based cycle detection in
20
+ * `flattenSchema`/`buildNode` below — they track "have I seen this object
21
+ * before" via a `Set`, which only works if the same input always resolves
22
+ * to the same output object.
23
+ */
24
+ const mergeCache = new WeakMap();
25
+ /**
26
+ * Shallow-merges an `allOf` composition into a single schema so downstream
27
+ * type/property resolution sees one object instead of an opaque array.
28
+ * `$ref`s are already dereferenced by `normalize.ts` by the time this runs,
29
+ * so each `allOf` entry is a real schema object, not a `$ref` string.
30
+ * Recurses so nested `allOf` (e.g. a `$ref`'d base schema that itself uses
31
+ * `allOf`) flattens fully; `oneOf`/`anyOf` inside a branch are left alone
32
+ * for `resolveUnion` to handle afterward.
33
+ */
34
+ function mergeAllOf(schema) {
35
+ const branches = schema.allOf;
36
+ if (!branches || branches.length === 0)
37
+ return schema;
38
+ const cached = mergeCache.get(schema);
39
+ if (cached)
40
+ return cached;
41
+ const merged = { ...schema };
42
+ mergeCache.set(schema, merged);
43
+ delete merged.allOf;
44
+ let properties = { ...merged.properties };
45
+ let required = [...(merged.required ?? [])];
46
+ for (const branch of branches) {
47
+ const resolvedBranch = mergeAllOf(branch);
48
+ if (resolvedBranch.type && !merged.type)
49
+ merged.type = resolvedBranch.type;
50
+ if (resolvedBranch.properties) {
51
+ properties = { ...properties, ...resolvedBranch.properties };
52
+ }
53
+ if (Array.isArray(resolvedBranch.required)) {
54
+ required = [...required, ...resolvedBranch.required];
55
+ }
56
+ }
57
+ if (Object.keys(properties).length > 0) {
58
+ merged.properties = properties;
59
+ if (!merged.type)
60
+ merged.type = 'object';
61
+ }
62
+ if (required.length > 0)
63
+ merged.required = [...new Set(required)];
64
+ return merged;
65
+ }
66
+ /** Resolves `allOf`/`oneOf`/`anyOf` to a single concrete schema. Returns the variant count when a `oneOf`/`anyOf` union was found. */
67
+ export function resolveUnion(schema) {
68
+ if (!schema)
69
+ return { schema: undefined };
70
+ const flattened = mergeAllOf(schema);
71
+ const union = flattened.oneOf ?? flattened.anyOf;
72
+ if (union && union.length > 0) {
73
+ // The chosen variant (e.g. `DigitalProduct`) may itself be an `allOf`
74
+ // composition — merge it too, otherwise it surfaces with no `type`/
75
+ // `properties` and falls back to the literal string "object".
76
+ return { schema: mergeAllOf(union[0]), unionSize: union.length };
77
+ }
78
+ return { schema: flattened };
79
+ }
80
+ /** Text used wherever a schema cycle is detected, instead of expanding it again. */
81
+ export function circularMarker(schema) {
82
+ const title = typeof schema.title === 'string' ? schema.title : undefined;
83
+ return title ? `(circular reference to ${title})` : '(circular reference)';
84
+ }
85
+ /**
86
+ * Hard backstop against pathologically deep (but non-cyclic) schemas.
87
+ * Real cycles are caught immediately by ancestor tracking below — this
88
+ * only matters for the rare case of genuinely 20+ levels of distinct
89
+ * nesting.
90
+ */
91
+ const MAX_DEPTH = 20;
92
+ /**
93
+ * Flattens a JSON Schema into an example using TYPE NAMES as values
94
+ * ("name": "string"), never fake data — per the Copy for AI spec, section
95
+ * 3.2 step 3 ("chave: tipo, não valores fake"). Shared between the Copy
96
+ * for AI transformer and the code snippet generators so both render the
97
+ * exact same request/response body shape.
98
+ *
99
+ * `ancestors` tracks resolved schema objects currently on the recursion
100
+ * path (by identity, not a depth counter) — `SwaggerParser.dereference()`
101
+ * turns recursive DTOs (`User.parent: User`) into real object cycles, and
102
+ * a depth cap alone just unrolls the cycle N times before giving up,
103
+ * producing a huge wall of duplicated, useless nesting. Detecting the
104
+ * cycle the first time it repeats produces one concise marker instead.
105
+ */
106
+ function flattenSchema(schema, depth, unionSizes, ancestors) {
107
+ if (!schema || depth > MAX_DEPTH)
108
+ return 'object';
109
+ const { schema: resolved, unionSize } = resolveUnion(schema);
110
+ if (unionSize)
111
+ unionSizes.push(unionSize);
112
+ if (!resolved)
113
+ return 'object';
114
+ if (ancestors.has(resolved))
115
+ return circularMarker(resolved);
116
+ const rawType = resolved.type;
117
+ const type = Array.isArray(rawType) ? rawType.find((t) => t !== 'null') ?? rawType[0] : rawType;
118
+ if (type === 'object' || resolved.properties) {
119
+ const props = resolved.properties ?? {};
120
+ ancestors.add(resolved);
121
+ const out = {};
122
+ for (const [key, propSchema] of Object.entries(props)) {
123
+ out[key] = flattenSchema(propSchema, depth + 1, unionSizes, ancestors);
124
+ }
125
+ ancestors.delete(resolved);
126
+ return out;
127
+ }
128
+ if (type === 'array') {
129
+ ancestors.add(resolved);
130
+ const item = flattenSchema(resolved.items, depth + 1, unionSizes, ancestors);
131
+ ancestors.delete(resolved);
132
+ return [item];
133
+ }
134
+ if (typeof type === 'string')
135
+ return type;
136
+ return 'object';
137
+ }
138
+ export function buildSchemaExample(schema) {
139
+ if (!schema)
140
+ return undefined;
141
+ const unionSizes = [];
142
+ const example = flattenSchema(schema, 0, unionSizes, new Set());
143
+ // Defense in depth: flattenSchema's own cycle detection should make this
144
+ // a no-op in practice, but capDepth stays as a second guard against any
145
+ // structure that's deep without being a detectable identity cycle.
146
+ const safe = capDepth(example, { maxDepth: 12 });
147
+ return { example: safe, json: JSON.stringify(safe, null, 2), unionSizes };
148
+ }
149
+ export function withUnionNotes(body, unionSizes) {
150
+ if (unionSizes.length === 0)
151
+ return body;
152
+ const notes = unionSizes.map((n) => `(one of ${n} possible shapes)`);
153
+ return `${body}\n${notes.join('\n')}`;
154
+ }
155
+ /**
156
+ * Primary 2xx response: smallest numeric 2xx code declared. Non-numeric
157
+ * statuses (e.g. "default") are not considered.
158
+ */
159
+ export function pickPrimarySuccessResponse(responses) {
160
+ const numeric2xx = responses
161
+ .filter((r) => /^2\d\d$/.test(r.status))
162
+ .sort((a, b) => Number(a.status) - Number(b.status));
163
+ return numeric2xx[0];
164
+ }
@@ -0,0 +1,13 @@
1
+ import type { DocumentModel } from './types.js';
2
+ /**
3
+ * Normalizes a raw OpenAPI 3.0/3.1 spec (JSON or already-parsed object)
4
+ * into the in-memory Document Model consumed by the sidebar and detail
5
+ * panel. Dereferences all `$ref`s — see parser-spike.spec.ts for why
6
+ * @apidevtools/swagger-parser was chosen over the alternative considered.
7
+ *
8
+ * Note: dereferencing turns recursive schemas into real object cycles.
9
+ * This function does not break on that (cycles only matter at
10
+ * serialization time) — callers that JSON.stringify a schema from this
11
+ * model must run it through `capDepth()` first.
12
+ */
13
+ export declare function normalizeDocument(rawSpec: unknown): Promise<DocumentModel>;
@@ -0,0 +1,146 @@
1
+ import SwaggerParser from '@apidevtools/swagger-parser';
2
+ const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
3
+ const DEFAULT_TAG = 'Default';
4
+ const JSON_CONTENT_TYPE_PREFERENCE = ['application/json'];
5
+ function pickContent(content) {
6
+ if (!content)
7
+ return undefined;
8
+ const keys = Object.keys(content);
9
+ if (keys.length === 0)
10
+ return undefined;
11
+ const preferred = JSON_CONTENT_TYPE_PREFERENCE.find((ct) => keys.includes(ct));
12
+ const contentType = preferred ?? keys[0];
13
+ return { contentType, schema: content[contentType]?.schema };
14
+ }
15
+ function buildParameters(rawParams) {
16
+ if (!rawParams)
17
+ return [];
18
+ return rawParams.map((p) => ({
19
+ name: p.name,
20
+ in: p.in,
21
+ required: Boolean(p.required),
22
+ schema: p.schema,
23
+ description: p.description,
24
+ }));
25
+ }
26
+ function buildRequestBody(rawRequestBody) {
27
+ if (!rawRequestBody)
28
+ return undefined;
29
+ const picked = pickContent(rawRequestBody.content);
30
+ if (!picked)
31
+ return undefined;
32
+ return {
33
+ required: Boolean(rawRequestBody.required),
34
+ contentType: picked.contentType,
35
+ schema: picked.schema,
36
+ };
37
+ }
38
+ function buildResponses(rawResponses) {
39
+ if (!rawResponses)
40
+ return [];
41
+ return Object.entries(rawResponses).map(([status, value]) => {
42
+ const picked = pickContent(value?.content);
43
+ return {
44
+ status,
45
+ description: value?.description,
46
+ contentType: picked?.contentType,
47
+ schema: picked?.schema,
48
+ };
49
+ });
50
+ }
51
+ /**
52
+ * Returns tag names in the order the sidebar should display them:
53
+ * 1. Tags declared in the top-level `tags` array, in that order.
54
+ * 2. Any tag used by an operation but not declared at top level, in the
55
+ * order it's first encountered while walking `paths`.
56
+ * 3. A synthetic "Default" group, last, for operations with no tags at all.
57
+ */
58
+ function getOrderedTagNames(spec) {
59
+ const declared = (spec.tags ?? []).map((t) => t.name);
60
+ const seen = new Set(declared);
61
+ const discovered = [];
62
+ let hasUntagged = false;
63
+ for (const pathItem of Object.values(spec.paths ?? {})) {
64
+ for (const method of HTTP_METHODS) {
65
+ const operation = pathItem[method];
66
+ if (!operation)
67
+ continue;
68
+ const tags = operation.tags ?? [];
69
+ if (tags.length === 0) {
70
+ hasUntagged = true;
71
+ continue;
72
+ }
73
+ for (const tag of tags) {
74
+ if (!seen.has(tag)) {
75
+ seen.add(tag);
76
+ discovered.push(tag);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ const ordered = [...declared, ...discovered];
82
+ if (hasUntagged)
83
+ ordered.push(DEFAULT_TAG);
84
+ return ordered;
85
+ }
86
+ function getTagDescription(spec, tagName) {
87
+ const declared = (spec.tags ?? []).find((t) => t.name === tagName);
88
+ return declared?.description;
89
+ }
90
+ /**
91
+ * Normalizes a raw OpenAPI 3.0/3.1 spec (JSON or already-parsed object)
92
+ * into the in-memory Document Model consumed by the sidebar and detail
93
+ * panel. Dereferences all `$ref`s — see parser-spike.spec.ts for why
94
+ * @apidevtools/swagger-parser was chosen over the alternative considered.
95
+ *
96
+ * Note: dereferencing turns recursive schemas into real object cycles.
97
+ * This function does not break on that (cycles only matter at
98
+ * serialization time) — callers that JSON.stringify a schema from this
99
+ * model must run it through `capDepth()` first.
100
+ */
101
+ export async function normalizeDocument(rawSpec) {
102
+ const spec = (await SwaggerParser.dereference(rawSpec));
103
+ const endpointsByTag = new Map();
104
+ for (const tagName of getOrderedTagNames(spec)) {
105
+ endpointsByTag.set(tagName, []);
106
+ }
107
+ for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
108
+ for (const method of HTTP_METHODS) {
109
+ const operation = pathItem[method];
110
+ if (!operation)
111
+ continue;
112
+ const tags = operation.tags?.length ? operation.tags : [DEFAULT_TAG];
113
+ const endpoint = {
114
+ method: method.toUpperCase(),
115
+ path,
116
+ operationId: operation.operationId,
117
+ summary: operation.summary,
118
+ description: operation.description,
119
+ tags,
120
+ parameters: buildParameters(operation.parameters),
121
+ requestBody: buildRequestBody(operation.requestBody),
122
+ responses: buildResponses(operation.responses),
123
+ };
124
+ for (const tag of tags) {
125
+ if (!endpointsByTag.has(tag))
126
+ endpointsByTag.set(tag, []);
127
+ endpointsByTag.get(tag).push(endpoint);
128
+ }
129
+ }
130
+ }
131
+ const tagGroups = Array.from(endpointsByTag.entries())
132
+ .filter(([, endpoints]) => endpoints.length > 0)
133
+ .map(([name, endpoints]) => ({
134
+ name,
135
+ description: getTagDescription(spec, name),
136
+ endpoints,
137
+ }));
138
+ return {
139
+ info: {
140
+ title: spec.info?.title ?? '',
141
+ version: spec.info?.version ?? '',
142
+ description: spec.info?.description,
143
+ },
144
+ tagGroups,
145
+ };
146
+ }
@@ -0,0 +1,49 @@
1
+ /** Loosely-typed JSON Schema node — we don't re-implement a schema type system, just pass it through. */
2
+ export type JSONSchemaLike = Record<string, unknown>;
3
+ export interface ParameterInfo {
4
+ name: string;
5
+ in: 'query' | 'path' | 'header' | 'cookie';
6
+ required: boolean;
7
+ schema: JSONSchemaLike | undefined;
8
+ description: string | undefined;
9
+ }
10
+ export interface RequestBodyInfo {
11
+ required: boolean;
12
+ /** The content type actually selected (application/json preferred, else first available). */
13
+ contentType: string;
14
+ schema: JSONSchemaLike | undefined;
15
+ }
16
+ export interface ResponseInfo {
17
+ /** "200", "404", "default", etc. */
18
+ status: string;
19
+ description: string | undefined;
20
+ contentType: string | undefined;
21
+ schema: JSONSchemaLike | undefined;
22
+ }
23
+ export interface Endpoint {
24
+ method: string;
25
+ path: string;
26
+ operationId: string | undefined;
27
+ summary: string | undefined;
28
+ description: string | undefined;
29
+ tags: string[];
30
+ parameters: ParameterInfo[];
31
+ requestBody: RequestBodyInfo | undefined;
32
+ responses: ResponseInfo[];
33
+ }
34
+ export interface TagGroup {
35
+ name: string;
36
+ description: string | undefined;
37
+ endpoints: Endpoint[];
38
+ }
39
+ export interface DocumentModel {
40
+ info: {
41
+ title: string;
42
+ version: string;
43
+ description: string | undefined;
44
+ };
45
+ /** Tags in declared order (from the top-level `tags` array), then any
46
+ * tags used by operations but not declared, in first-appearance order.
47
+ * Operations with no tags fall into a synthetic "Default" group, last. */
48
+ tagGroups: TagGroup[];
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ export { normalizeDocument } from './document-model/normalize.js';
2
+ export { STATUS_TEXT, buildSchemaExample, circularMarker, pickPrimarySuccessResponse, resolveUnion, withUnionNotes, } from './document-model/example.js';
3
+ export type { SchemaExampleResult } from './document-model/example.js';
4
+ export { capDepth } from './document-model/cap-depth.js';
5
+ export type { CapDepthOptions } from './document-model/cap-depth.js';
6
+ export { operationToAiText } from './transformers/copy-for-ai.js';
7
+ export type { DocumentModel, Endpoint, JSONSchemaLike, ParameterInfo, RequestBodyInfo, ResponseInfo, TagGroup, } from './document-model/types';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { normalizeDocument } from './document-model/normalize.js';
2
+ export { STATUS_TEXT, buildSchemaExample, circularMarker, pickPrimarySuccessResponse, resolveUnion, withUnionNotes, } from './document-model/example.js';
3
+ export { capDepth } from './document-model/cap-depth.js';
4
+ export { operationToAiText } from './transformers/copy-for-ai.js';
@@ -0,0 +1,11 @@
1
+ import type { Endpoint } from '../document-model/types.js';
2
+ /**
3
+ * Transforms a normalized Endpoint into the plain-text, LLM-ready
4
+ * representation behind the "Copy for AI" button. Pure function, no I/O —
5
+ * runs entirely client-side in well under 5ms.
6
+ *
7
+ * Section order: Endpoint → Purpose → Request → Parameters → Validation →
8
+ * Success Response → Error Responses. Empty sections are omitted, and
9
+ * sections are separated by a blank line for scannability.
10
+ */
11
+ export declare function operationToAiText(endpoint: Endpoint): string;
@@ -0,0 +1,138 @@
1
+ import { STATUS_TEXT, buildSchemaExample, pickPrimarySuccessResponse, resolveUnion, withUnionNotes, } from '../document-model/example.js';
2
+ function truncateToSentences(text, maxSentences) {
3
+ const sentences = text.trim().split(/(?<=[.!?])\s+/).filter(Boolean);
4
+ return sentences.slice(0, maxSentences).join(' ');
5
+ }
6
+ function buildPurpose(endpoint) {
7
+ if (endpoint.summary)
8
+ return endpoint.summary;
9
+ if (endpoint.description)
10
+ return truncateToSentences(endpoint.description, 2);
11
+ return undefined;
12
+ }
13
+ /**
14
+ * Extracts explicit validation rules from a request body schema:
15
+ * format, minLength/maxLength, minimum/maximum, pattern, enum.
16
+ *
17
+ * Deliberate decision: `required` does NOT produce its own line. The
18
+ * worked example in section 3.3 shows zero "X is required" lines for a
19
+ * register endpoint where email/password are almost certainly required —
20
+ * presence is already conveyed by the field simply appearing in the
21
+ * Request example, so a separate "required" line would be redundant
22
+ * noise for the AI consumer. Reproduced exactly against that example.
23
+ *
24
+ * `ancestors` tracks resolved schema objects on the current recursion
25
+ * path by identity — dereferenced schemas can be real object cycles
26
+ * (recursive DTOs). Unlike a plain depth cap (which used to unroll the
27
+ * cycle ~10 times, producing duplicated "parent.parent...x10 email must
28
+ * be valid" lines), stopping the moment a schema repeats means a
29
+ * recursive property contributes at most one pass of rules — its own
30
+ * rules already cover what the cycle would otherwise repeat.
31
+ */
32
+ function extractValidationRules(schema, prefix = '', ancestors = new Set()) {
33
+ const { schema: resolved } = resolveUnion(schema);
34
+ if (!resolved)
35
+ return [];
36
+ if (ancestors.has(resolved))
37
+ return [];
38
+ const properties = resolved.properties ?? {};
39
+ const rules = [];
40
+ ancestors.add(resolved);
41
+ for (const [key, rawPropSchema] of Object.entries(properties)) {
42
+ const name = prefix ? `${prefix}.${key}` : key;
43
+ const { schema: prop } = resolveUnion(rawPropSchema);
44
+ if (!prop)
45
+ continue;
46
+ if (prop.format)
47
+ rules.push(`${name} must be valid`);
48
+ if (prop.minLength !== undefined)
49
+ rules.push(`${name} min length ${prop.minLength}`);
50
+ if (prop.maxLength !== undefined)
51
+ rules.push(`${name} max length ${prop.maxLength}`);
52
+ if (prop.minimum !== undefined)
53
+ rules.push(`${name} min ${prop.minimum}`);
54
+ if (prop.maximum !== undefined)
55
+ rules.push(`${name} max ${prop.maximum}`);
56
+ if (prop.pattern)
57
+ rules.push(`${name} must match pattern ${prop.pattern}`);
58
+ if (Array.isArray(prop.enum))
59
+ rules.push(`${name} must be one of: ${prop.enum.join(', ')}`);
60
+ if (prop.type === 'object' && prop.properties) {
61
+ rules.push(...extractValidationRules(prop, name, ancestors));
62
+ }
63
+ }
64
+ ancestors.delete(resolved);
65
+ return rules;
66
+ }
67
+ const PARAM_GROUP_LABELS = {
68
+ path: 'Path Parameters',
69
+ query: 'Query Parameters',
70
+ header: 'Headers',
71
+ };
72
+ /** Same grouping/order/labels as the visual `ParametersSection` — cookie params stay out of scope for both. */
73
+ const PARAM_GROUP_ORDER = ['path', 'query', 'header'];
74
+ function formatParameterLine(param) {
75
+ const requirement = param.required ? 'required' : 'optional';
76
+ const type = typeof param.schema?.type === 'string' ? param.schema.type : 'unknown';
77
+ const description = param.description ? ` — ${param.description}` : '';
78
+ return `- ${param.name} (${requirement}): ${type}${description}`;
79
+ }
80
+ /** Groups parameters into Path/Query/Headers sub-sections, each with an explicit required/optional per line. */
81
+ function buildParametersBlock(parameters) {
82
+ const groups = PARAM_GROUP_ORDER.map((kind) => ({
83
+ kind,
84
+ items: parameters.filter((p) => p.in === kind),
85
+ })).filter((g) => g.items.length > 0);
86
+ if (groups.length === 0)
87
+ return undefined;
88
+ const body = groups
89
+ .map((g) => `${PARAM_GROUP_LABELS[g.kind]}:\n${g.items.map(formatParameterLine).join('\n')}`)
90
+ .join('\n');
91
+ return `Parameters:\n${body}`;
92
+ }
93
+ function buildErrorResponseLines(responses) {
94
+ return responses
95
+ .filter((r) => /^[45]\d\d$/.test(r.status))
96
+ .map((r) => `${r.status} ${r.description || STATUS_TEXT[r.status] || ''}`.trim());
97
+ }
98
+ /**
99
+ * Transforms a normalized Endpoint into the plain-text, LLM-ready
100
+ * representation behind the "Copy for AI" button. Pure function, no I/O —
101
+ * runs entirely client-side in well under 5ms.
102
+ *
103
+ * Section order: Endpoint → Purpose → Request → Parameters → Validation →
104
+ * Success Response → Error Responses. Empty sections are omitted, and
105
+ * sections are separated by a blank line for scannability.
106
+ */
107
+ export function operationToAiText(endpoint) {
108
+ const sections = [];
109
+ sections.push(`Endpoint: ${endpoint.method} ${endpoint.path}`);
110
+ const purpose = buildPurpose(endpoint);
111
+ if (purpose)
112
+ sections.push(`Purpose:\n${purpose}`);
113
+ if (endpoint.requestBody?.schema) {
114
+ const result = buildSchemaExample(endpoint.requestBody.schema);
115
+ if (result)
116
+ sections.push(withUnionNotes(`Request:\n${result.json}`, result.unionSizes));
117
+ }
118
+ const parametersBlock = buildParametersBlock(endpoint.parameters);
119
+ if (parametersBlock)
120
+ sections.push(parametersBlock);
121
+ if (endpoint.requestBody?.schema) {
122
+ const rules = extractValidationRules(endpoint.requestBody.schema);
123
+ if (rules.length > 0) {
124
+ sections.push(`Validation:\n${rules.map((r) => `- ${r}`).join('\n')}`);
125
+ }
126
+ }
127
+ const success = pickPrimarySuccessResponse(endpoint.responses);
128
+ if (success) {
129
+ const result = buildSchemaExample(success.schema);
130
+ const body = result ? withUnionNotes(result.json, result.unionSizes) : (success.description || 'No content');
131
+ sections.push(`Success Response (${success.status}):\n${body}`);
132
+ }
133
+ const errorLines = buildErrorResponseLines(endpoint.responses);
134
+ if (errorLines.length > 0) {
135
+ sections.push(`Error Responses:\n${errorLines.join('\n')}`);
136
+ }
137
+ return sections.join('\n\n');
138
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "docfy-core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pure OpenAPI document-model + Copy-for-AI transformer shared by docfy-ui and docfy-mcp",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "author": "Marvin Rocha",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/MarvinRF/docfy-core.git"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest"
28
+ },
29
+ "dependencies": {
30
+ "@apidevtools/swagger-parser": "^12.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.16.5",
34
+ "typescript": "^5.9.3",
35
+ "vitest": "^4.1.9"
36
+ }
37
+ }