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.
- package/README.md +45 -13
- package/dist/archive.d.ts +1 -1
- package/dist/archive.js +3 -0
- package/dist/capture.js +1 -1
- package/dist/common.d.ts +2 -1
- package/dist/common.js +24 -4
- package/dist/form-behavior-runtime.d.mts +1 -0
- package/dist/form-behavior-runtime.mjs +681 -0
- package/dist/form-behavior.d.ts +11 -0
- package/dist/form-behavior.js +169 -0
- package/dist/form-context.d.ts +4 -0
- package/dist/form-context.js +7 -5
- package/dist/form-generation.d.ts +24 -0
- package/dist/form-generation.js +367 -0
- package/dist/form-runtime.mjs +11 -2
- package/dist/host-ledger.d.ts +23 -0
- package/dist/host-ledger.js +183 -0
- package/dist/host-watch.d.ts +102 -0
- package/dist/host-watch.js +112 -0
- package/dist/html-handoff.d.ts +1 -0
- package/dist/html-handoff.js +30 -2
- package/dist/html-inspect.d.ts +2 -2
- package/dist/html-inspect.js +23 -51
- package/dist/html-interact.d.ts +36 -0
- package/dist/html-interact.js +216 -0
- package/dist/html-review-budget.d.ts +9 -0
- package/dist/html-review-budget.js +47 -0
- package/dist/html.d.ts +77 -5
- package/dist/html.js +155 -41
- package/dist/index.js +119 -44
- package/dist/model.d.ts +2 -1
- package/dist/offline-render.d.ts +11 -0
- package/dist/offline-render.js +71 -0
- package/dist/offline-store.mjs +10 -0
- package/dist/runtime-support.d.mts +29 -1
- package/dist/runtime-support.mjs +55 -5
- package/dist/site.js +66 -51
- package/dist/store.js +18 -4
- package/dist/templates/form-guide.md +6 -20
- package/dist/templates/form-task-core.md +45 -5
- package/dist/templates/index.html +5 -3
- package/dist/templates/workflow-guide.md +4 -2
- package/dist/vendor/environment-auth.d.ts +1 -0
- package/dist/vendor/environment-auth.js +4 -4
- package/docs/PROTOCOL.md +341 -39
- 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
|
+
}
|
package/dist/form-context.d.ts
CHANGED
|
@@ -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>;
|
package/dist/form-context.js
CHANGED
|
@@ -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*
|
|
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 =
|
|
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*
|
|
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*
|
|
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
|
|
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
|
+
}
|
package/dist/form-runtime.mjs
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
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', {
|