@script-it/json-views-core 0.1.0-beta.1

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -0
  3. package/THIRD_PARTY_NOTICES.txt +4917 -0
  4. package/dist/annotation-capabilities.d.ts +17 -0
  5. package/dist/annotation-capabilities.js +21 -0
  6. package/dist/atomic-value.d.ts +3 -0
  7. package/dist/atomic-value.js +6 -0
  8. package/dist/date-type.d.ts +48 -0
  9. package/dist/date-type.js +244 -0
  10. package/dist/diagnostics.d.ts +8 -0
  11. package/dist/diagnostics.js +252 -0
  12. package/dist/document-session.d.ts +52 -0
  13. package/dist/document-session.js +164 -0
  14. package/dist/formats/conversion.d.ts +17 -0
  15. package/dist/formats/conversion.js +75 -0
  16. package/dist/formats/csv-format.d.ts +31 -0
  17. package/dist/formats/csv-format.js +269 -0
  18. package/dist/formats/document-format.d.ts +35 -0
  19. package/dist/formats/document-format.js +1 -0
  20. package/dist/formats/json-format.d.ts +12 -0
  21. package/dist/formats/json-format.js +109 -0
  22. package/dist/html-view-bindings.d.ts +4 -0
  23. package/dist/html-view-bindings.js +32 -0
  24. package/dist/html-view-limits.d.ts +1 -0
  25. package/dist/html-view-limits.js +1 -0
  26. package/dist/index.d.ts +19 -0
  27. package/dist/index.js +19 -0
  28. package/dist/inference-fields.d.ts +5 -0
  29. package/dist/inference-fields.js +23 -0
  30. package/dist/inference.d.ts +26 -0
  31. package/dist/inference.js +304 -0
  32. package/dist/json-limits.d.ts +8 -0
  33. package/dist/json-limits.js +25 -0
  34. package/dist/json-path.d.ts +14 -0
  35. package/dist/json-path.js +84 -0
  36. package/dist/json-source-patcher.d.ts +97 -0
  37. package/dist/json-source-patcher.js +778 -0
  38. package/dist/metadata.d.ts +137 -0
  39. package/dist/metadata.js +952 -0
  40. package/dist/safe-pattern.d.ts +5 -0
  41. package/dist/safe-pattern.js +20 -0
  42. package/dist/schema-validation.d.ts +3 -0
  43. package/dist/schema-validation.js +4 -0
  44. package/dist/source-default.d.ts +2 -0
  45. package/dist/source-default.js +39 -0
  46. package/dist/table-suitability.d.ts +5 -0
  47. package/dist/table-suitability.js +55 -0
  48. package/dist/type-registry.d.ts +57 -0
  49. package/dist/type-registry.js +238 -0
  50. package/dist/value-comparison.d.ts +18 -0
  51. package/dist/value-comparison.js +133 -0
  52. package/dist/view-model.d.ts +18 -0
  53. package/dist/view-model.js +134 -0
  54. package/package.json +52 -0
  55. package/schema/jsonviews.schema.json +102 -0
@@ -0,0 +1,109 @@
1
+ import { assertJsonDepth } from '../json-limits.js';
2
+ import { appendJsonArrayItemInSource, inspectJsonSource, removeJsonValuesInSource, replaceJsonValueInSource, replaceJsonValuesInSource, stringifyJsonValue, } from '../json-source-patcher.js';
3
+ function jsonCapability(value) {
4
+ try {
5
+ stringifyJsonValue(value);
6
+ return { representable: true };
7
+ }
8
+ catch {
9
+ return { representable: false, reason: 'The value is not losslessly representable as JSON data' };
10
+ }
11
+ }
12
+ function adapter(format) {
13
+ return {
14
+ format,
15
+ inspect(source) {
16
+ const inspected = inspectJsonSource(source);
17
+ const actual = Array.isArray(inspected.value) ? 'json-array' : 'json-object';
18
+ if (actual !== format)
19
+ throw new TypeError(`Expected ${format === 'json-array' ? 'an array' : 'a non-array'} JSON root`);
20
+ const diagnostics = inspected.diagnostics.map((item) => ({
21
+ code: item.code,
22
+ message: item.message,
23
+ severity: 'warning',
24
+ path: item.sourcePath,
25
+ start: item.start,
26
+ end: item.end,
27
+ help: item.help,
28
+ }));
29
+ return { root: inspected.value, state: { root: inspected.value }, diagnostics };
30
+ },
31
+ validate(source) { JSON.parse(source); },
32
+ replace(source, _state, path, value) { return replaceJsonValueInSource(source, path, value); },
33
+ replaceMany(source, _state, changes) {
34
+ return replaceJsonValuesInSource(source, changes);
35
+ },
36
+ removeMany(source, _state, paths) {
37
+ return removeJsonValuesInSource(source, paths);
38
+ },
39
+ append(source, _state, path, value) { return appendJsonArrayItemInSource(source, path, value); },
40
+ canRepresent: jsonCapability,
41
+ };
42
+ }
43
+ export const jsonObjectFormat = adapter('json-object');
44
+ export const jsonArrayFormat = adapter('json-array');
45
+ export function jsonFormatForSource(source) {
46
+ return Array.isArray(JSON.parse(source)) ? jsonArrayFormat : jsonObjectFormat;
47
+ }
48
+ export function jsonFormatForRoot(root) {
49
+ return Array.isArray(root) ? jsonArrayFormat : jsonObjectFormat;
50
+ }
51
+ /** Pretty-print JSON without changing numeric spelling, duplicate keys, or string contents. */
52
+ export function formatJsonSource(source) {
53
+ assertJsonDepth(JSON.parse(source));
54
+ let formatted = '';
55
+ let depth = 0;
56
+ let escaped = false;
57
+ let inString = false;
58
+ const expandedContainers = [];
59
+ const indentation = () => ' '.repeat(depth);
60
+ for (let index = 0; index < source.length; index += 1) {
61
+ const character = source[index];
62
+ if (inString) {
63
+ formatted += character;
64
+ if (escaped)
65
+ escaped = false;
66
+ else if (character === '\\')
67
+ escaped = true;
68
+ else if (character === '"')
69
+ inString = false;
70
+ continue;
71
+ }
72
+ if (/\s/.test(character))
73
+ continue;
74
+ if (character === '"') {
75
+ inString = true;
76
+ formatted += character;
77
+ continue;
78
+ }
79
+ if (character === '{' || character === '[') {
80
+ const closingCharacter = character === '{' ? '}' : ']';
81
+ let nextIndex = index + 1;
82
+ while (nextIndex < source.length && /\s/.test(source[nextIndex]))
83
+ nextIndex += 1;
84
+ const expanded = source[nextIndex] !== closingCharacter;
85
+ expandedContainers.push(expanded);
86
+ formatted += character;
87
+ if (expanded) {
88
+ depth += 1;
89
+ formatted += `\n${indentation()}`;
90
+ }
91
+ continue;
92
+ }
93
+ if (character === '}' || character === ']') {
94
+ const expanded = expandedContainers.pop() ?? false;
95
+ if (expanded) {
96
+ depth -= 1;
97
+ formatted += `\n${indentation()}`;
98
+ }
99
+ formatted += character;
100
+ continue;
101
+ }
102
+ if (character === ',') {
103
+ formatted += `,\n${indentation()}`;
104
+ continue;
105
+ }
106
+ formatted += character === ':' ? ': ' : character;
107
+ }
108
+ return formatted;
109
+ }
@@ -0,0 +1,4 @@
1
+ import type { ValuePath } from './json-path.js';
2
+ export { MAX_HTML_VIEW_BYTES } from './html-view-limits.js';
3
+ export declare function resolveHtmlViewBinding(root: unknown, expression: string, aliases: Readonly<Record<string, ValuePath>>, scope: ValuePath): import("./metadata.js").ResolvedJsonViewPath;
4
+ export declare function htmlRepeatKeys(items: readonly unknown[], key?: string): string[];
@@ -0,0 +1,32 @@
1
+ import { parseJsonViewPath, resolveJsonViewPath } from './metadata.js';
2
+ export { MAX_HTML_VIEW_BYTES } from './html-view-limits.js';
3
+ export function resolveHtmlViewBinding(root, expression, aliases, scope) {
4
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)(.*)$/.exec(expression);
5
+ const prefix = expression.startsWith('$') ? [] : match && Object.prototype.hasOwnProperty.call(aliases, match[1]) ? aliases[match[1]] : undefined;
6
+ if (!prefix)
7
+ throw new Error(`Unknown binding: ${expression}`);
8
+ const parsed = parseJsonViewPath(expression.startsWith('$') ? expression : `$${match[2]}`, { allowWildcard: false });
9
+ const relative = parsed.segments.map(segment => {
10
+ if (segment.kind === 'wildcard')
11
+ throw new Error('Single bindings cannot contain wildcards');
12
+ return segment.kind === 'property' ? segment.key : segment.index;
13
+ });
14
+ const path = [...prefix, ...relative];
15
+ if (path.includes('$jsonviews') || !scope.every((part, index) => path[index] === part) || path.length < scope.length)
16
+ throw new Error(`Binding outside the view data scope: ${expression}`);
17
+ const absolute = parseJsonViewPath('$' + path.map(part => typeof part === 'number' ? `[${part}]` : `['${part.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`).join(''));
18
+ return resolveJsonViewPath(root, absolute);
19
+ }
20
+ export function htmlRepeatKeys(items, key) {
21
+ if (!key)
22
+ return items.map((_, i) => `index:${i}`);
23
+ const keys = items.map(item => {
24
+ const value = item && typeof item === 'object' && Object.prototype.hasOwnProperty.call(item, key) ? item[key] : undefined;
25
+ if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value)))
26
+ throw new Error(`Repeat key ${key} must be a string or finite number on every item`);
27
+ return `${typeof value}:${value}`;
28
+ });
29
+ if (new Set(keys).size !== keys.length)
30
+ throw new Error(`Repeat key ${key} must be unique`);
31
+ return keys;
32
+ }
@@ -0,0 +1 @@
1
+ export declare const MAX_HTML_VIEW_BYTES: number;
@@ -0,0 +1 @@
1
+ export const MAX_HTML_VIEW_BYTES = 256 * 1024;
@@ -0,0 +1,19 @@
1
+ export * from './json-path.js';
2
+ export * from './json-source-patcher.js';
3
+ export * from './date-type.js';
4
+ export * from './inference.js';
5
+ export * from './table-suitability.js';
6
+ export * from './metadata.js';
7
+ export * from './diagnostics.js';
8
+ export * from './schema-validation.js';
9
+ export * from './type-registry.js';
10
+ export * from './view-model.js';
11
+ export * from './document-session.js';
12
+ export * from './formats/document-format.js';
13
+ export * from './formats/json-format.js';
14
+ export * from './formats/csv-format.js';
15
+ export * from './formats/conversion.js';
16
+ export * from './value-comparison.js';
17
+ export * from './json-limits.js';
18
+ export { prefersSource } from './source-default.js';
19
+ export * from './html-view-bindings.js';
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ export * from './json-path.js';
2
+ export * from './json-source-patcher.js';
3
+ export * from './date-type.js';
4
+ export * from './inference.js';
5
+ export * from './table-suitability.js';
6
+ export * from './metadata.js';
7
+ export * from './diagnostics.js';
8
+ export * from './schema-validation.js';
9
+ export * from './type-registry.js';
10
+ export * from './view-model.js';
11
+ export * from './document-session.js';
12
+ export * from './formats/document-format.js';
13
+ export * from './formats/json-format.js';
14
+ export * from './formats/csv-format.js';
15
+ export * from './formats/conversion.js';
16
+ export * from './value-comparison.js';
17
+ export * from './json-limits.js';
18
+ export { prefersSource } from './source-default.js';
19
+ export * from './html-view-bindings.js';
@@ -0,0 +1,5 @@
1
+ export declare function normalizedKey(key: string): string;
2
+ /** A complete HTML document or a styled HTML fragment needs its own layout. */
3
+ export declare function isHtmlDocument(value: string): boolean;
4
+ /** Recognize common Markdown and supported HTML; keep bare hashtags and identifiers literal. */
5
+ export declare function isMarkdownText(value: string): boolean;
@@ -0,0 +1,23 @@
1
+ export function normalizedKey(key) {
2
+ return key.toLocaleLowerCase().replace(/[^a-z0-9]+/g, '');
3
+ }
4
+ /** A complete HTML document or a styled HTML fragment needs its own layout. */
5
+ export function isHtmlDocument(value) {
6
+ return /<!doctype\s+html\b/i.test(value)
7
+ || /<html\b[^>]*>[\s\S]*<\/html\s*>/i.test(value)
8
+ || (/<style\b[^>]*>[\s\S]*<\/style\s*>/i.test(value)
9
+ && /<(?:body|div|main|section|article|table)\b/i.test(value));
10
+ }
11
+ /** Recognize common Markdown and supported HTML; keep bare hashtags and identifiers literal. */
12
+ export function isMarkdownText(value) {
13
+ return /(?:^|\n)#{1,6}\s+\S/.test(value)
14
+ || /(?:^|\n)(?:```|~~~)[^\n]*\n[\s\S]+\n(?:```|~~~)/.test(value)
15
+ || /(?:\*\*[^*\n]+\*\*|~~[^~\n]+~~|`[^`\n]+`)/.test(value)
16
+ || /!?\[[^\]\n]*\]\([^\s)]+\)/.test(value)
17
+ || /(?:^|\n)\s*(?:[-*+]|\d+[.)]|>)\s+\S/.test(value)
18
+ || /(?:^|[\s(])(?:_[^_\n]+_|\*[^*\n]+\*)(?=$|[\s).,!?:;])/.test(value)
19
+ || /(?:^|\n)[^\n]+\n(?:={3,}|-{3,})(?:\n|$)/.test(value)
20
+ || /(?:^|\n)\s*\|?\s*:?-{3,}:?\s*\|/.test(value)
21
+ || /<(?:br|hr|img)\b[^>]*\/?>/i.test(value)
22
+ || /<(div|span|svg|pre|code|p|h[1-6]|strong|em|ul|ol|li|table|blockquote|a)\b[^>]*>[\s\S]*<\/\1>/i.test(value);
23
+ }
@@ -0,0 +1,26 @@
1
+ export interface InferredJsonViewSchemaDescriptor {
2
+ type: string;
3
+ title: string;
4
+ options?: string[];
5
+ defaultIncludeTime?: boolean;
6
+ }
7
+ export interface InferredJsonViewDefinition {
8
+ id: string;
9
+ name: string;
10
+ path: string;
11
+ columns?: Array<{
12
+ label: string;
13
+ path: string;
14
+ }>;
15
+ display?: 'kanban';
16
+ groupBy?: string;
17
+ }
18
+ export interface InferredJsonViewMetadata {
19
+ version: 1;
20
+ schema: Record<string, InferredJsonViewSchemaDescriptor>;
21
+ views: InferredJsonViewDefinition[];
22
+ }
23
+ /** Uses the metadata title policy. Disable the first-text fallback for record headings. */
24
+ export declare function inferJsonViewRecordTitleKey(rows: readonly Record<string, unknown>[], allowFallback?: boolean): string | undefined;
25
+ /** Predicts useful metadata without mutating the source JSON. */
26
+ export declare function inferJsonViewMetadata(root: unknown): InferredJsonViewMetadata;
@@ -0,0 +1,304 @@
1
+ import { parseDateValue } from './date-type.js';
2
+ import { isJsonViewTableCandidate } from './table-suitability.js';
3
+ import { isHtmlDocument, isMarkdownText, normalizedKey } from './inference-fields.js';
4
+ const MAX_COLLECTIONS = 4;
5
+ const MAX_PREDICTED_VIEWS = 2;
6
+ const MAX_FIELDS_PER_SHAPE = 64;
7
+ const MAX_DISCOVERY_DEPTH = 3;
8
+ const MAX_SAMPLE_ROWS = 10;
9
+ const WORKFLOW_KEYS = new Set(['phase', 'stage', 'state', 'status']);
10
+ const WORKFLOW_VALUES = new Set(['new', 'open', 'todo', 'to do', 'backlog', 'pending', 'in progress', 'in review', 'review', 'blocked', 'done', 'completed', 'closed', 'cancelled', 'qualified', 'contacted', 'proposal', 'negotiation', 'won', 'lost', 'customer']);
11
+ const TITLE_KEY_ORDER = [
12
+ 'name', 'title', 'label', 'displayname', 'fullname', 'firstname', 'headline', 'subject', 'email', 'id',
13
+ ];
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+ function friendlyLabel(value) {
18
+ const spaced = value
19
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
20
+ .replace(/[_-]+/g, ' ')
21
+ .trim();
22
+ return spaced ? `${spaced[0].toLocaleUpperCase()}${spaced.slice(1)}` : 'Value';
23
+ }
24
+ function appendProperty(source, key) {
25
+ if (/^[A-Za-z_$][\w$]*$/.test(key))
26
+ return `${source}.${key}`;
27
+ const escaped = key
28
+ .replace(/\\/g, '\\\\')
29
+ .replace(/'/g, "\\'")
30
+ .replace(/\n/g, '\\n')
31
+ .replace(/\r/g, '\\r')
32
+ .replace(/\t/g, '\\t');
33
+ return `${source}['${escaped}']`;
34
+ }
35
+ function jsonPath(path) {
36
+ return path.reduce((source, segment) => (typeof segment === 'number' ? `${source}[${segment}]` : appendProperty(source, segment)), '$');
37
+ }
38
+ function fieldPath(base, collection, parts) {
39
+ return parts.reduce(appendProperty, `${jsonPath(base)}${collection ? '[*]' : ''}`);
40
+ }
41
+ function slug(value) {
42
+ return value.toLocaleLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'view';
43
+ }
44
+ function recordCollection(value) {
45
+ if (Array.isArray(value)) {
46
+ if (value.length === 0 || !value.every(isRecord))
47
+ return undefined;
48
+ const rows = value.slice(0, MAX_SAMPLE_ROWS);
49
+ return rows;
50
+ }
51
+ if (!isRecord(value))
52
+ return undefined;
53
+ const values = Object.values(value);
54
+ if (values.length < 2 || !values.every(isRecord))
55
+ return undefined;
56
+ const rows = values.slice(0, MAX_SAMPLE_ROWS);
57
+ return rows;
58
+ }
59
+ function collectionLabel(path, rootValue) {
60
+ const last = path[path.length - 1];
61
+ if (typeof last === 'string')
62
+ return friendlyLabel(last);
63
+ return Array.isArray(rootValue) ? 'Data' : 'Records';
64
+ }
65
+ function discoverCollections(root) {
66
+ const candidates = [];
67
+ const visit = (value, path, depth, promote) => {
68
+ const rows = recordCollection(value);
69
+ if (rows) {
70
+ const table = isJsonViewTableCandidate(value);
71
+ candidates.push({
72
+ label: collectionLabel(path, root),
73
+ path,
74
+ rows,
75
+ score: (table && promote ? 2000 : table ? 1000 : 0) + 100 - depth * 12 + Math.min(rows.length, 20),
76
+ table,
77
+ promote,
78
+ value,
79
+ });
80
+ // Never promote a child of a record collection as a document-level view.
81
+ // Continue discovering its field types when the parent is not tabular.
82
+ if (table)
83
+ return;
84
+ }
85
+ if (depth >= MAX_DISCOVERY_DEPTH || !isRecord(value))
86
+ return;
87
+ const children = Object.entries(value).filter(([key]) => key !== '$jsonviews').map(([, child]) => child);
88
+ const recordDictionary = children.length >= 2 && children.every(isRecord);
89
+ Object.entries(value).forEach(([key, child]) => {
90
+ if (key === '$jsonviews')
91
+ return;
92
+ if (Array.isArray(child) || isRecord(child))
93
+ visit(child, [...path, key], depth + 1, promote && !rows && !recordDictionary);
94
+ });
95
+ };
96
+ visit(root, [], 0, true);
97
+ return candidates
98
+ .sort((left, right) => right.score - left.score)
99
+ .slice(0, MAX_COLLECTIONS);
100
+ }
101
+ function validWebUrl(value) {
102
+ try {
103
+ const parsed = new URL(value);
104
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ function uniqueStrings(values, limit = 64) {
111
+ const unique = [];
112
+ const seen = new Set();
113
+ for (const value of values) {
114
+ if (!value || seen.has(value))
115
+ continue;
116
+ seen.add(value);
117
+ unique.push(value);
118
+ if (unique.length >= limit)
119
+ break;
120
+ }
121
+ return unique;
122
+ }
123
+ function descriptorFor(key, values) {
124
+ const present = values.filter((value) => value !== null && value !== undefined);
125
+ if (present.length === 0)
126
+ return undefined;
127
+ const title = friendlyLabel(key);
128
+ if (present.every((value) => typeof value === 'boolean'))
129
+ return { type: 'checkbox', title };
130
+ if (present.every((value) => typeof value === 'number' && Number.isFinite(value)))
131
+ return { type: 'number', title };
132
+ if (present.every((value) => Array.isArray(value) && value.every((item) => typeof item === 'string'))) {
133
+ const options = uniqueStrings(present.flatMap((value) => value), 41);
134
+ if (options.length === 0)
135
+ return undefined;
136
+ if (options.length <= 40 && options.every((option) => option.length <= 40 && !/[\n@]/.test(option) && !validWebUrl(option))) {
137
+ return { type: 'multi-select', title, options };
138
+ }
139
+ return undefined;
140
+ }
141
+ if (!present.every((value) => typeof value === 'string'))
142
+ return undefined;
143
+ const strings = present;
144
+ if (strings.every((value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)))
145
+ return { type: 'email', title };
146
+ if (strings.every(validWebUrl))
147
+ return { type: 'url', title };
148
+ const dates = strings.map(parseDateValue);
149
+ if (dates.every((date) => date.kind === 'date' || date.kind === 'datetime' || date.kind === 'floating-datetime')) {
150
+ return {
151
+ type: 'date',
152
+ title,
153
+ ...(dates.some((date) => date.kind !== 'date') ? { defaultIncludeTime: true } : {}),
154
+ };
155
+ }
156
+ const nonempty = strings.filter((value) => value.trim().length > 0);
157
+ if (nonempty.length > 0 && nonempty.some(isHtmlDocument))
158
+ return { type: 'html', title };
159
+ if (nonempty.length > 0 && nonempty.some(isMarkdownText))
160
+ return { type: 'markdown', title };
161
+ const options = uniqueStrings(strings, 21);
162
+ const repeatedSmallSet = options.length <= 20 && options.length < strings.length;
163
+ if (repeatedSmallSet && options.length >= 1
164
+ && options.every((option) => option.trim().length > 0 && option.length <= 40 && !/[\n@]/.test(option) && !validWebUrl(option))) {
165
+ return { type: 'select', title, options };
166
+ }
167
+ return { type: 'text', title };
168
+ }
169
+ function inferFields(rows, base, collection) {
170
+ const fields = [];
171
+ const visit = (records, parts, depth) => {
172
+ if (fields.length >= MAX_FIELDS_PER_SHAPE)
173
+ return;
174
+ const keys = [];
175
+ const seen = new Set();
176
+ records.forEach((record) => Object.keys(record).forEach((key) => {
177
+ if (key === '$jsonviews' || seen.has(key))
178
+ return;
179
+ seen.add(key);
180
+ keys.push(key);
181
+ }));
182
+ for (const key of keys) {
183
+ if (fields.length >= MAX_FIELDS_PER_SHAPE)
184
+ return;
185
+ const values = records.flatMap((record) => Object.prototype.hasOwnProperty.call(record, key) ? [record[key]] : []);
186
+ const present = values.filter((value) => value !== null && value !== undefined);
187
+ if (depth < 2 && present.length / records.length >= 0.8 && present.every(isRecord)
188
+ && !present.some((value) => recordCollection(value) !== undefined)) {
189
+ visit(present, [...parts, key], depth + 1);
190
+ continue;
191
+ }
192
+ const descriptor = descriptorFor(key, values);
193
+ if (descriptor || parts.length === 0) {
194
+ fields.push({
195
+ ...(parts.length > 0 ? { label: [...parts, key].map(friendlyLabel).join(' · ') } : {}),
196
+ ...(descriptor ? { descriptor } : {}),
197
+ direct: parts.length === 0,
198
+ key,
199
+ path: fieldPath(base, collection, [...parts, key]),
200
+ });
201
+ }
202
+ }
203
+ };
204
+ visit(rows, [], 0);
205
+ return fields;
206
+ }
207
+ function titleField(fields, allowFallback = true) {
208
+ for (const key of TITLE_KEY_ORDER) {
209
+ const field = fields.find((candidate) => (candidate.direct
210
+ && normalizedKey(candidate.key) === key
211
+ && candidate.descriptor !== undefined
212
+ && ['text', 'email', 'select', 'number'].includes(candidate.descriptor.type)));
213
+ if (field)
214
+ return field;
215
+ }
216
+ return allowFallback ? fields.find((field) => field.descriptor && field.descriptor.type === 'text') : undefined;
217
+ }
218
+ /** Uses the metadata title policy. Disable the first-text fallback for record headings. */
219
+ export function inferJsonViewRecordTitleKey(rows, allowFallback = true) {
220
+ return titleField(inferFields(rows, [], true), allowFallback)?.key;
221
+ }
222
+ function visibleColumns(fields, title) {
223
+ return title ? [title, ...fields.filter((field) => field !== title)] : [...fields];
224
+ }
225
+ function groupField(fields, value, title) {
226
+ if (!title || normalizedKey(title.key) === 'id' || !TITLE_KEY_ORDER.includes(normalizedKey(title.key)))
227
+ return undefined;
228
+ // Tables sample shape, but a board must account for every group it will show.
229
+ const rows = (Array.isArray(value) ? value : Object.values(value));
230
+ const titles = rows.map((row) => row[title.key]);
231
+ if (!titles.every((value) => typeof value === 'string' && value.trim().length > 0)
232
+ || new Set(titles).size / rows.length < 0.8)
233
+ return undefined;
234
+ return fields.find((field) => (field.direct
235
+ && field.descriptor?.type === 'select'
236
+ && WORKFLOW_KEYS.has(normalizedKey(field.key))
237
+ && field.descriptor.options?.every((option) => WORKFLOW_VALUES.has(option.toLowerCase().replace(/[_-]+/g, ' ').trim()))
238
+ && (field.descriptor.options?.length ?? 0) >= 2
239
+ && (field.descriptor.options?.length ?? 0) <= 6
240
+ && rows.every((row) => typeof row[field.key] === 'string' && String(row[field.key]).trim().length > 0
241
+ && field.descriptor.options.includes(row[field.key]))
242
+ && field.descriptor.options.every((option) => rows.filter((row) => row[field.key] === option).length >= 2)));
243
+ }
244
+ function uniqueId(preferred, used) {
245
+ let id = preferred;
246
+ let suffix = 2;
247
+ while (used.has(id)) {
248
+ id = `${preferred}-${suffix}`;
249
+ suffix += 1;
250
+ }
251
+ used.add(id);
252
+ return id;
253
+ }
254
+ /** Predicts useful metadata without mutating the source JSON. */
255
+ export function inferJsonViewMetadata(root) {
256
+ const schema = {};
257
+ const views = [];
258
+ const usedIds = new Set();
259
+ const collections = discoverCollections(root);
260
+ if (isRecord(root) && recordCollection(root) === undefined) {
261
+ inferFields([root], [], false).forEach((field) => {
262
+ if (field.descriptor)
263
+ schema[field.path] = field.descriptor;
264
+ });
265
+ }
266
+ collections.forEach((collection) => {
267
+ const fields = inferFields(collection.rows, collection.path, true);
268
+ const allRows = (Array.isArray(collection.value) ? collection.value : Object.values(collection.value));
269
+ for (const key of new Set(allRows.flatMap((row) => Object.keys(row)))) {
270
+ if (key === '$jsonviews' || fields.some((field) => field.key === key && field.direct || field.path.startsWith(`${fieldPath(collection.path, true, [key])}.`)))
271
+ continue;
272
+ fields.push({ key, direct: true, path: fieldPath(collection.path, true, [key]) });
273
+ }
274
+ fields.forEach((field) => {
275
+ if (field.descriptor)
276
+ schema[field.path] = field.descriptor;
277
+ });
278
+ if (collection.path.length === 0 || !collection.table || !collection.promote)
279
+ return;
280
+ const title = titleField(fields);
281
+ const columns = visibleColumns(fields, title);
282
+ const path = jsonPath(collection.path);
283
+ const baseId = slug(collection.path.map(String).join('-') || collection.label);
284
+ const table = {
285
+ id: uniqueId(baseId, usedIds),
286
+ name: collection.label,
287
+ path,
288
+ ...(columns.length > 0 ? { columns: columns.map((field) => ({ label: field.label ?? field.descriptor?.title ?? friendlyLabel(field.key), path: field.path })) } : {}),
289
+ };
290
+ if (views.length < MAX_PREDICTED_VIEWS)
291
+ views.push(table);
292
+ const group = groupField(fields, collection.value, title);
293
+ if (group && views.length < MAX_PREDICTED_VIEWS) {
294
+ views.push({
295
+ ...table,
296
+ id: uniqueId(`${baseId}-board`, usedIds),
297
+ name: `${collection.label} board`,
298
+ display: 'kanban',
299
+ groupBy: group.path,
300
+ });
301
+ }
302
+ });
303
+ return { version: 1, schema, views };
304
+ }
@@ -0,0 +1,8 @@
1
+ /** Maximum number of nested object/array containers supported by structured views. */
2
+ export declare const MAX_JSON_DEPTH = 512;
3
+ export declare class JsonDepthLimitError extends RangeError {
4
+ readonly code = "json-depth-limit";
5
+ constructor();
6
+ }
7
+ /** Iterative guard for callers supplying already-parsed data. Cycles also fail boundedly. */
8
+ export declare function assertJsonDepth(root: unknown): void;
@@ -0,0 +1,25 @@
1
+ /** Maximum number of nested object/array containers supported by structured views. */
2
+ export const MAX_JSON_DEPTH = 512;
3
+ export class JsonDepthLimitError extends RangeError {
4
+ code = 'json-depth-limit';
5
+ constructor() {
6
+ super(`This document exceeds the supported nesting limit of ${MAX_JSON_DEPTH} containers. Its original JSON source is preserved; reduce nesting to use structured views.`);
7
+ this.name = 'JsonDepthLimitError';
8
+ }
9
+ }
10
+ /** Iterative guard for callers supplying already-parsed data. Cycles also fail boundedly. */
11
+ export function assertJsonDepth(root) {
12
+ const pending = [{ value: root, depth: 0 }];
13
+ while (pending.length) {
14
+ const { value, depth } = pending.pop();
15
+ if (value === null || typeof value !== 'object')
16
+ continue;
17
+ if (depth >= MAX_JSON_DEPTH)
18
+ throw new JsonDepthLimitError();
19
+ for (const key of Object.keys(value)) {
20
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
21
+ if (descriptor && 'value' in descriptor)
22
+ pending.push({ value: descriptor.value, depth: depth + 1 });
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,14 @@
1
+ export type ValuePathSegment = string | number;
2
+ export type ValuePath = readonly ValuePathSegment[];
3
+ export declare const VALUE_PATH_MISSING: unique symbol;
4
+ export declare function describeValuePath(path: ValuePath): string;
5
+ /** Resolves an existing typed path or explains why it is invalid. */
6
+ export declare function requireValueAtPath(root: unknown, path: ValuePath): unknown;
7
+ /** Resolves typed segments without conflating array indices and object keys. */
8
+ export declare function getValueAtPath(root: unknown, path: ValuePath): unknown | typeof VALUE_PATH_MISSING;
9
+ /** Stable key for view state associated with an exact JSON path. */
10
+ export declare function valuePathKey(path: ValuePath): string;
11
+ /** Creates nested JSON data properties without following inherited objects or setters.
12
+ * Array writes may replace an existing item or append one item; sparse arrays are rejected.
13
+ */
14
+ export declare function setJsonValueAtPath(root: object, path: ValuePath, value: unknown): void;