okf 1.4.0 → 1.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +88 -43
- data/lib/okf/bundle/search.rb +155 -0
- data/lib/okf/bundle.rb +2 -2
- data/lib/okf/cli.rb +98 -4
- data/lib/okf/server/app.rb +59 -0
- data/lib/okf/server/graph/template.html.erb +271 -45
- data/lib/okf/server/graph.rb +25 -9
- data/lib/okf/skill/SKILL.md +23 -21
- data/lib/okf/skill/playbooks/consume.md +4 -2
- data/lib/okf/skill/playbooks/maintain.md +6 -4
- data/lib/okf/skill/playbooks/menu.md +10 -6
- data/lib/okf/skill/playbooks/search.md +42 -0
- data/lib/okf/skill/reference/authoring.md +2 -2
- data/lib/okf/skill/reference/cli.md +73 -18
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +1 -0
- metadata +5 -28
- data/.okf/capabilities/agent-skill.md +0 -46
- data/.okf/capabilities/graph-server.md +0 -63
- data/.okf/capabilities/index.md +0 -20
- data/.okf/capabilities/library-api.md +0 -72
- data/.okf/capabilities/linter.md +0 -49
- data/.okf/capabilities/read-views.md +0 -84
- data/.okf/capabilities/validator.md +0 -40
- data/.okf/cli.md +0 -52
- data/.okf/design/core-shell-split.md +0 -58
- data/.okf/design/index.md +0 -10
- data/.okf/design/ruby-floor.md +0 -45
- data/.okf/design/runtime-dependencies.md +0 -44
- data/.okf/design/server-trust-boundary.md +0 -40
- data/.okf/format/citations.md +0 -33
- data/.okf/format/cross-links.md +0 -52
- data/.okf/format/frontmatter.md +0 -38
- data/.okf/format/index.md +0 -9
- data/.okf/format/okf-format.md +0 -43
- data/.okf/index.md +0 -18
- data/.okf/log.md +0 -10
- data/.okf/model/bundle.md +0 -38
- data/.okf/model/concept.md +0 -44
- data/.okf/model/graph.md +0 -44
- data/.okf/model/index.md +0 -8
- data/.okf/overview.md +0 -67
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<meta property="og:site_name" content="OKF">
|
|
9
9
|
<meta property="og:title" content="<%= og_title %>">
|
|
10
10
|
<meta property="og:description" content="<%= og_desc %>">
|
|
11
|
-
<meta property="og:image" content="https://okfgem.com/og-demo.png">
|
|
11
|
+
<meta property="og:image" content="https://okfgem.com/og-demo-v2.png">
|
|
12
12
|
<meta property="og:image:type" content="image/png">
|
|
13
13
|
<meta property="og:image:width" content="1200">
|
|
14
14
|
<meta property="og:image:height" content="630">
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<meta name="twitter:card" content="summary_large_image">
|
|
17
17
|
<meta name="twitter:title" content="<%= og_title %>">
|
|
18
18
|
<meta name="twitter:description" content="<%= og_desc %>">
|
|
19
|
-
<meta name="twitter:image" content="https://okfgem.com/og-demo.png">
|
|
19
|
+
<meta name="twitter:image" content="https://okfgem.com/og-demo-v2.png">
|
|
20
20
|
<meta name="twitter:image:alt" content="An interactive Open Knowledge Format knowledge graph.">
|
|
21
21
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='24' fill='%231a1a1a'/><polygon points='38,44 62,44 50,82' fill='%237a0a1e'/><polygon points='18,44 38,44 50,82' fill='%23a8112c'/><polygon points='62,44 82,44 50,82' fill='%23a8112c'/><polygon points='35,28 18,44 38,44' fill='%23dc1e3c'/><polygon points='65,28 82,44 62,44' fill='%23dc1e3c'/><polygon points='35,28 65,28 62,44 38,44' fill='%23f43f5e'/><polygon points='36,29 49,29 42,42' fill='%23fff' opacity='.38'/><polygon points='35,28 65,28 82,44 50,82 18,44' fill='none' stroke='%23ff6b7f' stroke-width='2' stroke-linejoin='round'/></svg>">
|
|
22
22
|
<script>/* Resolve theme before first paint so there is no flash. */
|
|
@@ -109,8 +109,13 @@
|
|
|
109
109
|
.btn.on-filter .fbadge{display:grid}
|
|
110
110
|
#graph-controls{display:none;align-items:center;gap:9px}
|
|
111
111
|
#app[data-view=graph] #graph-controls{display:flex}
|
|
112
|
+
#file-controls{display:none;align-items:center;gap:8px}
|
|
113
|
+
#app[data-view=files] #file-controls{display:flex}
|
|
114
|
+
/* below this width the topbar wraps in the files view so search + combos share two rows */
|
|
115
|
+
@media (max-width:700px){#app[data-view=files] #topbar{flex-wrap:wrap}
|
|
116
|
+
#app[data-view=files] #file-controls{flex:1 1 100%}#file-controls .combo{flex:1;width:auto;min-width:0}}
|
|
112
117
|
#search-wrap{display:flex;flex:1}
|
|
113
|
-
#app[data-view=stats] #search-wrap
|
|
118
|
+
#app[data-view=stats] #search-wrap{visibility:hidden}
|
|
114
119
|
#btn-theme .sun{display:none} :root[data-theme=dark] #btn-theme .sun{display:block} :root[data-theme=dark] #btn-theme .moon{display:none}
|
|
115
120
|
#btn-full .compress{display:none} #btn-full[aria-pressed=true] .compress{display:block} #btn-full[aria-pressed=true] .expand{display:none}
|
|
116
121
|
|
|
@@ -167,7 +172,6 @@
|
|
|
167
172
|
.slideover .fgroup h4{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);margin:0 0 9px}
|
|
168
173
|
.slideover .fchips{display:flex;flex-wrap:wrap;gap:6px}
|
|
169
174
|
.cat-types{align-items:center;gap:10px} .cat-types #cat-types{display:flex;flex-wrap:wrap;gap:7px}
|
|
170
|
-
.ftree-head .combo{width:100%} .ftree-head .combo+.combo{margin-top:8px}
|
|
171
175
|
.link{background:none;border:0;color:var(--accent-ink);font:inherit;font-size:12.5px;cursor:pointer;padding:0;font-weight:500}
|
|
172
176
|
.link:hover{text-decoration:underline}
|
|
173
177
|
|
|
@@ -197,9 +201,22 @@
|
|
|
197
201
|
.body td,.body th{border:1px solid var(--line);padding:5px 9px}
|
|
198
202
|
.body img{max-width:100%}
|
|
199
203
|
.body blockquote{margin:.6em 0;padding:.2em 0 .2em 14px;border-left:3px solid var(--line);color:var(--muted)}
|
|
200
|
-
.mermaid{margin:14px 0;display:flex;justify-content:center;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;padding:12px;overflow:auto}
|
|
204
|
+
.mermaid{margin:14px 0;display:flex;justify-content:center;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;padding:12px;overflow:auto;cursor:zoom-in}
|
|
205
|
+
.mermaid:hover{border-color:var(--accent)}
|
|
206
|
+
.mermaid:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
201
207
|
.mermaid svg{max-width:100%;height:auto}
|
|
202
208
|
|
|
209
|
+
/* ── fullscreen diagram viewer (Panzoom, lazy like Mermaid itself) ── */
|
|
210
|
+
#dgv{position:fixed;inset:0;z-index:80;background:var(--canvas)}
|
|
211
|
+
#dgv[hidden]{display:none}
|
|
212
|
+
#dgv-stage{position:absolute;inset:0;overflow:hidden;touch-action:none;cursor:grab;display:flex;align-items:center;justify-content:center}
|
|
213
|
+
#dgv-stage:active{cursor:grabbing}
|
|
214
|
+
#dgv-pan{flex:none}
|
|
215
|
+
#dgv-pan svg{display:block;max-width:none}
|
|
216
|
+
.dgv-bar{position:absolute;top:14px;right:14px;display:flex;gap:8px;z-index:2}
|
|
217
|
+
.dgv-bar .btn{background:var(--panel);box-shadow:var(--shadow)}
|
|
218
|
+
.dgv-hint{position:absolute;bottom:14px;left:0;right:0;text-align:center;font-size:12px;color:var(--faint);pointer-events:none}
|
|
219
|
+
|
|
203
220
|
/* ── catalog ── */
|
|
204
221
|
.pad{padding:22px 24px;overflow:auto;flex:1}
|
|
205
222
|
.cat-types{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:18px}
|
|
@@ -224,9 +241,23 @@
|
|
|
224
241
|
#ftree-resizer{position:absolute;right:-4px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:4;touch-action:none}
|
|
225
242
|
#ftree-resizer::after{content:"";position:absolute;right:3px;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
|
|
226
243
|
#ftree-resizer:hover::after,#ftree-resizer.dragging::after{opacity:.7}
|
|
227
|
-
.ftree-head{padding:12px;border-bottom:1px solid var(--line);flex:none}
|
|
228
244
|
.ftree-list{overflow:auto;flex:1;padding:8px}
|
|
229
|
-
.ffolder{
|
|
245
|
+
.ffolder{display:flex;align-items:center;gap:6px;width:100%;text-align:left;border:0;background:none;cursor:pointer;font-family:inherit;
|
|
246
|
+
font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);padding:12px 8px 5px;font-weight:600}
|
|
247
|
+
.ffolder:hover{color:var(--muted)}
|
|
248
|
+
.ffolder .chev{width:11px;height:11px;flex:none;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}
|
|
249
|
+
.ffolder.closed .chev{transform:rotate(-90deg)}
|
|
250
|
+
.file.res .fn{color:var(--muted)}
|
|
251
|
+
.file .fn .ricon{width:12px;height:12px;flex:none;stroke:var(--faint);fill:none;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
|
|
252
|
+
.ftabs{display:flex;gap:2px;padding:8px 10px 0;border-bottom:1px solid var(--line);flex:none}
|
|
253
|
+
.ftab{flex:1;border:0;background:none;font:inherit;font-size:12.5px;font-weight:600;color:var(--muted);
|
|
254
|
+
padding:8px 10px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
|
|
255
|
+
.ftab:hover{color:var(--ink)}
|
|
256
|
+
.ftab.active{color:var(--ink);border-bottom-color:var(--accent)}
|
|
257
|
+
/* the type/tag combos filter concepts; they mean nothing on the Indexes tab */
|
|
258
|
+
#app[data-ftab=indexes] #file-controls .combo{display:none}
|
|
259
|
+
.file .fn .badge-res{margin-left:auto;flex:none;font-size:9.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
|
260
|
+
color:var(--faint);background:var(--line-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px}
|
|
230
261
|
.file{display:block;width:100%;text-align:left;border:0;background:none;border-radius:8px;padding:7px 9px;cursor:pointer;font-family:inherit;color:var(--ink)}
|
|
231
262
|
.file:hover{background:var(--panel-2)} .file.sel{background:var(--accent-soft)}
|
|
232
263
|
.file .fn{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px}
|
|
@@ -280,6 +311,13 @@
|
|
|
280
311
|
.opt:hover,.opt.active{background:var(--line-2)} .opt.sel{color:var(--accent-ink);font-weight:600}
|
|
281
312
|
@keyframes pop{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:none}}
|
|
282
313
|
|
|
314
|
+
/* ── the synthesized-map listing (inspector + files preview) ── */
|
|
315
|
+
.ix-listing{margin:10px 0 0;padding:0;list-style:none}
|
|
316
|
+
.ix-listing li{padding:3px 0;font-size:13.5px}
|
|
317
|
+
.ix-listing a{color:var(--accent-ink);text-decoration:none;cursor:pointer}
|
|
318
|
+
.ix-listing a:hover{text-decoration:underline}
|
|
319
|
+
.ix-listing .d{color:var(--muted)}
|
|
320
|
+
|
|
283
321
|
@media (max-width:720px){#rail{width:64px}.rail-item{width:52px}.card,.grid{}}
|
|
284
322
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
285
323
|
</style></head>
|
|
@@ -290,8 +328,9 @@
|
|
|
290
328
|
<svg viewBox="0 0 100 100"><rect width="100" height="100" rx="24" fill="#1a1a1a"/><polygon points="38,44 62,44 50,82" fill="#7a0a1e"/><polygon points="18,44 38,44 50,82" fill="#a8112c"/><polygon points="62,44 82,44 50,82" fill="#a8112c"/><polygon points="35,28 18,44 38,44" fill="#dc1e3c"/><polygon points="65,28 82,44 62,44" fill="#dc1e3c"/><polygon points="35,28 65,28 62,44 38,44" fill="#f43f5e"/><polygon points="36,29 49,29 42,42" fill="#fff" opacity=".38"/><polygon points="35,28 65,28 82,44 50,82 18,44" fill="none" stroke="#ff6b7f" stroke-width="2" stroke-linejoin="round"/></svg>
|
|
291
329
|
</span>
|
|
292
330
|
<button class="rail-item active" data-view="graph"><span class="ri-ico"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="9" r="2.4"/><circle cx="9" cy="18" r="2.4"/><path d="M7.7 7.3 15.9 8.6M7.5 16 8.2 8.3M10.9 16.4 16.3 10.5"/></svg></span><span class="ri-lbl">Graph</span></button>
|
|
293
|
-
<button class="rail-item" data-view="
|
|
331
|
+
<button class="rail-item" data-view="index"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></span><span class="ri-lbl">Index</span></button>
|
|
294
332
|
<button class="rail-item" data-view="files"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="ri-lbl">Files</span></button>
|
|
333
|
+
<button class="rail-item" data-view="catalog"><span class="ri-ico"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="4" width="7.5" height="7.5" rx="1.6"/><rect x="3" y="14.5" width="7.5" height="5.5" rx="1.6"/><rect x="13.5" y="14.5" width="7.5" height="5.5" rx="1.6"/></svg></span><span class="ri-lbl">Catalog</span></button>
|
|
295
334
|
<button class="rail-item" data-view="tags"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M4 4.5h6.8L20 13.7l-6.3 6.3L4.5 10.8V4.5Z"/><circle cx="8.4" cy="8.4" r="1.4" fill="currentColor" stroke="none"/></svg></span><span class="ri-lbl">Tags</span></button>
|
|
296
335
|
<button class="rail-item" data-view="stats"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M5 20V11M12 20V4M19 20v-6"/></svg></span><span class="ri-lbl">Stats</span></button>
|
|
297
336
|
<div class="rail-sp"></div>
|
|
@@ -310,6 +349,10 @@
|
|
|
310
349
|
<span class="ident"><%= escaped_name %><span class="muted"><%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links</span><%= source_link %></span>
|
|
311
350
|
<div id="search-wrap"><label class="search"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
312
351
|
<input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search"></label></div>
|
|
352
|
+
<div id="file-controls">
|
|
353
|
+
<div class="combo" id="file-type-combo"><input id="file-type-input" class="field" placeholder="all types" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-type-list" aria-autocomplete="list" aria-label="Filter files by type"><button class="clear" id="file-type-clear" type="button" aria-label="Clear type filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-type-list" role="listbox" hidden></ul></div>
|
|
354
|
+
<div class="combo" id="file-tag-combo"><input id="file-tag-input" class="field" placeholder="all tags" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-tag-list" aria-autocomplete="list" aria-label="Filter files by tag"><button class="clear" id="file-tag-clear" type="button" aria-label="Clear tag filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-tag-list" role="listbox" hidden></ul></div>
|
|
355
|
+
</div>
|
|
313
356
|
<div id="graph-controls">
|
|
314
357
|
<button class="btn text" id="btn-filters" type="button" aria-label="Filters"><svg viewBox="0 0 24 24"><path d="M3 5h18l-7 8v6l-4-2v-4Z"/></svg>Filters<span class="fbadge">0</span></button>
|
|
315
358
|
<span class="selwrap"><select id="layout" class="field" aria-label="Layout">
|
|
@@ -389,10 +432,10 @@
|
|
|
389
432
|
<section class="view" id="view-files">
|
|
390
433
|
<div class="files-grid">
|
|
391
434
|
<div class="ftree">
|
|
392
|
-
<div class="
|
|
393
|
-
<
|
|
394
|
-
<
|
|
395
|
-
|
|
435
|
+
<div class="ftabs" role="tablist" aria-label="File kinds">
|
|
436
|
+
<button class="ftab active" id="ftab-files" type="button" role="tab" aria-selected="true">Files</button>
|
|
437
|
+
<button class="ftab" id="ftab-indexes" type="button" role="tab" aria-selected="false">Indexes</button>
|
|
438
|
+
</div>
|
|
396
439
|
<div class="ftree-list" id="ftree-list"><p class="empty" style="padding:14px">Loading files…</p></div>
|
|
397
440
|
<div id="ftree-resizer" title="Drag to resize · double-click to reset" aria-hidden="true"></div>
|
|
398
441
|
</div>
|
|
@@ -437,10 +480,27 @@
|
|
|
437
480
|
</div>
|
|
438
481
|
</div>
|
|
439
482
|
|
|
483
|
+
<!-- fullscreen diagram viewer — click (or tap) a rendered Mermaid block to open it -->
|
|
484
|
+
<div id="dgv" hidden role="dialog" aria-modal="true" aria-label="Diagram viewer">
|
|
485
|
+
<div id="dgv-stage"><div id="dgv-pan"></div></div>
|
|
486
|
+
<div class="dgv-bar">
|
|
487
|
+
<button class="btn" id="dgv-out" type="button" aria-label="Zoom out"><svg viewBox="0 0 24 24"><path d="M5 12h14"/></svg></button>
|
|
488
|
+
<button class="btn" id="dgv-reset" type="button" aria-label="Reset view"><svg viewBox="0 0 24 24"><path d="M9 3H4v5M15 3h5v5M9 21H4v-5M15 21h5v-5"/></svg></button>
|
|
489
|
+
<button class="btn" id="dgv-in" type="button" aria-label="Zoom in"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
490
|
+
<button class="btn" id="dgv-close" type="button" aria-label="Close diagram viewer"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
|
491
|
+
</div>
|
|
492
|
+
<p class="dgv-hint">drag to pan · scroll or pinch to zoom · double-click resets · Esc closes</p>
|
|
493
|
+
</div>
|
|
494
|
+
|
|
440
495
|
<script>
|
|
441
496
|
const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
|
|
442
497
|
const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
|
|
443
|
-
const CATALOG_ENDPOINT="catalog";
|
|
498
|
+
const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
|
|
499
|
+
/* null when served live (`okf server`) — the getters below fetch the endpoints
|
|
500
|
+
above. `okf render` injects a payload {catalog,index,logs,bodies,meta}, and
|
|
501
|
+
every getter resolves from it instead, so one file needs no server and the
|
|
502
|
+
endpoint consts above go inert. */
|
|
503
|
+
const EMBED=<%= embed_json %>;
|
|
444
504
|
const MIN=<%= OKF::Server::Graph::MIN_SIZE %>, MAX=<%= OKF::Server::Graph::MAX_SIZE %>;
|
|
445
505
|
const PALETTE=["#6E56CF","#D97757","#22C55E","#3B82F6","#EAB308","#EC4899","#14B8A6","#F97316","#A855F7","#0EA5E9","#84CC16","#EF4444","#64748B"];
|
|
446
506
|
const root=document.documentElement;
|
|
@@ -498,7 +558,9 @@ function loadMermaid(){return _mermaid||(_mermaid=new Promise((res,rej)=>{const
|
|
|
498
558
|
let mermaidSeq=0;
|
|
499
559
|
function processMermaid(el){const blocks=[...el.querySelectorAll('code.language-mermaid')];if(!blocks.length)return;
|
|
500
560
|
loadMermaid().then(m=>{const nodes=[];blocks.forEach(b=>{const pre=b.closest('pre');const d=document.createElement('div');
|
|
501
|
-
d.className='mermaid';d.dataset.src=b.textContent;d.id='mmd-'+(++mermaidSeq);d.textContent=b.textContent;
|
|
561
|
+
d.className='mermaid';d.dataset.src=b.textContent;d.id='mmd-'+(++mermaidSeq);d.textContent=b.textContent;
|
|
562
|
+
d.tabIndex=0;d.setAttribute('role','button');d.setAttribute('aria-label','Expand diagram');d.title='Expand — pan & zoom';
|
|
563
|
+
pre.replaceWith(d);nodes.push(d);});
|
|
502
564
|
m.run({nodes}).catch(()=>{});}).catch(()=>{});}
|
|
503
565
|
function rethemeMermaid(){if(!window.mermaid)return;const nodes=[...document.querySelectorAll('.mermaid')];if(!nodes.length)return;
|
|
504
566
|
window.mermaid.initialize({startOnLoad:false,securityLevel:'strict',theme:isDark()?'dark':'default'});
|
|
@@ -506,6 +568,39 @@ function rethemeMermaid(){if(!window.mermaid)return;const nodes=[...document.que
|
|
|
506
568
|
window.mermaid.run({nodes}).catch(()=>{});}
|
|
507
569
|
function renderMarkdown(el,text){el.innerHTML=text?DOMPurify.sanitize(marked.parse(text)):'<span class="empty">empty body</span>';processMermaid(el);}
|
|
508
570
|
|
|
571
|
+
/* ── fullscreen diagram viewer — Panzoom (lazy, like Mermaid): click a rendered
|
|
572
|
+
diagram to inspect it full screen; drag pans, wheel or pinch zooms. ── */
|
|
573
|
+
let _panzoom=null;
|
|
574
|
+
function loadPanzoom(){return _panzoom||(_panzoom=loadScript('https://cdn.jsdelivr.net/npm/@panzoom/panzoom@4/dist/panzoom.min.js').then(()=>window.Panzoom));}
|
|
575
|
+
const dgv=document.getElementById('dgv'),dgvStage=document.getElementById('dgv-stage'),dgvPan=document.getElementById('dgv-pan');
|
|
576
|
+
let dgvPz=null,dgvBack=null,dgvSeq=0;
|
|
577
|
+
function openDiagram(m){const src=m.dataset.src;if(!src||!m.querySelector('svg'))return;
|
|
578
|
+
/* re-render from source rather than cloning the inline svg — mermaid scopes
|
|
579
|
+
the styles it embeds to the svg's id, so a clone would lose its colors */
|
|
580
|
+
Promise.all([ loadPanzoom(), loadMermaid() ]).then(all=>
|
|
581
|
+
all[1].render('dgv-mmd-'+(++dgvSeq),src).then(out=>{
|
|
582
|
+
dgvPan.innerHTML=out.svg;
|
|
583
|
+
const c=dgvPan.querySelector('svg'),vb=c.viewBox&&c.viewBox.baseVal;
|
|
584
|
+
const w=(vb&&vb.width)||800,h=(vb&&vb.height)||500;
|
|
585
|
+
c.style.maxWidth='none';c.setAttribute('width',w);c.setAttribute('height',h);
|
|
586
|
+
dgvBack=m;dgv.hidden=false;
|
|
587
|
+
const fit=Math.min((innerWidth-48)/w,(innerHeight-96)/h,2);
|
|
588
|
+
dgvPz=Panzoom(dgvPan,{maxScale:10,minScale:.1,startScale:fit,canvas:true,step:.2});
|
|
589
|
+
document.getElementById('dgv-close').focus();
|
|
590
|
+
})).catch(()=>{});}
|
|
591
|
+
function closeDiagram(){if(dgv.hidden)return;
|
|
592
|
+
if(dgvPz){dgvPz.destroy();dgvPz=null;}
|
|
593
|
+
dgv.hidden=true;dgvPan.innerHTML='';
|
|
594
|
+
if(dgvBack){try{dgvBack.focus();}catch(e){}dgvBack=null;}}
|
|
595
|
+
dgvStage.addEventListener('wheel',e=>{if(dgvPz)dgvPz.zoomWithWheel(e);},{passive:false});
|
|
596
|
+
dgvStage.addEventListener('dblclick',()=>{if(dgvPz)dgvPz.reset();});
|
|
597
|
+
document.getElementById('dgv-in').onclick=()=>{if(dgvPz)dgvPz.zoomIn();};
|
|
598
|
+
document.getElementById('dgv-out').onclick=()=>{if(dgvPz)dgvPz.zoomOut();};
|
|
599
|
+
document.getElementById('dgv-reset').onclick=()=>{if(dgvPz)dgvPz.reset();};
|
|
600
|
+
document.getElementById('dgv-close').onclick=closeDiagram;
|
|
601
|
+
document.addEventListener('click',e=>{const m=e.target.closest&&e.target.closest('.mermaid');if(m&&!dgv.contains(e.target))openDiagram(m);});
|
|
602
|
+
document.addEventListener('keydown',e=>{if((e.key==='Enter'||e.key===' ')&&e.target.classList&&e.target.classList.contains('mermaid')){e.preventDefault();openDiagram(e.target);}});
|
|
603
|
+
|
|
509
604
|
/* ── inspector ── */
|
|
510
605
|
const gbody=document.querySelector('.graph-body'), sideBody=document.getElementById('side-body');
|
|
511
606
|
const btnPanel=document.getElementById('btn-panel'), sideWiden=document.getElementById('side-widen');
|
|
@@ -556,22 +651,67 @@ function relList(t,arr){if(!arr.length)return'';return `<div class="rel"><h4>${t
|
|
|
556
651
|
Resolve them against the open concept and navigate in-app instead; external
|
|
557
652
|
links open in a new tab; anything unresolvable is disabled, never a 404. */
|
|
558
653
|
let shownId=null;
|
|
559
|
-
|
|
654
|
+
/* Every directory that holds a concept — the sync existence check for links
|
|
655
|
+
that point at a directory or its index rather than at a concept file. */
|
|
656
|
+
const DIRSET=new Set();
|
|
657
|
+
NODES.forEach(n=>{const parts=n.id.split('/');for(let i=1;i<parts.length;i++)DIRSET.add(parts.slice(0,i).join('/'));});
|
|
658
|
+
/* A markdown href resolves to a concept ({id}) or to the authored layer
|
|
659
|
+
({res:{kind:'index',dir}} / {res:{kind:'log',path}}) — or to null (dead).
|
|
660
|
+
Directory links (`model/`, `/`) count as that directory's index. */
|
|
661
|
+
function resolveTarget(href,baseId){let p=href.split('#')[0].split('?')[0];if(!p)return null;
|
|
560
662
|
try{p=decodeURIComponent(p);}catch(e){}
|
|
561
663
|
const out=p.charAt(0)==='/'?[]:(baseId?baseId.split('/').slice(0,-1):[]);
|
|
562
664
|
for(const s of p.replace(/^\//,'').split('/')){if(!s||s==='.')continue;if(s==='..'){out.pop();continue;}out.push(s);}
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
665
|
+
const path=out.join('/');
|
|
666
|
+
const id=path.replace(/\.md$/i,'');
|
|
667
|
+
if(byId[id])return {id:id};
|
|
668
|
+
if(/(^|\/)index\.md$/i.test(path))return {res:{kind:'index',dir:dirOfPath(path)}};
|
|
669
|
+
if(/(^|\/)log\.md$/i.test(path))return {res:{kind:'log',path:path}};
|
|
670
|
+
if(path===''||DIRSET.has(path))return {res:{kind:'index',dir:path===''?'.':path}};
|
|
671
|
+
return null;}
|
|
672
|
+
function interceptMdLinks(container,baseIdOf,go,goRes){container.addEventListener('click',e=>{
|
|
566
673
|
const a=e.target.closest('a');
|
|
567
674
|
if(!a||!container.contains(a)||a.hasAttribute('data-go'))return;
|
|
568
675
|
const href=a.getAttribute('href')||'';
|
|
569
676
|
if(/^[a-z][a-z0-9+.-]*:/i.test(href)||href.indexOf('//')===0){e.preventDefault();window.open(a.href,'_blank','noopener');return;}
|
|
570
677
|
e.preventDefault();if(href.charAt(0)==='#')return;
|
|
571
|
-
const
|
|
572
|
-
if(id)go(id);
|
|
573
|
-
|
|
574
|
-
|
|
678
|
+
const t=resolveTarget(href,baseIdOf());
|
|
679
|
+
if(t&&t.id)go(t.id);
|
|
680
|
+
else if(t&&t.res&&goRes)goRes(t.res);
|
|
681
|
+
else{a.classList.add('dead');a.title='not a file in this bundle';}});}
|
|
682
|
+
const ixPathOf=dir=>dir==='.'?'index.md':dir+'/index.md';
|
|
683
|
+
interceptMdLinks(sideBody,()=>shownId,select,r=>r.kind==='index'?showDir(r.dir):showLog(r.path));
|
|
684
|
+
interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id),
|
|
685
|
+
r=>openReserved(r.kind,r.kind==='index'?ixPathOf(r.dir):r.path));
|
|
686
|
+
/* Clicking a folder node (tree mode) or an area box (cluster mode) opens that
|
|
687
|
+
directory's §6 index entry in the inspector: the authored map when one
|
|
688
|
+
exists, the synthesized listing when not. */
|
|
689
|
+
function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);if(!d)return;
|
|
690
|
+
const opened=openPanel();sideBody.scrollTop=0;
|
|
691
|
+
shownId=d.dir==='.'?'index':d.dir+'/index'; /* pseudo-id: relative links in the map resolve against its directory */
|
|
692
|
+
const label=d.dir==='.'?'(root)':d.dir+'/';
|
|
693
|
+
const types=Object.entries(d.types||{}).map(([t,n])=>esc(t||'Untyped')+' '+n).join(' · ');
|
|
694
|
+
sideBody.innerHTML=`<span class="type">${d.present?'index.md':'no index.md · synthesized'}</span>
|
|
695
|
+
<h2 class="title">${esc(label)}</h2>
|
|
696
|
+
<div class="desc">${d.count} concept${d.count===1?'':'s'}${types?' · '+types:''}</div>
|
|
697
|
+
<div class="body" id="dir-body"></div>`;
|
|
698
|
+
if(d.present)renderMarkdown(document.getElementById('dir-body'),d.body||'');
|
|
699
|
+
else document.getElementById('dir-body').innerHTML=listingHtml(d.listing);
|
|
700
|
+
sideBody.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
|
|
701
|
+
if(opened)requestAnimationFrame(()=>cy.resize());});}
|
|
702
|
+
/* A log.md link opens the history in the inspector, fetched fresh. */
|
|
703
|
+
function showLog(path){LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);if(!l)return;
|
|
704
|
+
const opened=openPanel();sideBody.scrollTop=0;
|
|
705
|
+
shownId=path.replace(/\.md$/,'');
|
|
706
|
+
sideBody.innerHTML=`<span class="type">update log · §7</span><h2 class="title">${esc(path)}</h2><div class="body" id="dir-body"></div>`;
|
|
707
|
+
renderMarkdown(document.getElementById('dir-body'),l.content||'');
|
|
708
|
+
if(opened)requestAnimationFrame(()=>cy.resize());});}
|
|
709
|
+
/* Two per-concept reads shared by the inspector and the files preview: the raw
|
|
710
|
+
markdown body (rendered client-side) and the description fragment. Live they
|
|
711
|
+
hit /node and /node/meta; under EMBED they resolve from the baked payload.
|
|
712
|
+
Never memoized in server mode, so on-disk edits keep reflecting. */
|
|
713
|
+
function getNodeBody(id){return EMBED?Promise.resolve(EMBED.bodies[id]||''):fetch(NODE_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
|
|
714
|
+
function getNodeMeta(id){return EMBED?Promise.resolve(EMBED.meta[id]||''):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
|
|
575
715
|
function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
|
|
576
716
|
shownId=id;const opened=openPanel();sideBody.scrollTop=0;
|
|
577
717
|
sideBody.innerHTML=`<span class="type"><span class="dot" style="background:${c}"></span>${esc(ty)}</span>
|
|
@@ -579,10 +719,9 @@ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Unty
|
|
|
579
719
|
<div class="tags">${(tagsOf[id]||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
|
|
580
720
|
${relList('Links to',outL[id])}${relList('Linked from',inL[id])}<div class="body" id="body"></div>`;
|
|
581
721
|
sideBody.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
|
|
582
|
-
const
|
|
583
|
-
fetch(META_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
|
|
722
|
+
getNodeMeta(id).then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
|
|
584
723
|
const bodyEl=sideBody.querySelector('#body');
|
|
585
|
-
|
|
724
|
+
getNodeBody(id).then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
|
|
586
725
|
return opened;}
|
|
587
726
|
/* One camera move per selection. When the click also opens the panel, the
|
|
588
727
|
container is still animating (--side-w, .22s), so pan now and the debounced
|
|
@@ -597,7 +736,10 @@ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const
|
|
|
597
736
|
cy.elements().removeClass('hl').addClass('dim');const nb=ele.closedNeighborhood();nb.removeClass('dim');ele.addClass('hl');
|
|
598
737
|
centerOn(ele,opened);
|
|
599
738
|
try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
|
|
600
|
-
cy.on('tap','node',e=>{
|
|
739
|
+
cy.on('tap','node',e=>{const t=e.target;
|
|
740
|
+
if(t.hasClass('dir'))return showDir(t.id().slice(DIR.length)||'.');
|
|
741
|
+
if(t.isParent()){const a=t.id().indexOf('area::')===0?t.id().slice(6):null;return showDir(a&&a!=='(root)'?a:'.');}
|
|
742
|
+
select(t.id());});
|
|
601
743
|
cy.on('tap',e=>{if(e.target===cy)cy.elements().removeClass('dim hl');});
|
|
602
744
|
|
|
603
745
|
/* ── graph filters (slide-over) ── */
|
|
@@ -711,6 +853,13 @@ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false
|
|
|
711
853
|
cy.batch(()=>{cy.edges('.tree').remove();cy.nodes('.dir').remove();cy.edges().removeClass('linkhid');});
|
|
712
854
|
runLayout(layoutSel.value);}}
|
|
713
855
|
btnTree.onclick=()=>setTree(!treeMode);
|
|
856
|
+
/* A reserved file's "Open in graph": switch to the file tree, select its
|
|
857
|
+
folder node, and put the directory's map in the inspector. */
|
|
858
|
+
function openInTree(dir){setView('graph');setTree(true);
|
|
859
|
+
showDir(dir);
|
|
860
|
+
requestAnimationFrame(()=>{const ele=cy.getElementById(DIR+(dir==='.'?'':dir));if(!ele.length)return;
|
|
861
|
+
cy.elements().removeClass('hl').addClass('dim');ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
|
|
862
|
+
centerOn(ele,true);});}
|
|
714
863
|
|
|
715
864
|
/* ── fit to screen ── */
|
|
716
865
|
// cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
|
|
@@ -726,22 +875,31 @@ let view='graph';
|
|
|
726
875
|
const inited={};
|
|
727
876
|
function goToGraph(id){setView('graph');requestAnimationFrame(()=>select(id));}
|
|
728
877
|
function setView(v){if(v===view)return;view=v;document.getElementById('app').setAttribute('data-view',v);
|
|
729
|
-
|
|
878
|
+
railActive();
|
|
730
879
|
document.querySelectorAll('.view').forEach(s=>s.classList.toggle('active',s.id==='view-'+v));
|
|
731
|
-
const ph={graph:'search concepts…',catalog:'search concepts…',files:'',tags:'search tags…'}[v];
|
|
880
|
+
const ph={graph:'search concepts…',catalog:'search concepts…',files:'filter files…',tags:'search tags…'}[v];
|
|
732
881
|
if(ph!==undefined){searchInput.placeholder=ph;searchInput.value=q[v]||'';}
|
|
733
882
|
if(v==='graph')requestAnimationFrame(()=>cy.resize());
|
|
734
883
|
if(v==='catalog'&&!inited.catalog)initCatalog();
|
|
735
884
|
if(v==='files'&&!inited.files)initFiles();
|
|
736
885
|
if(v==='tags'&&!inited.tags)initTags();
|
|
737
886
|
if(v==='stats'&&!inited.stats)initStats();}
|
|
738
|
-
|
|
887
|
+
/* The Files view answers to two rail items: Files when the Files tab is up,
|
|
888
|
+
Index when the Indexes tab is — the Index rail item is a shortcut into it. */
|
|
889
|
+
function railActive(){const v=view==='files'&&typeof ftab!=='undefined'&&ftab==='indexes'?'index':view;
|
|
890
|
+
document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
|
|
891
|
+
function goIndexes(){if(view!=='files')setView('files');setFtab('indexes');railActive();}
|
|
892
|
+
document.querySelectorAll('.rail-item').forEach(b=>b.onclick=()=>{
|
|
893
|
+
if(b.dataset.view==='index')return goIndexes();
|
|
894
|
+
if(b.dataset.view==='files'&&typeof ftab!=='undefined'&&ftab!=='files'){if(view!=='files')setView('files');setFtab('files');railActive();return;}
|
|
895
|
+
setView(b.dataset.view);});
|
|
739
896
|
searchInput.oninput=()=>{q[view]=searchInput.value;
|
|
740
|
-
if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();
|
|
897
|
+
if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();
|
|
898
|
+
else if(view==='files')refreshTree();};
|
|
741
899
|
|
|
742
900
|
/* ── catalog ── */
|
|
743
901
|
let CATALOG=null;
|
|
744
|
-
function getCatalog(){return CATALOG||(CATALOG=fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts));}
|
|
902
|
+
function getCatalog(){return CATALOG||(CATALOG=EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts));}
|
|
745
903
|
const catActiveTypes=new Set(), catActiveAreas=new Set(), catActiveTags=new Set();
|
|
746
904
|
const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
|
|
747
905
|
const catChip=t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} <span class="c">${(TYPES[t]||[]).length}</span></span>`;
|
|
@@ -793,18 +951,50 @@ function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.
|
|
|
793
951
|
|
|
794
952
|
/* ── files ── */
|
|
795
953
|
let fileSel=null;
|
|
796
|
-
function initFiles(){inited.files=true;
|
|
797
|
-
|
|
954
|
+
function initFiles(){inited.files=true;refreshTree();}
|
|
955
|
+
/* The tree column has two tabs. Files: the per-directory concept groups,
|
|
956
|
+
foldable. Indexes: the authored layer flat — the log first (it is the
|
|
957
|
+
chronological index), then every index.md, root before nested. Reserved
|
|
958
|
+
files come from the /index and /log payloads — they are not concepts, so
|
|
959
|
+
the catalog cannot know them. */
|
|
960
|
+
let IXDIRS=null,LOGPATHS=null,ftab='files';
|
|
961
|
+
const collapsedDirs=new Set();
|
|
962
|
+
function dirOfPath(p){return p.indexOf('/')<0?'.':p.split('/').slice(0,-1).join('/');}
|
|
963
|
+
function renderTree(list){const s=(q.files||'').toLowerCase();
|
|
964
|
+
const el=document.getElementById('ftree-list');
|
|
965
|
+
if(ftab==='indexes'){
|
|
966
|
+
const reserved=[];
|
|
967
|
+
(LOGPATHS||[]).forEach(p=>{if(!s||p.toLowerCase().includes(s))reserved.push({kind:'log',path:p});});
|
|
968
|
+
(IXDIRS||[]).forEach(d=>{if(d.present&&(!s||d.index_path.toLowerCase().includes(s)))reserved.push({kind:'index',path:d.index_path});});
|
|
969
|
+
const rank=r=>[ r.kind==='log'?0:1, r.path.indexOf('/')<0?0:1, r.path ];
|
|
970
|
+
reserved.sort((a,b)=>{const x=rank(a),y=rank(b);return (x[0]-y[0])||(x[1]-y[1])||(x[2]<y[2]?-1:1);});
|
|
971
|
+
if(!reserved.length){el.innerHTML='<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';return;}
|
|
972
|
+
el.innerHTML=reserved.map(r=>{
|
|
973
|
+
const icon=r.kind==='index'?'<path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/>':'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.3V12l3.2 2"/>';
|
|
974
|
+
return `<button class="file res${fileSel===r.path?' sel':''}" data-res="${r.kind}" data-path="${esc(r.path)}"><span class="fn"><svg class="ricon" viewBox="0 0 24 24">${icon}</svg>${esc(r.path)}<span class="badge-res">${r.kind==='index'?'map':'log'}</span></span></button>`;}).join('');
|
|
975
|
+
el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));
|
|
976
|
+
return;}
|
|
798
977
|
const ft=fileTypeCombo.get(), fg=fileTagCombo.get();
|
|
799
978
|
const rows=list.filter(c=>(!ft||c.type===ft)&&(!fg||c.tags.indexOf(fg)>=0)&&(!s||(c.title+' '+c.id+' '+c.description).toLowerCase().includes(s)));
|
|
800
979
|
const groups={};rows.forEach(c=>{(groups[c.dir]=groups[c.dir]||[]).push(c);});
|
|
801
|
-
const
|
|
802
|
-
if(!
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
980
|
+
const dirs=Object.keys(groups).sort();
|
|
981
|
+
if(!dirs.length){el.innerHTML='<p class="empty" style="padding:14px">No files match.</p>';return;}
|
|
982
|
+
const filtering=!!(s||ft||fg);
|
|
983
|
+
el.innerHTML=dirs.map(dir=>{
|
|
984
|
+
const closed=!filtering&&collapsedDirs.has(dir);
|
|
985
|
+
const head=`<button class="ffolder${closed?' closed':''}" data-dir="${esc(dir)}"><svg class="chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>${dir==='.'?'(root)':esc(dir)+'/'}</button>`;
|
|
986
|
+
if(closed)return head;
|
|
987
|
+
return head+(groups[dir]||[]).map(c=>{const cc=color[c.type]||'#64748b';const name=c.id.split('/').pop()+'.md';
|
|
988
|
+
return `<button class="file${fileSel===c.id?' sel':''}" data-id="${esc(c.id)}"><span class="fn"><span class="dot" style="background:${cc}"></span>${esc(name)}</span>${c.description?`<span class="fd">${esc(c.description)}</span>`:''}</button>`;}).join('');}).join('');
|
|
989
|
+
el.querySelectorAll('.ffolder').forEach(b=>b.onclick=()=>{const d=b.dataset.dir;
|
|
990
|
+
if(collapsedDirs.has(d))collapsedDirs.delete(d);else collapsedDirs.add(d);renderTree(list);});
|
|
991
|
+
el.querySelectorAll('.file[data-id]').forEach(b=>b.onclick=()=>openFile(b.dataset.id,list));}
|
|
992
|
+
function setFtab(t){if(ftab===t)return;
|
|
993
|
+
ftab=t;document.getElementById('app').setAttribute('data-ftab',t);
|
|
994
|
+
[ 'files','indexes' ].forEach(x=>{const b=document.getElementById('ftab-'+x);
|
|
995
|
+
b.classList.toggle('active',x===t);b.setAttribute('aria-selected',String(x===t));});
|
|
996
|
+
railActive();refreshTree();}
|
|
997
|
+
[ 'files','indexes' ].forEach(t=>{document.getElementById('ftab-'+t).onclick=()=>setFtab(t);});
|
|
808
998
|
/* Searchable single-select combobox (keyboard-navigable) — one per files filter:
|
|
809
999
|
type and tag. `items` is lazy so counts always reflect the current indexes. */
|
|
810
1000
|
function makeCombo(name,allLabel,items,onChange){
|
|
@@ -831,19 +1021,40 @@ function makeCombo(name,allLabel,items,onChange){
|
|
|
831
1021
|
list.addEventListener('mousedown',e=>{const li=e.target.closest('li');if(!li)return;e.preventDefault();pick(li);});
|
|
832
1022
|
document.getElementById(name+'-clear').addEventListener('click',()=>{value=null;input.value='';combo.classList.remove('has');onChange();input.focus();});
|
|
833
1023
|
return {get:()=>value};}
|
|
834
|
-
const refreshTree=()=>getCatalog().then(renderTree);
|
|
1024
|
+
const refreshTree=()=>Promise.all([ getCatalog(),getIndex(),getLogs() ]).then(([ list,dirs,logs ])=>{IXDIRS=dirs;LOGPATHS=logs.map(l=>l.path);renderTree(list);});
|
|
835
1025
|
const fileTypeCombo=makeCombo('file-type','All types',()=>catByCount.map(t=>({v:t,n:(TYPES[t]||[]).length,c:color[t]})),refreshTree);
|
|
836
1026
|
const fileTagCombo=makeCombo('file-tag','All tags',()=>tagsByCount.map(t=>({v:t,n:(TAGS[t]||[]).length})),refreshTree);
|
|
837
1027
|
function openFile(id,list){fileSel=id;const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
|
|
1028
|
+
setFtab('files');
|
|
838
1029
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
|
|
839
1030
|
const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
|
|
840
1031
|
document.getElementById('fp-head').hidden=false;
|
|
841
1032
|
document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
|
|
842
1033
|
document.getElementById('fp-title').textContent=c.title||(byId[id]||{}).title||id;
|
|
1034
|
+
document.getElementById('fp-graph').hidden=false;
|
|
843
1035
|
document.getElementById('fp-graph').onclick=()=>goToGraph(id);
|
|
844
1036
|
const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
|
|
845
|
-
|
|
1037
|
+
getNodeBody(id).then(txt=>{
|
|
846
1038
|
const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);}).catch(()=>{body.innerHTML='<p class="empty">could not load this file</p>';});}
|
|
1039
|
+
/* Reserved files read in the preview too: the index map from its payload, the
|
|
1040
|
+
log fetched fresh on every click so a just-appended entry shows. */
|
|
1041
|
+
function openReserved(kind,path){fileSel=path;
|
|
1042
|
+
setFtab('indexes');
|
|
1043
|
+
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
|
|
1044
|
+
document.getElementById('fp-head').hidden=false;
|
|
1045
|
+
document.getElementById('fp-title').textContent=path;
|
|
1046
|
+
const fg=document.getElementById('fp-graph');
|
|
1047
|
+
fg.hidden=false;fg.onclick=()=>openInTree(dirOfPath(path));
|
|
1048
|
+
const type=document.getElementById('fp-type');
|
|
1049
|
+
const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
|
|
1050
|
+
const done=txt=>{const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);};
|
|
1051
|
+
if(kind==='index')getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dirOfPath(path));
|
|
1052
|
+
type.textContent=d&&d.synthesized?'directory index · synthesized':'directory index · §6';
|
|
1053
|
+
if(d&&d.synthesized){body.innerHTML='<div class="body">'+listingHtml(d.listing)+'</div>';
|
|
1054
|
+
body.querySelectorAll('[data-go]').forEach(x=>x.onclick=()=>openFile(x.getAttribute('data-go')));}
|
|
1055
|
+
else done((d&&d.body)||'');});
|
|
1056
|
+
else{type.textContent='update log · §7';
|
|
1057
|
+
LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
|
|
847
1058
|
|
|
848
1059
|
/* ── tags ── */
|
|
849
1060
|
const tagSel=new Set(), tagTypes=new Set(), tagAreas=new Set();
|
|
@@ -916,10 +1127,22 @@ const fsOn=()=>document.fullscreenElement===appEl;
|
|
|
916
1127
|
btnFull.onclick=()=>{if(fsOn())document.exitFullscreen();else if(appEl.requestFullscreen)appEl.requestFullscreen();};
|
|
917
1128
|
document.addEventListener('fullscreenchange',()=>btnFull.setAttribute('aria-pressed',String(fsOn())));
|
|
918
1129
|
|
|
1130
|
+
/* ── the §6 map payload — feeds folder clicks, the Indexes tab, and previews ── */
|
|
1131
|
+
let INDEX=null;
|
|
1132
|
+
function getIndex(){return INDEX||(INDEX=EMBED?Promise.resolve(EMBED.index):fetch(INDEX_ENDPOINT).then(r=>r.json()).then(d=>d.directories));}
|
|
1133
|
+
function listingHtml(items){if(!items||!items.length)return '<p class="empty" style="margin:10px 0 0">No concepts directly here.</p>';
|
|
1134
|
+
return '<ul class="ix-listing">'+items.map(it=>`<li><a data-go="${esc(it.id)}">${esc(it.title||it.id)}</a>${it.description?` <span class="d">— ${esc(it.description)}</span>`:''}</li>`).join('')+'</ul>';}
|
|
1135
|
+
|
|
1136
|
+
/* ── log payload (the §7 history) — read by the Files view's log entries ── */
|
|
1137
|
+
let LOGS=null;
|
|
1138
|
+
function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
|
|
1139
|
+
|
|
919
1140
|
/* ── keyboard ── */
|
|
920
|
-
const VIEW_KEYS={'1':'graph','
|
|
921
|
-
addEventListener('keydown',e=>{if(e.
|
|
922
|
-
if(
|
|
1141
|
+
const VIEW_KEYS={'1':'graph','3':'files','4':'catalog','5':'tags','6':'stats'};
|
|
1142
|
+
addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram();return;}
|
|
1143
|
+
if(e.target.matches('input,select,textarea'))return;
|
|
1144
|
+
if(e.key==='2'){goIndexes();}
|
|
1145
|
+
else if(VIEW_KEYS[e.key]){setView(VIEW_KEYS[e.key]);}
|
|
923
1146
|
else if(e.key==='/'){e.preventDefault();searchInput.focus();}
|
|
924
1147
|
else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
|
|
925
1148
|
else if(e.key==='0'&&view==='graph'){fitGraph();}
|
|
@@ -928,6 +1151,9 @@ addEventListener('keydown',e=>{if(e.target.matches('input,select,textarea'))retu
|
|
|
928
1151
|
/* ── deep links ── */
|
|
929
1152
|
const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
|
|
930
1153
|
if(QL&&[...layoutSel.options].some(o=>o.value===QL)){layoutSel.value=QL;runLayout(QL);}
|
|
1154
|
+
const QV=Q.get('view');
|
|
1155
|
+
if(QV==='index')goIndexes();
|
|
1156
|
+
else if(QV&&document.querySelector('.rail-item[data-view="'+CSS.escape(QV)+'"]'))setView(QV);
|
|
931
1157
|
function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])select(h);}catch(e){}}
|
|
932
1158
|
addEventListener('hashchange',fromHash);
|
|
933
1159
|
if(QS&&byId[QS])select(QS);else fromHash();
|
data/lib/okf/server/graph.rb
CHANGED
|
@@ -7,15 +7,23 @@ module OKF
|
|
|
7
7
|
# returns the HTML string.
|
|
8
8
|
#
|
|
9
9
|
# The page boots from a *minimal* payload — nodes carry only id + title, plus
|
|
10
|
-
# compact TYPES/TAGS inverted indexes for colouring and filtering
|
|
11
|
-
#
|
|
12
|
-
# fetch, rendering it client-side with marked. Node bodies are therefore NOT
|
|
13
|
-
# embedded here.
|
|
10
|
+
# compact TYPES/TAGS inverted indexes for colouring and filtering. It has two
|
|
11
|
+
# data modes, both driven by one template:
|
|
14
12
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# (
|
|
13
|
+
# server mode (embed: nil) — the default. Each concept's markdown body,
|
|
14
|
+
# metadata, catalog, index and log are pulled from OKF::Server::App on
|
|
15
|
+
# demand via fetch, so the initial payload stays small and bodies read
|
|
16
|
+
# live from disk (edits show without a restart). Nothing extra embedded.
|
|
17
|
+
# render mode (embed: payload) — `okf render` bakes the whole bundle in:
|
|
18
|
+
# the same fetch getters resolve from the injected payload instead, so
|
|
19
|
+
# the single file needs no server (e.g. hosting on GitHub Pages).
|
|
20
|
+
#
|
|
21
|
+
# NOTE (trust boundary): the page loads Cytoscape + marked from a CDN, so it
|
|
22
|
+
# needs network for those libraries even in render mode. Fetched/embedded
|
|
23
|
+
# markdown is sanitized client-side (DOMPurify.sanitize(marked.parse(...)))
|
|
24
|
+
# and all inline-<script> data — including any embedded body — is
|
|
25
|
+
# </script>-escaped by #json_for_script (stdlib ERB does not auto-escape).
|
|
26
|
+
# Still, only serve bundles you trust.
|
|
19
27
|
class Graph
|
|
20
28
|
TEMPLATE = File.expand_path("graph/template.html.erb", __dir__)
|
|
21
29
|
LAYOUTS = %w[cose concentric breadthfirst circle grid].freeze
|
|
@@ -31,13 +39,15 @@ module OKF
|
|
|
31
39
|
# +node_endpoint+/+meta_endpoint+ are the (mount-relative) URLs the page
|
|
32
40
|
# fetches a concept's raw markdown and metadata fragment from — relative so
|
|
33
41
|
# the page works whether served at "/" or mounted under a Rails prefix.
|
|
34
|
-
|
|
42
|
+
# +embed+ is the render-mode payload (nil = server mode); see the class doc.
|
|
43
|
+
def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta", embed: nil)
|
|
35
44
|
@graph = graph
|
|
36
45
|
@title = title
|
|
37
46
|
@link = link
|
|
38
47
|
@layout = layout
|
|
39
48
|
@node_endpoint = node_endpoint
|
|
40
49
|
@meta_endpoint = meta_endpoint
|
|
50
|
+
@embed = embed
|
|
41
51
|
end
|
|
42
52
|
|
|
43
53
|
def render
|
|
@@ -86,6 +96,12 @@ module OKF
|
|
|
86
96
|
json_for_script(@graph.tag_index)
|
|
87
97
|
end
|
|
88
98
|
|
|
99
|
+
# The render-mode payload, or the literal `null` in server mode — both from
|
|
100
|
+
# the same </script>-escaping helper, so injection stays uniform and safe.
|
|
101
|
+
def embed_json
|
|
102
|
+
json_for_script(@embed)
|
|
103
|
+
end
|
|
104
|
+
|
|
89
105
|
# JSON-encode for safe embedding in an inline <script>: escaping every `<` to
|
|
90
106
|
# its JSON unicode escape neutralizes </script>, <!-- and <script in one
|
|
91
107
|
# stroke, and the result stays valid JSON *and* JavaScript.
|