e10-ebuilder-prototype 0.5.5 → 0.5.7

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 (46) hide show
  1. package/README.md +45 -13
  2. package/dist/archive.d.ts +1 -1
  3. package/dist/archive.js +3 -0
  4. package/dist/capture.js +1 -1
  5. package/dist/common.d.ts +2 -1
  6. package/dist/common.js +24 -4
  7. package/dist/form-behavior-runtime.d.mts +1 -0
  8. package/dist/form-behavior-runtime.mjs +681 -0
  9. package/dist/form-behavior.d.ts +11 -0
  10. package/dist/form-behavior.js +169 -0
  11. package/dist/form-context.d.ts +4 -0
  12. package/dist/form-context.js +7 -5
  13. package/dist/form-generation.d.ts +24 -0
  14. package/dist/form-generation.js +367 -0
  15. package/dist/form-runtime.mjs +11 -2
  16. package/dist/host-ledger.d.ts +23 -0
  17. package/dist/host-ledger.js +183 -0
  18. package/dist/host-watch.d.ts +102 -0
  19. package/dist/host-watch.js +112 -0
  20. package/dist/html-handoff.d.ts +1 -0
  21. package/dist/html-handoff.js +30 -2
  22. package/dist/html-inspect.d.ts +2 -2
  23. package/dist/html-inspect.js +23 -51
  24. package/dist/html-interact.d.ts +36 -0
  25. package/dist/html-interact.js +216 -0
  26. package/dist/html-review-budget.d.ts +9 -0
  27. package/dist/html-review-budget.js +47 -0
  28. package/dist/html.d.ts +77 -5
  29. package/dist/html.js +155 -41
  30. package/dist/index.js +119 -44
  31. package/dist/model.d.ts +2 -1
  32. package/dist/offline-render.d.ts +11 -0
  33. package/dist/offline-render.js +71 -0
  34. package/dist/offline-store.mjs +10 -0
  35. package/dist/runtime-support.d.mts +29 -1
  36. package/dist/runtime-support.mjs +55 -5
  37. package/dist/site.js +66 -51
  38. package/dist/store.js +18 -4
  39. package/dist/templates/form-guide.md +6 -20
  40. package/dist/templates/form-task-core.md +45 -5
  41. package/dist/templates/index.html +5 -3
  42. package/dist/templates/workflow-guide.md +4 -2
  43. package/dist/vendor/environment-auth.d.ts +1 -0
  44. package/dist/vendor/environment-auth.js +4 -4
  45. package/docs/PROTOCOL.md +341 -39
  46. package/package.json +1 -1
@@ -0,0 +1,11 @@
1
+ /** No guesses from labels. This is the fixed execution domain, not a full EBuilder interpreter. */
2
+ export declare function formBehaviorContract(raw: any): {
3
+ schema: number;
4
+ menuId: string;
5
+ objId: string;
6
+ fields: any;
7
+ buttons: any[];
8
+ scopes: any[];
9
+ };
10
+ export declare function formBehaviorRuntime(contract: any): string;
11
+ export declare function attachFormBehavior(source: string, input: any): string;
@@ -0,0 +1,169 @@
1
+ import { formOptionData } from './form-options.js';
2
+ import { installFormBehavior } from './form-behavior-runtime.mjs';
3
+ const kinds = {
4
+ Text: 'text',
5
+ String: 'text',
6
+ TextArea: 'textarea',
7
+ Number: 'number',
8
+ NumberComponent: 'number',
9
+ Money: 'number',
10
+ Date: 'date',
11
+ DateComponent: 'date',
12
+ Select: 'select',
13
+ RadioBox: 'radio',
14
+ Checkbox: 'multi',
15
+ CheckBox: 'multi',
16
+ Cascader: 'cascader',
17
+ Employee: 'entity',
18
+ Department: 'entity',
19
+ Ebuilder: 'entity',
20
+ RelateBrowser: 'entity',
21
+ Phone: 'tel',
22
+ Mobile: 'tel',
23
+ Email: 'email',
24
+ };
25
+ const operations = {
26
+ tableAdd: 'add',
27
+ add: 'add',
28
+ view: 'view',
29
+ edit: 'edit',
30
+ tableEdit: 'edit',
31
+ save: 'save',
32
+ saveAndCreate: 'saveNew',
33
+ cancel: 'cancel',
34
+ tableDelete: 'delete',
35
+ delete: 'delete',
36
+ };
37
+ const bool = (v, fallback) => v === undefined
38
+ ? fallback
39
+ : [true, 1, '1', 'true'].includes(v)
40
+ ? true
41
+ : [false, 0, '0', 'false'].includes(v)
42
+ ? false
43
+ : undefined;
44
+ const encode = (v) => JSON.stringify(v)
45
+ .replaceAll('<', '\\u003c')
46
+ .replaceAll('\u2028', '\\u2028')
47
+ .replaceAll('\u2029', '\\u2029');
48
+ /** No guesses from labels. This is the fixed execution domain, not a full EBuilder interpreter. */
49
+ export function formBehaviorContract(raw) {
50
+ const input = formOptionData(raw).context;
51
+ const fields = (input.fields || []).map((f, i) => {
52
+ const c = f.config || {}, component = c.componentKey || f.compType || f.type;
53
+ const issues = [];
54
+ const flag = (key, value, fallback = false) => {
55
+ const normalized = bool(value, fallback);
56
+ if (normalized === undefined)
57
+ issues.push(`unknown-${key}`);
58
+ return normalized ?? true; // uncertain constraints cannot silently enable editing
59
+ };
60
+ const number = (value, key) => {
61
+ if (value === undefined || value === null || value === '')
62
+ return undefined;
63
+ if (!Number.isFinite(Number(value))) {
64
+ issues.push(`unknown-${key}`);
65
+ return undefined;
66
+ }
67
+ return Number(value);
68
+ };
69
+ const kind = kinds[component] || 'unsupported';
70
+ if (kind === 'unsupported')
71
+ issues.push(`unsupported-control:${component}`);
72
+ if (c.richEditor === true || c.supportHtml === true)
73
+ issues.push('rich-text-as-plain-text');
74
+ if (c.isUnique === true || c.unique === true)
75
+ issues.push('uniqueness-source-service-unavailable');
76
+ if (c.formula || c.eventGroup || c.condition || c.displayCondition)
77
+ issues.push('dynamic-field-rule-requires-decision');
78
+ return {
79
+ key: String(f.id),
80
+ source: `/fields/${i}`,
81
+ id: String(f.id),
82
+ label: c.title || f.text || f.name || String(f.id),
83
+ kind,
84
+ component,
85
+ group: f.isDetail ? String(f.subFormId || f.groupId) : undefined,
86
+ readOnly: flag('readonly', c.readOnly ?? c.readonly, !!f.system || kind === 'unsupported'),
87
+ required: flag('required', c.required ?? c.isRequired),
88
+ hidden: flag('hidden', c.hidden),
89
+ multiple: flag('multiple', f.multiSelect ?? (c.isSingle === undefined ? undefined : !bool(c.isSingle, true))),
90
+ maxLength: number(c.maxLen ?? c.maxLength, 'length'),
91
+ precision: number(c.pointSize ?? c.precision, 'precision'),
92
+ min: number(c.min, 'min'),
93
+ max: number(c.max, 'max'),
94
+ format: c.format || f.format,
95
+ options: kind === 'cascader' ? c.options || f.options || [] : f.options || c.options || [],
96
+ refObjId: f.refObjId,
97
+ issues,
98
+ };
99
+ });
100
+ // Duplicate field IDs are possible across main/detail groups; keep separate identities.
101
+ for (const f of fields)
102
+ if (fields.filter((other) => other.id === f.id).length > 1)
103
+ f.key = `${f.group || 'main'}:${f.id}`;
104
+ const buttons = [], scopes = [];
105
+ for (const [mode, group, source] of [
106
+ ['list', input.buttons, '/buttons'],
107
+ ...['add', 'view', 'edit'].map((mode) => [
108
+ mode,
109
+ input.formButtons?.[mode],
110
+ `/formButtons/${mode}`,
111
+ ]),
112
+ ]) {
113
+ scopes.push({ mode, status: group?.status || 'unavailable', source });
114
+ for (const [i, b] of (group?.items || []).entries()) {
115
+ const c = b.config || {}, location = `${source}/items/${i}`;
116
+ const actions = (c.actions || []).map((a, j) => ({
117
+ source: `${location}/config/actions/${j}`,
118
+ order: Number(a.showOrder ?? j),
119
+ enabled: bool(a.deleteType, false) === true
120
+ ? false
121
+ : bool(a.deleteType, false) === false
122
+ ? bool(a.enable, true)
123
+ : undefined,
124
+ op: a.actionType === 'system' &&
125
+ !a.url &&
126
+ !a.params &&
127
+ bool(a.thirdPartyFlag, false) === false &&
128
+ bool(a.deleteType, false) === false
129
+ ? operations[b.key]
130
+ : undefined,
131
+ conditionRequired: !!(a.conditionId || a.datarule || a.conditionTxt),
132
+ }));
133
+ buttons.push({
134
+ key: location,
135
+ source: location,
136
+ id: String(b.id),
137
+ label: b.name,
138
+ mode,
139
+ positions: b.positions,
140
+ order: b.order,
141
+ enabled: group?.status === 'ready' &&
142
+ bool(b.enabled, true) === true &&
143
+ bool(b.hidden, false) === false,
144
+ conditionRequired: bool(c.conditionEnable, !!c.conditionId) !== false,
145
+ actions: actions.sort((a, b) => a.order - b.order),
146
+ issues: !actions.length
147
+ ? ['no-configured-action']
148
+ : actions.some((a) => !a.op && a.enabled !== false)
149
+ ? ['action-requires-decision']
150
+ : [],
151
+ });
152
+ }
153
+ }
154
+ return {
155
+ schema: 1,
156
+ menuId: String(input.page?.id || ''),
157
+ objId: String(input.objId || ''),
158
+ fields,
159
+ buttons,
160
+ scopes,
161
+ };
162
+ }
163
+ export function formBehaviorRuntime(contract) {
164
+ return `<script id="e10-form-behavior">(${installFormBehavior.toString()})(${encode(contract)});</script>`;
165
+ }
166
+ export function attachFormBehavior(source, input) {
167
+ const clean = source.replace(/<script\b[^>]*\bid\s*=\s*["']e10-form-behavior["'][^>]*>[\s\S]*?<\/script\s*>/gi, '');
168
+ return clean.replace(/<head(?:\s[^>]*)?>/i, (match) => match + formBehaviorRuntime(formBehaviorContract(input)));
169
+ }
@@ -1,3 +1,7 @@
1
1
  import type { Store } from './store.js';
2
+ export declare function contextParts(value: any, location: string, encode?: (value: any) => string): Generator<{
3
+ location: string;
4
+ value: any;
5
+ }>;
2
6
  /** Bounded, lossless views. Fixed metadata is grouped, never reinterpreted or discarded. */
3
7
  export declare function formContext(store: Store, key: string): Promise<string>;
@@ -6,21 +6,23 @@ import { formInputPath } from './forms.js';
6
6
  import { navigationKey } from './menus.js';
7
7
  const limit = 18000;
8
8
  const lineLimit = 1800; // WorkBuddy Read truncates long individual lines, not only large files.
9
- function* parts(value, location) {
9
+ export function* contextParts(value, location, encode = (value) => JSON.stringify(value, null, 2)) {
10
10
  if (JSON.stringify(location).length > lineLimit - 96)
11
11
  throw new CaptureError('FORM_CONTEXT_LIMIT', '配置属性路径超过宿主可完整读取的单行长度');
12
- const encoded = JSON.stringify({ entries: [{ location, value }] }, null, 2);
12
+ const encoded = encode({ entries: [{ location, value }] });
13
13
  if (encoded.length <= limit && encoded.split('\n').every((line) => line.length <= lineLimit)) {
14
14
  yield { location, value };
15
15
  return;
16
16
  }
17
17
  if (Array.isArray(value)) {
18
18
  for (const [i, item] of value.entries())
19
- yield* parts(item, `${location}[${i}]`);
19
+ yield* contextParts(item, location.startsWith('/') ? `${location}/${i}` : `${location}[${i}]`, encode);
20
20
  }
21
21
  else if (value && typeof value === 'object') {
22
22
  for (const [key, item] of Object.entries(value))
23
- yield* parts(item, `${location}.${key}`);
23
+ yield* contextParts(item, location.startsWith('/')
24
+ ? `${location}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`
25
+ : `${location}.${key}`, encode);
24
26
  }
25
27
  else if (typeof value === 'string') {
26
28
  for (let offset = 0; offset < value.length;) {
@@ -82,7 +84,7 @@ export async function formContext(store, key) {
82
84
  batch = [];
83
85
  };
84
86
  for (const [category, value] of Object.entries(options.context)) {
85
- for (const part of parts(value, category)) {
87
+ for (const part of contextParts(value, category)) {
86
88
  if (batch.length && JSON.stringify({ entries: [...batch, part] }, null, 2).length > limit)
87
89
  await flush();
88
90
  batch.push(part);
@@ -0,0 +1,24 @@
1
+ import type { Store } from './store.js';
2
+ /** JSON Pointer references always address the decoded source, never encoded `set` paths. */
3
+ export declare function compactConfiguration(input: any): any;
4
+ /** Also used before writing views: a size optimization may never change configuration. */
5
+ export declare function expandConfiguration(input: any): any;
6
+ /** Compact lines are still readable by the host's line-limited Read tool. */
7
+ export declare function generationJson(value: any, depth?: number): string;
8
+ /** Extract structure, never value text, event handlers, scripts or remote resources. */
9
+ export declare function layoutStructure(html: string, fields: any[], name?: string): any;
10
+ export declare function generationView(input: any): {
11
+ configuration: any;
12
+ directory: any[];
13
+ layouts: {
14
+ location: string;
15
+ original: string;
16
+ }[];
17
+ optionDatasets: {
18
+ id: string;
19
+ count: number;
20
+ locations: string[];
21
+ }[];
22
+ };
23
+ /** Separate from immutable raw/context views, with digest-verified caching. */
24
+ export declare function formGeneration(store: Store, key: string): Promise<string>;
@@ -0,0 +1,367 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { parse } from 'parse5';
5
+ import { CaptureError, digest, fileDigest, atomicJson } from './common.js';
6
+ import { contextParts, formContext } from './form-context.js';
7
+ import { formInputPath } from './forms.js';
8
+ import { formOptionData } from './form-options.js';
9
+ const pointer = (parent, key) => `${parent}/${String(key).replace(/~/g, '~0').replace(/\//g, '~1')}`;
10
+ const markers = ['$e10Ref', '$e10Base', '$e10Literal'];
11
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
12
+ /** JSON Pointer references always address the decoded source, never encoded `set` paths. */
13
+ export function compactConfiguration(input) {
14
+ const exact = new Map();
15
+ const bases = [];
16
+ function visit(value, location) {
17
+ if (value === null || typeof value !== 'object')
18
+ return value;
19
+ const raw = JSON.stringify(value);
20
+ if (raw.length >= 240 && exact.has(raw))
21
+ return { $e10Ref: exact.get(raw) };
22
+ let base;
23
+ let saved = 0;
24
+ if (object(value) &&
25
+ !markers.some((k) => Object.hasOwn(value, k)) &&
26
+ Object.keys(value).length >= 8) {
27
+ for (const candidate of bases.slice(-120)) {
28
+ let gain = -120 -
29
+ JSON.stringify(Object.keys(candidate.value).filter((k) => !Object.hasOwn(value, k)))
30
+ .length;
31
+ for (const [key, item] of Object.entries(value))
32
+ if (candidate.encoded[key] === JSON.stringify(item))
33
+ gain += JSON.stringify([key, item]).length;
34
+ if (gain > saved && gain > raw.length * 0.2) {
35
+ base = candidate;
36
+ saved = gain;
37
+ }
38
+ }
39
+ }
40
+ let result;
41
+ if (base) {
42
+ result = {
43
+ $e10Base: base.location,
44
+ set: Object.fromEntries(Object.entries(value)
45
+ .filter(([key, item]) => base.encoded[key] !== JSON.stringify(item))
46
+ .map(([key, item]) => [key, visit(item, pointer(location, key))])),
47
+ remove: Object.keys(base.value).filter((key) => !Object.hasOwn(value, key)),
48
+ };
49
+ }
50
+ else if (Array.isArray(value))
51
+ result = value.map((item, i) => visit(item, pointer(location, i)));
52
+ else {
53
+ const entries = Object.entries(value).map(([key, item]) => [
54
+ key,
55
+ visit(item, pointer(location, key)),
56
+ ]);
57
+ result = markers.some((key) => Object.hasOwn(value, key))
58
+ ? { $e10Literal: entries }
59
+ : Object.fromEntries(entries);
60
+ if (Object.keys(value).length >= 8 && !markers.some((key) => Object.hasOwn(value, key)))
61
+ bases.push({
62
+ value,
63
+ location,
64
+ encoded: Object.fromEntries(Object.entries(value).map(([k, v]) => [k, JSON.stringify(v)])),
65
+ });
66
+ }
67
+ // Only completed nodes are eligible: no forward references or base cycles.
68
+ if (raw.length >= 240)
69
+ exact.set(raw, location);
70
+ return result;
71
+ }
72
+ return visit(input, '');
73
+ }
74
+ /** Also used before writing views: a size optimization may never change configuration. */
75
+ export function expandConfiguration(input) {
76
+ const decoded = new Map();
77
+ function register(value, location) {
78
+ decoded.set(location, value);
79
+ if (value && typeof value === 'object')
80
+ for (const [key, item] of Object.entries(value))
81
+ register(item, pointer(location, key));
82
+ }
83
+ function reference(location) {
84
+ if (!decoded.has(location))
85
+ throw new CaptureError('FORM_GENERATION_REFERENCE', '配置视图存在无效的重复引用');
86
+ return structuredClone(decoded.get(location));
87
+ }
88
+ function visit(value, location) {
89
+ let result = value;
90
+ if (Array.isArray(value))
91
+ result = value.map((item, i) => visit(item, pointer(location, i)));
92
+ else if (object(value)) {
93
+ if (Object.hasOwn(value, '$e10Ref'))
94
+ result = reference(value.$e10Ref);
95
+ else if (Object.hasOwn(value, '$e10Base')) {
96
+ result = reference(value.$e10Base);
97
+ for (const key of value.remove)
98
+ delete result[key];
99
+ // Inherited descendants are valid reference targets for later changed values.
100
+ register(result, location);
101
+ for (const [key, item] of Object.entries(value.set))
102
+ Object.defineProperty(result, key, {
103
+ value: visit(item, pointer(location, key)),
104
+ enumerable: true,
105
+ writable: true,
106
+ configurable: true,
107
+ });
108
+ }
109
+ else
110
+ result = Object.fromEntries((value.$e10Literal ?? Object.entries(value)).map(([key, item]) => [
111
+ key,
112
+ visit(item, pointer(location, key)),
113
+ ]));
114
+ }
115
+ register(result, location);
116
+ return result;
117
+ }
118
+ return visit(input, '');
119
+ }
120
+ /** Compact lines are still readable by the host's line-limited Read tool. */
121
+ export function generationJson(value, depth = 0) {
122
+ const compact = JSON.stringify(value);
123
+ if (compact.length + depth * 2 <= 1500)
124
+ return compact;
125
+ if (!value || typeof value !== 'object')
126
+ return compact;
127
+ const pad = ' '.repeat(depth), inner = `${pad} `;
128
+ const entries = Array.isArray(value)
129
+ ? value.map((v) => generationJson(v, depth + 1))
130
+ : Object.entries(value).map(([k, v]) => `${JSON.stringify(k)}: ${generationJson(v, depth + 1)}`);
131
+ return `${Array.isArray(value) ? '[' : '{'}\n${entries.map((v) => inner + v).join(',\n')}\n${pad}${Array.isArray(value) ? ']' : '}'}`;
132
+ }
133
+ /** Extract structure, never value text, event handlers, scripts or remote resources. */
134
+ export function layoutStructure(html, fields, name) {
135
+ const nodes = [], issues = [], visibilityHints = [];
136
+ const byId = new Map();
137
+ const labels = new Set([name, ...fields.flatMap((f) => [f.groupName, f.name, f.config?.title])].filter(Boolean));
138
+ for (const field of fields)
139
+ byId.set(String(field.id), [...(byId.get(String(field.id)) || []), field]);
140
+ const text = (n) => n.nodeName === '#text' ? n.value : (n.childNodes || []).map(text).join('');
141
+ function walk(node, parent, inValue = false, inBoundField = false) {
142
+ const attrs = Object.fromEntries((node.attrs || []).map((a) => [a.name, a.value]));
143
+ const classes = attrs.class || '';
144
+ const span = node.sourceCodeLocation && {
145
+ start: node.sourceCodeLocation.startOffset,
146
+ end: node.sourceCodeLocation.endOffset,
147
+ };
148
+ if (node.tagName === 'style') {
149
+ // Hints, not a CSS evaluator. Keep only the specific visibility declarations
150
+ // and their selector/source range; never force a whole stylesheet fallback.
151
+ for (const child of node.childNodes || []) {
152
+ if (child.nodeName !== '#text')
153
+ continue;
154
+ const css = child.value, offset = child.sourceCodeLocation?.startOffset;
155
+ const seen = new Set();
156
+ for (const match of css.matchAll(/(?:display\s*:\s*none|visibility\s*:\s*hidden)/gi)) {
157
+ const open = css.lastIndexOf('{', match.index), end = css.indexOf('}', match.index);
158
+ if (open < 0 || end < 0 || seen.has(open))
159
+ continue;
160
+ seen.add(open);
161
+ const start = Math.max(css.lastIndexOf('}', open - 1), css.lastIndexOf('{', open - 1)) + 1;
162
+ visibilityHints.push({
163
+ selector: css.slice(start, open).trim(),
164
+ declarations: [
165
+ ...css.slice(open + 1, end).matchAll(/(?:display|visibility)\s*:[^;}]+/gi),
166
+ ].map((m) => m[0]),
167
+ ...(offset === undefined
168
+ ? {}
169
+ : { source: { start: offset + start, end: offset + end + 1 } }),
170
+ });
171
+ }
172
+ }
173
+ }
174
+ if (['script', 'style', 'link', 'template'].includes(node.tagName))
175
+ return;
176
+ const fieldIds = [...classes.matchAll(/(?:^|\s)field_([^\s]+)/g)].map((m) => m[1]);
177
+ const field = fieldIds.length === 1 ? byId.get(fieldIds[0]) : undefined;
178
+ const heading = /^h[1-6]$/.test(node.tagName || '') ||
179
+ /(?:^|\s)weapp-form-(?:tab-title-item|widget-title)(?:\s|$)/.test(classes);
180
+ const structural = /(?:^|\s)weapp-form-(?:layout|column-content|tab-content)(?:\s|$)/.test(classes);
181
+ const widget = /(?:^|\s)weapp-form-widget(?:\s|$)/.test(classes);
182
+ const hidden = Object.hasOwn(attrs, 'hidden') ||
183
+ attrs['aria-hidden'] === 'true' ||
184
+ /(?:display\s*:\s*none|visibility\s*:\s*hidden)/i.test(attrs.style || '');
185
+ if (fieldIds.length ||
186
+ structural ||
187
+ widget ||
188
+ hidden ||
189
+ (heading && !inValue && !inBoundField)) {
190
+ const type = fieldIds.length
191
+ ? 'field'
192
+ : structural
193
+ ? 'group'
194
+ : widget
195
+ ? 'widget'
196
+ : hidden
197
+ ? 'visibility'
198
+ : 'heading';
199
+ const entry = {
200
+ order: nodes.length,
201
+ ...(parent === undefined ? {} : { parent }),
202
+ type,
203
+ source: span,
204
+ };
205
+ if (classes)
206
+ entry.classes = classes;
207
+ // Keep only layout declarations: arbitrary style URLs/text can contain private values.
208
+ if (attrs.style) {
209
+ const style = attrs.style
210
+ .split(';')
211
+ .filter((v) => /^\s*(display|visibility|grid-template-columns|grid-column|flex-direction|flex-wrap|width|height|col(?:umn)?-span)\s*:/i.test(v) && !/url\s*\(|[<>]/i.test(v))
212
+ .join(';');
213
+ if (style)
214
+ entry.style = style;
215
+ }
216
+ if (hidden)
217
+ entry.hidden = true;
218
+ if (fieldIds.length) {
219
+ entry.fieldIds = fieldIds;
220
+ entry.bindings = (field || []).map((f) => ({
221
+ id: f.id,
222
+ groupId: f.groupId,
223
+ subFormId: f.subFormId,
224
+ isDetail: f.isDetail,
225
+ label: f.config?.title || f.name,
226
+ }));
227
+ if (fieldIds.length !== 1 || field?.length !== 1)
228
+ issues.push({ reason: 'ambiguous-field-binding', source: span, fieldIds });
229
+ }
230
+ else if (heading && !inValue && !inBoundField) {
231
+ const label = text(node).trim();
232
+ if (labels.has(label))
233
+ entry.label = label;
234
+ else if (label)
235
+ issues.push({ reason: 'unverified-heading-text', source: span });
236
+ }
237
+ else if (widget && !structural)
238
+ issues.push({ reason: 'unbound-widget', source: span });
239
+ parent = nodes.length;
240
+ nodes.push(entry);
241
+ }
242
+ const childValue = inValue || /(?:^|\s)weapp-form-widget-content(?:\s|$)/.test(classes);
243
+ for (const child of node.childNodes || [])
244
+ walk(child, parent, childValue, inBoundField || (fieldIds.length === 1 && field?.length === 1));
245
+ }
246
+ walk(parse(html, { sourceCodeLocationInfo: true }));
247
+ if (!nodes.some((n) => n.type === 'field'))
248
+ issues.push({ reason: 'no-recognized-field-binding' });
249
+ return JSON.parse(JSON.stringify({
250
+ sourceSha256: digest(html),
251
+ nodes,
252
+ issues,
253
+ visibilityHints,
254
+ limits: '结构参考;visibilityHints 仅为静态线索,不推断脚本、CSS选择器或媒体条件计算结果,只对匹配当前结构的规则定向核对;配置显隐仍以字段/按钮条件为准。issues 给出需定向核对的原 HTML 字符范围;不读取整份脚本/CSS,不保留记录值。',
255
+ }));
256
+ }
257
+ export function generationView(input) {
258
+ const options = formOptionData(input);
259
+ const source = structuredClone(options.context);
260
+ const layouts = [];
261
+ const directory = [];
262
+ function describe(form, location) {
263
+ if (!form || typeof form !== 'object')
264
+ return;
265
+ const fields = Array.isArray(form.fields) ? form.fields : [];
266
+ const groups = new Map();
267
+ for (const [i, f] of fields.entries()) {
268
+ const identity = JSON.stringify([f.groupId, f.subFormId, f.isDetail]);
269
+ if (!groups.has(identity))
270
+ groups.set(identity, {
271
+ groupId: f.groupId,
272
+ groupName: f.groupName,
273
+ subFormId: f.subFormId,
274
+ isDetail: f.isDetail,
275
+ fieldCount: 0,
276
+ firstField: pointer(pointer(location, 'fields'), i),
277
+ });
278
+ groups.get(identity).fieldCount++;
279
+ }
280
+ directory.push({
281
+ location,
282
+ objId: form.objId,
283
+ fieldCount: fields.length,
284
+ fieldsLocation: pointer(location, 'fields'),
285
+ groups: [...groups.values()],
286
+ relations: fields.flatMap((f, i) => f.refObjId
287
+ ? [{ objId: f.refObjId, fieldLocation: pointer(pointer(location, 'fields'), i) }]
288
+ : []),
289
+ sections: Object.keys(form)
290
+ .filter((key) => key !== 'fields')
291
+ .map((key) => pointer(location, key)),
292
+ });
293
+ if (form.layout?.source === 'html-reference' && typeof form.layout.html === 'string') {
294
+ layouts.push({
295
+ location: pointer(pointer(location, 'layout'), 'html'),
296
+ original: form.layout.html,
297
+ });
298
+ form.layout.html = { $e10Layout: layoutStructure(form.layout.html, fields, form.name) };
299
+ }
300
+ }
301
+ describe(source, '');
302
+ if (Array.isArray(source.forms))
303
+ source.forms.forEach((form, i) => describe(form, `/forms/${i}`));
304
+ const configuration = compactConfiguration(source);
305
+ if (!isDeepStrictEqual(expandConfiguration(configuration), source))
306
+ throw new CaptureError('FORM_GENERATION_INTEGRITY', '配置视图无损校验失败,不能提供不完整输入');
307
+ return { configuration, directory, layouts, optionDatasets: options.descriptions };
308
+ }
309
+ /** Separate from immutable raw/context views, with digest-verified caching. */
310
+ export async function formGeneration(store, key) {
311
+ const fallbackPath = await formContext(store, key);
312
+ const folder = path.join(path.dirname(fallbackPath), 'generation-v1'), indexPath = path.join(folder, 'index.json');
313
+ const source = formInputPath(store, key), sourceSha256 = await fileDigest(source);
314
+ const old = await fs
315
+ .readFile(indexPath, 'utf8')
316
+ .then(JSON.parse)
317
+ .catch(() => null);
318
+ if (old?.schema === 1 &&
319
+ old.sourceSha256 === sourceSha256 &&
320
+ Array.isArray(old.fragments) &&
321
+ old.fragments.length) {
322
+ const valid = await Promise.all(old.fragments.map(async (f) => typeof f.path === 'string' &&
323
+ path.basename(f.path) === f.path &&
324
+ (await fileDigest(path.join(folder, f.path)).catch(() => '')) === f.sha256));
325
+ if (valid.every(Boolean))
326
+ return indexPath;
327
+ }
328
+ const view = generationView(JSON.parse(await fs.readFile(source, 'utf8')));
329
+ await fs.mkdir(folder, { recursive: true });
330
+ const fragments = [], batch = [];
331
+ async function flush() {
332
+ if (!batch.length)
333
+ return;
334
+ const filename = `config-${fragments.length + 1}.json`, bytes = generationJson({ entries: batch });
335
+ await fs.writeFile(path.join(folder, filename), bytes, { mode: 0o600 });
336
+ fragments.push({
337
+ path: filename,
338
+ locations: batch.map((v) => v.location),
339
+ sha256: digest(bytes),
340
+ });
341
+ batch.length = 0;
342
+ }
343
+ for (const [key, value] of Object.entries(view.configuration)) {
344
+ for (const entry of contextParts(value, pointer('', key), generationJson)) {
345
+ if (batch.length && generationJson({ entries: [...batch, entry] }).length > 18000)
346
+ await flush();
347
+ batch.push(entry);
348
+ }
349
+ }
350
+ await flush();
351
+ await atomicJson(indexPath, {
352
+ schema: 1,
353
+ source,
354
+ sourceSha256,
355
+ fallbackPath,
356
+ format: '先读 directory,再各读 fragments 一次。entries.location 是编码文档位置;directory 和引用使用原配置 JSON Pointer(~1 为 /,~0 为 ~)。$e10Ref 完全等于目标;$e10Base 复制目标后逐键覆盖 set、删除 remove,不做深合并,遗漏与空值不同。这些是相同元数据的简写,不合并菜单/按钮身份,无需写解码脚本。$e10Literal 保存碰巧同名属性的键值对。所有未知属性、条件、动作和模式差异均保留。$e10Layout 是去除记录值的布局结构,只有 issues/绑定不明时按 source 字符范围定向查 fallbackPath 的原 HTML 分片。长字符串按字符区间拼接;$e10Options 用 E10FormOptions.get(id) 取全量。不要重复读取原始全部分片、runtime 或无关联对象。',
357
+ directory: view.directory,
358
+ optionDatasets: view.optionDatasets,
359
+ layoutSources: view.layouts.map(({ location, original }) => ({
360
+ location,
361
+ sha256: digest(original),
362
+ characters: original.length,
363
+ })),
364
+ fragments,
365
+ });
366
+ return indexPath;
367
+ }
@@ -38,7 +38,10 @@ function installFormStore(scope, objectIds, createStorage) {
38
38
  const request = (targetScope, operation, value) => {
39
39
  if (!allowed.has(targetScope))
40
40
  return Promise.reject(new Error('表单不在当前应用的已发布范围'));
41
- if (!valid(value) || JSON.stringify(value).length > 2_000_000)
41
+ if (!(operation === 'compareSave'
42
+ ? valid(value?.expected) && valid(value?.next)
43
+ : valid(value)) ||
44
+ JSON.stringify(value).length > 2_000_000)
42
45
  return Promise.reject(new Error('本地数据格式无效或超过容量'));
43
46
  if (parent === window)
44
47
  return Promise.resolve(local(targetScope, operation, value));
@@ -63,7 +66,12 @@ function installFormStore(scope, objectIds, createStorage) {
63
66
  };
64
67
  const timer = setTimeout(() => {
65
68
  finish();
66
- resolve(local(targetScope, operation, value));
69
+ // A timed-out write may already have committed in the shell. Never replay
70
+ // a compare/save locally and report a different outcome as success.
71
+ if (operation === 'compareSave')
72
+ reject(new Error('FORM_COMMIT_UNKNOWN: 主框架未确认保存,请重新加载核对'));
73
+ else
74
+ resolve(local(targetScope, operation, value));
67
75
  }, 1000);
68
76
  addEventListener('message', receive);
69
77
  parent.postMessage({ type: 'e10-form-store', requestId, operation, scope: targetScope, value }, '*');
@@ -114,6 +122,7 @@ function installFormStore(scope, objectIds, createStorage) {
114
122
  const objectStore = (targetScope) => Object.freeze({
115
123
  load: (initial) => request(targetScope, 'load', initial),
116
124
  save: (state) => request(targetScope, 'save', state),
125
+ compareSave: (expected, next) => request(targetScope, 'compareSave', { expected, next }),
117
126
  reset: (initial) => request(targetScope, 'reset', initial),
118
127
  });
119
128
  Object.defineProperty(window, 'E10FormStore', {