@yrest/cli 0.10.0 → 0.11.0

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/dist/index.js CHANGED
@@ -56,37 +56,100 @@ function parseRelations(raw) {
56
56
  }
57
57
  function normaliseRelationDef(key, value) {
58
58
  if (typeof value === "string") {
59
- return { type: "many2one", target: value };
59
+ return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
60
60
  }
61
61
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
62
62
  const v = value;
63
- const type = v["type"];
64
- const nested = v["nested"] === true ? true : void 0;
63
+ const type = v["_type"];
64
+ const nested = v["_nested"] === true ? true : void 0;
65
+ const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
66
+ const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
65
67
  if (type === "many2one" || type === void 0) {
66
- const target = v["target"];
68
+ const target = v["_target"];
67
69
  if (typeof target !== "string") return null;
68
- return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
70
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
71
+ return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
69
72
  }
70
73
  if (type === "one2one") {
71
- const target = v["target"];
74
+ const target = v["_target"];
72
75
  if (typeof target !== "string") return null;
73
- return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
76
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
77
+ return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
74
78
  }
75
79
  if (type === "many2many") {
76
- const target = typeof v["target"] === "string" ? v["target"] : key;
77
- const through = v["through"];
78
- const foreignKey = v["foreignKey"];
79
- const otherKey = v["otherKey"];
80
+ const target = typeof v["_target"] === "string" ? v["_target"] : key;
81
+ const through = v["_through"];
82
+ const foreignKey = v["_foreignKey"];
83
+ const otherKey = v["_otherKey"];
80
84
  if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
81
85
  return null;
82
- return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
86
+ return compact({
87
+ type: "many2many",
88
+ target,
89
+ through,
90
+ foreignKey,
91
+ otherKey,
92
+ nested,
93
+ carDirect,
94
+ carInverse
95
+ });
83
96
  }
84
97
  return null;
85
98
  }
99
+ function parseDslString(raw) {
100
+ const nested = raw.endsWith("+nested") ? true : void 0;
101
+ let s = nested ? raw.slice(0, -7) : raw;
102
+ let carDirect;
103
+ let carInverse;
104
+ const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
105
+ if (carMatch) {
106
+ carDirect = carMatch[1];
107
+ carInverse = carMatch[2];
108
+ s = s.slice(0, s.length - carMatch[0].length);
109
+ }
110
+ const colonIdx = s.indexOf(":");
111
+ if (colonIdx === -1) return null;
112
+ const typeAlias = s.slice(0, colonIdx);
113
+ const body = s.slice(colonIdx + 1);
114
+ const type = TYPE_ALIASES[typeAlias];
115
+ if (!type) return null;
116
+ if (type === "many2many") {
117
+ const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
118
+ if (!m) return null;
119
+ const [, target2, through, foreignKey2, otherKey] = m;
120
+ return compact({
121
+ type: "many2many",
122
+ target: target2,
123
+ through,
124
+ foreignKey: foreignKey2,
125
+ otherKey,
126
+ nested,
127
+ carDirect,
128
+ carInverse
129
+ });
130
+ }
131
+ const atIdx = body.indexOf("@");
132
+ const target = atIdx === -1 ? body : body.slice(0, atIdx);
133
+ const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
134
+ if (!target) return null;
135
+ return compact({ type, target, foreignKey, nested, carDirect, carInverse });
136
+ }
137
+ function compact(obj) {
138
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
139
+ }
140
+ var TYPE_ALIASES;
86
141
  var init_parseRelations = __esm({
87
142
  "src/storage/parseRelations.ts"() {
88
143
  "use strict";
89
144
  init_cjs_shims();
145
+ TYPE_ALIASES = {
146
+ many2one: "many2one",
147
+ m2o: "many2one",
148
+ one2one: "one2one",
149
+ o2o: "one2one",
150
+ many2many: "many2many",
151
+ m2m: "many2many"
152
+ };
90
153
  }
91
154
  });
92
155
 
@@ -110,13 +173,13 @@ function normaliseFieldDef(value) {
110
173
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
111
174
  const v = value;
112
175
  const def = {};
113
- if (v["required"] === true || v["required"] === false) def.required = v["required"];
114
- if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
115
- def.type = v["type"];
116
- if (typeof v["format"] === "string") def.format = v["format"];
117
- if (Array.isArray(v["enum"])) def.enum = v["enum"];
118
- if (typeof v["description"] === "string") def.description = v["description"];
119
- if (v["default"] !== void 0) def.default = v["default"];
176
+ if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
177
+ if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
178
+ def.type = v["_type"];
179
+ if (typeof v["_format"] === "string") def.format = v["_format"];
180
+ if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
181
+ if (typeof v["_description"] === "string") def.description = v["_description"];
182
+ if (v["_default"] !== void 0) def.default = v["_default"];
120
183
  return def;
121
184
  }
122
185
  var init_parseSchema = __esm({
@@ -139,17 +202,125 @@ var init_deepCopy = __esm({
139
202
  }
140
203
  });
141
204
 
205
+ // src/storage/parseRoutes.ts
206
+ function parseRoutes(raw) {
207
+ if (!Array.isArray(raw)) return [];
208
+ return raw.map(normaliseRoute).filter((r) => r !== null);
209
+ }
210
+ function normaliseRoute(raw) {
211
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
212
+ const v = raw;
213
+ const method = v["_method"];
214
+ const path = v["_path"];
215
+ if (typeof method !== "string" || typeof path !== "string") return null;
216
+ const route = { method, path };
217
+ if (typeof v["_handler"] === "string") route.handler = v["_handler"];
218
+ if (typeof v["_delay"] === "number") route.delay = v["_delay"];
219
+ if (typeof v["_error"] === "number") route.error = v["_error"];
220
+ if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
221
+ const response = normaliseResponse(v["_response"]);
222
+ if (response) route.response = response;
223
+ const otherwise = normaliseResponse(v["_otherwise"]);
224
+ if (otherwise) route.otherwise = otherwise;
225
+ const scenarios = normaliseScenarios(v["_scenarios"]);
226
+ if (scenarios.length > 0) route.scenarios = scenarios;
227
+ return route;
228
+ }
229
+ function normaliseResponse(raw) {
230
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
231
+ const v = raw;
232
+ const response = {};
233
+ if (typeof v["_status"] === "number") response.status = v["_status"];
234
+ if (v["_body"] !== void 0) response.body = v["_body"];
235
+ if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
236
+ response.headers = v["_headers"];
237
+ return Object.keys(response).length > 0 ? response : void 0;
238
+ }
239
+ function normaliseScenarios(raw) {
240
+ if (!Array.isArray(raw)) return [];
241
+ return raw.map(normaliseScenario).filter((s) => s !== null);
242
+ }
243
+ function normaliseScenario(raw) {
244
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
245
+ const v = raw;
246
+ const when = v["_when"];
247
+ if (!when || typeof when !== "object") return null;
248
+ const response = normaliseResponse(v["_response"]);
249
+ if (!response) return null;
250
+ return { when, response };
251
+ }
252
+ var init_parseRoutes = __esm({
253
+ "src/storage/parseRoutes.ts"() {
254
+ "use strict";
255
+ init_cjs_shims();
256
+ }
257
+ });
258
+
142
259
  // src/storage/yrestStorage.ts
143
260
  var yrestStorage_exports = {};
144
261
  __export(yrestStorage_exports, {
145
262
  createYrestStorage: () => createYrestStorage
146
263
  });
264
+ function serializeRelations(relations) {
265
+ const out = {};
266
+ for (const [col, fields] of Object.entries(relations)) {
267
+ out[col] = {};
268
+ for (const [key, def] of Object.entries(fields)) {
269
+ out[col][key] = serializeRelationDef(def);
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+ function serializeRelationDef(def) {
275
+ if (def.type === "many2many") {
276
+ const entry2 = {
277
+ _type: def.type,
278
+ _target: def.target,
279
+ _through: def.through,
280
+ _foreignKey: def.foreignKey,
281
+ _otherKey: def.otherKey
282
+ };
283
+ if (def.nested) entry2._nested = true;
284
+ if (def.carDirect) entry2["_car-direct"] = def.carDirect;
285
+ if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
286
+ return entry2;
287
+ }
288
+ const entry = { _type: def.type, _target: def.target };
289
+ if (def.foreignKey) entry._foreignKey = def.foreignKey;
290
+ if (def.nested) entry._nested = true;
291
+ if (def.carDirect) entry["_car-direct"] = def.carDirect;
292
+ if (def.carInverse) entry["_car-inverse"] = def.carInverse;
293
+ return entry;
294
+ }
295
+ function serializeRoutes(routes) {
296
+ return routes.map((r) => {
297
+ const entry = { _method: r.method, _path: r.path };
298
+ if (r.handler) entry._handler = r.handler;
299
+ if (r.delay !== void 0) entry._delay = r.delay;
300
+ if (r.error !== void 0) entry._error = r.error;
301
+ if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
302
+ if (r.response) entry._response = serializeResponse(r.response);
303
+ if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
304
+ if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
305
+ return entry;
306
+ });
307
+ }
308
+ function serializeResponse(res) {
309
+ const out = {};
310
+ if (res.status !== void 0) out._status = res.status;
311
+ if (res.body !== void 0) out._body = res.body;
312
+ if (res.headers) out._headers = res.headers;
313
+ return out;
314
+ }
315
+ function serializeScenario(s) {
316
+ return { _when: s.when, _response: serializeResponse(s.response) };
317
+ }
147
318
  function createYrestStorage(filePath) {
148
319
  const absPath = (0, import_node_path2.resolve)(filePath);
149
320
  const raw = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
150
321
  const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
151
322
  const relations = parseRelations(raw["_rel"]);
152
- const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
323
+ const routes = parseRoutes(raw["_routes"]);
153
324
  const schema = parseSchema(raw["_schema"]);
154
325
  const data = Object.fromEntries(
155
326
  Object.entries(raw).filter(([key]) => !RESERVED.has(key))
@@ -180,8 +351,8 @@ function createYrestStorage(filePath) {
180
351
  },
181
352
  persist() {
182
353
  const payload = {};
183
- if (Object.keys(relations).length > 0) payload._rel = relations;
184
- if (routes.length > 0) payload._routes = routes;
354
+ if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
355
+ if (routes.length > 0) payload._routes = serializeRoutes(routes);
185
356
  Object.assign(payload, data);
186
357
  const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto2.randomUUID)()}.tmp`);
187
358
  (0, import_node_fs3.writeFileSync)(tmp, (0, import_yaml3.stringify)(payload), "utf8");
@@ -228,6 +399,7 @@ var init_yrestStorage = __esm({
228
399
  import_node_crypto2 = require("crypto");
229
400
  import_yaml3 = require("yaml");
230
401
  init_parseRelations();
402
+ init_parseRoutes();
231
403
  init_parseSchema();
232
404
  init_deepCopy();
233
405
  }
@@ -680,12 +852,510 @@ curl ${curlFlag}${fullPath}`);
680
852
  return `<pre>${highlighted}</pre>`;
681
853
  }
682
854
 
855
+ // src/router/templates/about.diagram.ts
856
+ init_cjs_shims();
857
+ function inferType(value) {
858
+ if (value === null || value === void 0) return "string";
859
+ if (typeof value === "boolean") return "boolean";
860
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
861
+ if (Array.isArray(value)) return "array";
862
+ if (typeof value === "object") return "object";
863
+ return "string";
864
+ }
865
+ function generateERData(storage) {
866
+ const data = storage.getData();
867
+ const relations = storage.getRelations();
868
+ const schema = storage.getSchema();
869
+ const pivotTables = /* @__PURE__ */ new Set();
870
+ for (const fields of Object.values(relations)) {
871
+ for (const def of Object.values(fields)) {
872
+ if (def.type === "many2many") pivotTables.add(def.through);
873
+ }
874
+ }
875
+ const fkFields = /* @__PURE__ */ new Map();
876
+ for (const [source, fields] of Object.entries(relations)) {
877
+ for (const [key, def] of Object.entries(fields)) {
878
+ if (def.type !== "many2many") {
879
+ if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
880
+ fkFields.get(source).add(key);
881
+ }
882
+ }
883
+ }
884
+ const entities = Object.entries(data).map(([name, items]) => {
885
+ const sample = items[0] ?? {};
886
+ const schemaFields = schema[name] ?? {};
887
+ const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
888
+ const fields = [...allFields].map((fieldName) => ({
889
+ name: fieldName,
890
+ type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
891
+ pk: fieldName === "id",
892
+ fk: fkFields.get(name)?.has(fieldName) ?? false,
893
+ required: schemaFields[fieldName]?.required ?? false
894
+ }));
895
+ fields.sort((a, b) => {
896
+ if (a.pk) return -1;
897
+ if (b.pk) return 1;
898
+ if (a.fk && !b.fk) return 1;
899
+ if (!a.fk && b.fk) return -1;
900
+ return 0;
901
+ });
902
+ return { name, fields, isPivot: pivotTables.has(name) };
903
+ });
904
+ const erRelations = [];
905
+ for (const [source, fields] of Object.entries(relations)) {
906
+ for (const [key, def] of Object.entries(fields)) {
907
+ if (def.type === "many2many") {
908
+ erRelations.push({
909
+ source,
910
+ target: def.target,
911
+ type: "many2many",
912
+ label: def.through,
913
+ through: def.through,
914
+ foreignKey: def.foreignKey,
915
+ otherKey: def.otherKey
916
+ });
917
+ } else {
918
+ erRelations.push({
919
+ source,
920
+ target: def.target,
921
+ type: def.type,
922
+ label: key,
923
+ carDirect: def.carDirect,
924
+ carInverse: def.carInverse
925
+ });
926
+ }
927
+ }
928
+ }
929
+ return { entities, relations: erRelations };
930
+ }
931
+
932
+ // src/router/templates/about.diagram.canvas.ts
933
+ init_cjs_shims();
934
+ function erDiagramSection(data) {
935
+ if (!data.entities.length) return "";
936
+ const json = JSON.stringify(data);
937
+ return (
938
+ /*html*/
939
+ `
940
+ <h2>Data Model</h2>
941
+
942
+ <div class="card" style="padding:0;overflow:hidden;position:relative;">
943
+ <canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
944
+ <button id="er-dl" title="Download PNG" style="position:absolute;top:10px;right:14px;background:#161b22;border:1px solid #30363d;color:#7d8590;padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;line-height:1.4;">\u2193 PNG</button>
945
+ </div>
946
+
947
+ <script>${/*js*/
948
+ `
949
+ (function(){
950
+
951
+ var D=${json};
952
+
953
+ // \u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
954
+ var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
955
+
956
+ var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
957
+ bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
958
+ pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
959
+
960
+ // \u2500\u2500 Canvas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
961
+ var cv = document.getElementById('er-canvas');
962
+ var dpr = window.devicePixelRatio||1;
963
+ var ctx = cv.getContext('2d');
964
+
965
+ function resize(h){
966
+ if(h)cv.style.height = h + 'px';
967
+ var r = cv.getBoundingClientRect();
968
+ cv.width = Math.round(r.width*dpr);
969
+ cv.height = Math.round(r.height*dpr);
970
+ }
971
+
972
+ // \u2500\u2500 Nodes & adjacency \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
973
+ var nodes = D.entities.map(function(e){
974
+ return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
975
+ });
976
+
977
+ var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
978
+
979
+ var adj=nodes.map(function(){return[];}); // forward
980
+ var radj=nodes.map(function(){return[];}); // reverse
981
+
982
+ D.relations.forEach(function(r){
983
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
984
+ var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
985
+ if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
986
+ if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
987
+ }else{
988
+ var s=nIdx[r.source],t=nIdx[r.target];
989
+ if(s==null||t==null||s===t)return;
990
+ adj[s].push(t);radj[t].push(s);
991
+ }
992
+ });
993
+
994
+ // \u2500\u2500 Layered layout (simplified Sugiyama) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
995
+ var groups=[];
996
+
997
+ function median(arr){
998
+ if(!arr.length)return 0;
999
+ var s=[].concat(arr).sort(function(a,b){return a-b;});
1000
+ return s[Math.floor(s.length/2)];
1001
+ }
1002
+
1003
+ function computeLayout(W,H){
1004
+ var layer=nodes.map(function(){return 0;});
1005
+ var inDeg=nodes.map(function(){return 0;});
1006
+ adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
1007
+ var q=[],qi=0;
1008
+ for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
1009
+ if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
1010
+ while(qi<q.length){
1011
+ var u=q[qi++];
1012
+ adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
1013
+ }
1014
+ var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
1015
+ groups=[];
1016
+ for(var l=0;l<=maxL;l++)groups.push([]);
1017
+ for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
1018
+ for(var l=1;l<=maxL;l++){
1019
+ var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
1020
+ groups[l].sort(function(a,b){
1021
+ return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
1022
+ median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
1023
+ });
1024
+ }
1025
+ for(var l=maxL-1;l>=0;l--){
1026
+ var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
1027
+ groups[l].sort(function(a,b){
1028
+ return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
1029
+ median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
1030
+ });
1031
+ }
1032
+ var colW=NW+COL_GAP;
1033
+ var totalW=(maxL+1)*colW-COL_GAP;
1034
+ var startX=Math.max(20,(W-totalW)/2);
1035
+ for(var l=0;l<=maxL;l++){
1036
+ var g=groups[l];
1037
+ var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
1038
+ var y=Math.max(20,(H-totalH)/2);
1039
+ g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
1040
+ }
1041
+ }
1042
+
1043
+ function requiredH(){
1044
+ return Math.max(340,Math.min(720,groups.reduce(function(m,g){
1045
+ return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
1046
+ },300)));
1047
+ }
1048
+
1049
+ // \u2500\u2500 View \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1050
+ var pan={x:0,y:0},zm=1;
1051
+ var origPos=[];
1052
+
1053
+ function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
1054
+
1055
+ function fitView(W,H){
1056
+ var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
1057
+ nodes.forEach(function(n){x0=Math.min(x0,n.x);y0=Math.min(y0,n.y);x1=Math.max(x1,n.x+n.w);y1=Math.max(y1,n.y+n.h);});
1058
+ var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
1059
+ zm=Math.min(W/gW,(H-LGH)/gH,1);
1060
+ pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
1061
+ pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
1062
+ }
1063
+
1064
+ // \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1065
+ function rr(x,y,w,h,r){
1066
+ if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
1067
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
1068
+ ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();
1069
+ }
1070
+
1071
+ function edgePt(n,tx,ty){
1072
+ var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
1073
+ if(!dx&&!dy)return{x:cx,y:cy};
1074
+ var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
1075
+ return{x:cx+dx*s,y:cy+dy*s};
1076
+ }
1077
+
1078
+ var badges=[];
1079
+
1080
+ function pushBadge(x,y,ux,uy,many,col){
1081
+ badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
1082
+ }
1083
+
1084
+ // Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
1085
+ // ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
1086
+ // Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
1087
+ function drawBadge(b){
1088
+ var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
1089
+ ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
1090
+ ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
1091
+ ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
1092
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
1093
+ ctx.beginPath();
1094
+ if(b.many){
1095
+ ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
1096
+ ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
1097
+ ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
1098
+ ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
1099
+ }else{
1100
+ ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
1101
+ ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
1102
+ }
1103
+ ctx.stroke();
1104
+ ctx.restore();
1105
+ }
1106
+
1107
+ function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
1108
+
1109
+ // offset: pixels to shift perpendicular to the edge direction (for parallel edges)
1110
+ function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
1111
+ var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
1112
+ var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
1113
+ var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
1114
+ var ux=dx/d,uy=dy/d;
1115
+ var px=-uy*off,py=ux*off; // perpendicular shift
1116
+ var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
1117
+ ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
1118
+ ctx.setLineDash(dashed?[5,4]:[]);
1119
+ ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
1120
+ ctx.setLineDash([]);
1121
+ pushBadge(q1x,q1y,ux,uy,manyA,col);
1122
+ pushBadge(q2x,q2y,-ux,-uy,manyB,col);
1123
+ if(label){
1124
+ var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
1125
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
1126
+ ctx.fillText(label,mx-uy*14,my+ux*14);
1127
+ ctx.textBaseline='alphabetic';
1128
+ }
1129
+ }
1130
+
1131
+ // canonical key for a node pair regardless of direction
1132
+ function pairKey(a,b){
1133
+ var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
1134
+ return ai<bi?ai+'-'+bi:bi+'-'+ai;
1135
+ }
1136
+
1137
+ function collectEdges(){
1138
+ var edges=[];
1139
+ D.relations.forEach(function(r){
1140
+ var col=ecol(r.type);
1141
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1142
+ var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
1143
+ if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
1144
+ if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
1145
+ }else{
1146
+ var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
1147
+ if(sn&&tn&&sn!==tn){
1148
+ var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
1149
+ var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
1150
+ edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
1151
+ }
1152
+ }
1153
+ });
1154
+ // assign perpendicular offsets to parallel edges (same node pair)
1155
+ var cnt={},idx={};
1156
+ edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
1157
+ edges.forEach(function(e){
1158
+ var k=pairKey(e.a,e.b),n=cnt[k];
1159
+ var i=idx[k]=(idx[k]||0);
1160
+ e.off=n>1?(i-(n-1)/2)*16:0;
1161
+ idx[k]++;
1162
+ });
1163
+ return edges;
1164
+ }
1165
+
1166
+ function drawNode(n){
1167
+ var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
1168
+ var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
1169
+ ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
1170
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
1171
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
1172
+ ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
1173
+ ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
1174
+ ctx.strokeStyle=C.bo;ctx.lineWidth=1;
1175
+ ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
1176
+ ctx.fillStyle=e.isPivot?C.mu:C.tx;
1177
+ ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
1178
+ ctx.textAlign='left';ctx.textBaseline='middle';
1179
+ ctx.fillText(e.name,x+FP,y+HH/2);
1180
+ if(e.isPivot){
1181
+ ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
1182
+ ctx.fillText('pivot',x+w-FP,y+HH/2);
1183
+ }
1184
+ e.fields.forEach(function(f,fi){
1185
+ var fy=y+HH+fi*FH+FH/2+3;
1186
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1187
+ ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
1188
+ if(f.pk||f.fk){
1189
+ var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
1190
+ ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
1191
+ rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
1192
+ ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
1193
+ ctx.fillText(tag,x+FP+6,fy+0.5);
1194
+ }
1195
+ ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
1196
+ ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
1197
+ ctx.textAlign='left';ctx.textBaseline='middle';
1198
+ ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
1199
+ if(f.required&&!f.pk){
1200
+ ctx.fillStyle='#f85149';ctx.beginPath();
1201
+ ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
1202
+ }
1203
+ });
1204
+ ctx.textBaseline='alphabetic';
1205
+ }
1206
+
1207
+ function drawLegend(){
1208
+ var cvW=cv.width/dpr,cvH=cv.height/dpr;
1209
+ var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
1210
+
1211
+ ctx.fillStyle='#161b22';
1212
+ ctx.fillRect(0,y0,cvW,LGH);
1213
+ ctx.strokeStyle='#30363d';ctx.lineWidth=1;
1214
+ ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
1215
+
1216
+ // Row 1: relation line types
1217
+ var types=[
1218
+ {c:C.m2o,l:'many2one',d:false},
1219
+ {c:C.o2o,l:'one2one',d:false},
1220
+ {c:C.m2m,l:'many2many',d:true}
1221
+ ];
1222
+ ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
1223
+ var x=14;
1224
+ types.forEach(function(it){
1225
+ ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
1226
+ ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
1227
+ ctx.setLineDash([]);
1228
+ ctx.fillStyle=C.mu;
1229
+ ctx.fillText(it.l,x+18,y1);
1230
+ x+=18+ctx.measureText(it.l).width+22;
1231
+ });
1232
+
1233
+ // Row 2: cardinality symbols
1234
+ function badge2(cx,cy,many,col){
1235
+ ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
1236
+ ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
1237
+ ctx.save();ctx.translate(cx,cy);
1238
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
1239
+ ctx.beginPath();
1240
+ if(many){
1241
+ ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
1242
+ ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
1243
+ }else{
1244
+ ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
1245
+ }
1246
+ ctx.stroke();ctx.restore();
1247
+ }
1248
+
1249
+ ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1250
+ x=14;
1251
+ var pairs=[
1252
+ {a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
1253
+ {a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
1254
+ {a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
1255
+ ];
1256
+ pairs.forEach(function(p){
1257
+ ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
1258
+ ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
1259
+ ctx.setLineDash([]);
1260
+ badge2(x+8,y2,p.a,p.col);
1261
+ badge2(x+56,y2,p.b,p.col);
1262
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1263
+ ctx.fillText(p.l,x+66,y2);
1264
+ x+=66+ctx.measureText(p.l).width+24;
1265
+ });
1266
+
1267
+ ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1268
+ ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
1269
+ ctx.textBaseline='alphabetic';
1270
+ }
1271
+
1272
+ function draw(){
1273
+ var W=cv.width/dpr,H=cv.height/dpr;
1274
+ ctx.setTransform(dpr,0,0,dpr,0,0);
1275
+ ctx.clearRect(0,0,W,H);
1276
+ ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
1277
+ ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
1278
+ badges=[];
1279
+ var edges=collectEdges();
1280
+ edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
1281
+ nodes.forEach(function(n){drawNode(n);});
1282
+ badges.forEach(function(b){drawBadge(b);});
1283
+ ctx.restore();
1284
+ drawLegend();
1285
+ }
1286
+
1287
+ // \u2500\u2500 Interaction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1288
+ var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
1289
+
1290
+ function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
1291
+ function hitTest(wx,wy){
1292
+ for(var i=nodes.length-1;i>=0;i--){var n=nodes[i];if(wx>=n.x&&wx<=n.x+n.w&&wy>=n.y&&wy<=n.y+n.h)return n;}
1293
+ return null;
1294
+ }
1295
+
1296
+ cv.addEventListener('mousedown',function(ev){
1297
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1298
+ var w=toW(mx,my),h=hitTest(w.x,w.y);
1299
+ if(h){drag=h;dox=w.x-h.x;doy=w.y-h.y;}else{isPan=true;ps={x:mx-pan.x,y:my-pan.y};}
1300
+ cv.style.cursor='grabbing';
1301
+ });
1302
+ window.addEventListener('mousemove',function(ev){
1303
+ if(!drag&&!isPan)return;
1304
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1305
+ if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
1306
+ else{pan.x=mx-ps.x;pan.y=my-ps.y;}
1307
+ draw();
1308
+ });
1309
+ window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
1310
+ cv.addEventListener('wheel',function(ev){
1311
+ ev.preventDefault();
1312
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1313
+ var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
1314
+ pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
1315
+ },{passive:false});
1316
+ cv.addEventListener('dblclick',function(){
1317
+ nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
1318
+ var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
1319
+ });
1320
+ window.addEventListener('resize',function(){
1321
+ resize();var r=cv.getBoundingClientRect();
1322
+ computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
1323
+ });
1324
+
1325
+ document.getElementById('er-dl').addEventListener('click',function(){
1326
+ var SCALE=3;
1327
+ var cw=cv.width/dpr,ch=cv.height/dpr;
1328
+ var oc=document.createElement('canvas');
1329
+ oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
1330
+ var sav={cv:cv,ctx:ctx,dpr:dpr};
1331
+ cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
1332
+ draw();
1333
+ cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
1334
+ var a=document.createElement('a');
1335
+ a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
1336
+ });
1337
+
1338
+ // \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1339
+ requestAnimationFrame(function(){
1340
+ resize();
1341
+ var r=cv.getBoundingClientRect();
1342
+ computeLayout(r.width,r.height);
1343
+ var h=requiredH();
1344
+ resize(h);r=cv.getBoundingClientRect();
1345
+ computeLayout(r.width,r.height);
1346
+ savePos();fitView(r.width,r.height);draw();
1347
+ });
1348
+ })();
1349
+ `}</script>`
1350
+ );
1351
+ }
1352
+
683
1353
  // src/router/templates/about.template.ts
684
1354
  var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
685
1355
  var LOGO_SRC = (() => {
686
1356
  try {
687
- const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
688
- return `data:image/png;base64,${buf.toString("base64")}`;
1357
+ const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/yrest-lockup.svg"));
1358
+ return `data:image/svg+xml;base64,${buf.toString("base64")}`;
689
1359
  } catch {
690
1360
  return "";
691
1361
  }
@@ -696,6 +1366,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
696
1366
  const customRoutes = storage.getRoutes();
697
1367
  const base = options.base;
698
1368
  const host = `http://${options.host}:${options.port}`;
1369
+ const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
699
1370
  const modes = [];
700
1371
  if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
701
1372
  if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
@@ -706,9 +1377,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
706
1377
  if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
707
1378
  if (options.idStrategy !== "increment")
708
1379
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
1380
+ const erDiagram = erDiagramSection(generateERData(storage));
709
1381
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
710
1382
  const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
711
- return `<!DOCTYPE html>
1383
+ return (
1384
+ /*html*/
1385
+ `<!DOCTYPE html>
712
1386
  <html lang="en">
713
1387
  <head>
714
1388
  <meta charset="UTF-8">
@@ -735,20 +1409,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
735
1409
  width: 100%;
736
1410
  background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
737
1411
  border-bottom: 1px solid var(--border);
738
- padding: 48px 32px 40px;
1412
+ padding: 20px 32px 16px;
739
1413
  }
740
- .banner-inner { max-width: 1100px; margin: 0 auto; }
741
- .banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
1414
+ .banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
1415
+ .banner-left { flex:1; min-width:0; }
1416
+ .banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
742
1417
  .banner h1 .y { color: var(--text); }
743
1418
  .banner h1 .rest { color: var(--accent); }
744
- .banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
745
- .banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
746
- .banner-meta span { color: var(--text-muted); font-size: 13px; }
1419
+ .banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
1420
+ .banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
1421
+ .banner-meta span { color: var(--text-muted); font-size: 12px; }
747
1422
  .banner-meta span strong { color: var(--text); font-family: monospace; }
1423
+ .api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
1424
+ .btn-api { display:inline-block; padding:5px 12px; border-radius:6px; border:1px solid var(--border); color:var(--text-muted); text-decoration:none; font-size:12px; font-weight:500; background:var(--bg-card); white-space:nowrap; transition:border-color .15s,color .15s; }
1425
+ .btn-api:hover { border-color:var(--border-hi); color:var(--text); }
1426
+ .btn-swagger { border-color:var(--accent); color:var(--accent); }
1427
+ .btn-swagger:hover { background:#58a6ff18; }
748
1428
 
749
1429
  /* \u2500\u2500 Layout \u2500\u2500 */
750
- .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
1430
+ .wrap { padding: 24px 32px 48px; }
751
1431
  h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
1432
+ .about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
1433
+ @media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
1434
+ .col-left, .col-right { min-width:0; }
1435
+ .col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
752
1436
 
753
1437
  /* \u2500\u2500 Cards \u2500\u2500 */
754
1438
  .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
@@ -764,10 +1448,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
764
1448
  .modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
765
1449
  .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
766
1450
 
767
- /* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
768
- .endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
769
- @media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
770
- .nested-card { grid-column: 1 / -1; }
1451
+ /* \u2500\u2500 Endpoints list \u2500\u2500 */
1452
+ .endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
771
1453
 
772
1454
  /* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
773
1455
  .resource-card {
@@ -832,64 +1514,80 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
832
1514
 
833
1515
  <div class="banner">
834
1516
  <div class="banner-inner">
835
- ${LOGO_SRC ? `<img src="${LOGO_SRC}" alt="yRest" height="68" style="display:block;margin-bottom:0px" />` : `<h1><span class="y">y</span><span class="rest">Rest</span></h1>`}
836
- <p>Zero-config REST API mock server</p>
837
- <div class="banner-meta">
838
- <span>URL <strong>${host}</strong></span>
839
- <span>Base <strong>${base || "/"}</strong></span>
840
- <span>File <strong>${options.file}</strong></span>
841
- <span>Collections <strong>${collections.length}</strong></span>
1517
+ <div class="banner-left">
1518
+ ${LOGO_SRC ? `<img src="${LOGO_SRC}" alt="yRest" height="36" style="display:block;margin-bottom:4px" />` : `<h1><span class="y">y</span><span class="rest">Rest</span></h1>`}
1519
+ <p>Zero-config REST API mock server</p>
1520
+ <div class="banner-meta">
1521
+ <span>URL <strong>${host}</strong></span>
1522
+ <span>Base <strong>${base || "/"}</strong></span>
1523
+ <span>File <strong>${options.file}</strong></span>
1524
+ <span>Collections <strong>${collections.length}</strong></span>
1525
+ </div>
1526
+ </div>
1527
+ <div class="api-links">
1528
+ <a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
1529
+ <a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
1530
+ <a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
842
1531
  </div>
843
1532
  </div>
844
1533
  </div>
845
1534
 
846
1535
  <div class="wrap">
1536
+ <div class="about-grid">
1537
+
1538
+ <div class="col-left">
1539
+ <h2>Active Modes</h2>
1540
+ <div class="card">
1541
+ <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
1542
+ ${options.watch ? `<div class="warn">\u26A0 <strong>Watch mode:</strong> data changes in existing collections reload automatically. Adding or removing entire collections requires a server restart.</div>` : ""}
1543
+ </div>
1544
+
1545
+ <h2>Endpoints</h2>
1546
+ <div class="endpoints-grid">
1547
+ ${accordions}
1548
+ ${nestedRoutesAccordion(relations, base)}
1549
+ ${options.snapshot ? snapshotAccordion() : ""}
1550
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1551
+ ${handlersAccordion(handlers, customRoutes, base)}
1552
+ </div>
1553
+
1554
+ <h2>Query Parameters</h2>
1555
+ <div class="card">
1556
+ <table class="param-table">
1557
+ <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
1558
+ <tbody>
1559
+ <tr><td><code>?field=value</code></td><td><code>?name=Ana&amp;role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
1560
+ <tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&amp;price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
1561
+ <tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
1562
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
1563
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
1564
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
1565
+ <tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
1566
+ <tr><td><code>?_sort &amp; ?_order</code></td><td><code>?_sort=name&amp;_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
1567
+ <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
1568
+ <tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
1569
+ <tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
1570
+ <tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
1571
+ </tbody>
1572
+ </table>
1573
+ </div>
1574
+ </div>
847
1575
 
848
- <h2>Active Modes</h2>
849
- <div class="card">
850
- <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
851
- ${options.watch ? `<div class="warn">\u26A0 <strong>Watch mode:</strong> data changes in existing collections reload automatically. Adding or removing entire collections requires a server restart.</div>` : ""}
852
- </div>
853
-
854
- <h2>Endpoints</h2>
855
- <div class="endpoints-grid">
856
- ${accordions}
857
- ${nestedRoutesAccordion(relations, base)}
858
- ${options.snapshot ? snapshotAccordion() : ""}
859
- ${customRoutesAccordion(customRoutes, base, handlers)}
860
- ${handlersAccordion(handlers, customRoutes, base)}
861
- </div>
1576
+ <div class="col-right">
1577
+ ${erDiagram}
1578
+ ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1579
+ </div>
862
1580
 
863
- <h2>Query Parameters</h2>
864
- <div class="card">
865
- <table class="param-table">
866
- <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
867
- <tbody>
868
- <tr><td><code>?field=value</code></td><td><code>?name=Ana&amp;role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
869
- <tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&amp;price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
870
- <tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
871
- <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
872
- <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
873
- <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
874
- <tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
875
- <tr><td><code>?_sort &amp; ?_order</code></td><td><code>?_sort=name&amp;_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
876
- <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
877
- <tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
878
- <tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
879
- <tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
880
- </tbody>
881
- </table>
882
1581
  </div>
883
1582
 
884
- ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
885
-
886
1583
  <footer>
887
1584
  Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
888
1585
  </footer>
889
1586
 
890
1587
  </div>
891
1588
  </body>
892
- </html>`;
1589
+ </html>`
1590
+ );
893
1591
  }
894
1592
 
895
1593
  // src/router/routes/about.routes.ts
@@ -1490,12 +2188,23 @@ var NestedRouteCommand = class {
1490
2188
  relations;
1491
2189
  base;
1492
2190
  register(server) {
2191
+ const registered = /* @__PURE__ */ new Set();
1493
2192
  for (const [source, fields] of Object.entries(this.relations)) {
1494
2193
  for (const [key, def] of Object.entries(fields)) {
1495
2194
  if (def.type === "many2many") {
1496
- this.registerMany2Many(server, source, key, def);
2195
+ const fwd = `${this.base}/${source}/:id/${key}`;
2196
+ const inv = `${this.base}/${def.target}/:id/${source}`;
2197
+ if (!registered.has(fwd) && !registered.has(inv)) {
2198
+ registered.add(fwd);
2199
+ registered.add(inv);
2200
+ this.registerMany2Many(server, source, key, def);
2201
+ }
1497
2202
  } else {
1498
- this.registerFkRelation(server, source, key, def.target, def.type);
2203
+ const collPath = `${this.base}/${def.target}/:id/${source}`;
2204
+ if (!registered.has(collPath)) {
2205
+ registered.add(collPath);
2206
+ this.registerFkRelation(server, source, key, def.target, def.type);
2207
+ }
1499
2208
  }
1500
2209
  }
1501
2210
  }