@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.mjs CHANGED
@@ -33,37 +33,100 @@ function parseRelations(raw) {
33
33
  }
34
34
  function normaliseRelationDef(key, value) {
35
35
  if (typeof value === "string") {
36
- return { type: "many2one", target: value };
36
+ return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
37
37
  }
38
38
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
39
39
  const v = value;
40
- const type = v["type"];
41
- const nested = v["nested"] === true ? true : void 0;
40
+ const type = v["_type"];
41
+ const nested = v["_nested"] === true ? true : void 0;
42
+ const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
43
+ const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
42
44
  if (type === "many2one" || type === void 0) {
43
- const target = v["target"];
45
+ const target = v["_target"];
44
46
  if (typeof target !== "string") return null;
45
- return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
47
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
48
+ return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
46
49
  }
47
50
  if (type === "one2one") {
48
- const target = v["target"];
51
+ const target = v["_target"];
49
52
  if (typeof target !== "string") return null;
50
- return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
53
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
54
+ return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
51
55
  }
52
56
  if (type === "many2many") {
53
- const target = typeof v["target"] === "string" ? v["target"] : key;
54
- const through = v["through"];
55
- const foreignKey = v["foreignKey"];
56
- const otherKey = v["otherKey"];
57
+ const target = typeof v["_target"] === "string" ? v["_target"] : key;
58
+ const through = v["_through"];
59
+ const foreignKey = v["_foreignKey"];
60
+ const otherKey = v["_otherKey"];
57
61
  if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
58
62
  return null;
59
- return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
63
+ return compact({
64
+ type: "many2many",
65
+ target,
66
+ through,
67
+ foreignKey,
68
+ otherKey,
69
+ nested,
70
+ carDirect,
71
+ carInverse
72
+ });
60
73
  }
61
74
  return null;
62
75
  }
76
+ function parseDslString(raw) {
77
+ const nested = raw.endsWith("+nested") ? true : void 0;
78
+ let s = nested ? raw.slice(0, -7) : raw;
79
+ let carDirect;
80
+ let carInverse;
81
+ const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
82
+ if (carMatch) {
83
+ carDirect = carMatch[1];
84
+ carInverse = carMatch[2];
85
+ s = s.slice(0, s.length - carMatch[0].length);
86
+ }
87
+ const colonIdx = s.indexOf(":");
88
+ if (colonIdx === -1) return null;
89
+ const typeAlias = s.slice(0, colonIdx);
90
+ const body = s.slice(colonIdx + 1);
91
+ const type = TYPE_ALIASES[typeAlias];
92
+ if (!type) return null;
93
+ if (type === "many2many") {
94
+ const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
95
+ if (!m) return null;
96
+ const [, target2, through, foreignKey2, otherKey] = m;
97
+ return compact({
98
+ type: "many2many",
99
+ target: target2,
100
+ through,
101
+ foreignKey: foreignKey2,
102
+ otherKey,
103
+ nested,
104
+ carDirect,
105
+ carInverse
106
+ });
107
+ }
108
+ const atIdx = body.indexOf("@");
109
+ const target = atIdx === -1 ? body : body.slice(0, atIdx);
110
+ const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
111
+ if (!target) return null;
112
+ return compact({ type, target, foreignKey, nested, carDirect, carInverse });
113
+ }
114
+ function compact(obj) {
115
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
116
+ }
117
+ var TYPE_ALIASES;
63
118
  var init_parseRelations = __esm({
64
119
  "src/storage/parseRelations.ts"() {
65
120
  "use strict";
66
121
  init_esm_shims();
122
+ TYPE_ALIASES = {
123
+ many2one: "many2one",
124
+ m2o: "many2one",
125
+ one2one: "one2one",
126
+ o2o: "one2one",
127
+ many2many: "many2many",
128
+ m2m: "many2many"
129
+ };
67
130
  }
68
131
  });
69
132
 
@@ -87,13 +150,13 @@ function normaliseFieldDef(value) {
87
150
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
88
151
  const v = value;
89
152
  const def = {};
90
- if (v["required"] === true || v["required"] === false) def.required = v["required"];
91
- if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
92
- def.type = v["type"];
93
- if (typeof v["format"] === "string") def.format = v["format"];
94
- if (Array.isArray(v["enum"])) def.enum = v["enum"];
95
- if (typeof v["description"] === "string") def.description = v["description"];
96
- if (v["default"] !== void 0) def.default = v["default"];
153
+ if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
154
+ if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
155
+ def.type = v["_type"];
156
+ if (typeof v["_format"] === "string") def.format = v["_format"];
157
+ if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
158
+ if (typeof v["_description"] === "string") def.description = v["_description"];
159
+ if (v["_default"] !== void 0) def.default = v["_default"];
97
160
  return def;
98
161
  }
99
162
  var init_parseSchema = __esm({
@@ -116,6 +179,60 @@ var init_deepCopy = __esm({
116
179
  }
117
180
  });
118
181
 
182
+ // src/storage/parseRoutes.ts
183
+ function parseRoutes(raw) {
184
+ if (!Array.isArray(raw)) return [];
185
+ return raw.map(normaliseRoute).filter((r) => r !== null);
186
+ }
187
+ function normaliseRoute(raw) {
188
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
189
+ const v = raw;
190
+ const method = v["_method"];
191
+ const path2 = v["_path"];
192
+ if (typeof method !== "string" || typeof path2 !== "string") return null;
193
+ const route = { method, path: path2 };
194
+ if (typeof v["_handler"] === "string") route.handler = v["_handler"];
195
+ if (typeof v["_delay"] === "number") route.delay = v["_delay"];
196
+ if (typeof v["_error"] === "number") route.error = v["_error"];
197
+ if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
198
+ const response = normaliseResponse(v["_response"]);
199
+ if (response) route.response = response;
200
+ const otherwise = normaliseResponse(v["_otherwise"]);
201
+ if (otherwise) route.otherwise = otherwise;
202
+ const scenarios = normaliseScenarios(v["_scenarios"]);
203
+ if (scenarios.length > 0) route.scenarios = scenarios;
204
+ return route;
205
+ }
206
+ function normaliseResponse(raw) {
207
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
208
+ const v = raw;
209
+ const response = {};
210
+ if (typeof v["_status"] === "number") response.status = v["_status"];
211
+ if (v["_body"] !== void 0) response.body = v["_body"];
212
+ if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
213
+ response.headers = v["_headers"];
214
+ return Object.keys(response).length > 0 ? response : void 0;
215
+ }
216
+ function normaliseScenarios(raw) {
217
+ if (!Array.isArray(raw)) return [];
218
+ return raw.map(normaliseScenario).filter((s) => s !== null);
219
+ }
220
+ function normaliseScenario(raw) {
221
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
222
+ const v = raw;
223
+ const when = v["_when"];
224
+ if (!when || typeof when !== "object") return null;
225
+ const response = normaliseResponse(v["_response"]);
226
+ if (!response) return null;
227
+ return { when, response };
228
+ }
229
+ var init_parseRoutes = __esm({
230
+ "src/storage/parseRoutes.ts"() {
231
+ "use strict";
232
+ init_esm_shims();
233
+ }
234
+ });
235
+
119
236
  // src/storage/yrestStorage.ts
120
237
  var yrestStorage_exports = {};
121
238
  __export(yrestStorage_exports, {
@@ -125,12 +242,66 @@ import { readFileSync as readFileSync2, writeFileSync, renameSync } from "fs";
125
242
  import { resolve, dirname as dirname2 } from "path";
126
243
  import { randomUUID as randomUUID2 } from "crypto";
127
244
  import { parse as parse2, stringify as stringify2 } from "yaml";
245
+ function serializeRelations(relations) {
246
+ const out = {};
247
+ for (const [col, fields] of Object.entries(relations)) {
248
+ out[col] = {};
249
+ for (const [key, def] of Object.entries(fields)) {
250
+ out[col][key] = serializeRelationDef(def);
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+ function serializeRelationDef(def) {
256
+ if (def.type === "many2many") {
257
+ const entry2 = {
258
+ _type: def.type,
259
+ _target: def.target,
260
+ _through: def.through,
261
+ _foreignKey: def.foreignKey,
262
+ _otherKey: def.otherKey
263
+ };
264
+ if (def.nested) entry2._nested = true;
265
+ if (def.carDirect) entry2["_car-direct"] = def.carDirect;
266
+ if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
267
+ return entry2;
268
+ }
269
+ const entry = { _type: def.type, _target: def.target };
270
+ if (def.foreignKey) entry._foreignKey = def.foreignKey;
271
+ if (def.nested) entry._nested = true;
272
+ if (def.carDirect) entry["_car-direct"] = def.carDirect;
273
+ if (def.carInverse) entry["_car-inverse"] = def.carInverse;
274
+ return entry;
275
+ }
276
+ function serializeRoutes(routes) {
277
+ return routes.map((r) => {
278
+ const entry = { _method: r.method, _path: r.path };
279
+ if (r.handler) entry._handler = r.handler;
280
+ if (r.delay !== void 0) entry._delay = r.delay;
281
+ if (r.error !== void 0) entry._error = r.error;
282
+ if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
283
+ if (r.response) entry._response = serializeResponse(r.response);
284
+ if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
285
+ if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
286
+ return entry;
287
+ });
288
+ }
289
+ function serializeResponse(res) {
290
+ const out = {};
291
+ if (res.status !== void 0) out._status = res.status;
292
+ if (res.body !== void 0) out._body = res.body;
293
+ if (res.headers) out._headers = res.headers;
294
+ return out;
295
+ }
296
+ function serializeScenario(s) {
297
+ return { _when: s.when, _response: serializeResponse(s.response) };
298
+ }
128
299
  function createYrestStorage(filePath) {
129
300
  const absPath = resolve(filePath);
130
301
  const raw = parse2(readFileSync2(absPath, "utf8")) ?? {};
131
302
  const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
132
303
  const relations = parseRelations(raw["_rel"]);
133
- const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
304
+ const routes = parseRoutes(raw["_routes"]);
134
305
  const schema = parseSchema(raw["_schema"]);
135
306
  const data = Object.fromEntries(
136
307
  Object.entries(raw).filter(([key]) => !RESERVED.has(key))
@@ -161,8 +332,8 @@ function createYrestStorage(filePath) {
161
332
  },
162
333
  persist() {
163
334
  const payload = {};
164
- if (Object.keys(relations).length > 0) payload._rel = relations;
165
- if (routes.length > 0) payload._routes = routes;
335
+ if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
336
+ if (routes.length > 0) payload._routes = serializeRoutes(routes);
166
337
  Object.assign(payload, data);
167
338
  const tmp = resolve(dirname2(absPath), `.yrest-${randomUUID2()}.tmp`);
168
339
  writeFileSync(tmp, stringify2(payload), "utf8");
@@ -204,6 +375,7 @@ var init_yrestStorage = __esm({
204
375
  "use strict";
205
376
  init_esm_shims();
206
377
  init_parseRelations();
378
+ init_parseRoutes();
207
379
  init_parseSchema();
208
380
  init_deepCopy();
209
381
  }
@@ -646,12 +818,510 @@ curl ${curlFlag}${fullPath}`);
646
818
  return `<pre>${highlighted}</pre>`;
647
819
  }
648
820
 
821
+ // src/router/templates/about.diagram.ts
822
+ init_esm_shims();
823
+ function inferType(value) {
824
+ if (value === null || value === void 0) return "string";
825
+ if (typeof value === "boolean") return "boolean";
826
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
827
+ if (Array.isArray(value)) return "array";
828
+ if (typeof value === "object") return "object";
829
+ return "string";
830
+ }
831
+ function generateERData(storage) {
832
+ const data = storage.getData();
833
+ const relations = storage.getRelations();
834
+ const schema = storage.getSchema();
835
+ const pivotTables = /* @__PURE__ */ new Set();
836
+ for (const fields of Object.values(relations)) {
837
+ for (const def of Object.values(fields)) {
838
+ if (def.type === "many2many") pivotTables.add(def.through);
839
+ }
840
+ }
841
+ const fkFields = /* @__PURE__ */ new Map();
842
+ for (const [source, fields] of Object.entries(relations)) {
843
+ for (const [key, def] of Object.entries(fields)) {
844
+ if (def.type !== "many2many") {
845
+ if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
846
+ fkFields.get(source).add(key);
847
+ }
848
+ }
849
+ }
850
+ const entities = Object.entries(data).map(([name, items]) => {
851
+ const sample = items[0] ?? {};
852
+ const schemaFields = schema[name] ?? {};
853
+ const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
854
+ const fields = [...allFields].map((fieldName) => ({
855
+ name: fieldName,
856
+ type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
857
+ pk: fieldName === "id",
858
+ fk: fkFields.get(name)?.has(fieldName) ?? false,
859
+ required: schemaFields[fieldName]?.required ?? false
860
+ }));
861
+ fields.sort((a, b) => {
862
+ if (a.pk) return -1;
863
+ if (b.pk) return 1;
864
+ if (a.fk && !b.fk) return 1;
865
+ if (!a.fk && b.fk) return -1;
866
+ return 0;
867
+ });
868
+ return { name, fields, isPivot: pivotTables.has(name) };
869
+ });
870
+ const erRelations = [];
871
+ for (const [source, fields] of Object.entries(relations)) {
872
+ for (const [key, def] of Object.entries(fields)) {
873
+ if (def.type === "many2many") {
874
+ erRelations.push({
875
+ source,
876
+ target: def.target,
877
+ type: "many2many",
878
+ label: def.through,
879
+ through: def.through,
880
+ foreignKey: def.foreignKey,
881
+ otherKey: def.otherKey
882
+ });
883
+ } else {
884
+ erRelations.push({
885
+ source,
886
+ target: def.target,
887
+ type: def.type,
888
+ label: key,
889
+ carDirect: def.carDirect,
890
+ carInverse: def.carInverse
891
+ });
892
+ }
893
+ }
894
+ }
895
+ return { entities, relations: erRelations };
896
+ }
897
+
898
+ // src/router/templates/about.diagram.canvas.ts
899
+ init_esm_shims();
900
+ function erDiagramSection(data) {
901
+ if (!data.entities.length) return "";
902
+ const json = JSON.stringify(data);
903
+ return (
904
+ /*html*/
905
+ `
906
+ <h2>Data Model</h2>
907
+
908
+ <div class="card" style="padding:0;overflow:hidden;position:relative;">
909
+ <canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
910
+ <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>
911
+ </div>
912
+
913
+ <script>${/*js*/
914
+ `
915
+ (function(){
916
+
917
+ var D=${json};
918
+
919
+ // \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
920
+ var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
921
+
922
+ var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
923
+ bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
924
+ pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
925
+
926
+ // \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
927
+ var cv = document.getElementById('er-canvas');
928
+ var dpr = window.devicePixelRatio||1;
929
+ var ctx = cv.getContext('2d');
930
+
931
+ function resize(h){
932
+ if(h)cv.style.height = h + 'px';
933
+ var r = cv.getBoundingClientRect();
934
+ cv.width = Math.round(r.width*dpr);
935
+ cv.height = Math.round(r.height*dpr);
936
+ }
937
+
938
+ // \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
939
+ var nodes = D.entities.map(function(e){
940
+ return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
941
+ });
942
+
943
+ var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
944
+
945
+ var adj=nodes.map(function(){return[];}); // forward
946
+ var radj=nodes.map(function(){return[];}); // reverse
947
+
948
+ D.relations.forEach(function(r){
949
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
950
+ var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
951
+ if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
952
+ if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
953
+ }else{
954
+ var s=nIdx[r.source],t=nIdx[r.target];
955
+ if(s==null||t==null||s===t)return;
956
+ adj[s].push(t);radj[t].push(s);
957
+ }
958
+ });
959
+
960
+ // \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
961
+ var groups=[];
962
+
963
+ function median(arr){
964
+ if(!arr.length)return 0;
965
+ var s=[].concat(arr).sort(function(a,b){return a-b;});
966
+ return s[Math.floor(s.length/2)];
967
+ }
968
+
969
+ function computeLayout(W,H){
970
+ var layer=nodes.map(function(){return 0;});
971
+ var inDeg=nodes.map(function(){return 0;});
972
+ adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
973
+ var q=[],qi=0;
974
+ for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
975
+ if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
976
+ while(qi<q.length){
977
+ var u=q[qi++];
978
+ adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
979
+ }
980
+ var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
981
+ groups=[];
982
+ for(var l=0;l<=maxL;l++)groups.push([]);
983
+ for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
984
+ for(var l=1;l<=maxL;l++){
985
+ var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
986
+ groups[l].sort(function(a,b){
987
+ return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
988
+ median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
989
+ });
990
+ }
991
+ for(var l=maxL-1;l>=0;l--){
992
+ var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
993
+ groups[l].sort(function(a,b){
994
+ return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
995
+ median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
996
+ });
997
+ }
998
+ var colW=NW+COL_GAP;
999
+ var totalW=(maxL+1)*colW-COL_GAP;
1000
+ var startX=Math.max(20,(W-totalW)/2);
1001
+ for(var l=0;l<=maxL;l++){
1002
+ var g=groups[l];
1003
+ var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
1004
+ var y=Math.max(20,(H-totalH)/2);
1005
+ g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
1006
+ }
1007
+ }
1008
+
1009
+ function requiredH(){
1010
+ return Math.max(340,Math.min(720,groups.reduce(function(m,g){
1011
+ return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
1012
+ },300)));
1013
+ }
1014
+
1015
+ // \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
1016
+ var pan={x:0,y:0},zm=1;
1017
+ var origPos=[];
1018
+
1019
+ function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
1020
+
1021
+ function fitView(W,H){
1022
+ var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
1023
+ 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);});
1024
+ var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
1025
+ zm=Math.min(W/gW,(H-LGH)/gH,1);
1026
+ pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
1027
+ pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
1028
+ }
1029
+
1030
+ // \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
1031
+ function rr(x,y,w,h,r){
1032
+ if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
1033
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
1034
+ 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();
1035
+ }
1036
+
1037
+ function edgePt(n,tx,ty){
1038
+ var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
1039
+ if(!dx&&!dy)return{x:cx,y:cy};
1040
+ var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
1041
+ return{x:cx+dx*s,y:cy+dy*s};
1042
+ }
1043
+
1044
+ var badges=[];
1045
+
1046
+ function pushBadge(x,y,ux,uy,many,col){
1047
+ badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
1048
+ }
1049
+
1050
+ // Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
1051
+ // ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
1052
+ // Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
1053
+ function drawBadge(b){
1054
+ var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
1055
+ ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
1056
+ ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
1057
+ ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
1058
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
1059
+ ctx.beginPath();
1060
+ if(b.many){
1061
+ ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
1062
+ ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
1063
+ ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
1064
+ ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
1065
+ }else{
1066
+ ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
1067
+ ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
1068
+ }
1069
+ ctx.stroke();
1070
+ ctx.restore();
1071
+ }
1072
+
1073
+ function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
1074
+
1075
+ // offset: pixels to shift perpendicular to the edge direction (for parallel edges)
1076
+ function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
1077
+ var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
1078
+ var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
1079
+ var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
1080
+ var ux=dx/d,uy=dy/d;
1081
+ var px=-uy*off,py=ux*off; // perpendicular shift
1082
+ var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
1083
+ ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
1084
+ ctx.setLineDash(dashed?[5,4]:[]);
1085
+ ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
1086
+ ctx.setLineDash([]);
1087
+ pushBadge(q1x,q1y,ux,uy,manyA,col);
1088
+ pushBadge(q2x,q2y,-ux,-uy,manyB,col);
1089
+ if(label){
1090
+ var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
1091
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
1092
+ ctx.fillText(label,mx-uy*14,my+ux*14);
1093
+ ctx.textBaseline='alphabetic';
1094
+ }
1095
+ }
1096
+
1097
+ // canonical key for a node pair regardless of direction
1098
+ function pairKey(a,b){
1099
+ var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
1100
+ return ai<bi?ai+'-'+bi:bi+'-'+ai;
1101
+ }
1102
+
1103
+ function collectEdges(){
1104
+ var edges=[];
1105
+ D.relations.forEach(function(r){
1106
+ var col=ecol(r.type);
1107
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1108
+ var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
1109
+ if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
1110
+ if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
1111
+ }else{
1112
+ var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
1113
+ if(sn&&tn&&sn!==tn){
1114
+ var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
1115
+ var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
1116
+ edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
1117
+ }
1118
+ }
1119
+ });
1120
+ // assign perpendicular offsets to parallel edges (same node pair)
1121
+ var cnt={},idx={};
1122
+ edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
1123
+ edges.forEach(function(e){
1124
+ var k=pairKey(e.a,e.b),n=cnt[k];
1125
+ var i=idx[k]=(idx[k]||0);
1126
+ e.off=n>1?(i-(n-1)/2)*16:0;
1127
+ idx[k]++;
1128
+ });
1129
+ return edges;
1130
+ }
1131
+
1132
+ function drawNode(n){
1133
+ var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
1134
+ var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
1135
+ ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
1136
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
1137
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
1138
+ ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
1139
+ ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
1140
+ ctx.strokeStyle=C.bo;ctx.lineWidth=1;
1141
+ ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
1142
+ ctx.fillStyle=e.isPivot?C.mu:C.tx;
1143
+ ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
1144
+ ctx.textAlign='left';ctx.textBaseline='middle';
1145
+ ctx.fillText(e.name,x+FP,y+HH/2);
1146
+ if(e.isPivot){
1147
+ ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
1148
+ ctx.fillText('pivot',x+w-FP,y+HH/2);
1149
+ }
1150
+ e.fields.forEach(function(f,fi){
1151
+ var fy=y+HH+fi*FH+FH/2+3;
1152
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1153
+ ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
1154
+ if(f.pk||f.fk){
1155
+ var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
1156
+ ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
1157
+ rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
1158
+ ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
1159
+ ctx.fillText(tag,x+FP+6,fy+0.5);
1160
+ }
1161
+ ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
1162
+ ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
1163
+ ctx.textAlign='left';ctx.textBaseline='middle';
1164
+ ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
1165
+ if(f.required&&!f.pk){
1166
+ ctx.fillStyle='#f85149';ctx.beginPath();
1167
+ ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
1168
+ }
1169
+ });
1170
+ ctx.textBaseline='alphabetic';
1171
+ }
1172
+
1173
+ function drawLegend(){
1174
+ var cvW=cv.width/dpr,cvH=cv.height/dpr;
1175
+ var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
1176
+
1177
+ ctx.fillStyle='#161b22';
1178
+ ctx.fillRect(0,y0,cvW,LGH);
1179
+ ctx.strokeStyle='#30363d';ctx.lineWidth=1;
1180
+ ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
1181
+
1182
+ // Row 1: relation line types
1183
+ var types=[
1184
+ {c:C.m2o,l:'many2one',d:false},
1185
+ {c:C.o2o,l:'one2one',d:false},
1186
+ {c:C.m2m,l:'many2many',d:true}
1187
+ ];
1188
+ ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
1189
+ var x=14;
1190
+ types.forEach(function(it){
1191
+ ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
1192
+ ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
1193
+ ctx.setLineDash([]);
1194
+ ctx.fillStyle=C.mu;
1195
+ ctx.fillText(it.l,x+18,y1);
1196
+ x+=18+ctx.measureText(it.l).width+22;
1197
+ });
1198
+
1199
+ // Row 2: cardinality symbols
1200
+ function badge2(cx,cy,many,col){
1201
+ ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
1202
+ ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
1203
+ ctx.save();ctx.translate(cx,cy);
1204
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
1205
+ ctx.beginPath();
1206
+ if(many){
1207
+ ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
1208
+ ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
1209
+ }else{
1210
+ ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
1211
+ }
1212
+ ctx.stroke();ctx.restore();
1213
+ }
1214
+
1215
+ ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1216
+ x=14;
1217
+ var pairs=[
1218
+ {a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
1219
+ {a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
1220
+ {a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
1221
+ ];
1222
+ pairs.forEach(function(p){
1223
+ ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
1224
+ ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
1225
+ ctx.setLineDash([]);
1226
+ badge2(x+8,y2,p.a,p.col);
1227
+ badge2(x+56,y2,p.b,p.col);
1228
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1229
+ ctx.fillText(p.l,x+66,y2);
1230
+ x+=66+ctx.measureText(p.l).width+24;
1231
+ });
1232
+
1233
+ ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1234
+ ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
1235
+ ctx.textBaseline='alphabetic';
1236
+ }
1237
+
1238
+ function draw(){
1239
+ var W=cv.width/dpr,H=cv.height/dpr;
1240
+ ctx.setTransform(dpr,0,0,dpr,0,0);
1241
+ ctx.clearRect(0,0,W,H);
1242
+ ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
1243
+ ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
1244
+ badges=[];
1245
+ var edges=collectEdges();
1246
+ edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
1247
+ nodes.forEach(function(n){drawNode(n);});
1248
+ badges.forEach(function(b){drawBadge(b);});
1249
+ ctx.restore();
1250
+ drawLegend();
1251
+ }
1252
+
1253
+ // \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
1254
+ var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
1255
+
1256
+ function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
1257
+ function hitTest(wx,wy){
1258
+ 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;}
1259
+ return null;
1260
+ }
1261
+
1262
+ cv.addEventListener('mousedown',function(ev){
1263
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1264
+ var w=toW(mx,my),h=hitTest(w.x,w.y);
1265
+ 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};}
1266
+ cv.style.cursor='grabbing';
1267
+ });
1268
+ window.addEventListener('mousemove',function(ev){
1269
+ if(!drag&&!isPan)return;
1270
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1271
+ if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
1272
+ else{pan.x=mx-ps.x;pan.y=my-ps.y;}
1273
+ draw();
1274
+ });
1275
+ window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
1276
+ cv.addEventListener('wheel',function(ev){
1277
+ ev.preventDefault();
1278
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1279
+ var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
1280
+ pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
1281
+ },{passive:false});
1282
+ cv.addEventListener('dblclick',function(){
1283
+ nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
1284
+ var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
1285
+ });
1286
+ window.addEventListener('resize',function(){
1287
+ resize();var r=cv.getBoundingClientRect();
1288
+ computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
1289
+ });
1290
+
1291
+ document.getElementById('er-dl').addEventListener('click',function(){
1292
+ var SCALE=3;
1293
+ var cw=cv.width/dpr,ch=cv.height/dpr;
1294
+ var oc=document.createElement('canvas');
1295
+ oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
1296
+ var sav={cv:cv,ctx:ctx,dpr:dpr};
1297
+ cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
1298
+ draw();
1299
+ cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
1300
+ var a=document.createElement('a');
1301
+ a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
1302
+ });
1303
+
1304
+ // \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
1305
+ requestAnimationFrame(function(){
1306
+ resize();
1307
+ var r=cv.getBoundingClientRect();
1308
+ computeLayout(r.width,r.height);
1309
+ var h=requiredH();
1310
+ resize(h);r=cv.getBoundingClientRect();
1311
+ computeLayout(r.width,r.height);
1312
+ savePos();fitView(r.width,r.height);draw();
1313
+ });
1314
+ })();
1315
+ `}</script>`
1316
+ );
1317
+ }
1318
+
649
1319
  // src/router/templates/about.template.ts
650
1320
  var _dir = dirname(fileURLToPath2(import.meta.url));
651
1321
  var LOGO_SRC = (() => {
652
1322
  try {
653
- const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
654
- return `data:image/png;base64,${buf.toString("base64")}`;
1323
+ const buf = readFileSync(join(_dir, "../../assets/yrest-lockup.svg"));
1324
+ return `data:image/svg+xml;base64,${buf.toString("base64")}`;
655
1325
  } catch {
656
1326
  return "";
657
1327
  }
@@ -662,6 +1332,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
662
1332
  const customRoutes = storage.getRoutes();
663
1333
  const base = options.base;
664
1334
  const host = `http://${options.host}:${options.port}`;
1335
+ const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
665
1336
  const modes = [];
666
1337
  if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
667
1338
  if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
@@ -672,9 +1343,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
672
1343
  if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
673
1344
  if (options.idStrategy !== "increment")
674
1345
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
1346
+ const erDiagram = erDiagramSection(generateERData(storage));
675
1347
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
676
1348
  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.`;
677
- return `<!DOCTYPE html>
1349
+ return (
1350
+ /*html*/
1351
+ `<!DOCTYPE html>
678
1352
  <html lang="en">
679
1353
  <head>
680
1354
  <meta charset="UTF-8">
@@ -701,20 +1375,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
701
1375
  width: 100%;
702
1376
  background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
703
1377
  border-bottom: 1px solid var(--border);
704
- padding: 48px 32px 40px;
1378
+ padding: 20px 32px 16px;
705
1379
  }
706
- .banner-inner { max-width: 1100px; margin: 0 auto; }
707
- .banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
1380
+ .banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
1381
+ .banner-left { flex:1; min-width:0; }
1382
+ .banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
708
1383
  .banner h1 .y { color: var(--text); }
709
1384
  .banner h1 .rest { color: var(--accent); }
710
- .banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
711
- .banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
712
- .banner-meta span { color: var(--text-muted); font-size: 13px; }
1385
+ .banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
1386
+ .banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
1387
+ .banner-meta span { color: var(--text-muted); font-size: 12px; }
713
1388
  .banner-meta span strong { color: var(--text); font-family: monospace; }
1389
+ .api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
1390
+ .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; }
1391
+ .btn-api:hover { border-color:var(--border-hi); color:var(--text); }
1392
+ .btn-swagger { border-color:var(--accent); color:var(--accent); }
1393
+ .btn-swagger:hover { background:#58a6ff18; }
714
1394
 
715
1395
  /* \u2500\u2500 Layout \u2500\u2500 */
716
- .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
1396
+ .wrap { padding: 24px 32px 48px; }
717
1397
  h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
1398
+ .about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
1399
+ @media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
1400
+ .col-left, .col-right { min-width:0; }
1401
+ .col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
718
1402
 
719
1403
  /* \u2500\u2500 Cards \u2500\u2500 */
720
1404
  .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
@@ -730,10 +1414,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
730
1414
  .modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
731
1415
  .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
732
1416
 
733
- /* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
734
- .endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
735
- @media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
736
- .nested-card { grid-column: 1 / -1; }
1417
+ /* \u2500\u2500 Endpoints list \u2500\u2500 */
1418
+ .endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
737
1419
 
738
1420
  /* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
739
1421
  .resource-card {
@@ -798,64 +1480,80 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
798
1480
 
799
1481
  <div class="banner">
800
1482
  <div class="banner-inner">
801
- ${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>`}
802
- <p>Zero-config REST API mock server</p>
803
- <div class="banner-meta">
804
- <span>URL <strong>${host}</strong></span>
805
- <span>Base <strong>${base || "/"}</strong></span>
806
- <span>File <strong>${options.file}</strong></span>
807
- <span>Collections <strong>${collections.length}</strong></span>
1483
+ <div class="banner-left">
1484
+ ${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>`}
1485
+ <p>Zero-config REST API mock server</p>
1486
+ <div class="banner-meta">
1487
+ <span>URL <strong>${host}</strong></span>
1488
+ <span>Base <strong>${base || "/"}</strong></span>
1489
+ <span>File <strong>${options.file}</strong></span>
1490
+ <span>Collections <strong>${collections.length}</strong></span>
1491
+ </div>
1492
+ </div>
1493
+ <div class="api-links">
1494
+ <a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
1495
+ <a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
1496
+ <a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
808
1497
  </div>
809
1498
  </div>
810
1499
  </div>
811
1500
 
812
1501
  <div class="wrap">
1502
+ <div class="about-grid">
1503
+
1504
+ <div class="col-left">
1505
+ <h2>Active Modes</h2>
1506
+ <div class="card">
1507
+ <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
1508
+ ${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>` : ""}
1509
+ </div>
1510
+
1511
+ <h2>Endpoints</h2>
1512
+ <div class="endpoints-grid">
1513
+ ${accordions}
1514
+ ${nestedRoutesAccordion(relations, base)}
1515
+ ${options.snapshot ? snapshotAccordion() : ""}
1516
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1517
+ ${handlersAccordion(handlers, customRoutes, base)}
1518
+ </div>
1519
+
1520
+ <h2>Query Parameters</h2>
1521
+ <div class="card">
1522
+ <table class="param-table">
1523
+ <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
1524
+ <tbody>
1525
+ <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>
1526
+ <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>
1527
+ <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>
1528
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
1529
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
1530
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
1531
+ <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>
1532
+ <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>
1533
+ <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
1534
+ <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>
1535
+ <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>
1536
+ <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>
1537
+ </tbody>
1538
+ </table>
1539
+ </div>
1540
+ </div>
813
1541
 
814
- <h2>Active Modes</h2>
815
- <div class="card">
816
- <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
817
- ${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>` : ""}
818
- </div>
819
-
820
- <h2>Endpoints</h2>
821
- <div class="endpoints-grid">
822
- ${accordions}
823
- ${nestedRoutesAccordion(relations, base)}
824
- ${options.snapshot ? snapshotAccordion() : ""}
825
- ${customRoutesAccordion(customRoutes, base, handlers)}
826
- ${handlersAccordion(handlers, customRoutes, base)}
827
- </div>
1542
+ <div class="col-right">
1543
+ ${erDiagram}
1544
+ ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1545
+ </div>
828
1546
 
829
- <h2>Query Parameters</h2>
830
- <div class="card">
831
- <table class="param-table">
832
- <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
833
- <tbody>
834
- <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>
835
- <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>
836
- <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>
837
- <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
838
- <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
839
- <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
840
- <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>
841
- <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>
842
- <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
843
- <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>
844
- <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>
845
- <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>
846
- </tbody>
847
- </table>
848
1547
  </div>
849
1548
 
850
- ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
851
-
852
1549
  <footer>
853
1550
  Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
854
1551
  </footer>
855
1552
 
856
1553
  </div>
857
1554
  </body>
858
- </html>`;
1555
+ </html>`
1556
+ );
859
1557
  }
860
1558
 
861
1559
  // src/router/routes/about.routes.ts
@@ -1456,12 +2154,23 @@ var NestedRouteCommand = class {
1456
2154
  relations;
1457
2155
  base;
1458
2156
  register(server) {
2157
+ const registered = /* @__PURE__ */ new Set();
1459
2158
  for (const [source, fields] of Object.entries(this.relations)) {
1460
2159
  for (const [key, def] of Object.entries(fields)) {
1461
2160
  if (def.type === "many2many") {
1462
- this.registerMany2Many(server, source, key, def);
2161
+ const fwd = `${this.base}/${source}/:id/${key}`;
2162
+ const inv = `${this.base}/${def.target}/:id/${source}`;
2163
+ if (!registered.has(fwd) && !registered.has(inv)) {
2164
+ registered.add(fwd);
2165
+ registered.add(inv);
2166
+ this.registerMany2Many(server, source, key, def);
2167
+ }
1463
2168
  } else {
1464
- this.registerFkRelation(server, source, key, def.target, def.type);
2169
+ const collPath = `${this.base}/${def.target}/:id/${source}`;
2170
+ if (!registered.has(collPath)) {
2171
+ registered.add(collPath);
2172
+ this.registerFkRelation(server, source, key, def.target, def.type);
2173
+ }
1465
2174
  }
1466
2175
  }
1467
2176
  }