@dforge-core/metadata 0.0.31 → 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 CHANGED
@@ -5,6 +5,57 @@ All notable changes to `@dforge-core/metadata` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.0.32] — 2026-09-28
11
+
12
+ ### Added
13
+
14
+ - **`translations.schema.json`** for `translations/<locale>.json` (#1299): every section
15
+ the installer reads (`entities` with `fields` / `columnGroups` / `constraints` and option
16
+ translations, `domains`, `folders`, `views`, `menus`, `settings`, `roles`, `reports`,
17
+ `actions`, `print_templates`) plus the new `messages` block. Strict at every level, so
18
+ the mistakes install ignores silently are errors: a misspelled or singular section
19
+ (`entity`, `menu`), nested menu `items`, `description` where `desc` is meant. Type:
20
+ `TranslationsFile`.
21
+ - **`extractMessages(doc)`** (`@dforge-core/metadata/dsl`): the `info` / `warn` / `error` /
22
+ `exit` calls of a script, classified `literal` / `concatenated` / `dynamic`, with their
23
+ placeholder-value names — the list the server's `ActionDslCompiler` extracts. A shared
24
+ fixture (`test/fixtures/dsl-messages.json`) runs in both test suites.
25
+ - **Message rules in `checkDsl`:** `dsl/message-placeholder` (a `{name}` the call gives no
26
+ value) always; `dsl/message-concatenated` and `dsl/message-untranslated` when the host
27
+ passes the new `DslContext.messageTranslations`.
28
+
29
+ ### Fixed
30
+
31
+ - **String escapes are read as JavaScript reads them.** The lexer unescaped only `\n`, `\t`
32
+ and `\r`, so `'\u00e9'` came out as `u00e9`. It now handles `\uXXXX`, `\u{…}`, `\xHH`,
33
+ `\b`, `\f`, `\v`, `\0` and line continuations.
34
+
35
+ - **`auditHistory` levels are `basic` / `fields` / `full`.** `manifest.schema.json` and
36
+ `ManifestDef` listed `none` / `minimal` / `full`, which the platform never accepted: the
37
+ install failed on the entity table's check constraint. `basic` logs each insert, update
38
+ and delete; `fields` adds the changed values; `full` also stores display names. Omit the
39
+ property to turn audit off. The levels are one type, `AuditHistoryLevel`.
40
+ - **`entity.schema.json` accepts `auditHistory`.** The per-entity override of the
41
+ manifest default was missing from the schema, so editors flagged it as an unknown
42
+ property. `EntityDef.auditHistory` is added to match.
43
+
44
+ ### Changed
45
+
46
+ - **DSL message built-ins take placeholder values** (`DSL_BUILTINS`, #1299):
47
+ `info(message, values?, opts?)`, `warn(message, values?, opts?)`, `error(message, values?)`,
48
+ `exit(message?, level?, values?)`. The message is looked up in the module's `messages`
49
+ translations and `{name}` placeholders are filled from `values`; record links move to
50
+ `opts`. `exit()` levels are documented as `info` / `warning` / `danger` / `success` — the
51
+ doc said `warn`, which the client does not recognise.
52
+ - **`entity.schema.json` documents `params.currency` and `params.currencyColumn`.** A
53
+ `currency` field can name a column of the same entity that holds the ISO code per
54
+ record, instead of one fixed code (#1220).
55
+ - **`entity.schema.json` documents nav placeholders in `refFilter`.** A condition value
56
+ `@[ref].[field]` reads a field through a Reference column of the current record, e.g.
57
+ `@[purchase_order].[supplier_id]` on an order line (#1279).
58
+
8
59
  ## [0.0.31] — 2026-09-24
9
60
 
10
61
  ### Changed
@@ -18,7 +18,8 @@ declare function stringValue(token: Token): string;
18
18
  /**
19
19
  * The literal text of one template chunk, delimiters removed. A chunk opens on
20
20
  * a backtick or on the `}` that closed the hole before it, and closes on a
21
- * backtick, on the `${` of the next hole, or on the end of the file.
21
+ * backtick, on the `${` of the next hole, or on the end of the file. A line
22
+ * break in it reads as \n, as JavaScript reads a template written with CRLF.
22
23
  */
23
24
  declare function templateChunkValue(token: Token): string;
24
25
 
@@ -152,6 +153,20 @@ declare const BUILTIN_BY_NAME: Map<string, Builtin>;
152
153
  /** Bare identifiers the runtime injects — no parentheses. */
153
154
  declare const BUILTIN_VALUES: Record<string, string>;
154
155
 
156
+ type DslMessageKind = "literal" | "concatenated" | "dynamic";
157
+ interface DslMessage {
158
+ builtin: "info" | "warn" | "error" | "exit";
159
+ kind: DslMessageKind;
160
+ /** The message text for `literal`, exactly as the runtime receives it; otherwise undefined. */
161
+ text?: string;
162
+ /** Keys of the placeholder-values object; empty without one, null when it is not an object literal. */
163
+ valueNames: string[] | null;
164
+ /** The first argument. */
165
+ span: Span;
166
+ }
167
+ /** Message calls in `execute:` and `onBeforeStart:`, in source order. */
168
+ declare function extractMessages(doc: DslDocument): DslMessage[];
169
+
155
170
  type DslSeverity = "error" | "warning" | "info";
156
171
  /** One reported problem. `rule` is stable — hosts filter and suppress on it. */
157
172
  interface DslIssue {
@@ -236,16 +251,23 @@ interface DslContext {
236
251
  * none: pass `null`, not the fragment. See `EntityShape.columns`.
237
252
  */
238
253
  currentEntity?: EntityShape | null;
254
+ /**
255
+ * Each non-English locale the manifest lists in `supportedLocales` → the
256
+ * `messages` block of its `translations/<locale>.json` (`{}` when the file has
257
+ * none). Absent, the untranslated and concatenated message rules stand down,
258
+ * exactly as install only reports them for a module that declares locales.
259
+ */
260
+ messageTranslations?: Record<string, Record<string, string>>;
239
261
  }
240
262
 
241
263
  /**
242
264
  * Check a DSL body. Returns [] for a clean script.
243
265
  *
244
266
  * Issues come back in source-feature order (field reads, params, execution
245
- * mode, block spelling, built-ins, top-level returns, inline assignments)
267
+ * mode, block spelling, built-ins, top-level returns, inline assignments, messages)
246
268
  * rather than sorted by position — a host that wants them in file order can
247
269
  * sort on `start`.
248
270
  */
249
271
  declare function checkDsl(text: string, ctx?: DslContext): DslIssue[];
250
272
 
251
- export { BLOCK_KINDS, BUILTINS, BUILTIN_BY_NAME, BUILTIN_VALUES, type BlockKind, type Builtin, type CallRef, type ColumnLookup, type DslBlock, type DslContext, type DslDocument, type DslIssue, type DslParam, type DslSeverity, type EntityShape, type FieldRef, type GlobalRef, type Span, type StringArg, type Token, type TokenKind, checkDsl, parseDsl, stringValue, templateChunkValue, tokenize };
273
+ export { BLOCK_KINDS, BUILTINS, BUILTIN_BY_NAME, BUILTIN_VALUES, type BlockKind, type Builtin, type CallRef, type ColumnLookup, type DslBlock, type DslContext, type DslDocument, type DslIssue, type DslMessage, type DslMessageKind, type DslParam, type DslSeverity, type EntityShape, type FieldRef, type GlobalRef, type Span, type StringArg, type Token, type TokenKind, checkDsl, extractMessages, parseDsl, stringValue, templateChunkValue, tokenize };
package/dist/dsl/index.js CHANGED
@@ -273,24 +273,48 @@ function stringValue(token) {
273
273
  }
274
274
  function templateChunkValue(token) {
275
275
  if (token.kind !== "template") return token.text;
276
- const body = token.text.slice(1);
276
+ const body = token.text.slice(1).replace(/\r\n?/g, "\n");
277
277
  if (body.endsWith("${")) return unescape(body.slice(0, -2));
278
278
  if (body.endsWith("`")) return unescape(body.slice(0, -1));
279
279
  return unescape(body);
280
280
  }
281
281
  function unescape(body) {
282
- return body.replace(/\\(.)/g, (_, ch) => {
283
- switch (ch) {
284
- case "n":
285
- return "\n";
286
- case "t":
287
- return " ";
288
- case "r":
289
- return "\r";
290
- default:
291
- return ch;
292
- }
293
- });
282
+ return body.replace(
283
+ /\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|\r\n|[\s\S])/g,
284
+ (_, esc) => {
285
+ switch (esc[0]) {
286
+ case "n":
287
+ return "\n";
288
+ case "t":
289
+ return " ";
290
+ case "r":
291
+ return "\r";
292
+ case "b":
293
+ return "\b";
294
+ case "f":
295
+ return "\f";
296
+ case "v":
297
+ return "\v";
298
+ case "0":
299
+ return "\0";
300
+ case "\r":
301
+ case "\n":
302
+ case "\u2028":
303
+ case "\u2029":
304
+ return "";
305
+ case "u":
306
+ case "x":
307
+ if (esc.length > 1) {
308
+ const hex = esc[1] === "{" ? esc.slice(2, -1) : esc.slice(1);
309
+ const cp = parseInt(hex, 16);
310
+ return cp <= 1114111 ? String.fromCodePoint(cp) : esc;
311
+ }
312
+ return esc;
313
+ default:
314
+ return esc;
315
+ }
316
+ }
317
+ );
294
318
  }
295
319
 
296
320
  // src/dsl/parse.ts
@@ -435,10 +459,10 @@ function templateArg(tokens, start) {
435
459
  for (let j = start; j < tokens.length; j++) {
436
460
  const t = tokens[j];
437
461
  if (t.kind !== "template") continue;
438
- const opens = t.text.startsWith("`");
439
- if (opens) depth++;
462
+ const opens2 = t.text.startsWith("`");
463
+ if (opens2) depth++;
440
464
  if (depth === 1) {
441
- if (!opens) parts.push(" ");
465
+ if (!opens2) parts.push(" ");
442
466
  parts.push(templateChunkValue(t));
443
467
  last = t;
444
468
  endIndex = j;
@@ -761,22 +785,27 @@ var BUILTINS = [
761
785
  // Messaging
762
786
  {
763
787
  name: "error",
764
- signature: "error(message)",
788
+ signature: "error(message, values?)",
765
789
  returns: "never",
766
- doc: "Abort the action and show an error. Rolls back all changes."
790
+ 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`."
791
+ },
792
+ {
793
+ name: "warn",
794
+ signature: "warn(message, values?, opts?)",
795
+ returns: "void",
796
+ doc: "Show a warning and continue. The text is looked up in the module's `messages` translations; `{name}` placeholders are filled from `values`."
767
797
  },
768
- { name: "warn", signature: "warn(message)", returns: "void", doc: "Show a warning and continue." },
769
798
  {
770
799
  name: "info",
771
- signature: "info(message)",
800
+ signature: "info(message, values?, opts?)",
772
801
  returns: "void",
773
- doc: "Show an informational message \u2014 a receipt for work the action did, not a way to publish a computed value."
802
+ doc: "Show an informational message \u2014 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."
774
803
  },
775
804
  {
776
805
  name: "exit",
777
- signature: "exit(message?, level?)",
806
+ signature: "exit(message?, level?, values?)",
778
807
  returns: "never",
779
- doc: "Stop the script early **without** error, keeping changes made so far. Level `'info'` (default) or `'warn'`. This is the DSL's early return \u2014 the block compiles to a bare script, so a `return` outside a function is a syntax error wherever it sits."
808
+ 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 \u2014 the block compiles to a bare script, so a `return` outside a function is a syntax error wherever it sits."
780
809
  },
781
810
  {
782
811
  name: "notify",
@@ -890,6 +919,172 @@ var BUILTIN_VALUES = {
890
919
  old: "Pre-change values, in a trigger-invoked action \u2014 read with `old[field]`. Read-only."
891
920
  };
892
921
 
922
+ // src/dsl/messages.ts
923
+ var VALUES_INDEX = /* @__PURE__ */ new Map([
924
+ ["info", 1],
925
+ ["warn", 1],
926
+ ["error", 1],
927
+ ["exit", 2]
928
+ ]);
929
+ function extractMessages(doc) {
930
+ const out = [];
931
+ const { tokens } = doc;
932
+ for (let i = 0; i < tokens.length - 1; i++) {
933
+ const t = tokens[i];
934
+ const valuesIndex = VALUES_INDEX.get(t.text);
935
+ if (t.kind !== "ident" || valuesIndex === void 0 || tokens[i + 1].text !== "(") continue;
936
+ if (tokens[i - 1]?.text === ".") continue;
937
+ const block = doc.blocks.find((b) => t.start >= b.body.start && t.start < b.body.end)?.kind;
938
+ if (block !== "execute" && block !== "onBeforeStart") continue;
939
+ const args = splitArgs(tokens, i + 1);
940
+ if (!args[0]?.length) continue;
941
+ const [kind, text] = classify(args[0]);
942
+ const valuesArg = args[valuesIndex];
943
+ out.push({
944
+ builtin: t.text,
945
+ kind,
946
+ text,
947
+ valueNames: valuesArg ? valueNames(valuesArg) : [],
948
+ span: { start: args[0][0].start, end: args[0][args[0].length - 1].end }
949
+ });
950
+ }
951
+ return out;
952
+ }
953
+ function splitArgs(tokens, open) {
954
+ const args = [];
955
+ let current = [];
956
+ let depth = 0;
957
+ for (let j = open + 1; j < tokens.length; j++) {
958
+ const t = tokens[j];
959
+ if (t.kind === "comment") continue;
960
+ if (depth === 0 && t.kind === "punct" && closes(t)) break;
961
+ if (depth === 0 && t.kind === "punct" && t.text === ",") {
962
+ args.push(current);
963
+ current = [];
964
+ continue;
965
+ }
966
+ depth += depthDelta(t);
967
+ current.push(t);
968
+ }
969
+ if (current.length > 0) args.push(current);
970
+ return args;
971
+ }
972
+ var isWholeTemplate = (t) => t.kind === "template" && t.text.length > 1 && t.text.startsWith("`") && t.text.endsWith("`");
973
+ function classify(arg) {
974
+ const t = unwrap(arg);
975
+ if (t.length === 1 && t[0].kind === "string") return ["literal", stringValue(t[0])];
976
+ if (t.length === 1 && isWholeTemplate(t[0])) return ["literal", templateChunkValue(t[0])];
977
+ return [joinsText(t) ? "concatenated" : "dynamic"];
978
+ }
979
+ function joinsText(tokens) {
980
+ const t = unwrap(tokens);
981
+ if (t.length === 1 && t[0].kind === "string" || isTemplate(t)) return true;
982
+ let depth = 0;
983
+ let root = -1;
984
+ for (let j = 0; j < t.length; j++) {
985
+ const tok = t[j];
986
+ if (depth === 0) {
987
+ if (isLooserThanPlus(t, j)) return false;
988
+ if (tok.kind === "punct" && (tok.text === "+" || tok.text === "-") && endsOperand(t[j - 1])) root = j;
989
+ }
990
+ depth += depthDelta(tok);
991
+ }
992
+ if (root < 0 || t[root].text === "-") return false;
993
+ return joinsText(t.slice(0, root)) || joinsText(t.slice(root + 1));
994
+ }
995
+ var LOOSER_THAN_PLUS = /* @__PURE__ */ new Set([
996
+ "?",
997
+ ":",
998
+ "&&",
999
+ "||",
1000
+ "=",
1001
+ "==",
1002
+ "!=",
1003
+ "===",
1004
+ "!==",
1005
+ "<",
1006
+ ">",
1007
+ "<=",
1008
+ ">=",
1009
+ "&",
1010
+ "|",
1011
+ "^",
1012
+ ",",
1013
+ "=>",
1014
+ "+=",
1015
+ "-=",
1016
+ "*=",
1017
+ "/=",
1018
+ "%="
1019
+ ]);
1020
+ function isLooserThanPlus(t, j) {
1021
+ const tok = t[j];
1022
+ if (tok.kind === "ident") return tok.text === "in" || tok.text === "instanceof";
1023
+ if (tok.kind !== "punct" || !LOOSER_THAN_PLUS.has(tok.text)) return false;
1024
+ return !(tok.text === "?" && t[j + 1]?.text === ".");
1025
+ }
1026
+ function endsOperand(prev) {
1027
+ if (!prev) return false;
1028
+ switch (prev.kind) {
1029
+ case "number":
1030
+ case "string":
1031
+ case "regex":
1032
+ return true;
1033
+ case "ident":
1034
+ return !["return", "typeof", "void", "delete", "new", "in", "instanceof", "case"].includes(prev.text);
1035
+ case "template":
1036
+ return prev.text.endsWith("`");
1037
+ case "punct":
1038
+ return [")", "]", "}", "++", "--"].includes(prev.text);
1039
+ default:
1040
+ return false;
1041
+ }
1042
+ }
1043
+ function isTemplate(tokens) {
1044
+ if (tokens[0]?.kind !== "template" || !tokens[0].text.startsWith("`")) return false;
1045
+ let depth = 0;
1046
+ for (let j = 0; j < tokens.length; j++) {
1047
+ depth += depthDelta(tokens[j]);
1048
+ if (depth === 0) return j === tokens.length - 1;
1049
+ }
1050
+ return false;
1051
+ }
1052
+ var opens = (t) => t.kind === "punct" ? "([{".includes(t.text) : t.kind === "template" && t.text.endsWith("${");
1053
+ var closes = (t) => t.kind === "punct" ? ")]}".includes(t.text) : t.kind === "template" && t.text.startsWith("}");
1054
+ var depthDelta = (t) => Number(opens(t)) - Number(closes(t));
1055
+ function unwrap(tokens) {
1056
+ while (tokens[0]?.text === "(" && isGroup(tokens)) tokens = tokens.slice(1, -1);
1057
+ return tokens;
1058
+ }
1059
+ function isGroup(tokens) {
1060
+ if (tokens.length < 2 || !opens(tokens[0])) return false;
1061
+ let depth = 0;
1062
+ for (let j = 0; j < tokens.length; j++) {
1063
+ depth += depthDelta(tokens[j]);
1064
+ if (depth === 0) return j === tokens.length - 1;
1065
+ }
1066
+ return false;
1067
+ }
1068
+ function valueNames(arg) {
1069
+ const t = unwrap(arg);
1070
+ if (t[0]?.text !== "{" || !isGroup(t)) return null;
1071
+ const names = [];
1072
+ let depth = 0;
1073
+ for (let j = 0; j < t.length; j++) {
1074
+ const tok = t[j];
1075
+ const at = depth;
1076
+ depth += depthDelta(tok);
1077
+ if (at !== 1 || tok.kind !== "ident" && tok.kind !== "string") continue;
1078
+ const prev = t[j - 1].text;
1079
+ const next = t[j + 1]?.text;
1080
+ if ((prev === "{" || prev === ",") && (next === ":" || next === "," || next === "}")) {
1081
+ const name = tok.kind === "string" ? stringValue(tok) : tok.text;
1082
+ if (name !== "links") names.push(name);
1083
+ }
1084
+ }
1085
+ return names;
1086
+ }
1087
+
893
1088
  // src/dsl/check.ts
894
1089
  function checkDsl(text, ctx = {}) {
895
1090
  const parsed = parseDsl(text);
@@ -1230,8 +1425,38 @@ function checkDsl(text, ctx = {}) {
1230
1425
  }
1231
1426
  }
1232
1427
  out.push(...inlineAssignmentIssues(parsed, positionAt));
1428
+ const locales = Object.entries(ctx.messageTranslations ?? {});
1429
+ for (const m of extractMessages(parsed)) {
1430
+ if (m.kind === "concatenated" && locales.length > 0) {
1431
+ add(
1432
+ m.span,
1433
+ `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 }).`,
1434
+ "warning",
1435
+ "dsl/message-concatenated"
1436
+ );
1437
+ }
1438
+ if (m.text === void 0) continue;
1439
+ if (m.valueNames && m.valueNames.length > 0) {
1440
+ for (const name of placeholders(m.text)) {
1441
+ if (!m.valueNames.includes(name))
1442
+ add(m.span, `{${name}} has no value in this call, so it shows as written.`, "warning", "dsl/message-placeholder");
1443
+ }
1444
+ }
1445
+ const missing = locales.filter(([, messages]) => !(Object.hasOwn(messages, m.text) && messages[m.text]?.trim())).map(([locale]) => locale);
1446
+ if (missing.length > 0) {
1447
+ add(
1448
+ m.span,
1449
+ `No ${missing.join(", ")} translation for this message \u2014 add it to "messages" in translations/<locale>.json, keyed by the exact text.`,
1450
+ "warning",
1451
+ "dsl/message-untranslated"
1452
+ );
1453
+ }
1454
+ }
1233
1455
  return out;
1234
1456
  }
1457
+ function placeholders(text) {
1458
+ return [...new Set([...text.matchAll(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g)].map((x) => x[1]))];
1459
+ }
1235
1460
  function columnMatcher(columns) {
1236
1461
  if (!columns) return () => true;
1237
1462
  if (typeof columns.keys !== "function")
@@ -1428,6 +1653,7 @@ export {
1428
1653
  BUILTIN_BY_NAME,
1429
1654
  BUILTIN_VALUES,
1430
1655
  checkDsl,
1656
+ extractMessages,
1431
1657
  parseDsl,
1432
1658
  stringValue,
1433
1659
  templateChunkValue,