sitelines 1.0.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/DESIGN.md +129 -0
- package/LICENSE +21 -0
- package/README.md +336 -0
- package/bin/sitelines.mjs +155 -0
- package/examples/demo-site/about/index.html +30 -0
- package/examples/demo-site/assets/auth.js +3 -0
- package/examples/demo-site/assets/site.css +27 -0
- package/examples/demo-site/blog/index.html +31 -0
- package/examples/demo-site/blog/introducing-meridian/index.html +30 -0
- package/examples/demo-site/changelog/index.html +30 -0
- package/examples/demo-site/contact/index.html +33 -0
- package/examples/demo-site/docs/api/index.html +31 -0
- package/examples/demo-site/docs/api/webhooks/index.html +30 -0
- package/examples/demo-site/docs/api/webhooks/retries/index.html +30 -0
- package/examples/demo-site/docs/index.html +32 -0
- package/examples/demo-site/docs/quickstart/index.html +31 -0
- package/examples/demo-site/index.html +37 -0
- package/examples/demo-site/legal/privacy/index.html +29 -0
- package/examples/demo-site/legal/terms/index.html +29 -0
- package/examples/demo-site/login/index.html +35 -0
- package/examples/demo-site/pricing/index.html +33 -0
- package/examples/demo-site/product/index.html +31 -0
- package/examples/demo-site/product/integrations/index.html +31 -0
- package/examples/demo-site/signup/index.html +35 -0
- package/examples/demo-site/status/index.html +15 -0
- package/examples/demo-site/thanks/index.html +21 -0
- package/package.json +53 -0
- package/scripts/install-skill.mjs +145 -0
- package/scripts/scan.mjs +399 -0
- package/scripts/serve.mjs +207 -0
- package/skill/AGENTS.md +61 -0
- package/skill/SKILL.md +170 -0
- package/skill/references/edit-protocol.md +57 -0
- package/viewer/app.js +1373 -0
- package/viewer/index.html +202 -0
- package/viewer/style.css +616 -0
package/viewer/app.js
ADDED
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
// sitelines viewer
|
|
2
|
+
const $ = (s) => document.querySelector(s);
|
|
3
|
+
const el = (t, c, txt) => { const n = document.createElement(t); if (c) n.className = c; if (txt != null) n.textContent = txt; return n; };
|
|
4
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
5
|
+
const svgEl = (t, a = {}) => { const n = document.createElementNS(SVGNS, t); for (const k in a) n.setAttribute(k, a[k]); return n; };
|
|
6
|
+
// glyphs come from the sprite in index.html; see DESIGN.md on why there is no icon package
|
|
7
|
+
const icon = (id, cls = 'icon') => {
|
|
8
|
+
const s = svgEl('svg', { class: cls });
|
|
9
|
+
s.appendChild(svgEl('use', { href: `#${id}` }));
|
|
10
|
+
return s;
|
|
11
|
+
};
|
|
12
|
+
const iconBtn = (id, cls, label) => {
|
|
13
|
+
const b = el('button', cls);
|
|
14
|
+
b.type = 'button';
|
|
15
|
+
b.appendChild(icon(id, 'icon sm'));
|
|
16
|
+
b.setAttribute('aria-label', label);
|
|
17
|
+
b.title = label;
|
|
18
|
+
return b;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const COL = 330, ROW = 232, PAD = 96, W = 232, H = 188, WS = 172, HS = 74, PER_COL = 7;
|
|
22
|
+
|
|
23
|
+
const S = {
|
|
24
|
+
flow: null, edits: [], sel: null, linkFrom: null,
|
|
25
|
+
cfg: null, mode: 'site', // which view tab is active (from .sitelines/views.json)
|
|
26
|
+
cam: { x: 60, y: 60, k: 0.8 },
|
|
27
|
+
nodes: new Map(), pos: new Map(), dom: new Map(), io: new Map(), ioAll: new Map(), depth: new Map(), colX: new Map(),
|
|
28
|
+
edges: [], collapsed: new Set(), groups: new Map(),
|
|
29
|
+
// page JS is off by default: previews are the real pages, and one with a
|
|
30
|
+
// polling loop or a failing API call will peg the renderer 40 frames over.
|
|
31
|
+
filters: { shared: false, api: false, ext: false, preview: true, js: false },
|
|
32
|
+
q: '',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const CACHE_KEY = 'sitelines:cache:v1';
|
|
36
|
+
const THEME_KEY = 'sitelines:theme';
|
|
37
|
+
|
|
38
|
+
boot();
|
|
39
|
+
|
|
40
|
+
async function boot() {
|
|
41
|
+
wireUI();
|
|
42
|
+
// paint from the last session's map immediately, then refresh from the server
|
|
43
|
+
try {
|
|
44
|
+
const c = JSON.parse(localStorage.getItem(CACHE_KEY) || 'null');
|
|
45
|
+
if (c && c.root === location.host + (c.flow?.root || '')) { paint(c.flow, c.cfg, c.edits || []); fit(); }
|
|
46
|
+
} catch { /* stale cache shape - ignore */ }
|
|
47
|
+
try {
|
|
48
|
+
await reload();
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return fatal(e);
|
|
51
|
+
}
|
|
52
|
+
if (S.pos.size) fit();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// the server is the only source of the map; if it is gone, say so plainly
|
|
56
|
+
function fatal(e) {
|
|
57
|
+
const box = el('div', 'fatal');
|
|
58
|
+
box.appendChild(icon('i-alert'));
|
|
59
|
+
box.appendChild(el('b', null, 'Cannot reach the sitelines server'));
|
|
60
|
+
const msg = el('span');
|
|
61
|
+
msg.append('The viewer is open but ', el('code', null, 'sitelines serve'), ' is not answering. Restart it, then reload this page.');
|
|
62
|
+
box.appendChild(msg);
|
|
63
|
+
box.appendChild(el('code', null, String(e && e.message || e)));
|
|
64
|
+
document.body.appendChild(box);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function reload() {
|
|
68
|
+
const grab = (u) => fetch(u).then((r) => { if (!r.ok) throw new Error(`${u} -> ${r.status}`); return r.json(); });
|
|
69
|
+
const [flow, edits, cfg] = await Promise.all([grab('/api/flow'), grab('/api/edits'), grab('/api/views')]);
|
|
70
|
+
const first = !S.flow;
|
|
71
|
+
paint(flow, cfg, edits);
|
|
72
|
+
try { localStorage.setItem(CACHE_KEY, JSON.stringify({ root: location.host + flow.root, flow, cfg, edits })); } catch { /* quota */ }
|
|
73
|
+
if (first && flow.nodes.filter((n) => n.type === 'page').length > 60) {
|
|
74
|
+
S.filters.preview = false; // past ~60 iframes even script-less previews drag
|
|
75
|
+
$('#fPreview').checked = false;
|
|
76
|
+
render();
|
|
77
|
+
toast('Large map, so previews are off. Turn them on under Layers, or click a page for its full preview.', true);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function paint(flow, cfg, edits) {
|
|
82
|
+
S.flow = flow; S.cfg = cfg; S.edits = edits;
|
|
83
|
+
S.mode = S.cfg.views.some((v) => v.id === S.mode) ? S.mode : (S.cfg.active || S.cfg.views[0].id);
|
|
84
|
+
S.nodes = new Map(S.flow.nodes.map((n) => [n.id, n]));
|
|
85
|
+
for (const e of pending('add-page')) if (!S.nodes.has(e.route)) {
|
|
86
|
+
S.nodes.set(e.route, { id: e.route, label: e.label || e.route, title: e.label || e.route, type: 'ghost', group: e.route.split('/').filter(Boolean)[0] || 'root', file: null });
|
|
87
|
+
}
|
|
88
|
+
$('#rootLabel').textContent = shortRoot(flow.root);
|
|
89
|
+
renderTabs();
|
|
90
|
+
recompute();
|
|
91
|
+
render();
|
|
92
|
+
renderRail();
|
|
93
|
+
renderEdits();
|
|
94
|
+
if (S.sel) select(S.sel, true);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ---------- view model ---------- */
|
|
98
|
+
const pending = (op) => S.edits.filter((e) => e.status !== 'applied' && (!op || e.op === op));
|
|
99
|
+
const view = () => S.cfg.views.find((v) => v.id === S.mode) || S.cfg.views[0];
|
|
100
|
+
|
|
101
|
+
// include/exclude rules are plain route patterns the user owns:
|
|
102
|
+
// /admin/** every route under it /admin/ that exact route
|
|
103
|
+
// /admin/* one level under it *report* substring
|
|
104
|
+
function matches(id, pat) {
|
|
105
|
+
if (!pat) return false;
|
|
106
|
+
if (pat.endsWith('/**')) { const p = pat.slice(0, -2); return id === p || id.startsWith(p); }
|
|
107
|
+
if (!pat.includes('*')) return id === pat || id === pat + '/';
|
|
108
|
+
const re = new RegExp('^' + pat.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
|
|
109
|
+
return re.test(id);
|
|
110
|
+
}
|
|
111
|
+
const excluded = (id) => {
|
|
112
|
+
const v = view();
|
|
113
|
+
if (v.include?.length && !v.include.some((p) => matches(id, p))) return true;
|
|
114
|
+
return !!v.exclude?.some((p) => matches(id, p));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function inView(n) {
|
|
118
|
+
if (n.type === 'api') return S.filters.api;
|
|
119
|
+
if (n.type === 'external') return S.filters.ext;
|
|
120
|
+
if (n.type === 'missing') return true;
|
|
121
|
+
return !excluded(n.id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function saveViews() {
|
|
125
|
+
S.cfg.active = S.mode;
|
|
126
|
+
renderTabs(); recompute(); render(); renderRail();
|
|
127
|
+
await fetch('/api/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(S.cfg) });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ---------- views ---------- */
|
|
131
|
+
// "everything" is the base view. It always shows every page and never takes a
|
|
132
|
+
// rule, so filtering while it is active creates a new view rather than quietly
|
|
133
|
+
// turning the one complete picture of the site into a partial one.
|
|
134
|
+
const isBase = (v) => !!(v || view()).base;
|
|
135
|
+
const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'view';
|
|
136
|
+
const tidy = (pat) => String(pat).replace(/[/*]+/g, ' ').trim() || 'root';
|
|
137
|
+
const labelFor = (kind, pat) => (kind === 'include' ? tidy(pat) : `no ${tidy(pat)}`);
|
|
138
|
+
|
|
139
|
+
function newView(label, rule) {
|
|
140
|
+
const taken = new Set(S.cfg.views.map((v) => v.id));
|
|
141
|
+
let id = slug(label), i = 2;
|
|
142
|
+
while (taken.has(id)) id = `${slug(label)}-${i++}`;
|
|
143
|
+
const v = {
|
|
144
|
+
id, label,
|
|
145
|
+
include: rule && rule.kind === 'include' ? [rule.pat] : [],
|
|
146
|
+
exclude: rule && rule.kind === 'exclude' ? [rule.pat] : [],
|
|
147
|
+
collapsed: [],
|
|
148
|
+
};
|
|
149
|
+
S.cfg.views.push(v);
|
|
150
|
+
S.mode = id;
|
|
151
|
+
return v;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// one entry point for every "hide this" affordance, so the base-view rule holds
|
|
155
|
+
// no matter which control the user reached for
|
|
156
|
+
function addExcludeRule(pat, kind = 'exclude') {
|
|
157
|
+
if (isBase()) {
|
|
158
|
+
const v = newView(labelFor(kind, pat), { kind, pat });
|
|
159
|
+
toast(`“everything” always shows every page, so this went into a new view: ${v.label}`);
|
|
160
|
+
} else {
|
|
161
|
+
const v = view();
|
|
162
|
+
v[kind] = [...new Set([...(v[kind] || []), pat])];
|
|
163
|
+
if (kind === 'exclude') v.include = (v.include || []).filter((p) => p !== pat);
|
|
164
|
+
toast(`${view().label}: ${kind === 'include' ? 'only ' : 'hiding '}${pat}`);
|
|
165
|
+
}
|
|
166
|
+
saveViews();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function promptNewView() {
|
|
170
|
+
const v = await modal('New view', [
|
|
171
|
+
{ name: 'label', label: 'Name, for example “docs only” or “no admin”', value: '' },
|
|
172
|
+
{ name: 'kind', label: 'First rule', type: 'select', options: ['hide these routes', 'show only these routes'], value: 'hide these routes' },
|
|
173
|
+
{ name: 'pat', label: 'Route pattern (optional), for example /admin/**', value: '' },
|
|
174
|
+
], 'Create view');
|
|
175
|
+
if (!v || !v.label.trim()) return;
|
|
176
|
+
const kind = v.kind.startsWith('show only') ? 'include' : 'exclude';
|
|
177
|
+
newView(v.label.trim(), v.pat.trim() ? { kind, pat: v.pat.trim() } : null);
|
|
178
|
+
S.sel = null; $('#inspect').hidden = true;
|
|
179
|
+
await saveViews();
|
|
180
|
+
fit();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function deleteView() {
|
|
184
|
+
const v = view();
|
|
185
|
+
if (isBase(v)) return;
|
|
186
|
+
S.cfg.views = S.cfg.views.filter((x) => x !== v);
|
|
187
|
+
S.mode = 'all';
|
|
188
|
+
S.sel = null; $('#inspect').hidden = true;
|
|
189
|
+
toast(`Deleted the view “${v.label}”`);
|
|
190
|
+
await saveViews();
|
|
191
|
+
fit();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderLayerCount() {
|
|
195
|
+
const on = ['shared', 'api', 'ext'].filter((k) => S.filters[k]).length + (S.filters.js ? 1 : 0) + (S.filters.preview ? 0 : 1);
|
|
196
|
+
const b = $('#layerCount');
|
|
197
|
+
b.textContent = String(on);
|
|
198
|
+
b.hidden = !on;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderTabs() {
|
|
202
|
+
const box = $('#tabs'); box.textContent = '';
|
|
203
|
+
S.cfg.views.forEach((v, i) => {
|
|
204
|
+
const b = el('button', 'tab' + (v.id === S.mode ? ' on' : ''), v.label || v.id);
|
|
205
|
+
b.type = 'button';
|
|
206
|
+
b.dataset.view = v.id;
|
|
207
|
+
b.setAttribute('aria-pressed', String(v.id === S.mode));
|
|
208
|
+
b.title = v.base
|
|
209
|
+
? `Every page, always. Filtering here creates a new view. (key ${i + 1})`
|
|
210
|
+
: `${v.include?.length ? 'Only ' + v.include.join(', ') : 'All routes'}${v.exclude?.length ? ', minus ' + v.exclude.join(', ') : ''} (key ${i + 1})`;
|
|
211
|
+
box.appendChild(b);
|
|
212
|
+
});
|
|
213
|
+
const add = el('button', 'tab add');
|
|
214
|
+
add.type = 'button';
|
|
215
|
+
add.dataset.add = '1';
|
|
216
|
+
add.appendChild(icon('i-plus', 'icon sm'));
|
|
217
|
+
add.title = 'New view: a saved subset of the map';
|
|
218
|
+
add.setAttribute('aria-label', 'New view');
|
|
219
|
+
box.appendChild(add);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A link that appears on more than a handful of pages is chrome: a header, a
|
|
223
|
+
// footer, a nav partial. It is real, but drawing it once per page hides
|
|
224
|
+
// everything that is specific to a page, so it folds away by default.
|
|
225
|
+
const sitewide = (e) => e.kind === 'js-shared' || e.repeated === true;
|
|
226
|
+
|
|
227
|
+
const visibleEdge = (e) => (!sitewide(e) || S.filters.shared)
|
|
228
|
+
&& S.nodes.has(e.from) && S.nodes.has(e.to) && inView(S.nodes.get(e.from)) && inView(S.nodes.get(e.to));
|
|
229
|
+
|
|
230
|
+
function allEdges() {
|
|
231
|
+
const removed = new Set(pending('remove-link').map((e) => `${e.from}>${e.to}>${e.label}`));
|
|
232
|
+
const base = S.flow.edges
|
|
233
|
+
.filter((e) => !removed.has(`${e.from}>${e.to}>${e.label}`))
|
|
234
|
+
.map((e) => {
|
|
235
|
+
const rt = pending('retarget').find((r) => r.from === e.from && r.to === e.to && r.label === e.label);
|
|
236
|
+
const rl = pending('relabel').find((r) => r.from === e.from && r.to === e.to && r.oldLabel === e.label);
|
|
237
|
+
return rt || rl ? { ...e, to: rt ? rt.newTo : e.to, label: rl ? rl.label : e.label, status: 'edited' } : e;
|
|
238
|
+
});
|
|
239
|
+
const proposed = pending('add-link').map((e, i) => ({
|
|
240
|
+
id: 'p' + i, from: e.from, to: e.to, label: e.label || 'new', via: e.via || 'link', kind: 'proposed', status: 'proposed', file: null, line: null,
|
|
241
|
+
}));
|
|
242
|
+
return [...base, ...proposed];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ---------- sections (group backdrops, collapsible) ---------- */
|
|
246
|
+
const groupOf = (n) => n.group || 'root';
|
|
247
|
+
const groupId = (g) => `section:${g}`;
|
|
248
|
+
const isGroup = (n) => n.type === 'section';
|
|
249
|
+
const collapsedSet = () => new Set(view().collapsed || []);
|
|
250
|
+
|
|
251
|
+
function toggleSection(g) {
|
|
252
|
+
const v = view();
|
|
253
|
+
const set = new Set(v.collapsed || []);
|
|
254
|
+
set.has(g) ? set.delete(g) : set.add(g);
|
|
255
|
+
v.collapsed = [...set];
|
|
256
|
+
if (S.sel && (S.sel === groupId(g) || S.nodes.get(S.sel)?.group === g)) { S.sel = null; $('#inspect').hidden = true; }
|
|
257
|
+
saveViews();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// every page in a collapsed section renders as one card; its internal links fold away
|
|
261
|
+
function renderId(id) {
|
|
262
|
+
const n = S.nodes.get(id);
|
|
263
|
+
if (!n || n.type !== 'page') return id;
|
|
264
|
+
const g = groupOf(n);
|
|
265
|
+
return S.collapsed.has(g) && inView(n) ? groupId(g) : id;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// collapse links into what a card actually shows: one wire per neighbour for a
|
|
269
|
+
// collapsed directory, one per control otherwise
|
|
270
|
+
function fold(list) {
|
|
271
|
+
const seenEdge = new Map();
|
|
272
|
+
const edges = [];
|
|
273
|
+
for (const e of list) {
|
|
274
|
+
const from = renderId(e.from), to = renderId(e.to);
|
|
275
|
+
if (from === to) continue; // link inside a collapsed section
|
|
276
|
+
const folded = from !== e.from || to !== e.to;
|
|
277
|
+
const k = folded ? `${from}>${to}` : `${from}>${to}>${e.label}`;
|
|
278
|
+
const hit = seenEdge.get(k);
|
|
279
|
+
if (hit) { hit.details.push(e); continue; }
|
|
280
|
+
const edge = folded
|
|
281
|
+
? { ...e, from, to, folded: true, kind: 'folded', details: [e] }
|
|
282
|
+
: { ...e, details: [e] };
|
|
283
|
+
seenEdge.set(k, edge);
|
|
284
|
+
edges.push(edge);
|
|
285
|
+
}
|
|
286
|
+
for (const e of edges) {
|
|
287
|
+
if (!e.folded) continue;
|
|
288
|
+
const n = e.details.length;
|
|
289
|
+
e.label = n === 1 ? e.details[0].label : `${n} links`;
|
|
290
|
+
}
|
|
291
|
+
return edges;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function ioOf(edges) {
|
|
295
|
+
const io = new Map();
|
|
296
|
+
for (const n of S.nodes.values()) if (inView(n) && renderId(n.id) === n.id) io.set(n.id, { in: [], out: [] });
|
|
297
|
+
for (const s of S.groups.values()) io.set(s.id, { in: [], out: [] });
|
|
298
|
+
for (const e of edges) { io.get(e.from)?.out.push(e); io.get(e.to)?.in.push(e); }
|
|
299
|
+
return io;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function recompute() {
|
|
303
|
+
S.collapsed = collapsedSet();
|
|
304
|
+
S.groups = new Map();
|
|
305
|
+
for (const n of S.nodes.values()) {
|
|
306
|
+
if (n.type !== 'page' || !inView(n)) continue;
|
|
307
|
+
const g = groupOf(n);
|
|
308
|
+
if (!S.collapsed.has(g)) continue;
|
|
309
|
+
if (!S.groups.has(g)) S.groups.set(g, { id: groupId(g), label: g, title: `/${g}/`, type: 'section', group: g, members: [], file: null });
|
|
310
|
+
S.groups.get(g).members.push(n);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const all = allEdges().filter((e) => S.nodes.has(e.from) && S.nodes.has(e.to)
|
|
314
|
+
&& inView(S.nodes.get(e.from)) && inView(S.nodes.get(e.to)));
|
|
315
|
+
|
|
316
|
+
// Two sets, deliberately. S.edges / S.io are what is DRAWN, so the wires and
|
|
317
|
+
// the port dots agree with each other. S.ioAll is what EXISTS in this view,
|
|
318
|
+
// and every judgement comes from it: a page reachable only through the footer
|
|
319
|
+
// is reachable, and calling it an orphan because the footer is folded away
|
|
320
|
+
// would be the tool lying about the site.
|
|
321
|
+
S.edges = fold(all.filter(visibleEdge));
|
|
322
|
+
S.io = ioOf(S.edges);
|
|
323
|
+
S.ioAll = ioOf(fold(all));
|
|
324
|
+
|
|
325
|
+
const entry = entryNode();
|
|
326
|
+
S.depth = new Map();
|
|
327
|
+
if (entry) {
|
|
328
|
+
S.depth.set(entry, 0);
|
|
329
|
+
const q = [entry];
|
|
330
|
+
while (q.length) {
|
|
331
|
+
const cur = q.shift();
|
|
332
|
+
for (const e of S.ioAll.get(cur)?.out || []) if (!S.depth.has(e.to)) { S.depth.set(e.to, S.depth.get(cur) + 1); q.push(e.to); }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
layout();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// everything that gets its own card: visible nodes not folded into a section,
|
|
339
|
+
// plus one card per collapsed section
|
|
340
|
+
const renderNodes = () => [
|
|
341
|
+
...[...S.nodes.values()].filter((n) => inView(n) && renderId(n.id) === n.id),
|
|
342
|
+
...S.groups.values(),
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
// where a visit starts: the site root if it is in this view, else the shallowest
|
|
346
|
+
// route, tie-broken by how many exits it has
|
|
347
|
+
function entryNode() {
|
|
348
|
+
const vis = renderNodes().filter((n) => (n.type === 'page' || n.type === 'ghost' || isGroup(n)));
|
|
349
|
+
if (!vis.length) return null;
|
|
350
|
+
if (view().entry && vis.some((n) => n.id === view().entry)) return view().entry;
|
|
351
|
+
if (vis.some((n) => n.id === '/')) return '/';
|
|
352
|
+
const depth = (id) => id.split('/').filter(Boolean).length;
|
|
353
|
+
const io = S.ioAll || S.io;
|
|
354
|
+
return vis.slice().sort((a, b) => depth(a.id) - depth(b.id)
|
|
355
|
+
|| (io.get(b.id)?.out.length || 0) - (io.get(a.id)?.out.length || 0))[0].id;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* ---------- layout ---------- */
|
|
359
|
+
function layout() {
|
|
360
|
+
S.pos = new Map();
|
|
361
|
+
const saved = S.flow.layout || {};
|
|
362
|
+
const cols = new Map();
|
|
363
|
+
const nodes = renderNodes();
|
|
364
|
+
const depths = nodes.filter((n) => n.type === 'page' || n.type === 'ghost' || isGroup(n)).map((n) => S.depth.get(n.id) ?? -1);
|
|
365
|
+
const maxDepth = Math.max(0, ...depths);
|
|
366
|
+
for (const n of nodes) {
|
|
367
|
+
const d = S.depth.get(n.id);
|
|
368
|
+
let c;
|
|
369
|
+
if (n.type === 'page' || n.type === 'ghost' || isGroup(n)) c = d == null ? maxDepth + 1 : d;
|
|
370
|
+
else c = maxDepth + 3 + (n.type === 'external' ? 1 : 0);
|
|
371
|
+
if (!cols.has(c)) cols.set(c, []);
|
|
372
|
+
cols.get(c).push(n);
|
|
373
|
+
}
|
|
374
|
+
S.colX = new Map();
|
|
375
|
+
S.maxPageCol = maxDepth;
|
|
376
|
+
let x = PAD;
|
|
377
|
+
for (const c of [...cols.keys()].sort((a, b) => a - b)) {
|
|
378
|
+
// section members sit next to each other so a section's backdrop stays tidy
|
|
379
|
+
const list = cols.get(c).sort((a, b) => groupOf(a).localeCompare(groupOf(b))
|
|
380
|
+
|| (S.io.get(b.id)?.in.length || 0) - (S.io.get(a.id)?.in.length || 0) || a.id.localeCompare(b.id));
|
|
381
|
+
S.colX.set(c, x);
|
|
382
|
+
const subs = Math.max(1, Math.ceil(list.length / PER_COL));
|
|
383
|
+
list.forEach((n, i) => {
|
|
384
|
+
const small = (n.type !== 'page' && n.type !== 'ghost' && !isGroup(n));
|
|
385
|
+
const sub = Math.floor(i / PER_COL), row = i % PER_COL;
|
|
386
|
+
const p = saved[n.id] || { x: x + sub * (W + 60), y: PAD + row * (small ? 108 : ROW) };
|
|
387
|
+
S.pos.set(n.id, { ...p, w: small ? WS : W, h: small ? HS : H, col: c });
|
|
388
|
+
});
|
|
389
|
+
x += subs * (W + 60) + COL - W - 20;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* ---------- render ---------- */
|
|
394
|
+
function render() {
|
|
395
|
+
const host = $('#nodes'), wires = $('#wires');
|
|
396
|
+
host.textContent = ''; wires.textContent = '';
|
|
397
|
+
S.dom = new Map();
|
|
398
|
+
|
|
399
|
+
drawColumns(wires);
|
|
400
|
+
drawSections(host);
|
|
401
|
+
for (const [id, p] of S.pos) host.appendChild(pageCard(nodeOf(id), p));
|
|
402
|
+
drawWires();
|
|
403
|
+
applyView();
|
|
404
|
+
applyFilterDim();
|
|
405
|
+
$('#empty').hidden = S.pos.size > 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const nodeOf = (id) => S.nodes.get(id) || S.groups.get(String(id).replace(/^section:/, ''));
|
|
409
|
+
|
|
410
|
+
// the drafting rule: one guide and one label per depth column
|
|
411
|
+
function drawColumns(wires) {
|
|
412
|
+
const bounds = new Map();
|
|
413
|
+
for (const [, p] of S.pos) {
|
|
414
|
+
const b = bounds.get(p.col) || { top: 1e9, bottom: -1e9 };
|
|
415
|
+
b.top = Math.min(b.top, p.y); b.bottom = Math.max(b.bottom, p.y + p.h);
|
|
416
|
+
bounds.set(p.col, b);
|
|
417
|
+
}
|
|
418
|
+
const ordered = [...S.colX.entries()].sort((a, b) => a[0] - b[0]);
|
|
419
|
+
for (const [c, b] of bounds) {
|
|
420
|
+
const x = S.colX.get(c) ?? 0;
|
|
421
|
+
const next = ordered.find(([cc]) => cc > c);
|
|
422
|
+
const room = (next ? next[1] : x + 420) - x - 24;
|
|
423
|
+
const sample = [...S.pos.entries()].find(([, p]) => p.col === c);
|
|
424
|
+
const n = sample && nodeOf(sample[0]);
|
|
425
|
+
const full = n && (n.type === 'api' || n.type === 'external') ? n.type.toUpperCase()
|
|
426
|
+
: c > S.maxPageCol ? 'UNREACHABLE BY CLICKING'
|
|
427
|
+
: c === 0 ? 'DEPTH 0 · ENTRY' : `DEPTH ${c}`;
|
|
428
|
+
|
|
429
|
+
wires.appendChild(svgEl('line', { class: 'col-guide', x1: x - 22, y1: b.top - 52, x2: x - 22, y2: b.bottom + 12 }));
|
|
430
|
+
const t = svgEl('text', { class: 'col-label', x, y: b.top - 58 });
|
|
431
|
+
t.textContent = full;
|
|
432
|
+
wires.appendChild(t);
|
|
433
|
+
// measure, then trim until the label fits its own column
|
|
434
|
+
let text = full;
|
|
435
|
+
while (text.length > 4 && t.getComputedTextLength() > room) {
|
|
436
|
+
text = text.slice(0, -2);
|
|
437
|
+
t.textContent = text + '…';
|
|
438
|
+
}
|
|
439
|
+
if (text !== full) t.appendChild(svgEl('title')).textContent = full;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Backdrop behind a directory's pages. One box per vertical run of same-directory
|
|
444
|
+
// cards, so a box only ever contains that directory. A single box spanning a
|
|
445
|
+
// group's full bounding rectangle would swallow every unrelated card standing
|
|
446
|
+
// between its first and last page.
|
|
447
|
+
function drawSections(host) {
|
|
448
|
+
const boxes = new Map(); // `${group}|${x}` -> box, one per column of cards
|
|
449
|
+
const total = new Map(); // group -> how many of its pages are on screen
|
|
450
|
+
for (const [id, p] of S.pos) {
|
|
451
|
+
const n = nodeOf(id);
|
|
452
|
+
if (!n || isGroup(n) || (n.type !== 'page' && n.type !== 'ghost')) continue;
|
|
453
|
+
const g = groupOf(n);
|
|
454
|
+
total.set(g, (total.get(g) || 0) + 1);
|
|
455
|
+
const k = `${g}|${Math.round(p.x)}`;
|
|
456
|
+
const b = boxes.get(k) || { g, x1: 1e9, y1: 1e9, x2: -1e9, y2: -1e9, n: 0 };
|
|
457
|
+
b.x1 = Math.min(b.x1, p.x); b.y1 = Math.min(b.y1, p.y);
|
|
458
|
+
b.x2 = Math.max(b.x2, p.x + p.w); b.y2 = Math.max(b.y2, p.y + p.h); b.n++;
|
|
459
|
+
boxes.set(k, b);
|
|
460
|
+
}
|
|
461
|
+
const drawn = [...boxes.values()].filter((b) => b.n > 1);
|
|
462
|
+
// only the leftmost, then topmost, box of a directory carries its label
|
|
463
|
+
const labelBox = new Map();
|
|
464
|
+
for (const b of drawn) {
|
|
465
|
+
const cur = labelBox.get(b.g);
|
|
466
|
+
if (!cur || b.x1 < cur.x1 || (b.x1 === cur.x1 && b.y1 < cur.y1)) labelBox.set(b.g, b);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (const b of drawn) {
|
|
470
|
+
const z = el('div', 'section');
|
|
471
|
+
z.style.cssText = `left:${b.x1 - 16}px;top:${b.y1 - 42}px;width:${b.x2 - b.x1 + 32}px;height:${b.y2 - b.y1 + 58}px`;
|
|
472
|
+
if (labelBox.get(b.g) === b) {
|
|
473
|
+
const head = el('button', 'section-head');
|
|
474
|
+
head.type = 'button';
|
|
475
|
+
head.appendChild(icon('i-chev-down', 'icon'));
|
|
476
|
+
head.appendChild(el('span', null, `/${b.g === 'root' ? '' : b.g + '/'}`));
|
|
477
|
+
head.appendChild(el('span', 'k', `${total.get(b.g)} pages`));
|
|
478
|
+
head.title = 'Collapse this directory into one card';
|
|
479
|
+
head.onclick = (ev) => { ev.stopPropagation(); toggleSection(b.g); };
|
|
480
|
+
z.appendChild(head);
|
|
481
|
+
}
|
|
482
|
+
host.appendChild(z);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function pageCard(n, p) {
|
|
487
|
+
const io = S.io.get(n.id) || { in: [], out: [] }; // drawn: ports must match wires
|
|
488
|
+
const tot = S.ioAll.get(n.id) || io; // real: counts and tags
|
|
489
|
+
if (isGroup(n)) return groupCard(n, p, io, tot);
|
|
490
|
+
const d = el('div', `node kind-${n.type}`);
|
|
491
|
+
d.style.cssText = `left:${p.x}px;top:${p.y}px;width:${p.w}px;--in:${Math.min(p.col * 40, 320)}ms`;
|
|
492
|
+
d.dataset.id = n.id;
|
|
493
|
+
d.tabIndex = 0;
|
|
494
|
+
d.setAttribute('aria-label', `${n.id}, ${tot.in.length} entrances, ${tot.out.length} exits`);
|
|
495
|
+
|
|
496
|
+
const h = el('div', 'node-head');
|
|
497
|
+
h.appendChild(el('span', 'node-route', n.type === 'page' || n.type === 'ghost' ? (n.label || '/') : n.id));
|
|
498
|
+
h.appendChild(el('span', 'node-badge', n.type === 'page' || n.type === 'ghost' ? `${tot.in.length} in · ${tot.out.length} out` : n.type));
|
|
499
|
+
d.appendChild(h);
|
|
500
|
+
|
|
501
|
+
if (n.type === 'page' || n.type === 'ghost') {
|
|
502
|
+
const t = el('div', 'node-thumb');
|
|
503
|
+
if (n.type === 'ghost') t.appendChild(el('div', 'ph', 'Proposed page'));
|
|
504
|
+
else if (S.filters.preview) { t.appendChild(el('div', 'ph skeleton')); lazyPreview(t, n.id); }
|
|
505
|
+
else t.appendChild(el('div', 'ph', n.id));
|
|
506
|
+
d.appendChild(t);
|
|
507
|
+
const f = el('div', 'node-foot');
|
|
508
|
+
f.appendChild(el('span', null, n.id));
|
|
509
|
+
if (!tot.in.length && n.id !== entryNode()) f.appendChild(el('span', 'tag warn', 'orphan'));
|
|
510
|
+
if (!tot.out.length) f.appendChild(el('span', 'tag', 'dead end'));
|
|
511
|
+
d.appendChild(f);
|
|
512
|
+
d.appendChild(ports(io.in, 'in'));
|
|
513
|
+
d.appendChild(ports(io.out, 'out'));
|
|
514
|
+
}
|
|
515
|
+
S.dom.set(n.id, d);
|
|
516
|
+
return d;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function groupCard(n, p, io, tot) {
|
|
520
|
+
const d = el('div', 'node is-group');
|
|
521
|
+
d.style.cssText = `left:${p.x}px;top:${p.y}px;width:${p.w}px;--in:${Math.min(p.col * 40, 320)}ms`;
|
|
522
|
+
d.dataset.id = n.id;
|
|
523
|
+
d.tabIndex = 0;
|
|
524
|
+
const h = el('div', 'node-head');
|
|
525
|
+
h.appendChild(icon('i-chev-right', 'icon sm'));
|
|
526
|
+
h.appendChild(el('span', 'node-route', `/${n.label}/`));
|
|
527
|
+
h.appendChild(el('span', 'node-badge', `${tot.in.length} in · ${tot.out.length} out`));
|
|
528
|
+
d.appendChild(h);
|
|
529
|
+
|
|
530
|
+
const body = el('div', 'group-body');
|
|
531
|
+
body.appendChild(el('span', 'group-count', String(n.members.length)));
|
|
532
|
+
body.appendChild(el('span', 'muted', 'pages\ncollapsed'));
|
|
533
|
+
const btn = el('button', 'btn sm', 'Expand');
|
|
534
|
+
btn.type = 'button';
|
|
535
|
+
btn.onclick = (ev) => { ev.stopPropagation(); toggleSection(n.group); };
|
|
536
|
+
body.appendChild(btn);
|
|
537
|
+
d.appendChild(body);
|
|
538
|
+
|
|
539
|
+
const f = el('div', 'node-foot');
|
|
540
|
+
f.appendChild(el('span', null, n.members.slice(0, 3).map((m) => m.label).join(', ') + (n.members.length > 3 ? '…' : '')));
|
|
541
|
+
d.appendChild(f);
|
|
542
|
+
d.appendChild(ports(io.in, 'in'));
|
|
543
|
+
d.appendChild(ports(io.out, 'out'));
|
|
544
|
+
S.dom.set(n.id, d);
|
|
545
|
+
return d;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Port geometry, mirrored from .ports / .port in style.css. A wire ends exactly
|
|
549
|
+
// on the dot that represents it, so the dots are the wire's real terminals
|
|
550
|
+
// rather than decoration beside them. Keep these three in sync with the CSS.
|
|
551
|
+
const PORT_TOP = 30, PORT_STEP = 14, PORT_R = 4.5, PORT_CAP = 7;
|
|
552
|
+
const PORTED = new Set(['page', 'ghost', 'section']);
|
|
553
|
+
|
|
554
|
+
// where on a card's edge this particular link attaches
|
|
555
|
+
function anchor(id, list, edge, side) {
|
|
556
|
+
const p = S.pos.get(id);
|
|
557
|
+
const n = nodeOf(id);
|
|
558
|
+
const x = side === 'out' ? p.x + p.w : p.x;
|
|
559
|
+
if (!n || !PORTED.has(n.type)) return { x, y: p.y + p.h / 2 }; // api/external cards show no ports
|
|
560
|
+
const i = Math.max(0, list.indexOf(edge));
|
|
561
|
+
// everything past the cap collapses onto the small "more" dot, exactly as drawn
|
|
562
|
+
if (i >= PORT_CAP) return { x, y: p.y + PORT_TOP + PORT_CAP * PORT_STEP + 2.5 };
|
|
563
|
+
return { x, y: p.y + PORT_TOP + i * PORT_STEP + PORT_R };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// the dots down each side of a card: entrances on the left, exits on the right
|
|
567
|
+
function ports(list, dir) {
|
|
568
|
+
const wrap = el('div', 'ports ' + dir);
|
|
569
|
+
for (const e of list.slice(0, 7)) {
|
|
570
|
+
const dot = el('button', 'port ' + dir + (e.status === 'proposed' ? ' proposed' : ''));
|
|
571
|
+
dot.type = 'button';
|
|
572
|
+
dot.dataset.tip = `${dir === 'out' ? 'EXIT' : 'ENTRANCE'} · “${e.label}”\n${e.from} → ${e.to}\n${e.via}${e.file ? ` · ${e.file}:${e.line}` : ''}`;
|
|
573
|
+
dot.dataset.go = dir === 'out' ? e.to : e.from;
|
|
574
|
+
dot.dataset.wire = `${e.from}>${e.to}`;
|
|
575
|
+
dot.setAttribute('aria-label', `${dir === 'out' ? 'Exit' : 'Entrance'}: ${e.label}, ${e.from} to ${e.to}`);
|
|
576
|
+
wrap.appendChild(dot);
|
|
577
|
+
}
|
|
578
|
+
if (list.length > 7) {
|
|
579
|
+
const more = el('i', 'port more ' + dir);
|
|
580
|
+
more.dataset.tip = `${list.length - 7} more`;
|
|
581
|
+
wrap.appendChild(more);
|
|
582
|
+
}
|
|
583
|
+
return wrap;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Thumbnails are real pages: mount few, drop the ones scrolled away from.
|
|
587
|
+
// Without the cap the browser composites 40+ documents on every pan.
|
|
588
|
+
const MOUNT_CAP = 12;
|
|
589
|
+
const io_ = new IntersectionObserver((es) => {
|
|
590
|
+
for (const e of es) {
|
|
591
|
+
if (e.isIntersecting) enqueuePreview(e.target);
|
|
592
|
+
else unmountPreview(e.target);
|
|
593
|
+
}
|
|
594
|
+
}, { root: null, rootMargin: '150px' });
|
|
595
|
+
|
|
596
|
+
const Q = { list: [], live: 0, max: 2, mounted: [] };
|
|
597
|
+
|
|
598
|
+
function unmountPreview(t) {
|
|
599
|
+
Q.list = Q.list.filter((x) => x !== t);
|
|
600
|
+
const f = t.querySelector('iframe');
|
|
601
|
+
if (!f) return;
|
|
602
|
+
if (t._done) t._done(); // still loading: free its slot or the queue stalls
|
|
603
|
+
f.remove();
|
|
604
|
+
Q.mounted = Q.mounted.filter((x) => x !== t);
|
|
605
|
+
if (!t.querySelector('.ph')) t.appendChild(el('div', 'ph', t.dataset.route));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function evict() {
|
|
609
|
+
while (Q.mounted.length > MOUNT_CAP) unmountPreview(Q.mounted.shift());
|
|
610
|
+
}
|
|
611
|
+
const sandbox = () => (S.filters.js ? 'allow-scripts allow-same-origin' : '');
|
|
612
|
+
function lazyPreview(t, route) { t.dataset.route = route; io_.observe(t); }
|
|
613
|
+
function enqueuePreview(t) {
|
|
614
|
+
if (t.querySelector('iframe') || Q.list.includes(t)) return;
|
|
615
|
+
Q.list.push(t); pump();
|
|
616
|
+
}
|
|
617
|
+
function pump() {
|
|
618
|
+
while (Q.live < Q.max && Q.list.length) {
|
|
619
|
+
const t = Q.list.shift();
|
|
620
|
+
if (!t.isConnected || t.querySelector('iframe')) continue;
|
|
621
|
+
Q.live++;
|
|
622
|
+
Q.mounted.push(t); evict();
|
|
623
|
+
const f = el('iframe');
|
|
624
|
+
f.setAttribute('sandbox', sandbox());
|
|
625
|
+
f.setAttribute('title', `Preview of ${t.dataset.route}`);
|
|
626
|
+
f.setAttribute('tabindex', '-1');
|
|
627
|
+
f.src = '/site' + t.dataset.route;
|
|
628
|
+
let settled = false;
|
|
629
|
+
const done = () => { if (settled) return; settled = true; t._done = null; t.querySelector('.ph')?.remove(); Q.live--; setTimeout(pump, 60); };
|
|
630
|
+
t._done = done;
|
|
631
|
+
f.addEventListener('load', done, { once: true });
|
|
632
|
+
f.addEventListener('error', done, { once: true });
|
|
633
|
+
setTimeout(done, 8000); // never let one slow page stall the queue
|
|
634
|
+
t.appendChild(f);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let wireRaf = 0;
|
|
639
|
+
const queueWires = () => { if (!wireRaf) wireRaf = requestAnimationFrame(() => { wireRaf = 0; drawWires(); }); };
|
|
640
|
+
|
|
641
|
+
function drawWires() {
|
|
642
|
+
const wires = $('#wires');
|
|
643
|
+
for (const w of wires.querySelectorAll('.wire,.wlabel,.hit')) w.remove();
|
|
644
|
+
if (!wires.querySelector('defs')) {
|
|
645
|
+
const defs = svgEl('defs');
|
|
646
|
+
const m = svgEl('marker', { id: 'ah', viewBox: '0 0 8 8', refX: '7', refY: '4', markerWidth: '5', markerHeight: '5', orient: 'auto-start-reverse' });
|
|
647
|
+
m.appendChild(svgEl('path', { d: 'M0,0 L8,4 L0,8', fill: 'none', stroke: 'context-stroke', 'stroke-width': '1.6' }));
|
|
648
|
+
defs.appendChild(m);
|
|
649
|
+
// a form submit gets a bar terminal at its source, so kind stays readable
|
|
650
|
+
// without spending another hue on it
|
|
651
|
+
const fb = svgEl('marker', { id: 'fb', viewBox: '0 0 4 8', refX: '1', refY: '4', markerWidth: '4', markerHeight: '6', orient: 'auto' });
|
|
652
|
+
fb.appendChild(svgEl('path', { d: 'M1,0 L1,8', fill: 'none', stroke: 'context-stroke', 'stroke-width': '2' }));
|
|
653
|
+
defs.appendChild(fb);
|
|
654
|
+
wires.appendChild(defs);
|
|
655
|
+
}
|
|
656
|
+
const seen = new Map();
|
|
657
|
+
for (const e of S.edges) {
|
|
658
|
+
const a = S.pos.get(e.from), b = S.pos.get(e.to);
|
|
659
|
+
if (!a || !b) continue;
|
|
660
|
+
const key = `${e.from}>${e.to}`;
|
|
661
|
+
const n = seen.get(key) || 0; seen.set(key, n + 1);
|
|
662
|
+
// start and end on the ports themselves; no synthetic fan-out offset is
|
|
663
|
+
// needed because two links out of one page already own two different dots
|
|
664
|
+
const A = anchor(e.from, S.io.get(e.from)?.out || [], e, 'out');
|
|
665
|
+
const B = anchor(e.to, S.io.get(e.to)?.in || [], e, 'in');
|
|
666
|
+
const x1 = A.x, y1 = A.y, x2 = B.x, y2 = B.y;
|
|
667
|
+
const dx = Math.max(60, Math.abs(x2 - x1) * 0.45);
|
|
668
|
+
const d = `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
|
|
669
|
+
const data = { 'data-from': e.from, 'data-to': e.to, 'data-wire': key };
|
|
670
|
+
const attrs = { class: `wire ${cls(e)}`, d, 'marker-end': 'url(#ah)', ...data };
|
|
671
|
+
if (e.via === 'form') attrs['marker-start'] = 'url(#fb)';
|
|
672
|
+
wires.appendChild(svgEl('path', attrs));
|
|
673
|
+
// fat transparent path on top so the thin wire is actually hoverable
|
|
674
|
+
const hit = svgEl('path', { class: 'hit', d, ...data });
|
|
675
|
+
hit.dataset.tip = e.folded
|
|
676
|
+
? `${e.label}: ${clean(e.from)} → ${clean(e.to)}\n` + e.details.slice(0, 6).map((x) => `· “${x.label}” ${x.from} → ${x.to}`).join('\n')
|
|
677
|
+
+ (e.details.length > 6 ? `\n· ${e.details.length - 6} more` : '')
|
|
678
|
+
: `“${e.label}”\n${e.from} → ${e.to}\n${e.via} · ${e.kind}${e.file ? ` · ${e.file}:${e.line}` : ''}`;
|
|
679
|
+
wires.appendChild(hit);
|
|
680
|
+
if (n < 2) {
|
|
681
|
+
const lab = svgEl('text', { class: 'wlabel', x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 4, 'text-anchor': 'middle', ...data });
|
|
682
|
+
lab.textContent = trunc(e.label, 26);
|
|
683
|
+
wires.appendChild(lab);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// hue = state, stroke style = kind
|
|
689
|
+
function cls(e) {
|
|
690
|
+
const out = [];
|
|
691
|
+
if (e.kind === 'proposed') out.push('proposed');
|
|
692
|
+
else if (S.nodes.get(e.to)?.type === 'missing') out.push('dead');
|
|
693
|
+
if (e.via === 'form') out.push('form');
|
|
694
|
+
else if (/js/.test(e.kind)) out.push('js');
|
|
695
|
+
if (e.via === 'redirect') out.push('redirect');
|
|
696
|
+
return out.join(' ');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function applyView() {
|
|
700
|
+
$('#canvas').style.transform = `translate(${S.cam.x}px,${S.cam.y}px) scale(${S.cam.k})`;
|
|
701
|
+
// zoomed out, wire labels and thumbnails are unreadable anyway - drop them
|
|
702
|
+
// from the render tree instead of compositing them
|
|
703
|
+
$('#stage').dataset.detail = S.cam.k < 0.26 ? 'low' : 'high';
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function applyFilterDim() {
|
|
707
|
+
const q = S.q.toLowerCase();
|
|
708
|
+
for (const [id, d] of S.dom) {
|
|
709
|
+
const n = nodeOf(id);
|
|
710
|
+
const hit = !q || id.toLowerCase().includes(q) || (n?.title || '').toLowerCase().includes(q);
|
|
711
|
+
d.classList.toggle('dim', !hit);
|
|
712
|
+
d.classList.toggle('sel', id === S.sel);
|
|
713
|
+
}
|
|
714
|
+
for (const w of $('#wires').querySelectorAll('.wire,.wlabel')) {
|
|
715
|
+
const hot = S.sel && (w.dataset.from === S.sel || w.dataset.to === S.sel);
|
|
716
|
+
w.classList.toggle('hot', !!hot);
|
|
717
|
+
w.classList.toggle('faint', !!S.sel && !hot);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/* ---------- rail ---------- */
|
|
722
|
+
function renderRail() {
|
|
723
|
+
const pages = [...S.nodes.values()].filter((n) => inView(n) && n.type === 'page' && renderId(n.id) === n.id);
|
|
724
|
+
const depths = pages.map((n) => S.depth.get(n.id)).filter((d) => d != null);
|
|
725
|
+
const box = $('#stats'); box.textContent = '';
|
|
726
|
+
const hiddenChrome = S.filters.shared ? 0 : allEdges().filter(sitewide).length;
|
|
727
|
+
const rows = [
|
|
728
|
+
['View', view().label || S.mode],
|
|
729
|
+
['Pages', `${pages.length} of ${S.flow.nodes.filter((n) => n.type === 'page').length}`],
|
|
730
|
+
['Links drawn', hiddenChrome ? `${S.edges.length} of ${S.edges.length + hiddenChrome}` : String(S.edges.length)],
|
|
731
|
+
['Deepest', depths.length ? `${Math.max(...depths)} clicks` : '0 clicks'],
|
|
732
|
+
['Scanned', (S.flow.generatedAt || '').slice(0, 16).replace('T', ' ')],
|
|
733
|
+
];
|
|
734
|
+
for (const [k, v] of rows) {
|
|
735
|
+
const r = el('div', 'stat');
|
|
736
|
+
r.appendChild(el('span', null, k));
|
|
737
|
+
r.appendChild(el('b', null, String(v)));
|
|
738
|
+
box.appendChild(r);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ENTRY POINTS - where a visit can start
|
|
742
|
+
const entry = entryNode();
|
|
743
|
+
const ins = pages.filter((n) => n.id === entry || !(S.ioAll.get(n.id)?.in.length));
|
|
744
|
+
$('#inCount').textContent = ins.length;
|
|
745
|
+
fillList('#inputs', ins.map((n) => ({
|
|
746
|
+
label: n.id, sub: n.id === entry ? 'front door' : 'direct URL only', go: n.id, warn: n.id !== entry,
|
|
747
|
+
})), 'Nothing can start a visit in this view.');
|
|
748
|
+
|
|
749
|
+
// LEAVES THE SITE - off-site links, API calls, and broken targets
|
|
750
|
+
const outs = new Map();
|
|
751
|
+
for (const e of S.edges) {
|
|
752
|
+
const t = S.nodes.get(e.to);
|
|
753
|
+
if (!t || (t.type !== 'external' && t.type !== 'api' && t.type !== 'missing')) continue;
|
|
754
|
+
if (!outs.has(e.to)) outs.set(e.to, { n: 0, type: t.type });
|
|
755
|
+
outs.get(e.to).n++;
|
|
756
|
+
}
|
|
757
|
+
const outRows = [...outs.entries()].sort((a, b) => b[1].n - a[1].n);
|
|
758
|
+
$('#outCount').textContent = outRows.length;
|
|
759
|
+
fillList('#outputs', outRows.map(([id, v]) => ({
|
|
760
|
+
label: id, sub: `${v.type} · ${v.n} link${v.n > 1 ? 's' : ''}`, go: id, warn: v.type === 'missing',
|
|
761
|
+
})), 'Nothing leaves the site in this view. Turn on API endpoints or external links under Layers.');
|
|
762
|
+
|
|
763
|
+
renderExcluded();
|
|
764
|
+
renderIssues(pages, entry);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function renderIssues(pages, entry) {
|
|
768
|
+
const i = S.flow.issues || {};
|
|
769
|
+
const shown = new Set(pages.map((n) => n.id));
|
|
770
|
+
const box = $('#issues'); box.textContent = '';
|
|
771
|
+
let count = 0;
|
|
772
|
+
const add = (bad, title, sub, go) => {
|
|
773
|
+
count++;
|
|
774
|
+
const d = el('button', 'issue' + (bad ? ' bad' : ''));
|
|
775
|
+
d.type = 'button';
|
|
776
|
+
d.appendChild(icon(bad ? 'i-broken' : 'i-alert', 'icon sm'));
|
|
777
|
+
d.appendChild(el('b', null, title));
|
|
778
|
+
d.appendChild(el('small', null, sub));
|
|
779
|
+
if (go) d.onclick = () => select(go);
|
|
780
|
+
box.appendChild(d);
|
|
781
|
+
};
|
|
782
|
+
for (const d of (i.deadLinks || []).filter((d) => shown.has(d.from)).slice(0, 12)) {
|
|
783
|
+
add(true, `Dead link to ${d.to}`, `${d.from} · “${d.label}”${d.file ? ` · ${d.file}:${d.line}` : ''}`, d.from);
|
|
784
|
+
}
|
|
785
|
+
for (const n of pages.filter((n) => !(S.ioAll.get(n.id)?.in.length) && n.id !== entry).slice(0, 10)) {
|
|
786
|
+
add(false, `Orphan ${n.id}`, 'No inbound link, unreachable by clicking', n.id);
|
|
787
|
+
}
|
|
788
|
+
for (const n of pages.filter((n) => S.depth.get(n.id) == null && n.id !== entry).slice(0, 10)) {
|
|
789
|
+
add(false, `Unreachable ${n.id}`, `Not reachable from ${entry}`, n.id);
|
|
790
|
+
}
|
|
791
|
+
for (const n of pages.filter((n) => (S.depth.get(n.id) ?? 0) > 3).slice(0, 8)) {
|
|
792
|
+
add(false, `Deep ${n.id}`, `${S.depth.get(n.id)} clicks from the entry`, n.id);
|
|
793
|
+
}
|
|
794
|
+
for (const n of pages.filter((n) => !(S.ioAll.get(n.id)?.out.length)).slice(0, 8)) {
|
|
795
|
+
add(false, `Dead end ${n.id}`, 'No way out, not even back', n.id);
|
|
796
|
+
}
|
|
797
|
+
for (const n of pages.filter((n) => (S.ioAll.get(n.id)?.out.length || 0) > 12).slice(0, 5)) {
|
|
798
|
+
add(false, `Hub ${n.id}`, `${S.ioAll.get(n.id).out.length} exits, consider grouping`, n.id);
|
|
799
|
+
}
|
|
800
|
+
$('#issueCount').textContent = count || '';
|
|
801
|
+
if (!count) {
|
|
802
|
+
const ok = el('div', 'empty-note');
|
|
803
|
+
ok.append('No dead links, orphans, or dead ends in this view.');
|
|
804
|
+
box.appendChild(ok);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// what this view leaves out: the rules first (removable), then the pages they hide
|
|
809
|
+
function renderExcluded() {
|
|
810
|
+
const v = view();
|
|
811
|
+
const box = $('#excluded'); box.textContent = '';
|
|
812
|
+
const hidden = S.flow.nodes.filter((n) => n.type === 'page' && excluded(n.id));
|
|
813
|
+
$('#exCount').textContent = hidden.length;
|
|
814
|
+
$('#viewDelete').hidden = isBase(v);
|
|
815
|
+
if (isBase(v)) {
|
|
816
|
+
box.appendChild(el('div', 'empty-note', 'The base view always shows every page. Add a rule below, or press + on the view tabs, to start a filtered view of your own.'));
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
for (const kind of ['include', 'exclude']) {
|
|
821
|
+
for (const pat of v[kind] || []) {
|
|
822
|
+
const row = el('div', 'row rule');
|
|
823
|
+
row.appendChild(el('span', 'k', kind === 'include' ? 'only' : 'hide'));
|
|
824
|
+
row.appendChild(el('span', null, pat));
|
|
825
|
+
const x = iconBtn('i-close', 'x', `Remove rule ${pat}`);
|
|
826
|
+
x.onclick = () => { v[kind] = v[kind].filter((p) => p !== pat); saveViews(); };
|
|
827
|
+
row.appendChild(x);
|
|
828
|
+
box.appendChild(row);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (!hidden.length && !(v.include || []).length && !(v.exclude || []).length) {
|
|
832
|
+
box.appendChild(el('div', 'empty-note', 'Nothing hidden. This view shows every page.'));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const cap = S.showAllExcluded ? hidden.length : 6;
|
|
836
|
+
for (const n of hidden.slice(0, cap)) {
|
|
837
|
+
const row = el('div', 'row gone');
|
|
838
|
+
row.appendChild(el('span', null, n.id));
|
|
839
|
+
const back = iconBtn('i-plus', 'x add', `Show ${n.id} in this view`);
|
|
840
|
+
back.onclick = () => includeBack(n.id);
|
|
841
|
+
row.appendChild(back);
|
|
842
|
+
box.appendChild(row);
|
|
843
|
+
}
|
|
844
|
+
if (hidden.length > cap) {
|
|
845
|
+
const more = el('button', 'btn sm', `Show ${hidden.length - cap} more`);
|
|
846
|
+
more.type = 'button';
|
|
847
|
+
more.onclick = () => { S.showAllExcluded = true; renderExcluded(); };
|
|
848
|
+
box.appendChild(more);
|
|
849
|
+
} else if (S.showAllExcluded && hidden.length > 6) {
|
|
850
|
+
const less = el('button', 'btn sm', 'Show fewer');
|
|
851
|
+
less.type = 'button';
|
|
852
|
+
less.onclick = () => { S.showAllExcluded = false; renderExcluded(); };
|
|
853
|
+
box.appendChild(less);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// re-including one page: drop the rules that hit it, and if an `only` list is
|
|
858
|
+
// in force, add the page to it
|
|
859
|
+
function includeBack(id, defer) {
|
|
860
|
+
const v = view();
|
|
861
|
+
v.exclude = (v.exclude || []).filter((p) => !matches(id, p));
|
|
862
|
+
if (v.include?.length && !v.include.some((p) => matches(id, p))) v.include.push(id);
|
|
863
|
+
if (!defer) saveViews();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function fillList(sel, rows, emptyMsg) {
|
|
867
|
+
const box = $(sel); box.textContent = '';
|
|
868
|
+
if (!rows.length) { box.appendChild(el('div', 'empty-note', emptyMsg)); return; }
|
|
869
|
+
for (const r of rows) {
|
|
870
|
+
const d = el('div', 'row' + (r.warn ? ' warn' : ''));
|
|
871
|
+
d.appendChild(el('span', null, r.label));
|
|
872
|
+
d.appendChild(el('span', 'k', r.sub));
|
|
873
|
+
if (r.go && S.pos.has(r.go)) { d.dataset.go = r.go; d.onclick = () => select(r.go); }
|
|
874
|
+
box.appendChild(d);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/* ---------- inspector ---------- */
|
|
879
|
+
function select(id, keepScroll) {
|
|
880
|
+
S.sel = id;
|
|
881
|
+
const n = nodeOf(id);
|
|
882
|
+
const ins = $('#inspect');
|
|
883
|
+
if (!n || !S.pos.has(id)) { ins.hidden = true; S.sel = null; applyFilterDim(); return; }
|
|
884
|
+
ins.hidden = false;
|
|
885
|
+
const grouped = isGroup(n);
|
|
886
|
+
$('#insTitle').textContent = grouped ? `${clean(n.id)} section` : (n.title || n.label || id);
|
|
887
|
+
$('#insRoute').textContent = grouped ? `${n.members.length} pages collapsed` : id;
|
|
888
|
+
$('#insFile').textContent = grouped ? '' : (n.file || '');
|
|
889
|
+
$('#openTab').href = grouped ? '/site/' + (n.label === 'root' ? '' : n.label + '/') : '/site' + id;
|
|
890
|
+
$('.devices').hidden = grouped;
|
|
891
|
+
$('.preview-wrap').hidden = grouped;
|
|
892
|
+
$('.viewctl').hidden = grouped;
|
|
893
|
+
if (grouped) renderGroupMembers(n);
|
|
894
|
+
else { $('#members')?.remove(); setPreview(id, Number(document.querySelector('.devices .on')?.dataset.w || 1280)); }
|
|
895
|
+
|
|
896
|
+
// the inspector is an editing surface, so it lists every link on the page,
|
|
897
|
+
// including the site-wide ones folded out of the drawing
|
|
898
|
+
const io = S.ioAll.get(id) || { in: [], out: [] };
|
|
899
|
+
$('#exitCount').textContent = io.out.length;
|
|
900
|
+
$('#entCount').textContent = io.in.length;
|
|
901
|
+
|
|
902
|
+
const exits = $('#exits'); exits.textContent = '';
|
|
903
|
+
if (!io.out.length) exits.appendChild(el('div', 'empty-note', 'Dead end. Nothing on this page navigates anywhere.'));
|
|
904
|
+
for (const e of io.out) exits.appendChild(exitRow(e));
|
|
905
|
+
for (const r of pending('remove-link').filter((r) => r.from === id)) {
|
|
906
|
+
const row = el('div', 'row gone');
|
|
907
|
+
row.appendChild(el('span', null, `${r.label} → ${r.to}`));
|
|
908
|
+
row.appendChild(el('span', 'k', 'queued delete'));
|
|
909
|
+
exits.appendChild(row);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const ent = $('#entrances'); ent.textContent = '';
|
|
913
|
+
if (!io.in.length) ent.appendChild(el('div', 'empty-note', 'Orphan. Nothing links here, so it is reachable only by typing the URL.'));
|
|
914
|
+
for (const e of io.in) {
|
|
915
|
+
const row = el('div', 'row');
|
|
916
|
+
row.dataset.go = e.from;
|
|
917
|
+
row.onclick = () => select(e.from);
|
|
918
|
+
row.appendChild(el('span', null, clean(e.from)));
|
|
919
|
+
row.appendChild(el('span', 'k', `“${trunc(e.label, 18)}” · ${e.via}${e.line ? ':' + e.line : ''}`));
|
|
920
|
+
ent.appendChild(row);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
$('#note').value = (S.flow.notes || {})[id] || '';
|
|
924
|
+
applyFilterDim();
|
|
925
|
+
if (!keepScroll) centerOn(id);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function renderGroupMembers(n) {
|
|
929
|
+
$('#members')?.remove();
|
|
930
|
+
const box = el('div', 'rows'); box.id = 'members';
|
|
931
|
+
const btn = el('button', 'btn sm', 'Expand section');
|
|
932
|
+
btn.type = 'button';
|
|
933
|
+
btn.onclick = () => toggleSection(n.group);
|
|
934
|
+
box.appendChild(btn);
|
|
935
|
+
for (const m of n.members) {
|
|
936
|
+
const row = el('div', 'row');
|
|
937
|
+
row.dataset.go = m.id;
|
|
938
|
+
row.appendChild(el('span', null, m.id));
|
|
939
|
+
row.appendChild(el('span', 'k', m.title || ''));
|
|
940
|
+
row.onclick = () => { toggleSection(n.group); setTimeout(() => select(m.id), 60); };
|
|
941
|
+
box.appendChild(row);
|
|
942
|
+
}
|
|
943
|
+
$('.preview-wrap').after(box);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function exitRow(e) {
|
|
947
|
+
if (e.folded || String(e.from).startsWith('section:') || String(e.to).startsWith('section:')) {
|
|
948
|
+
const row = el('div', 'row');
|
|
949
|
+
row.appendChild(el('span', null, `${e.label} → ${clean(e.to)}`));
|
|
950
|
+
row.appendChild(el('span', 'k', 'expand to edit'));
|
|
951
|
+
row.title = (e.details || []).map((x) => `“${x.label}” ${x.from} → ${x.to}`).join('\n');
|
|
952
|
+
row.addEventListener('mouseenter', () => hotWire(`${e.from}>${e.to}`, true));
|
|
953
|
+
row.addEventListener('mouseleave', () => hotWire(`${e.from}>${e.to}`, false));
|
|
954
|
+
return row;
|
|
955
|
+
}
|
|
956
|
+
const row = el('div', 'row' + (e.status === 'proposed' ? ' pending' : ''));
|
|
957
|
+
const label = el('input');
|
|
958
|
+
label.value = e.label;
|
|
959
|
+
label.title = 'Button or link text';
|
|
960
|
+
label.setAttribute('aria-label', `Text of the link to ${e.to}`);
|
|
961
|
+
const sel = el('select');
|
|
962
|
+
sel.setAttribute('aria-label', `Destination of “${e.label}”`);
|
|
963
|
+
for (const n of [...S.nodes.values()].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
964
|
+
const o = el('option', null, n.id); o.value = n.id; if (n.id === e.to) o.selected = true; sel.appendChild(o);
|
|
965
|
+
}
|
|
966
|
+
const k = el('span', 'k', e.status === 'proposed' ? 'queued' : `${e.via}${e.line ? ':' + e.line : ''}`);
|
|
967
|
+
const x = iconBtn('i-close', 'x', `Remove “${e.label}”`);
|
|
968
|
+
label.onchange = () => queue({ op: 'relabel', from: e.from, to: e.to, oldLabel: e.label, label: label.value, file: e.file, line: e.line, summary: `Rename “${e.label}” to “${label.value}” on ${e.from}` });
|
|
969
|
+
sel.onchange = () => queue({ op: 'retarget', from: e.from, to: e.to, newTo: sel.value, label: e.label, file: e.file, line: e.line, summary: `“${e.label}” on ${e.from}: ${e.to} becomes ${sel.value}` });
|
|
970
|
+
x.onclick = () => {
|
|
971
|
+
if (e.status === 'proposed') return unqueueAddLink(e);
|
|
972
|
+
queue({ op: 'remove-link', from: e.from, to: e.to, label: e.label, file: e.file, line: e.line, summary: `Remove “${e.label}” (${e.from} to ${e.to})` });
|
|
973
|
+
};
|
|
974
|
+
row.addEventListener('mouseenter', () => hotWire(`${e.from}>${e.to}`, true));
|
|
975
|
+
row.addEventListener('mouseleave', () => hotWire(`${e.from}>${e.to}`, false));
|
|
976
|
+
row.append(label, sel, k, x);
|
|
977
|
+
return row;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function setPreview(id, w) {
|
|
981
|
+
const f = $('#preview'), wrap = $('.preview-wrap');
|
|
982
|
+
f.style.width = w + 'px';
|
|
983
|
+
const scale = (wrap.clientWidth || 380) / w;
|
|
984
|
+
f.style.transform = `scale(${scale})`;
|
|
985
|
+
f.style.height = Math.ceil(236 / scale) + 'px';
|
|
986
|
+
const src = '/site' + id;
|
|
987
|
+
const key = src + w + sandbox();
|
|
988
|
+
if (f.dataset.src !== key) { f.setAttribute('sandbox', sandbox()); f.src = src; f.dataset.src = key; }
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/* ---------- change queue ---------- */
|
|
992
|
+
async function queue(edit) {
|
|
993
|
+
await fetch('/api/edits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(edit) });
|
|
994
|
+
toast(`Queued: ${edit.summary}`);
|
|
995
|
+
await reload();
|
|
996
|
+
}
|
|
997
|
+
async function unqueue(id) { await fetch('/api/edits?id=' + encodeURIComponent(id), { method: 'DELETE' }); await reload(); }
|
|
998
|
+
function unqueueAddLink(e) {
|
|
999
|
+
const m = pending('add-link').find((p) => p.from === e.from && p.to === e.to && (p.label || 'new') === e.label);
|
|
1000
|
+
if (m) unqueue(m.id);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function renderEdits() {
|
|
1004
|
+
const list = pending();
|
|
1005
|
+
$('#editCount').textContent = String(list.length);
|
|
1006
|
+
const box = $('#editList'); box.textContent = '';
|
|
1007
|
+
if (!list.length) {
|
|
1008
|
+
box.appendChild(el('div', 'empty-note', 'Nothing queued yet. Rename an exit, change where it points, add a link between two pages, or add a page. Nothing is written to your site until you ask an agent to apply it.'));
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
for (const e of list) {
|
|
1012
|
+
const row = el('div', 'row pending');
|
|
1013
|
+
row.appendChild(el('span', 'op', e.op));
|
|
1014
|
+
row.appendChild(el('span', 'grow', e.summary || JSON.stringify(e)));
|
|
1015
|
+
const x = iconBtn('i-close', 'x', 'Discard this change');
|
|
1016
|
+
x.onclick = () => unqueue(e.id);
|
|
1017
|
+
row.appendChild(x);
|
|
1018
|
+
box.appendChild(row);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/* ---------- hover tooltip ---------- */
|
|
1023
|
+
function showTip(text, ev) {
|
|
1024
|
+
const t = $('#tip');
|
|
1025
|
+
t.hidden = false;
|
|
1026
|
+
t.textContent = text;
|
|
1027
|
+
moveTip(ev);
|
|
1028
|
+
}
|
|
1029
|
+
function moveTip(ev) {
|
|
1030
|
+
const t = $('#tip');
|
|
1031
|
+
if (t.hidden) return;
|
|
1032
|
+
const r = t.getBoundingClientRect();
|
|
1033
|
+
t.style.left = Math.min(ev.clientX + 14, innerWidth - r.width - 12) + 'px';
|
|
1034
|
+
t.style.top = Math.min(ev.clientY + 16, innerHeight - r.height - 12) + 'px';
|
|
1035
|
+
}
|
|
1036
|
+
const hideTip = () => { $('#tip').hidden = true; };
|
|
1037
|
+
|
|
1038
|
+
function hotWire(key, on) {
|
|
1039
|
+
for (const w of $('#wires').querySelectorAll(`[data-wire="${CSS.escape(key)}"]`)) w.classList.toggle('trace', on);
|
|
1040
|
+
$('#wires').classList.toggle('tracing', on);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/* ---------- theme ---------- */
|
|
1044
|
+
function applyTheme(mode) {
|
|
1045
|
+
const root = document.documentElement;
|
|
1046
|
+
if (mode) root.dataset.theme = mode; else delete root.dataset.theme;
|
|
1047
|
+
const dark = mode ? mode === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
|
1048
|
+
$('#themeIcon').firstElementChild.setAttribute('href', dark ? '#i-sun' : '#i-moon');
|
|
1049
|
+
$('#btnTheme').title = dark ? 'Switch to light' : 'Switch to dark';
|
|
1050
|
+
$('#btnTheme').setAttribute('aria-label', $('#btnTheme').title);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function toggleTheme() {
|
|
1054
|
+
const dark = document.documentElement.dataset.theme
|
|
1055
|
+
? document.documentElement.dataset.theme === 'dark'
|
|
1056
|
+
: matchMedia('(prefers-color-scheme: dark)').matches;
|
|
1057
|
+
const next = dark ? 'light' : 'dark';
|
|
1058
|
+
try { localStorage.setItem(THEME_KEY, next); } catch { /* private mode */ }
|
|
1059
|
+
applyTheme(next);
|
|
1060
|
+
drawWires(); // wire strokes read from CSS variables that just changed
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/* ---------- interactions ---------- */
|
|
1064
|
+
function wireUI() {
|
|
1065
|
+
let saved = null;
|
|
1066
|
+
try { saved = localStorage.getItem(THEME_KEY); } catch { /* private mode */ }
|
|
1067
|
+
applyTheme(saved === 'light' || saved === 'dark' ? saved : null);
|
|
1068
|
+
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
1069
|
+
if (!document.documentElement.dataset.theme) applyTheme(null);
|
|
1070
|
+
});
|
|
1071
|
+
$('#btnTheme').onclick = toggleTheme;
|
|
1072
|
+
|
|
1073
|
+
const stage = $('#stage');
|
|
1074
|
+
|
|
1075
|
+
let panning = null;
|
|
1076
|
+
stage.addEventListener('pointerdown', (ev) => {
|
|
1077
|
+
const card = ev.target.closest('.node');
|
|
1078
|
+
if (card && !ev.target.closest('.port,button')) return startDrag(ev, card);
|
|
1079
|
+
if (card) return;
|
|
1080
|
+
panning = { x: ev.clientX, y: ev.clientY, vx: S.cam.x, vy: S.cam.y };
|
|
1081
|
+
stage.classList.add('panning'); stage.setPointerCapture(ev.pointerId);
|
|
1082
|
+
});
|
|
1083
|
+
stage.addEventListener('pointermove', (ev) => {
|
|
1084
|
+
if (!panning) return;
|
|
1085
|
+
S.cam.x = panning.vx + (ev.clientX - panning.x);
|
|
1086
|
+
S.cam.y = panning.vy + (ev.clientY - panning.y);
|
|
1087
|
+
applyView();
|
|
1088
|
+
});
|
|
1089
|
+
stage.addEventListener('pointerup', () => { panning = null; stage.classList.remove('panning'); });
|
|
1090
|
+
stage.addEventListener('wheel', (ev) => {
|
|
1091
|
+
ev.preventDefault();
|
|
1092
|
+
const k = Math.min(2, Math.max(0.15, S.cam.k * (ev.deltaY < 0 ? 1.12 : 0.89)));
|
|
1093
|
+
const r = stage.getBoundingClientRect();
|
|
1094
|
+
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
|
1095
|
+
S.cam.x = mx - (mx - S.cam.x) * (k / S.cam.k);
|
|
1096
|
+
S.cam.y = my - (my - S.cam.y) * (k / S.cam.k);
|
|
1097
|
+
S.cam.k = k; applyView();
|
|
1098
|
+
}, { passive: false });
|
|
1099
|
+
|
|
1100
|
+
// hover a wire or a port -> tooltip + trace
|
|
1101
|
+
stage.addEventListener('mouseover', (ev) => {
|
|
1102
|
+
const t = ev.target.closest('.hit,.port');
|
|
1103
|
+
if (!t || !t.dataset.tip) return;
|
|
1104
|
+
showTip(t.dataset.tip, ev);
|
|
1105
|
+
if (t.dataset.wire) hotWire(t.dataset.wire, true);
|
|
1106
|
+
});
|
|
1107
|
+
stage.addEventListener('mousemove', (ev) => moveTip(ev));
|
|
1108
|
+
stage.addEventListener('mouseout', (ev) => {
|
|
1109
|
+
const t = ev.target.closest('.hit,.port');
|
|
1110
|
+
if (!t) return;
|
|
1111
|
+
hideTip();
|
|
1112
|
+
if (t.dataset.wire) hotWire(t.dataset.wire, false);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
stage.addEventListener('click', (ev) => {
|
|
1116
|
+
const port = ev.target.closest('.port');
|
|
1117
|
+
if (port && port.dataset.go) { hideTip(); return select(port.dataset.go); }
|
|
1118
|
+
const hit = ev.target.closest('.hit');
|
|
1119
|
+
if (hit) return select(hit.dataset.from, true);
|
|
1120
|
+
const card = ev.target.closest('.node');
|
|
1121
|
+
if (!card) return;
|
|
1122
|
+
if (S.linkFrom) return finishLink(card.dataset.id);
|
|
1123
|
+
select(card.dataset.id, true);
|
|
1124
|
+
});
|
|
1125
|
+
stage.addEventListener('keydown', (ev) => {
|
|
1126
|
+
const card = ev.target.closest?.('.node');
|
|
1127
|
+
if (!card || (ev.key !== 'Enter' && ev.key !== ' ')) return;
|
|
1128
|
+
ev.preventDefault();
|
|
1129
|
+
if (S.linkFrom) return finishLink(card.dataset.id);
|
|
1130
|
+
select(card.dataset.id, true);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
$('#tabs').onclick = (ev) => {
|
|
1134
|
+
const b = ev.target.closest('.tab'); if (!b) return;
|
|
1135
|
+
if (b.dataset.add) return promptNewView();
|
|
1136
|
+
S.mode = b.dataset.view;
|
|
1137
|
+
S.sel = null; $('#inspect').hidden = true;
|
|
1138
|
+
renderTabs(); recompute(); render(); renderRail(); fit();
|
|
1139
|
+
S.cfg.active = S.mode;
|
|
1140
|
+
fetch('/api/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(S.cfg) });
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
const addRule = (kind) => {
|
|
1144
|
+
const pat = $('#ruleInput').value.trim();
|
|
1145
|
+
if (!pat) return toast('Type a route pattern first, for example /docs/**');
|
|
1146
|
+
$('#ruleInput').value = '';
|
|
1147
|
+
addExcludeRule(pat, kind);
|
|
1148
|
+
};
|
|
1149
|
+
$('#ruleAdd').onclick = () => addRule('exclude');
|
|
1150
|
+
$('#ruleAddIn').onclick = () => addRule('include');
|
|
1151
|
+
$('#ruleInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addRule('exclude'); } });
|
|
1152
|
+
$('#viewDelete').onclick = () => deleteView();
|
|
1153
|
+
|
|
1154
|
+
$('#exPage').onclick = () => {
|
|
1155
|
+
if (!S.sel) return;
|
|
1156
|
+
const pat = S.sel;
|
|
1157
|
+
S.sel = null; $('#inspect').hidden = true;
|
|
1158
|
+
addExcludeRule(pat);
|
|
1159
|
+
};
|
|
1160
|
+
$('#exGroup').onclick = () => {
|
|
1161
|
+
if (!S.sel) return;
|
|
1162
|
+
const seg = S.sel.split('/').filter(Boolean)[0];
|
|
1163
|
+
if (!seg) return toast('The root page is not inside a directory.');
|
|
1164
|
+
S.sel = null; $('#inspect').hidden = true;
|
|
1165
|
+
addExcludeRule(`/${seg}/**`);
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
$('.zoom').onclick = (ev) => {
|
|
1169
|
+
const b = ev.target.closest('[data-z]'); if (!b) return;
|
|
1170
|
+
const z = b.dataset.z;
|
|
1171
|
+
if (z === '0') return fit();
|
|
1172
|
+
S.cam.k = Math.min(2, Math.max(0.15, S.cam.k * (z === '+' ? 1.2 : 0.83))); applyView();
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
$('#search').addEventListener('input', (e) => { S.q = e.target.value; applyFilterDim(); });
|
|
1176
|
+
|
|
1177
|
+
const layers = $('#layers'), layersBtn = $('#btnLayers');
|
|
1178
|
+
const closeLayers = () => { layers.hidden = true; layersBtn.setAttribute('aria-expanded', 'false'); };
|
|
1179
|
+
layersBtn.onclick = (ev) => {
|
|
1180
|
+
ev.stopPropagation();
|
|
1181
|
+
layers.hidden = !layers.hidden;
|
|
1182
|
+
layersBtn.setAttribute('aria-expanded', String(!layers.hidden));
|
|
1183
|
+
};
|
|
1184
|
+
document.addEventListener('click', (ev) => { if (!ev.target.closest('.pop-wrap')) closeLayers(); });
|
|
1185
|
+
for (const [id, key] of [['fShared', 'shared'], ['fApi', 'api'], ['fExt', 'ext'], ['fPreview', 'preview'], ['fJs', 'js']]) {
|
|
1186
|
+
$('#' + id).addEventListener('change', (e) => {
|
|
1187
|
+
S.filters[key] = e.target.checked;
|
|
1188
|
+
if (key === 'js' && e.target.checked) toast('Page JavaScript is on. Heavy pages can stall the map, so turn it back off if this gets slow.');
|
|
1189
|
+
recompute(); render(); renderRail(); renderLayerCount();
|
|
1190
|
+
if (S.sel) select(S.sel, true);
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
renderLayerCount();
|
|
1194
|
+
|
|
1195
|
+
$('#btnRescan').onclick = async () => {
|
|
1196
|
+
const b = $('#btnRescan');
|
|
1197
|
+
b.disabled = true;
|
|
1198
|
+
toast('Rescanning…', true);
|
|
1199
|
+
try {
|
|
1200
|
+
const r = await fetch('/api/rescan', { method: 'POST' }).then((x) => x.json());
|
|
1201
|
+
await reload();
|
|
1202
|
+
toast(r.ok ? 'Rescanned' : 'Rescan failed. Check the terminal running sitelines.');
|
|
1203
|
+
} catch {
|
|
1204
|
+
toast('Rescan failed. Is sitelines still running?');
|
|
1205
|
+
} finally {
|
|
1206
|
+
b.disabled = false;
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
const drawer = $('#drawer');
|
|
1211
|
+
const setDrawer = (open) => { drawer.hidden = !open; $('#btnEdits').setAttribute('aria-expanded', String(open)); if (open) renderEdits(); };
|
|
1212
|
+
$('#btnEdits').onclick = () => setDrawer(drawer.hidden);
|
|
1213
|
+
$('#drawerClose').onclick = () => setDrawer(false);
|
|
1214
|
+
$('#copyPrompt').onclick = async () => {
|
|
1215
|
+
const list = pending();
|
|
1216
|
+
if (!list.length) return toast('Nothing queued to copy.');
|
|
1217
|
+
const txt = 'Apply my sitelines changes (.sitelines/edits.json):\n' + list.map((e) => `- [${e.op}] ${e.summary}`).join('\n');
|
|
1218
|
+
try { await navigator.clipboard.writeText(txt); toast('Prompt copied'); }
|
|
1219
|
+
catch { toast('Could not reach the clipboard. Open .sitelines/edits.json instead.'); }
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
$('#insClose').onclick = () => { $('#inspect').hidden = true; S.sel = null; applyFilterDim(); };
|
|
1223
|
+
$('#addExit').onclick = () => startLink(S.sel);
|
|
1224
|
+
$('#delPage').onclick = async () => {
|
|
1225
|
+
const n = S.nodes.get(S.sel); if (!n) return;
|
|
1226
|
+
const v = await modal('Delete page', [{ name: 'confirm', label: `Type ${n.id} to confirm. This queues a change; nothing is deleted until an agent applies it.`, value: '' }], 'Queue deletion');
|
|
1227
|
+
if (!v || v.confirm.trim() !== n.id) return toast('Cancelled');
|
|
1228
|
+
queue({ op: 'delete-page', route: n.id, file: n.file, summary: `Delete page ${n.id} (${n.file || 'no file'}) and every link to it` });
|
|
1229
|
+
};
|
|
1230
|
+
$('#btnAdd').onclick = async () => {
|
|
1231
|
+
const v = await modal('New page', [
|
|
1232
|
+
{ name: 'route', label: 'Route, for example /pricing/', value: '/' },
|
|
1233
|
+
{ name: 'label', label: 'Page title', value: '' },
|
|
1234
|
+
{ name: 'from', label: 'Linked from (optional route)', value: S.sel || '' },
|
|
1235
|
+
{ name: 'trigger', label: 'Button text on that page', value: '' },
|
|
1236
|
+
], 'Queue page');
|
|
1237
|
+
if (!v || !v.route) return;
|
|
1238
|
+
await queue({ op: 'add-page', route: v.route, label: v.label, summary: `New page ${v.route}${v.label ? ` - “${v.label}”` : ''}` });
|
|
1239
|
+
if (v.from) await queue({ op: 'add-link', from: v.from, to: v.route, label: v.trigger || v.label || v.route, via: 'link', summary: `Add “${v.trigger || v.label}” on ${v.from} to ${v.route}` });
|
|
1240
|
+
};
|
|
1241
|
+
$('#note').addEventListener('change', async (e) => {
|
|
1242
|
+
const notes = { ...(S.flow.notes || {}), [S.sel]: e.target.value };
|
|
1243
|
+
await fetch('/api/layout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ notes }) });
|
|
1244
|
+
S.flow.notes = notes;
|
|
1245
|
+
});
|
|
1246
|
+
$('.devices').onclick = (ev) => {
|
|
1247
|
+
const b = ev.target.closest('[data-w]'); if (!b || !b.dataset.w) return;
|
|
1248
|
+
for (const x of document.querySelectorAll('.devices .btn')) x.classList.remove('on');
|
|
1249
|
+
b.classList.add('on'); setPreview(S.sel, Number(b.dataset.w));
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
document.addEventListener('keydown', (ev) => {
|
|
1253
|
+
if (ev.target.matches('input,textarea,select')) { if (ev.key === 'Escape') ev.target.blur(); return; }
|
|
1254
|
+
if (ev.key === '/') { ev.preventDefault(); $('#search').focus(); }
|
|
1255
|
+
if (ev.key === 'Escape') {
|
|
1256
|
+
S.linkFrom = null; $('#stage').classList.remove('linking');
|
|
1257
|
+
if (!$('#modal').hidden) $('#modalCancel').click();
|
|
1258
|
+
setDrawer(false); closeLayers(); hideTip(); toast('');
|
|
1259
|
+
}
|
|
1260
|
+
if (ev.key === 'e' && S.sel) startLink(S.sel);
|
|
1261
|
+
if (ev.key === 'f') fit();
|
|
1262
|
+
if (['1', '2', '3', '4', '5'].includes(ev.key)) document.querySelectorAll('.tab')[Number(ev.key) - 1]?.click();
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function startDrag(ev, card) {
|
|
1267
|
+
const id = card.dataset.id, p = S.pos.get(id);
|
|
1268
|
+
const start = { x: ev.clientX, y: ev.clientY, px: p.x, py: p.y };
|
|
1269
|
+
let moved = false;
|
|
1270
|
+
card.setPointerCapture(ev.pointerId);
|
|
1271
|
+
const move = (e) => {
|
|
1272
|
+
const dx = (e.clientX - start.x) / S.cam.k, dy = (e.clientY - start.y) / S.cam.k;
|
|
1273
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
|
|
1274
|
+
p.x = start.px + dx; p.y = start.py + dy;
|
|
1275
|
+
card.style.left = p.x + 'px'; card.style.top = p.y + 'px';
|
|
1276
|
+
queueWires();
|
|
1277
|
+
};
|
|
1278
|
+
const up = () => {
|
|
1279
|
+
card.removeEventListener('pointermove', move);
|
|
1280
|
+
card.removeEventListener('pointerup', up);
|
|
1281
|
+
if (!moved) return;
|
|
1282
|
+
const layout_ = { [id]: { x: Math.round(p.x), y: Math.round(p.y) } };
|
|
1283
|
+
S.flow.layout = { ...S.flow.layout, ...layout_ };
|
|
1284
|
+
fetch('/api/layout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ layout: layout_ }) });
|
|
1285
|
+
};
|
|
1286
|
+
card.addEventListener('pointermove', move);
|
|
1287
|
+
card.addEventListener('pointerup', up);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function startLink(from) {
|
|
1291
|
+
if (!from) return;
|
|
1292
|
+
S.linkFrom = from;
|
|
1293
|
+
$('#stage').classList.add('linking');
|
|
1294
|
+
toast(`Click the destination page for a new link from ${from}. Escape cancels.`, true);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async function finishLink(to) {
|
|
1298
|
+
const from = S.linkFrom;
|
|
1299
|
+
S.linkFrom = null; $('#stage').classList.remove('linking');
|
|
1300
|
+
toast('');
|
|
1301
|
+
if (!from || from === to) return;
|
|
1302
|
+
const v = await modal('New link', [
|
|
1303
|
+
{ name: 'label', label: `Button or link text on ${from}`, value: '' },
|
|
1304
|
+
{ name: 'via', label: 'Kind', type: 'select', options: ['link', 'button', 'redirect', 'form'], value: 'link' },
|
|
1305
|
+
{ name: 'where', label: 'Where on the page (optional)', value: '' },
|
|
1306
|
+
], 'Queue link');
|
|
1307
|
+
if (!v) return;
|
|
1308
|
+
queue({ op: 'add-link', from, to, label: v.label || to, via: v.via, where: v.where, summary: `Add ${v.via} “${v.label || to}” on ${from} to ${to}${v.where ? ` (${v.where})` : ''}` });
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/* ---------- misc ui ---------- */
|
|
1312
|
+
function modal(title, fields, okLabel) {
|
|
1313
|
+
return new Promise((resolve) => {
|
|
1314
|
+
const m = $('#modal'), body = $('#modalBody');
|
|
1315
|
+
$('#modalTitle').textContent = title;
|
|
1316
|
+
$('#modalOk').textContent = okLabel || 'Confirm';
|
|
1317
|
+
body.textContent = ''; m.hidden = false;
|
|
1318
|
+
const inputs = {};
|
|
1319
|
+
for (const f of fields) {
|
|
1320
|
+
const l = el('label', null, f.label);
|
|
1321
|
+
let inp;
|
|
1322
|
+
if (f.type === 'select') { inp = el('select'); for (const o of f.options) { const op = el('option', null, o); op.value = o; inp.appendChild(op); } }
|
|
1323
|
+
else inp = el('input');
|
|
1324
|
+
inp.value = f.value ?? '';
|
|
1325
|
+
l.appendChild(inp); body.appendChild(l); inputs[f.name] = inp;
|
|
1326
|
+
}
|
|
1327
|
+
const first = body.querySelector('input,select');
|
|
1328
|
+
first?.focus();
|
|
1329
|
+
first?.select?.();
|
|
1330
|
+
const done = (v) => { m.hidden = true; $('#modalForm').onsubmit = null; resolve(v); };
|
|
1331
|
+
$('#modalForm').onsubmit = (e) => { e.preventDefault(); done(Object.fromEntries(Object.entries(inputs).map(([k, i]) => [k, i.value]))); };
|
|
1332
|
+
$('#modalCancel').onclick = () => done(null);
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function toast(msg, sticky) {
|
|
1337
|
+
const h = $('#toast');
|
|
1338
|
+
h.textContent = msg; h.classList.toggle('show', !!msg);
|
|
1339
|
+
clearTimeout(toast.t);
|
|
1340
|
+
if (msg && !sticky) toast.t = setTimeout(() => h.classList.remove('show'), 3200);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function centerOn(id) {
|
|
1344
|
+
const p = S.pos.get(id); if (!p) return;
|
|
1345
|
+
const r = $('#stage').getBoundingClientRect();
|
|
1346
|
+
S.cam.x = r.width / 2 - (p.x + p.w / 2) * S.cam.k;
|
|
1347
|
+
S.cam.y = r.height / 2 - (p.y + p.h / 2) * S.cam.k;
|
|
1348
|
+
applyView();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function fit() {
|
|
1352
|
+
const xs = [...S.pos.values()];
|
|
1353
|
+
if (!xs.length) return;
|
|
1354
|
+
const maxX = Math.max(...xs.map((p) => p.x + p.w)), maxY = Math.max(...xs.map((p) => p.y + p.h));
|
|
1355
|
+
const r = $('#stage').getBoundingClientRect();
|
|
1356
|
+
S.cam.k = Math.min(1, Math.max(0.3, Math.min((r.width - 40) / (maxX + 40), (r.height - 40) / (maxY + 40))));
|
|
1357
|
+
S.cam.x = 20; S.cam.y = 30 * S.cam.k;
|
|
1358
|
+
applyView();
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const trunc = (s, n) => (String(s || '').length > n ? String(s).slice(0, n - 1) + '…' : String(s || ''));
|
|
1362
|
+
|
|
1363
|
+
// the scan root is stored relative to wherever the scan ran, which can be a
|
|
1364
|
+
// ../../.. chain that tells the reader nothing. Show the tail that does.
|
|
1365
|
+
function shortRoot(root) {
|
|
1366
|
+
if (!root || root === '.') return '';
|
|
1367
|
+
const seg = String(root).split('/').filter((s) => s && s !== '.' && s !== '..');
|
|
1368
|
+
if (!seg.length) return '';
|
|
1369
|
+
return (seg.length > 2 ? '…/' : '') + seg.slice(-2).join('/') + '/';
|
|
1370
|
+
}
|
|
1371
|
+
// "section:admin" reads as "/admin/" everywhere it is shown to a human
|
|
1372
|
+
const clean = (id) => String(id).startsWith('section:')
|
|
1373
|
+
? `/${String(id).slice(8) === 'root' ? '' : String(id).slice(8) + '/'}` : String(id);
|