@dforge-core/metadata 0.0.30 → 0.0.32
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/CHANGELOG.md +100 -1
- package/dist/dsl/index.d.ts +25 -3
- package/dist/dsl/index.js +249 -23
- package/dist/dsl/index.js.map +1 -1
- package/dist/index.d.ts +84 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schemas/diagram.schema.json +8 -5
- package/schemas/entity.schema.json +14 -1
- package/schemas/manifest.schema.json +2 -2
- package/schemas/traits.schema.json +5 -0
- package/schemas/translations.schema.json +267 -0
- package/src/dsl/builtins.ts +12 -7
- package/src/dsl/check.ts +42 -1
- package/src/dsl/index.ts +3 -0
- package/src/dsl/lexer.ts +39 -14
- package/src/dsl/messages.ts +196 -0
- package/src/dsl/types.ts +7 -0
- package/src/entity.ts +5 -0
- package/src/index.ts +11 -1
- package/src/manifest.ts +4 -2
- package/src/translations.ts +61 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://dforge.dev/schemas/translations.schema.json",
|
|
4
|
+
"title": "dForge Translations",
|
|
5
|
+
"description": "A module's translations/<locale>.json, e.g. de-DE.json. English is the base text authored in the other module files, so an en-* file is not applied at install. Every key is optional: a missing entry falls back to the base text. Labels are required only for the locales the manifest lists in supportedLocales (constraint messages and DSL messages are warned about, never required).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"type": "string"
|
|
11
|
+
},
|
|
12
|
+
"entities": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"description": "Entity code → labels for the entity, its fields, card sections and constraint messages.",
|
|
15
|
+
"additionalProperties": {
|
|
16
|
+
"$ref": "#/$defs/entity"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"domains": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"description": "Column domain code (domains.json) → label and option labels, translated once for every column that uses the domain.",
|
|
22
|
+
"additionalProperties": {
|
|
23
|
+
"$ref": "#/$defs/withOptions"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"folders": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"description": "Folder code → label. A sub-folder is keyed by its own flat code, not its path.",
|
|
29
|
+
"additionalProperties": {
|
|
30
|
+
"$ref": "#/$defs/labelOnly"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"views": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"description": "Data view code → label.",
|
|
36
|
+
"additionalProperties": {
|
|
37
|
+
"$ref": "#/$defs/labelOnly"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"menus": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"description": "Menu code → label and item labels.",
|
|
43
|
+
"additionalProperties": {
|
|
44
|
+
"$ref": "#/$defs/menu"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"settings": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"description": "Setting code (settings.json) → label and description.",
|
|
50
|
+
"additionalProperties": {
|
|
51
|
+
"$ref": "#/$defs/labelDesc"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"roles": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"description": "Module-qualified role code (e.g. \"crm.admin\") → label.",
|
|
57
|
+
"additionalProperties": {
|
|
58
|
+
"$ref": "#/$defs/labelOnly"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"reports": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"description": "Report code → label, dataset captions and parameter labels.",
|
|
64
|
+
"additionalProperties": {
|
|
65
|
+
"$ref": "#/$defs/report"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"actions": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"description": "Action code (ui/actions.json) → label, tooltip and parameter labels.",
|
|
71
|
+
"additionalProperties": {
|
|
72
|
+
"$ref": "#/$defs/action"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"print_templates": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"description": "Print template code → label. Accepted, but not applied at install yet.",
|
|
78
|
+
"additionalProperties": {
|
|
79
|
+
"$ref": "#/$defs/labelOnly"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"messages": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"description": "Text of a DSL info() / warn() / error() / exit() call, exactly as written in the .dsl file → its translation. {name} placeholders are filled from the call's values and may be reordered. See docs/business-logic/message-localization.md.",
|
|
85
|
+
"propertyNames": {
|
|
86
|
+
"minLength": 1
|
|
87
|
+
},
|
|
88
|
+
"additionalProperties": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"minLength": 1
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"$defs": {
|
|
95
|
+
"label": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "Translated display label."
|
|
98
|
+
},
|
|
99
|
+
"labelOnly": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"additionalProperties": false,
|
|
102
|
+
"properties": {
|
|
103
|
+
"label": {
|
|
104
|
+
"$ref": "#/$defs/label"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"labelDesc": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"additionalProperties": false,
|
|
111
|
+
"properties": {
|
|
112
|
+
"label": {
|
|
113
|
+
"$ref": "#/$defs/label"
|
|
114
|
+
},
|
|
115
|
+
"desc": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"description": "Translated description or tooltip."
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"option": {
|
|
122
|
+
"description": "A translated option: the label alone, or the label with an icon and color override.",
|
|
123
|
+
"oneOf": [
|
|
124
|
+
{
|
|
125
|
+
"type": "string"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"type": "object",
|
|
129
|
+
"additionalProperties": false,
|
|
130
|
+
"properties": {
|
|
131
|
+
"label": {
|
|
132
|
+
"type": "string"
|
|
133
|
+
},
|
|
134
|
+
"icon": {
|
|
135
|
+
"type": "string"
|
|
136
|
+
},
|
|
137
|
+
"color": {
|
|
138
|
+
"type": "string"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
"options": {
|
|
145
|
+
"type": "object",
|
|
146
|
+
"description": "Option value (the stored code) → its translation.",
|
|
147
|
+
"additionalProperties": {
|
|
148
|
+
"$ref": "#/$defs/option"
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"withOptions": {
|
|
152
|
+
"type": "object",
|
|
153
|
+
"additionalProperties": false,
|
|
154
|
+
"properties": {
|
|
155
|
+
"label": {
|
|
156
|
+
"$ref": "#/$defs/label"
|
|
157
|
+
},
|
|
158
|
+
"options": {
|
|
159
|
+
"$ref": "#/$defs/options"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"entity": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"additionalProperties": false,
|
|
166
|
+
"properties": {
|
|
167
|
+
"label": {
|
|
168
|
+
"$ref": "#/$defs/label"
|
|
169
|
+
},
|
|
170
|
+
"desc": {
|
|
171
|
+
"type": "string"
|
|
172
|
+
},
|
|
173
|
+
"fields": {
|
|
174
|
+
"type": "object",
|
|
175
|
+
"description": "Column code → label and, for dropdown / radio / flags columns, option labels.",
|
|
176
|
+
"additionalProperties": {
|
|
177
|
+
"$ref": "#/$defs/withOptions"
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"columnGroups": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"description": "Card-layout section code → heading.",
|
|
183
|
+
"additionalProperties": {
|
|
184
|
+
"$ref": "#/$defs/labelOnly"
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"constraints": {
|
|
188
|
+
"type": "object",
|
|
189
|
+
"description": "Check / unique constraint name → violation message.",
|
|
190
|
+
"additionalProperties": {
|
|
191
|
+
"type": "object",
|
|
192
|
+
"additionalProperties": false,
|
|
193
|
+
"properties": {
|
|
194
|
+
"message": {
|
|
195
|
+
"type": "string"
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
"menu": {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"additionalProperties": false,
|
|
205
|
+
"properties": {
|
|
206
|
+
"label": {
|
|
207
|
+
"$ref": "#/$defs/label"
|
|
208
|
+
},
|
|
209
|
+
"items": {
|
|
210
|
+
"type": "object",
|
|
211
|
+
"description": "Menu item code → label. Flat even when ui/menus.json nests items under children: a group and its children are siblings here.",
|
|
212
|
+
"additionalProperties": {
|
|
213
|
+
"$ref": "#/$defs/labelOnly"
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
"params": {
|
|
219
|
+
"type": "object",
|
|
220
|
+
"description": "Parameter code → label and dropdown option labels.",
|
|
221
|
+
"additionalProperties": {
|
|
222
|
+
"$ref": "#/$defs/withOptions"
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
"report": {
|
|
226
|
+
"type": "object",
|
|
227
|
+
"additionalProperties": false,
|
|
228
|
+
"properties": {
|
|
229
|
+
"label": {
|
|
230
|
+
"$ref": "#/$defs/label"
|
|
231
|
+
},
|
|
232
|
+
"datasets": {
|
|
233
|
+
"type": "object",
|
|
234
|
+
"description": "Dataset code → caption.",
|
|
235
|
+
"additionalProperties": {
|
|
236
|
+
"type": "object",
|
|
237
|
+
"additionalProperties": false,
|
|
238
|
+
"properties": {
|
|
239
|
+
"caption": {
|
|
240
|
+
"type": "string"
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
"params": {
|
|
246
|
+
"$ref": "#/$defs/params"
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
"action": {
|
|
251
|
+
"type": "object",
|
|
252
|
+
"additionalProperties": false,
|
|
253
|
+
"properties": {
|
|
254
|
+
"label": {
|
|
255
|
+
"$ref": "#/$defs/label"
|
|
256
|
+
},
|
|
257
|
+
"desc": {
|
|
258
|
+
"type": "string",
|
|
259
|
+
"description": "Translated tooltip."
|
|
260
|
+
},
|
|
261
|
+
"params": {
|
|
262
|
+
"$ref": "#/$defs/params"
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
package/src/dsl/builtins.ts
CHANGED
|
@@ -74,22 +74,27 @@ export const BUILTINS: Builtin[] = [
|
|
|
74
74
|
// Messaging
|
|
75
75
|
{
|
|
76
76
|
name: "error",
|
|
77
|
-
signature: "error(message)",
|
|
77
|
+
signature: "error(message, values?)",
|
|
78
78
|
returns: "never",
|
|
79
|
-
doc: "Abort the action and show an error. Rolls back all changes.",
|
|
79
|
+
doc: "Abort the action and show an error. Rolls back all changes. The text is looked up in the module's `messages` translations; `{name}` placeholders are filled from `values`.",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "warn",
|
|
83
|
+
signature: "warn(message, values?, opts?)",
|
|
84
|
+
returns: "void",
|
|
85
|
+
doc: "Show a warning and continue. The text is looked up in the module's `messages` translations; `{name}` placeholders are filled from `values`.",
|
|
80
86
|
},
|
|
81
|
-
{ name: "warn", signature: "warn(message)", returns: "void", doc: "Show a warning and continue." },
|
|
82
87
|
{
|
|
83
88
|
name: "info",
|
|
84
|
-
signature: "info(message)",
|
|
89
|
+
signature: "info(message, values?, opts?)",
|
|
85
90
|
returns: "void",
|
|
86
|
-
doc: "Show an informational message — a receipt for work the action did, not a way to publish a computed value.",
|
|
91
|
+
doc: "Show an informational message — a receipt for work the action did, not a way to publish a computed value. The text is looked up in the module's `messages` translations; `{name}` placeholders are filled from `values`. `opts.links` adds record links.",
|
|
87
92
|
},
|
|
88
93
|
{
|
|
89
94
|
name: "exit",
|
|
90
|
-
signature: "exit(message?, level?)",
|
|
95
|
+
signature: "exit(message?, level?, values?)",
|
|
91
96
|
returns: "never",
|
|
92
|
-
doc: "Stop the script early **without** error, keeping changes made so far. Level `'info'` (default) or `'
|
|
97
|
+
doc: "Stop the script early **without** error, keeping changes made so far. Level `'info'` (default), `'warning'`, `'danger'` or `'success'`. The message is looked up in the module's `messages` translations; `{name}` placeholders are filled from `values`. This is the DSL's early return — the block compiles to a bare script, so a `return` outside a function is a syntax error wherever it sits.",
|
|
93
98
|
},
|
|
94
99
|
{
|
|
95
100
|
name: "notify",
|
package/src/dsl/check.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// omits, and the rules that depend on it stand down. See ./types.
|
|
12
12
|
|
|
13
13
|
import { BUILTIN_BY_NAME } from "./builtins";
|
|
14
|
+
import { extractMessages } from "./messages";
|
|
14
15
|
import { CONTROL_KEYWORDS, type Token } from "./lexer";
|
|
15
16
|
import {
|
|
16
17
|
type BlockKind,
|
|
@@ -25,7 +26,7 @@ import type { ColumnLookup, DslContext, DslIssue, DslSeverity } from "./types";
|
|
|
25
26
|
* Check a DSL body. Returns [] for a clean script.
|
|
26
27
|
*
|
|
27
28
|
* Issues come back in source-feature order (field reads, params, execution
|
|
28
|
-
* mode, block spelling, built-ins, top-level returns, inline assignments)
|
|
29
|
+
* mode, block spelling, built-ins, top-level returns, inline assignments, messages)
|
|
29
30
|
* rather than sorted by position — a host that wants them in file order can
|
|
30
31
|
* sort on `start`.
|
|
31
32
|
*/
|
|
@@ -573,9 +574,49 @@ export function checkDsl(text: string, ctx: DslContext = {}): DslIssue[] {
|
|
|
573
574
|
}
|
|
574
575
|
|
|
575
576
|
out.push(...inlineAssignmentIssues(parsed, positionAt));
|
|
577
|
+
|
|
578
|
+
// ── messages ─────────────────────────────────────────────────────
|
|
579
|
+
// Install reports the same, module-wide (ActionMessageScanner).
|
|
580
|
+
const locales = Object.entries(ctx.messageTranslations ?? {});
|
|
581
|
+
for (const m of extractMessages(parsed)) {
|
|
582
|
+
if (m.kind === "concatenated" && locales.length > 0) {
|
|
583
|
+
add(
|
|
584
|
+
m.span,
|
|
585
|
+
`This ${m.builtin}() message joins text and values, so it cannot be translated. Put the values in {placeholders}: ${m.builtin}('Only {n} left', ${m.builtin === "exit" ? "'warning', " : ""}{ n: qty }).`,
|
|
586
|
+
"warning",
|
|
587
|
+
"dsl/message-concatenated",
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (m.text === undefined) continue;
|
|
591
|
+
|
|
592
|
+
// A call without values keeps a literal "{x}" as text, as before placeholders existed.
|
|
593
|
+
if (m.valueNames && m.valueNames.length > 0) {
|
|
594
|
+
for (const name of placeholders(m.text)) {
|
|
595
|
+
if (!m.valueNames.includes(name))
|
|
596
|
+
add(m.span, `{${name}} has no value in this call, so it shows as written.`, "warning", "dsl/message-placeholder");
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// A blank translation is dropped at install, so the message still shows in English.
|
|
601
|
+
const missing = locales
|
|
602
|
+
.filter(([, messages]) => !(Object.hasOwn(messages, m.text!) && messages[m.text!]?.trim()))
|
|
603
|
+
.map(([locale]) => locale);
|
|
604
|
+
if (missing.length > 0) {
|
|
605
|
+
add(
|
|
606
|
+
m.span,
|
|
607
|
+
`No ${missing.join(", ")} translation for this message — add it to "messages" in translations/<locale>.json, keyed by the exact text.`,
|
|
608
|
+
"warning",
|
|
609
|
+
"dsl/message-untranslated",
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
576
613
|
return out;
|
|
577
614
|
}
|
|
578
615
|
|
|
616
|
+
function placeholders(text: string): string[] {
|
|
617
|
+
return [...new Set([...text.matchAll(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g)].map((x) => x[1]!))];
|
|
618
|
+
}
|
|
619
|
+
|
|
579
620
|
/**
|
|
580
621
|
* Column matching is case-insensitive at the other end — `EntityColumnLookup`
|
|
581
622
|
* hands the compiler an OrdinalIgnoreCase set — but a host passes us whatever
|
package/src/dsl/index.ts
CHANGED
|
@@ -27,6 +27,9 @@ export type {
|
|
|
27
27
|
export { BUILTINS, BUILTIN_BY_NAME, BUILTIN_VALUES } from "./builtins";
|
|
28
28
|
export type { Builtin } from "./builtins";
|
|
29
29
|
|
|
30
|
+
export { extractMessages } from "./messages";
|
|
31
|
+
export type { DslMessage, DslMessageKind } from "./messages";
|
|
32
|
+
|
|
30
33
|
export { checkDsl } from "./check";
|
|
31
34
|
export type {
|
|
32
35
|
ColumnLookup,
|
package/src/dsl/lexer.ts
CHANGED
|
@@ -366,11 +366,12 @@ export function stringValue(token: Token): string {
|
|
|
366
366
|
/**
|
|
367
367
|
* The literal text of one template chunk, delimiters removed. A chunk opens on
|
|
368
368
|
* a backtick or on the `}` that closed the hole before it, and closes on a
|
|
369
|
-
* backtick, on the `${` of the next hole, or on the end of the file.
|
|
369
|
+
* backtick, on the `${` of the next hole, or on the end of the file. A line
|
|
370
|
+
* break in it reads as \n, as JavaScript reads a template written with CRLF.
|
|
370
371
|
*/
|
|
371
372
|
export function templateChunkValue(token: Token): string {
|
|
372
373
|
if (token.kind !== "template") return token.text;
|
|
373
|
-
const body = token.text.slice(1);
|
|
374
|
+
const body = token.text.slice(1).replace(/\r\n?/g, "\n");
|
|
374
375
|
if (body.endsWith("${")) return unescape(body.slice(0, -2));
|
|
375
376
|
if (body.endsWith("`")) return unescape(body.slice(0, -1));
|
|
376
377
|
return unescape(body);
|
|
@@ -379,16 +380,40 @@ export function templateChunkValue(token: Token): string {
|
|
|
379
380
|
// A switch, not a lookup table: the escaped character comes out of the script,
|
|
380
381
|
// and an object would answer `\c` from Object.prototype.
|
|
381
382
|
function unescape(body: string): string {
|
|
382
|
-
return body.replace(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
383
|
+
return body.replace(
|
|
384
|
+
/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|\r\n|[\s\S])/g,
|
|
385
|
+
(_, esc: string) => {
|
|
386
|
+
switch (esc[0]) {
|
|
387
|
+
case "n":
|
|
388
|
+
return "\n";
|
|
389
|
+
case "t":
|
|
390
|
+
return "\t";
|
|
391
|
+
case "r":
|
|
392
|
+
return "\r";
|
|
393
|
+
case "b":
|
|
394
|
+
return "\b";
|
|
395
|
+
case "f":
|
|
396
|
+
return "\f";
|
|
397
|
+
case "v":
|
|
398
|
+
return "\v";
|
|
399
|
+
case "0":
|
|
400
|
+
return "\0";
|
|
401
|
+
case "\r":
|
|
402
|
+
case "\n":
|
|
403
|
+
case "\u2028":
|
|
404
|
+
case "\u2029":
|
|
405
|
+
return "";
|
|
406
|
+
case "u":
|
|
407
|
+
case "x":
|
|
408
|
+
if (esc.length > 1) {
|
|
409
|
+
const hex = esc[1] === "{" ? esc.slice(2, -1) : esc.slice(1);
|
|
410
|
+
const cp = parseInt(hex, 16);
|
|
411
|
+
return cp <= 0x10ffff ? String.fromCodePoint(cp) : esc;
|
|
412
|
+
}
|
|
413
|
+
return esc;
|
|
414
|
+
default:
|
|
415
|
+
return esc;
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
);
|
|
394
419
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The messages of a script's info / warn / error / exit calls — what the server's
|
|
2
|
+
// ActionDslCompiler extracts on CompileResult.Messages, for the editor and the MCP.
|
|
3
|
+
// The two must agree: test/fixtures/dsl-messages.json is run by both test suites.
|
|
4
|
+
|
|
5
|
+
import type { Token } from "./lexer";
|
|
6
|
+
import { stringValue, templateChunkValue } from "./lexer";
|
|
7
|
+
import type { DslDocument, Span } from "./parse";
|
|
8
|
+
|
|
9
|
+
export type DslMessageKind = "literal" | "concatenated" | "dynamic";
|
|
10
|
+
|
|
11
|
+
export interface DslMessage {
|
|
12
|
+
builtin: "info" | "warn" | "error" | "exit";
|
|
13
|
+
kind: DslMessageKind;
|
|
14
|
+
/** The message text for `literal`, exactly as the runtime receives it; otherwise undefined. */
|
|
15
|
+
text?: string;
|
|
16
|
+
/** Keys of the placeholder-values object; empty without one, null when it is not an object literal. */
|
|
17
|
+
valueNames: string[] | null;
|
|
18
|
+
/** The first argument. */
|
|
19
|
+
span: Span;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Built-in → position of its placeholder-values argument.
|
|
23
|
+
const VALUES_INDEX = new Map([
|
|
24
|
+
["info", 1],
|
|
25
|
+
["warn", 1],
|
|
26
|
+
["error", 1],
|
|
27
|
+
["exit", 2],
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** Message calls in `execute:` and `onBeforeStart:`, in source order. */
|
|
31
|
+
export function extractMessages(doc: DslDocument): DslMessage[] {
|
|
32
|
+
const out: DslMessage[] = [];
|
|
33
|
+
const { tokens } = doc;
|
|
34
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
35
|
+
const t = tokens[i]!;
|
|
36
|
+
const valuesIndex = VALUES_INDEX.get(t.text);
|
|
37
|
+
if (t.kind !== "ident" || valuesIndex === undefined || tokens[i + 1]!.text !== "(") continue;
|
|
38
|
+
if (tokens[i - 1]?.text === ".") continue;
|
|
39
|
+
const block = doc.blocks.find((b) => t.start >= b.body.start && t.start < b.body.end)?.kind;
|
|
40
|
+
if (block !== "execute" && block !== "onBeforeStart") continue;
|
|
41
|
+
|
|
42
|
+
const args = splitArgs(tokens, i + 1);
|
|
43
|
+
if (!args[0]?.length) continue;
|
|
44
|
+
const [kind, text] = classify(args[0]!);
|
|
45
|
+
const valuesArg = args[valuesIndex];
|
|
46
|
+
out.push({
|
|
47
|
+
builtin: t.text as DslMessage["builtin"],
|
|
48
|
+
kind,
|
|
49
|
+
text,
|
|
50
|
+
valueNames: valuesArg ? valueNames(valuesArg) : [],
|
|
51
|
+
span: { start: args[0]![0]!.start, end: args[0]![args[0]!.length - 1]!.end },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The top-level arguments of the call whose `(` is at `open`, each as its tokens. */
|
|
58
|
+
function splitArgs(tokens: Token[], open: number): Token[][] {
|
|
59
|
+
const args: Token[][] = [];
|
|
60
|
+
let current: Token[] = [];
|
|
61
|
+
let depth = 0;
|
|
62
|
+
for (let j = open + 1; j < tokens.length; j++) {
|
|
63
|
+
const t = tokens[j]!;
|
|
64
|
+
if (t.kind === "comment") continue;
|
|
65
|
+
if (depth === 0 && t.kind === "punct" && closes(t)) break;
|
|
66
|
+
if (depth === 0 && t.kind === "punct" && t.text === ",") {
|
|
67
|
+
args.push(current);
|
|
68
|
+
current = [];
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
depth += depthDelta(t);
|
|
72
|
+
current.push(t);
|
|
73
|
+
}
|
|
74
|
+
if (current.length > 0) args.push(current);
|
|
75
|
+
return args;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
const isWholeTemplate = (t: Token): boolean =>
|
|
80
|
+
t.kind === "template" && t.text.length > 1 && t.text.startsWith("`") && t.text.endsWith("`");
|
|
81
|
+
|
|
82
|
+
// Mirrors the compiler's classification of the parsed argument: a lone string or
|
|
83
|
+
// hole-free template is literal, a template with holes or a `+` joining text is
|
|
84
|
+
// concatenated, anything else (`'a'.trim()`, a call, a variable) is dynamic.
|
|
85
|
+
function classify(arg: Token[]): [DslMessageKind, string?] {
|
|
86
|
+
const t = unwrap(arg);
|
|
87
|
+
if (t.length === 1 && t[0]!.kind === "string") return ["literal", stringValue(t[0]!)];
|
|
88
|
+
if (t.length === 1 && isWholeTemplate(t[0]!)) return ["literal", templateChunkValue(t[0]!)];
|
|
89
|
+
return [joinsText(t) ? "concatenated" : "dynamic"];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The compiler's JoinsText: text, or a `+` whose operand joins text. The root of an
|
|
93
|
+
// expression is its last top-level operator of the loosest precedence, so
|
|
94
|
+
// `'a' + x - 1` is a subtraction and `x == 'a' + y` a comparison.
|
|
95
|
+
function joinsText(tokens: Token[]): boolean {
|
|
96
|
+
const t = unwrap(tokens);
|
|
97
|
+
if ((t.length === 1 && t[0]!.kind === "string") || isTemplate(t)) return true;
|
|
98
|
+
let depth = 0;
|
|
99
|
+
let root = -1;
|
|
100
|
+
for (let j = 0; j < t.length; j++) {
|
|
101
|
+
const tok = t[j]!;
|
|
102
|
+
if (depth === 0) {
|
|
103
|
+
if (isLooserThanPlus(t, j)) return false;
|
|
104
|
+
if (tok.kind === "punct" && (tok.text === "+" || tok.text === "-") && endsOperand(t[j - 1])) root = j;
|
|
105
|
+
}
|
|
106
|
+
depth += depthDelta(tok);
|
|
107
|
+
}
|
|
108
|
+
if (root < 0 || t[root]!.text === "-") return false;
|
|
109
|
+
return joinsText(t.slice(0, root)) || joinsText(t.slice(root + 1));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const LOOSER_THAN_PLUS = new Set([
|
|
113
|
+
"?", ":", "&&", "||", "=", "==", "!=", "===", "!==", "<", ">", "<=", ">=", "&", "|", "^", ",",
|
|
114
|
+
"=>", "+=", "-=", "*=", "/=", "%=",
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
function isLooserThanPlus(t: Token[], j: number): boolean {
|
|
118
|
+
const tok = t[j]!;
|
|
119
|
+
if (tok.kind === "ident") return tok.text === "in" || tok.text === "instanceof";
|
|
120
|
+
if (tok.kind !== "punct" || !LOOSER_THAN_PLUS.has(tok.text)) return false;
|
|
121
|
+
return !(tok.text === "?" && t[j + 1]?.text === "."); // `?.` is member access
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Whether a `+` / `-` after this token is binary rather than a sign.
|
|
125
|
+
function endsOperand(prev: Token | undefined): boolean {
|
|
126
|
+
if (!prev) return false;
|
|
127
|
+
switch (prev.kind) {
|
|
128
|
+
case "number":
|
|
129
|
+
case "string":
|
|
130
|
+
case "regex":
|
|
131
|
+
return true;
|
|
132
|
+
case "ident":
|
|
133
|
+
return !["return", "typeof", "void", "delete", "new", "in", "instanceof", "case"].includes(prev.text);
|
|
134
|
+
case "template":
|
|
135
|
+
return prev.text.endsWith("`");
|
|
136
|
+
case "punct":
|
|
137
|
+
return [")", "]", "}", "++", "--"].includes(prev.text);
|
|
138
|
+
default:
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// One template literal, holes included, and nothing after it.
|
|
144
|
+
function isTemplate(tokens: Token[]): boolean {
|
|
145
|
+
if (tokens[0]?.kind !== "template" || !tokens[0].text.startsWith("`")) return false;
|
|
146
|
+
let depth = 0;
|
|
147
|
+
for (let j = 0; j < tokens.length; j++) {
|
|
148
|
+
depth += depthDelta(tokens[j]!);
|
|
149
|
+
if (depth === 0) return j === tokens.length - 1;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Nesting: brackets, and a template chunk that opens (`a${`) or closes (`}b`) a hole.
|
|
155
|
+
const opens = (t: Token) => (t.kind === "punct" ? "([{".includes(t.text) : t.kind === "template" && t.text.endsWith("${"));
|
|
156
|
+
const closes = (t: Token) => (t.kind === "punct" ? ")]}".includes(t.text) : t.kind === "template" && t.text.startsWith("}"));
|
|
157
|
+
const depthDelta = (t: Token) => Number(opens(t)) - Number(closes(t));
|
|
158
|
+
|
|
159
|
+
// `('Done')` is the literal the parser sees.
|
|
160
|
+
function unwrap(tokens: Token[]): Token[] {
|
|
161
|
+
while (tokens[0]?.text === "(" && isGroup(tokens)) tokens = tokens.slice(1, -1);
|
|
162
|
+
return tokens;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Whether the bracket that opens `tokens` is closed by its last token.
|
|
166
|
+
function isGroup(tokens: Token[]): boolean {
|
|
167
|
+
if (tokens.length < 2 || !opens(tokens[0]!)) return false;
|
|
168
|
+
let depth = 0;
|
|
169
|
+
for (let j = 0; j < tokens.length; j++) {
|
|
170
|
+
depth += depthDelta(tokens[j]!);
|
|
171
|
+
if (depth === 0) return j === tokens.length - 1;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The keys of an object literal, as the compiler reads them: `({ n: 1 })` is one,
|
|
177
|
+
// and a name inside a nested value or a `${…}` is not a key.
|
|
178
|
+
function valueNames(arg: Token[]): string[] | null {
|
|
179
|
+
const t = unwrap(arg);
|
|
180
|
+
if (t[0]?.text !== "{" || !isGroup(t)) return null;
|
|
181
|
+
const names: string[] = [];
|
|
182
|
+
let depth = 0;
|
|
183
|
+
for (let j = 0; j < t.length; j++) {
|
|
184
|
+
const tok = t[j]!;
|
|
185
|
+
const at = depth;
|
|
186
|
+
depth += depthDelta(tok);
|
|
187
|
+
if (at !== 1 || (tok.kind !== "ident" && tok.kind !== "string")) continue;
|
|
188
|
+
const prev = t[j - 1]!.text;
|
|
189
|
+
const next = t[j + 1]?.text;
|
|
190
|
+
if ((prev === "{" || prev === ",") && (next === ":" || next === "," || next === "}")) {
|
|
191
|
+
const name = tok.kind === "string" ? stringValue(tok) : tok.text;
|
|
192
|
+
if (name !== "links") names.push(name);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return names;
|
|
196
|
+
}
|
package/src/dsl/types.ts
CHANGED
|
@@ -95,4 +95,11 @@ export interface DslContext {
|
|
|
95
95
|
* none: pass `null`, not the fragment. See `EntityShape.columns`.
|
|
96
96
|
*/
|
|
97
97
|
currentEntity?: EntityShape | null;
|
|
98
|
+
/**
|
|
99
|
+
* Each non-English locale the manifest lists in `supportedLocales` → the
|
|
100
|
+
* `messages` block of its `translations/<locale>.json` (`{}` when the file has
|
|
101
|
+
* none). Absent, the untranslated and concatenated message rules stand down,
|
|
102
|
+
* exactly as install only reports them for a module that declares locales.
|
|
103
|
+
*/
|
|
104
|
+
messageTranslations?: Record<string, Record<string, string>>;
|
|
98
105
|
}
|
package/src/entity.ts
CHANGED
|
@@ -19,6 +19,9 @@ export type TraitCd =
|
|
|
19
19
|
| "period"
|
|
20
20
|
| "file-library";
|
|
21
21
|
|
|
22
|
+
/** Audit trail level (`auditHistory` on an entity or manifest); omitted = off. */
|
|
23
|
+
export type AuditHistoryLevel = "basic" | "fields" | "full";
|
|
24
|
+
|
|
22
25
|
/** Referential action for a FK's ON DELETE / ON UPDATE clause (omitted = noAction). */
|
|
23
26
|
export type ReferentialAction = "cascade" | "setNull" | "restrict" | "noAction";
|
|
24
27
|
|
|
@@ -167,6 +170,8 @@ export interface EntityDef {
|
|
|
167
170
|
viewSql?: string;
|
|
168
171
|
/** Display pattern using column placeholders, e.g. "{first_name} {last_name}". */
|
|
169
172
|
toString?: string;
|
|
173
|
+
/** Audit trail level; overrides the manifest's `auditHistory`. */
|
|
174
|
+
auditHistory?: AuditHistoryLevel;
|
|
170
175
|
/** Records of this entity accept comments (composer + thread on the card). */
|
|
171
176
|
comments?: boolean;
|
|
172
177
|
/** Traits expanded at install time. */
|