@blogmcp/nav-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 +41 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/mutate/index.d.ts +41 -0
- package/dist/mutate/index.js +123 -0
- package/dist/mutate/index.js.map +1 -0
- package/dist/resolve/index.d.ts +105 -0
- package/dist/resolve/index.js +217 -0
- package/dist/resolve/index.js.map +1 -0
- package/dist/traverse/index.d.ts +70 -0
- package/dist/traverse/index.js +213 -0
- package/dist/traverse/index.js.map +1 -0
- package/dist/types/index.d.ts +165 -0
- package/dist/types/index.js +29 -0
- package/dist/types/index.js.map +1 -0
- package/dist/validate/index.d.ts +44 -0
- package/dist/validate/index.js +175 -0
- package/dist/validate/index.js.map +1 -0
- package/package.json +62 -0
- package/src/index.ts +18 -0
- package/src/mutate/index.ts +162 -0
- package/src/resolve/index.ts +303 -0
- package/src/traverse/index.ts +274 -0
- package/src/types/index.ts +206 -0
- package/src/validate/index.ts +233 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blogmcp/nav-core — types
|
|
3
|
+
*
|
|
4
|
+
* The framework-agnostic contract for the KB navigation (menu) tree.
|
|
5
|
+
* See competitor-monitor/docs/internal/navigation-model.md §4 for the design.
|
|
6
|
+
*
|
|
7
|
+
* Design invariants encoded here:
|
|
8
|
+
* - Stable UUID per node (deep links / anchored comments / moves survive reorder).
|
|
9
|
+
* - Discriminated `type` union: article-ref | group | external-link |
|
|
10
|
+
* generated-section | separator.
|
|
11
|
+
* - Single multilingual tree: per-node `labels` map keyed by locale; the
|
|
12
|
+
* referenced article's title is the default (resolved by the consumer via a
|
|
13
|
+
* `labelResolver`, since nav-core does not know article content).
|
|
14
|
+
* - `badge` and `icon` are typed first-class (Starlight precedent); everything
|
|
15
|
+
* else genuinely arbitrary lives in the opaque, size-capped `data` bag.
|
|
16
|
+
* - Trees are treated as immutable: every node/collection is `readonly`, and the
|
|
17
|
+
* mutate module returns new trees rather than editing in place.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Discriminator for {@link NavNode}. */
|
|
21
|
+
export type NavNodeType =
|
|
22
|
+
| 'article-ref'
|
|
23
|
+
| 'group'
|
|
24
|
+
| 'external-link'
|
|
25
|
+
| 'generated-section'
|
|
26
|
+
| 'separator';
|
|
27
|
+
|
|
28
|
+
/** All node type discriminators, for runtime validation. */
|
|
29
|
+
export const NAV_NODE_TYPES: readonly NavNodeType[] = [
|
|
30
|
+
'article-ref',
|
|
31
|
+
'group',
|
|
32
|
+
'external-link',
|
|
33
|
+
'generated-section',
|
|
34
|
+
'separator',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** BCP-47 / language code, e.g. `cs`, `en`, `pt-BR`. */
|
|
38
|
+
export type Locale = string;
|
|
39
|
+
|
|
40
|
+
/** Per-locale label overrides. Missing locales fall back (see localize). */
|
|
41
|
+
export type LocaleLabels = Readonly<Record<Locale, string>>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Typed first-class presentation hint. Mirrors Astro Starlight's `badge`.
|
|
45
|
+
* `variant` is intentionally open (`string`) so themes can define their own.
|
|
46
|
+
*/
|
|
47
|
+
export interface BadgeSpec {
|
|
48
|
+
readonly text: string;
|
|
49
|
+
readonly variant?: string;
|
|
50
|
+
readonly class?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Access-tier gating. nav-core stays policy-free: it only carries the declared
|
|
55
|
+
* tiers; consumers decide what a viewer may see (e.g. via {@link prune}).
|
|
56
|
+
* Omitted = inherit from ancestor / public.
|
|
57
|
+
*/
|
|
58
|
+
export interface Visibility {
|
|
59
|
+
readonly tiers?: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Opaque, namespaced customer extension bag. The platform stores and round-trips
|
|
64
|
+
* it but never interprets it. Size/depth-capped by {@link validate}. Renderers
|
|
65
|
+
* must treat values as untrusted.
|
|
66
|
+
*/
|
|
67
|
+
export type NavData = Readonly<Record<string, unknown>>;
|
|
68
|
+
|
|
69
|
+
/** Fields shared by every node type. */
|
|
70
|
+
export interface NavNodeBase {
|
|
71
|
+
/** Stable UUID. Unique across the whole tree. */
|
|
72
|
+
readonly id: string;
|
|
73
|
+
/** Sibling sort key. Fractional, so a node can be inserted between two siblings without renumbering. */
|
|
74
|
+
readonly order?: number;
|
|
75
|
+
/** Per-locale label overrides. */
|
|
76
|
+
readonly labels?: LocaleLabels;
|
|
77
|
+
/** Typed first-class badge hint. */
|
|
78
|
+
readonly badge?: BadgeSpec;
|
|
79
|
+
/** Typed first-class icon hint (icon name/key; renderer maps it). */
|
|
80
|
+
readonly icon?: string;
|
|
81
|
+
/** Reachable by direct URL but suppressed from this menu. Distinct from access-tier {@link visibility}. */
|
|
82
|
+
readonly hidden?: boolean;
|
|
83
|
+
/** Access-tier gating. */
|
|
84
|
+
readonly visibility?: Visibility;
|
|
85
|
+
/** Opaque customer extension bag. */
|
|
86
|
+
readonly data?: NavData;
|
|
87
|
+
/** Child nodes. Only `group` and `article-ref` may have children. */
|
|
88
|
+
readonly children?: readonly NavNode[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** References a `kb_articles.id`. */
|
|
92
|
+
export interface ArticleRefNode extends NavNodeBase {
|
|
93
|
+
readonly type: 'article-ref';
|
|
94
|
+
/** `kb_articles.id`. */
|
|
95
|
+
readonly ref: string;
|
|
96
|
+
/** Public URL handle (filename without extension) — lets consumers build links without a second lookup. */
|
|
97
|
+
readonly slug?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Exactly one nav location should *own* an article (drives prev/next pagination
|
|
100
|
+
* and the canonical breadcrumb). Other locations referencing the same article
|
|
101
|
+
* set `owned: false` (Docusaurus `doc` vs `ref`).
|
|
102
|
+
*/
|
|
103
|
+
readonly owned?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Optional clickable target for a {@link GroupNode} heading. */
|
|
107
|
+
export type GroupLink =
|
|
108
|
+
| { readonly kind: 'article'; readonly ref: string }
|
|
109
|
+
| { readonly kind: 'generated'; readonly slug: string };
|
|
110
|
+
|
|
111
|
+
/** A section. May be a clickable heading and may collapse. */
|
|
112
|
+
export interface GroupNode extends NavNodeBase {
|
|
113
|
+
readonly type: 'group';
|
|
114
|
+
/** Optional clickable section heading (existing article or a generated-section). */
|
|
115
|
+
readonly link?: GroupLink;
|
|
116
|
+
/** Default open/closed state. */
|
|
117
|
+
readonly collapsed?: boolean;
|
|
118
|
+
/** Whether the group can be collapsed at all. */
|
|
119
|
+
readonly collapsible?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Arbitrary external URL. Override-only — auto-build never emits these. */
|
|
123
|
+
export interface ExternalLinkNode extends NavNodeBase {
|
|
124
|
+
readonly type: 'external-link';
|
|
125
|
+
readonly href: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Query backing a {@link GeneratedSectionNode}. Open shape; `tag` is the v1 case. */
|
|
129
|
+
export interface GeneratedQuery {
|
|
130
|
+
readonly tag?: string;
|
|
131
|
+
readonly [key: string]: unknown;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A section resolved at read time (e.g. "all articles tagged X"). */
|
|
135
|
+
export interface GeneratedSectionNode extends NavNodeBase {
|
|
136
|
+
readonly type: 'generated-section';
|
|
137
|
+
/** URL slug of the generated landing page. */
|
|
138
|
+
readonly slug: string;
|
|
139
|
+
/** Query that selects the members. */
|
|
140
|
+
readonly query: GeneratedQuery;
|
|
141
|
+
readonly title?: string;
|
|
142
|
+
readonly description?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Pure visual divider. Leaf node. */
|
|
146
|
+
export interface SeparatorNode extends NavNodeBase {
|
|
147
|
+
readonly type: 'separator';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Any navigation node. */
|
|
151
|
+
export type NavNode =
|
|
152
|
+
| ArticleRefNode
|
|
153
|
+
| GroupNode
|
|
154
|
+
| ExternalLinkNode
|
|
155
|
+
| GeneratedSectionNode
|
|
156
|
+
| SeparatorNode;
|
|
157
|
+
|
|
158
|
+
/** A navigation tree is an ordered list of root nodes. */
|
|
159
|
+
export type NavTree = readonly NavNode[];
|
|
160
|
+
|
|
161
|
+
/** Node types that are allowed to have children. */
|
|
162
|
+
export const CONTAINER_NODE_TYPES: readonly NavNodeType[] = ['group', 'article-ref'];
|
|
163
|
+
|
|
164
|
+
/* --------------------------------------------------------------------------
|
|
165
|
+
* Flattened (FLAT) representation — agent- and diff-friendly.
|
|
166
|
+
* ------------------------------------------------------------------------ */
|
|
167
|
+
|
|
168
|
+
/** One node in FLAT form: parent pointer + depth instead of nesting. */
|
|
169
|
+
export interface FlatNode {
|
|
170
|
+
readonly id: string;
|
|
171
|
+
readonly parentId: string | null;
|
|
172
|
+
readonly type: NavNodeType;
|
|
173
|
+
/** 0-based position among siblings (post-sort). */
|
|
174
|
+
readonly index: number;
|
|
175
|
+
/** Depth from root (root nodes = 0). */
|
|
176
|
+
readonly depth: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* --------------------------------------------------------------------------
|
|
180
|
+
* Localized representation — labels resolved to a single locale.
|
|
181
|
+
* ------------------------------------------------------------------------ */
|
|
182
|
+
|
|
183
|
+
/** A node with its label resolved to one locale (no `labels` map). */
|
|
184
|
+
export interface LocalizedNode {
|
|
185
|
+
readonly id: string;
|
|
186
|
+
readonly type: NavNodeType;
|
|
187
|
+
/** Resolved label for the requested locale, if any could be determined. */
|
|
188
|
+
readonly label?: string;
|
|
189
|
+
readonly order?: number;
|
|
190
|
+
readonly badge?: BadgeSpec;
|
|
191
|
+
readonly icon?: string;
|
|
192
|
+
readonly hidden?: boolean;
|
|
193
|
+
readonly visibility?: Visibility;
|
|
194
|
+
readonly data?: NavData;
|
|
195
|
+
/** Carried through for renderers (ref/href/slug/link/collapsed…). */
|
|
196
|
+
readonly ref?: string;
|
|
197
|
+
readonly owned?: boolean;
|
|
198
|
+
readonly href?: string;
|
|
199
|
+
readonly slug?: string;
|
|
200
|
+
readonly link?: GroupLink;
|
|
201
|
+
readonly collapsed?: boolean;
|
|
202
|
+
readonly collapsible?: boolean;
|
|
203
|
+
readonly children: readonly LocalizedNode[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type LocalizedTree = readonly LocalizedNode[];
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blogmcp/nav-core — validate
|
|
3
|
+
*
|
|
4
|
+
* Structural validation of a navigation tree. Server-authoritative: the API
|
|
5
|
+
* layer re-runs this on every mutation (clients can't be trusted not to create
|
|
6
|
+
* a cycle / oversized data bag). nav-core never *interprets* the `data` bag — it
|
|
7
|
+
* only enforces size/depth caps so the JSONB-source-of-truth stays a small,
|
|
8
|
+
* one-row-read, cacheable artifact (see navigation-model.md §2, §4).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
CONTAINER_NODE_TYPES,
|
|
13
|
+
NAV_NODE_TYPES,
|
|
14
|
+
type NavNode,
|
|
15
|
+
type NavTree,
|
|
16
|
+
} from '../types/index.js';
|
|
17
|
+
|
|
18
|
+
/** Default caps. Override per call via {@link ValidateOptions}. */
|
|
19
|
+
export const DEFAULT_MAX_DEPTH = 6;
|
|
20
|
+
export const DEFAULT_MAX_DATA_BYTES = 4096;
|
|
21
|
+
export const DEFAULT_MAX_DATA_DEPTH = 8;
|
|
22
|
+
|
|
23
|
+
export interface ValidateOptions {
|
|
24
|
+
/** Max nesting depth (root nodes are depth 0). */
|
|
25
|
+
readonly maxDepth?: number;
|
|
26
|
+
/** Max serialized byte size of a single node's `data` bag. */
|
|
27
|
+
readonly maxDataBytes?: number;
|
|
28
|
+
/** Max nesting depth of a single node's `data` bag. */
|
|
29
|
+
readonly maxDataDepth?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ValidationCode =
|
|
33
|
+
| 'DUPLICATE_ID'
|
|
34
|
+
| 'MISSING_ID'
|
|
35
|
+
| 'UNKNOWN_TYPE'
|
|
36
|
+
| 'MAX_DEPTH_EXCEEDED'
|
|
37
|
+
| 'CHILDREN_NOT_ALLOWED'
|
|
38
|
+
| 'MISSING_REQUIRED_FIELD'
|
|
39
|
+
| 'INVALID_ORDER'
|
|
40
|
+
| 'MULTIPLE_OWNERS'
|
|
41
|
+
| 'DATA_TOO_LARGE'
|
|
42
|
+
| 'DATA_TOO_DEEP'
|
|
43
|
+
| 'INVALID_OVERRIDES';
|
|
44
|
+
|
|
45
|
+
export interface ValidationError {
|
|
46
|
+
readonly code: ValidationCode;
|
|
47
|
+
readonly message: string;
|
|
48
|
+
readonly nodeId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ValidationResult {
|
|
52
|
+
readonly valid: boolean;
|
|
53
|
+
readonly errors: readonly ValidationError[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate a navigation tree. Returns all errors found (does not throw); callers
|
|
58
|
+
* that prefer to throw can use {@link assertValid}.
|
|
59
|
+
*/
|
|
60
|
+
export function validate(tree: NavTree, options: ValidateOptions = {}): ValidationResult {
|
|
61
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
62
|
+
const maxDataBytes = options.maxDataBytes ?? DEFAULT_MAX_DATA_BYTES;
|
|
63
|
+
const maxDataDepth = options.maxDataDepth ?? DEFAULT_MAX_DATA_DEPTH;
|
|
64
|
+
|
|
65
|
+
const errors: ValidationError[] = [];
|
|
66
|
+
const seenIds = new Set<string>();
|
|
67
|
+
/** ref → count of owned:true nodes, to enforce at most one owner per article. */
|
|
68
|
+
const ownerCounts = new Map<string, number>();
|
|
69
|
+
|
|
70
|
+
const visit = (node: NavNode, depth: number): void => {
|
|
71
|
+
// id present + unique
|
|
72
|
+
if (!node.id) {
|
|
73
|
+
errors.push({ code: 'MISSING_ID', message: `Node of type "${node.type}" is missing an id.` });
|
|
74
|
+
} else if (seenIds.has(node.id)) {
|
|
75
|
+
errors.push({ code: 'DUPLICATE_ID', message: `Duplicate node id "${node.id}".`, nodeId: node.id });
|
|
76
|
+
} else {
|
|
77
|
+
seenIds.add(node.id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// known type
|
|
81
|
+
if (!NAV_NODE_TYPES.includes(node.type)) {
|
|
82
|
+
errors.push({ code: 'UNKNOWN_TYPE', message: `Unknown node type "${(node as NavNode).type}".`, nodeId: node.id });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// depth
|
|
86
|
+
if (depth > maxDepth) {
|
|
87
|
+
errors.push({
|
|
88
|
+
code: 'MAX_DEPTH_EXCEEDED',
|
|
89
|
+
message: `Node "${node.id}" at depth ${depth} exceeds max depth ${maxDepth}.`,
|
|
90
|
+
nodeId: node.id,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// order must be a finite number when present
|
|
95
|
+
if (node.order !== undefined && !Number.isFinite(node.order)) {
|
|
96
|
+
errors.push({ code: 'INVALID_ORDER', message: `Node "${node.id}" has a non-finite order.`, nodeId: node.id });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// type-specific required fields
|
|
100
|
+
validateRequiredFields(node, errors);
|
|
101
|
+
|
|
102
|
+
// children only on container types
|
|
103
|
+
const children = node.children ?? [];
|
|
104
|
+
if (children.length > 0 && !CONTAINER_NODE_TYPES.includes(node.type)) {
|
|
105
|
+
errors.push({
|
|
106
|
+
code: 'CHILDREN_NOT_ALLOWED',
|
|
107
|
+
message: `Node "${node.id}" of type "${node.type}" cannot have children.`,
|
|
108
|
+
nodeId: node.id,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// owner accounting
|
|
113
|
+
if (node.type === 'article-ref' && node.owned === true) {
|
|
114
|
+
ownerCounts.set(node.ref, (ownerCounts.get(node.ref) ?? 0) + 1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// data bag caps
|
|
118
|
+
if (node.data !== undefined) {
|
|
119
|
+
validateDataBag(node, maxDataBytes, maxDataDepth, errors);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const child of children) {
|
|
123
|
+
visit(child, depth + 1);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
for (const root of tree) {
|
|
128
|
+
visit(root, 0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const [ref, count] of ownerCounts) {
|
|
132
|
+
if (count > 1) {
|
|
133
|
+
errors.push({
|
|
134
|
+
code: 'MULTIPLE_OWNERS',
|
|
135
|
+
message: `Article "${ref}" is marked owned by ${count} nodes; at most one location may own an article.`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { valid: errors.length === 0, errors };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Throwing variant. */
|
|
144
|
+
export function assertValid(tree: NavTree, options?: ValidateOptions): void {
|
|
145
|
+
const result = validate(tree, options);
|
|
146
|
+
if (!result.valid) {
|
|
147
|
+
const summary = result.errors.map((e) => `${e.code}: ${e.message}`).join('; ');
|
|
148
|
+
throw new NavValidationError(summary, result.errors);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Error thrown by {@link assertValid}. */
|
|
153
|
+
export class NavValidationError extends Error {
|
|
154
|
+
constructor(message: string, public readonly errors: readonly ValidationError[]) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.name = 'NavValidationError';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validateRequiredFields(node: NavNode, errors: ValidationError[]): void {
|
|
161
|
+
switch (node.type) {
|
|
162
|
+
case 'article-ref':
|
|
163
|
+
if (!node.ref) {
|
|
164
|
+
errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `article-ref "${node.id}" is missing "ref".`, nodeId: node.id });
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
case 'external-link':
|
|
168
|
+
if (!node.href) {
|
|
169
|
+
errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `external-link "${node.id}" is missing "href".`, nodeId: node.id });
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
case 'generated-section':
|
|
173
|
+
if (!node.slug) {
|
|
174
|
+
errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `generated-section "${node.id}" is missing "slug".`, nodeId: node.id });
|
|
175
|
+
}
|
|
176
|
+
if (!node.query) {
|
|
177
|
+
errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `generated-section "${node.id}" is missing "query".`, nodeId: node.id });
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
case 'group':
|
|
181
|
+
case 'separator':
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateDataBag(
|
|
187
|
+
node: NavNode,
|
|
188
|
+
maxBytes: number,
|
|
189
|
+
maxDepth: number,
|
|
190
|
+
errors: ValidationError[],
|
|
191
|
+
): void {
|
|
192
|
+
const data = node.data;
|
|
193
|
+
// byte size (UTF-8) of the serialized bag
|
|
194
|
+
let serialized: string;
|
|
195
|
+
try {
|
|
196
|
+
serialized = JSON.stringify(data) ?? '';
|
|
197
|
+
} catch {
|
|
198
|
+
serialized = '';
|
|
199
|
+
}
|
|
200
|
+
const bytes = utf8ByteLength(serialized);
|
|
201
|
+
if (bytes > maxBytes) {
|
|
202
|
+
errors.push({
|
|
203
|
+
code: 'DATA_TOO_LARGE',
|
|
204
|
+
message: `Node "${node.id}" data bag is ${bytes} bytes, exceeding the ${maxBytes}-byte cap. Large payloads belong on the article, not the nav node.`,
|
|
205
|
+
nodeId: node.id,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const depth = jsonDepth(data);
|
|
209
|
+
if (depth > maxDepth) {
|
|
210
|
+
errors.push({
|
|
211
|
+
code: 'DATA_TOO_DEEP',
|
|
212
|
+
message: `Node "${node.id}" data bag nests ${depth} levels, exceeding the ${maxDepth}-level cap.`,
|
|
213
|
+
nodeId: node.id,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function utf8ByteLength(s: string): number {
|
|
219
|
+
// Avoid a hard dependency on Node's Buffer for portability (SDK/browser reuse).
|
|
220
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
221
|
+
return new TextEncoder().encode(s).length;
|
|
222
|
+
}
|
|
223
|
+
return s.length;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function jsonDepth(value: unknown, current = 1): number {
|
|
227
|
+
if (value === null || typeof value !== 'object') return current;
|
|
228
|
+
let max = current;
|
|
229
|
+
for (const v of Object.values(value as Record<string, unknown>)) {
|
|
230
|
+
max = Math.max(max, jsonDepth(v, current + 1));
|
|
231
|
+
}
|
|
232
|
+
return max;
|
|
233
|
+
}
|