@miadi/stateloom-web 0.1.9 → 0.2.1
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/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +2 -2
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +3 -3
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__d5879c0c._.js → [root-of-the-server]__b1b2bf74._.js} +2 -2
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +4 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/9390437c8b41a90e.js +11 -0
- package/server/.next/static/chunks/a8a37c487ef91a19.js +1 -0
- package/server/.next/static/chunks/c86e96e4d6eeb632.css +2 -0
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/api/file/route.ts +57 -12
- package/server/src/app/globals.css +15 -0
- package/server/src/app/page.tsx +52 -10
- package/server/src/components/BoardBoundary.tsx +46 -0
- package/server/src/components/PropertiesPanel.tsx +10 -0
- package/server/src/components/erd/ErdWorkspace.tsx +32 -3
- package/server/src/components/forms.tsx +129 -0
- package/server/src/components/sequence/SequenceWorkspace.tsx +1024 -0
- package/server/src/components/system/KindIcon.tsx +71 -0
- package/server/src/components/system/SystemStrip.tsx +285 -0
- package/server/src/components/system/SystemWorkspace.tsx +766 -0
- package/server/src/lib/docParam.ts +4 -1
- package/server/src/lib/machineFocus.ts +126 -0
- package/server/src/lib/projectFile.ts +180 -20
- package/server/src/lib/safely.ts +16 -0
- package/server/src/lib/systemLoad.ts +362 -0
- package/server/src/lib/systemParam.ts +110 -0
- package/server/src/types/definition.ts +2 -0
- package/server/stateloom-web.json +2 -2
- package/server/.next/static/chunks/636e7eb4aeda72e0.css +0 -2
- package/server/.next/static/chunks/92628504da59dcc8.js +0 -10
- package/server/.next/static/chunks/aea5b1716c68391c.js +0 -1
- /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_buildManifest.js +0 -0
- /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_ssgManifest.js +0 -0
|
@@ -11,7 +11,7 @@ import { useEffect, useState } from "react";
|
|
|
11
11
|
* byte-identical to the old behavior.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const DOC_CHANGE_EVENT = "stateloom:doc-change";
|
|
14
|
+
export const DOC_CHANGE_EVENT = "stateloom:doc-change";
|
|
15
15
|
|
|
16
16
|
export function requestedDoc(): string | null {
|
|
17
17
|
if (typeof window === "undefined") return null;
|
|
@@ -29,6 +29,9 @@ export function navigateToDoc(doc: string | null): void {
|
|
|
29
29
|
const url = new URL(window.location.href);
|
|
30
30
|
if (doc) url.searchParams.set("doc", doc);
|
|
31
31
|
else url.searchParams.delete("doc");
|
|
32
|
+
// A focus names an element of the document it was written for; it does not
|
|
33
|
+
// travel to another one. (`system=` does: the strip stays while you browse.)
|
|
34
|
+
url.searchParams.delete("focus");
|
|
32
35
|
window.history.pushState({}, "", url);
|
|
33
36
|
window.dispatchEvent(new Event(DOC_CHANGE_EVENT));
|
|
34
37
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `focus=` for the state designer (Spec 82): `state:X` drills to X's level,
|
|
5
|
+
* selects it and centres it; `event:E` does the same for the first transition
|
|
6
|
+
* that fires E; `machine:*` (and anything else) leaves the board as it is.
|
|
7
|
+
*
|
|
8
|
+
* And the other way: when a person selects a state or a transition, the URL's
|
|
9
|
+
* focus follows, so the system strip can list what that element links to.
|
|
10
|
+
*
|
|
11
|
+
* Kept as one hook beside the store rather than inside it, so the state
|
|
12
|
+
* designer changes by the single line that mounts it.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef } from "react";
|
|
15
|
+
import { fitToBoxes, parseFocus, type StateDef, type StateMachineDefinition } from "@miadi/stateloom-protocol";
|
|
16
|
+
import { useDesignerStore } from "@/store/useDesignerStore";
|
|
17
|
+
import { CANVAS_SVG_ID } from "@/lib/exportImage";
|
|
18
|
+
import { urlParam, useFocusRequest, writeFocus } from "@/lib/systemParam";
|
|
19
|
+
|
|
20
|
+
const kids = (s: StateDef): StateDef[] => (s.parallel ? s.parallel.states : (s.states ?? []));
|
|
21
|
+
|
|
22
|
+
/** Names from the root down to `name`, inclusive; null when it is not in the tree. */
|
|
23
|
+
function chainTo(root: StateDef | undefined, name: string): string[] | null {
|
|
24
|
+
if (!root) return null;
|
|
25
|
+
if (root.name === name) return [root.name];
|
|
26
|
+
for (const child of kids(root)) {
|
|
27
|
+
const below = chainTo(child, name);
|
|
28
|
+
if (below) return [root.name, ...below];
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first transition, in document order, that fires `event`. */
|
|
34
|
+
function firstTransition(root: StateDef | undefined, event: string): { state: string; index: number } | null {
|
|
35
|
+
if (!root) return null;
|
|
36
|
+
const i = (root.transitions ?? []).findIndex((t) => t?.event === event);
|
|
37
|
+
if (i >= 0) return { state: root.name, index: i };
|
|
38
|
+
for (const child of kids(root)) {
|
|
39
|
+
const hit = firstTransition(child, event);
|
|
40
|
+
if (hit) return hit;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function transitionEvent(def: StateMachineDefinition, id: string): string | null {
|
|
46
|
+
const cut = id.lastIndexOf(":");
|
|
47
|
+
const chain = chainTo(def.state, id.slice(0, cut));
|
|
48
|
+
if (!chain) return null;
|
|
49
|
+
let s: StateDef | undefined = def.state;
|
|
50
|
+
for (const name of chain.slice(1)) s = kids(s!).find((k) => k.name === name);
|
|
51
|
+
return s?.transitions?.[Number(id.slice(cut + 1))]?.event ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useMachineFocus(): void {
|
|
55
|
+
const request = useFocusRequest();
|
|
56
|
+
const pending = useRef<{ focus: string; docAt: string | null; defAt: StateMachineDefinition } | null>(null);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const tryApply = (): void => {
|
|
60
|
+
const p = pending.current;
|
|
61
|
+
if (!p) return;
|
|
62
|
+
const s = useDesignerStore.getState();
|
|
63
|
+
const doc = urlParam("doc");
|
|
64
|
+
// Not the requested document yet, or still its predecessor's definition.
|
|
65
|
+
if (doc && s.docPath !== doc) return;
|
|
66
|
+
if (p.docAt !== s.docPath && s.definition === p.defAt) return;
|
|
67
|
+
pending.current = null;
|
|
68
|
+
|
|
69
|
+
const f = parseFocus(p.focus);
|
|
70
|
+
if (!f) return;
|
|
71
|
+
let chain: string[] | null = null;
|
|
72
|
+
let select: { kind: "state" | "transition"; id: string } | null = null;
|
|
73
|
+
if (f.kind === "state") {
|
|
74
|
+
chain = chainTo(s.definition.state, f.name);
|
|
75
|
+
if (chain) select = { kind: "state", id: f.name };
|
|
76
|
+
} else if (f.kind === "event") {
|
|
77
|
+
const hit = firstTransition(s.definition.state, f.name);
|
|
78
|
+
if (hit) {
|
|
79
|
+
chain = chainTo(s.definition.state, hit.state);
|
|
80
|
+
select = { kind: "transition", id: `${hit.state}:${hit.index}` };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!chain || !select) return;
|
|
84
|
+
// The level that draws the element: its parent's children.
|
|
85
|
+
const level = chain.length > 1 ? chain.slice(0, -1) : chain;
|
|
86
|
+
useDesignerStore.setState({ navigationPath: level, currentParent: level[level.length - 1] });
|
|
87
|
+
s.select(select.kind, select.id);
|
|
88
|
+
|
|
89
|
+
const centre = (): void => {
|
|
90
|
+
const box = useDesignerStore.getState().layout.positions[chain![chain!.length - 1]];
|
|
91
|
+
const rect = document.getElementById(CANVAS_SVG_ID)?.getBoundingClientRect();
|
|
92
|
+
if (!box || !rect?.width) return;
|
|
93
|
+
const vp = fitToBoxes([box], rect.width, rect.height, { padding: 60, maxScale: 1 });
|
|
94
|
+
useDesignerStore.getState().setViewport(vp);
|
|
95
|
+
};
|
|
96
|
+
// Once when the board has drawn the level, and once more after this
|
|
97
|
+
// browser's remembered view has had its chance to land on top.
|
|
98
|
+
requestAnimationFrame(centre);
|
|
99
|
+
setTimeout(centre, 450);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (request.nonce > 0) {
|
|
103
|
+
const s = useDesignerStore.getState();
|
|
104
|
+
pending.current = request.focus ? { focus: request.focus, docAt: s.docPath, defAt: s.definition } : null;
|
|
105
|
+
tryApply();
|
|
106
|
+
}
|
|
107
|
+
return useDesignerStore.subscribe(tryApply);
|
|
108
|
+
}, [request]);
|
|
109
|
+
|
|
110
|
+
// A person's selection becomes the focus. Clearing it leaves the last one in
|
|
111
|
+
// the URL: the strip keeps listing what was last in hand.
|
|
112
|
+
useEffect(
|
|
113
|
+
() =>
|
|
114
|
+
useDesignerStore.subscribe((state, prev) => {
|
|
115
|
+
if (state.selection === prev.selection || !state.selection.id) return;
|
|
116
|
+
const { kind, id } = state.selection;
|
|
117
|
+
if (kind === "state") writeFocus(`state:${id}`);
|
|
118
|
+
else if (kind === "event") writeFocus(`event:${id}`);
|
|
119
|
+
else if (kind === "transition") {
|
|
120
|
+
const event = transitionEvent(state.definition, id);
|
|
121
|
+
if (event) writeFocus(`event:${event}`);
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
[],
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { closeSync, constants, lstatSync, openSync, readdirSync, readlinkSync, realpathSync, statSync, writeSync } from "fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
|
|
3
3
|
import { envAlias } from "@miadi/stateloom-protocol";
|
|
4
4
|
|
|
@@ -35,6 +35,44 @@ export function allowedDocRoots(): string[] {
|
|
|
35
35
|
return real;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The chronicle's diagrams (D2, approved by Guillaume 2026-09-30 — the narrow
|
|
40
|
+
* rule). A chronicle root, from STATELOOM_CHRONICLE_ROOT (SMCRAFT_ twin), admits
|
|
41
|
+
* exactly one shape of path and nothing else under it:
|
|
42
|
+
*
|
|
43
|
+
* <chronicleRoot>/<episode>/diagrams/<file>.json
|
|
44
|
+
*
|
|
45
|
+
* One episode directory, then `diagrams/`, then the file — not the episode's
|
|
46
|
+
* other files, not the root's own files, nothing deeper. The shape is checked on
|
|
47
|
+
* the REAL path, so a symlink in the episode or in `diagrams/` cannot lead out
|
|
48
|
+
* of it; and since the real path of the parent is required, a new file can be
|
|
49
|
+
* created only where `diagrams/` already exists. Nothing here makes a directory.
|
|
50
|
+
*/
|
|
51
|
+
export function chronicleRoot(): string | null {
|
|
52
|
+
const raw = envAlias("CHRONICLE_ROOT");
|
|
53
|
+
if (!raw) return null;
|
|
54
|
+
try {
|
|
55
|
+
return realpathSync(resolve(raw));
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // a chronicle root that does not exist admits nothing
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Is this real path `<root>/<episode>/diagrams/<file>.json`, with `diagrams/` a real directory? */
|
|
62
|
+
export function isChronicleDiagram(real: string, root: string): boolean {
|
|
63
|
+
if (!real.startsWith(root + sep)) return false;
|
|
64
|
+
const parts = real.slice(root.length + 1).split(sep);
|
|
65
|
+
if (parts.length !== 3) return false;
|
|
66
|
+
const [episode, folder, file] = parts;
|
|
67
|
+
if (!episode || episode.startsWith(".") || folder !== "diagrams") return false;
|
|
68
|
+
if (!file || file.startsWith(".") || !file.endsWith(".json")) return false;
|
|
69
|
+
try {
|
|
70
|
+
return statSync(join(root, episode, folder)).isDirectory();
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
38
76
|
export type DocResolution =
|
|
39
77
|
| { ok: true; path: string }
|
|
40
78
|
| { ok: false; error: string };
|
|
@@ -51,21 +89,60 @@ export function resolveDocPath(doc: string | null | undefined): DocResolution {
|
|
|
51
89
|
return { ok: false, error: "doc must name a .json document" };
|
|
52
90
|
}
|
|
53
91
|
|
|
92
|
+
if (doc.includes("\0")) {
|
|
93
|
+
return { ok: false, error: "doc must not contain a NUL byte" };
|
|
94
|
+
}
|
|
95
|
+
|
|
54
96
|
const lexical = resolve(doc);
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// the
|
|
97
|
+
// Where the request really lands, symlinks followed:
|
|
98
|
+
// - an existing file (or a live link): its realpath;
|
|
99
|
+
// - a DANGLING link: the path it points at — the write would follow it there,
|
|
100
|
+
// so that is what the roots must admit (the parent realpathed, as for a
|
|
101
|
+
// new file). A link that points at another link is refused outright.
|
|
102
|
+
// - a new file: its realpathed parent plus its name.
|
|
103
|
+
// A doc whose parent does not exist is refused; nothing here makes a directory.
|
|
58
104
|
let anchored: string;
|
|
59
105
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
106
|
+
let link: ReturnType<typeof lstatSync> | null = null;
|
|
107
|
+
try {
|
|
108
|
+
link = lstatSync(lexical);
|
|
109
|
+
} catch {
|
|
110
|
+
link = null;
|
|
111
|
+
}
|
|
112
|
+
if (link?.isSymbolicLink()) {
|
|
113
|
+
try {
|
|
114
|
+
anchored = realpathSync(lexical);
|
|
115
|
+
} catch {
|
|
116
|
+
const target = resolve(dirname(lexical), readlinkSync(lexical));
|
|
117
|
+
let chained = false;
|
|
118
|
+
try {
|
|
119
|
+
chained = lstatSync(target).isSymbolicLink();
|
|
120
|
+
} catch {
|
|
121
|
+
chained = false;
|
|
122
|
+
}
|
|
123
|
+
if (chained) return { ok: false, error: `${doc} is a link to a link, which is not followed` };
|
|
124
|
+
anchored = resolve(realpathSync(dirname(target)), basename(target));
|
|
125
|
+
}
|
|
126
|
+
} else if (link) {
|
|
127
|
+
anchored = realpathSync(lexical);
|
|
128
|
+
} else {
|
|
129
|
+
anchored = resolve(realpathSync(dirname(lexical)), basename(lexical));
|
|
130
|
+
}
|
|
63
131
|
} catch {
|
|
64
132
|
// Almost always a host path handed to a containerised server: the directory
|
|
65
133
|
// exists where the human is looking and not where the server is.
|
|
66
134
|
return { ok: false, error: outsideRootsMessage(doc, allowedDocRoots()) };
|
|
67
135
|
}
|
|
68
136
|
|
|
137
|
+
// Under the chronicle root only the chronicle rule decides — even when a doc
|
|
138
|
+
// root contains it, or is it — so the narrow rule cannot be widened by a
|
|
139
|
+
// broader root.
|
|
140
|
+
const chronicle = chronicleRoot();
|
|
141
|
+
if (chronicle && (anchored === chronicle || anchored.startsWith(chronicle + sep))) {
|
|
142
|
+
return isChronicleDiagram(anchored, chronicle)
|
|
143
|
+
? { ok: true, path: anchored }
|
|
144
|
+
: { ok: false, error: outsideRootsMessage(doc, allowedDocRoots()) };
|
|
145
|
+
}
|
|
69
146
|
const roots = allowedDocRoots();
|
|
70
147
|
for (const root of roots) {
|
|
71
148
|
if (anchored === root || anchored.startsWith(root + sep)) {
|
|
@@ -75,6 +152,22 @@ export function resolveDocPath(doc: string | null | undefined): DocResolution {
|
|
|
75
152
|
return { ok: false, error: outsideRootsMessage(doc, roots) };
|
|
76
153
|
}
|
|
77
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Write a resolved document without following a link at the destination:
|
|
157
|
+
* `O_NOFOLLOW` fails on a symlink in the last component, so a link placed there
|
|
158
|
+
* after `resolveDocPath` looked cannot redirect the write. The parent was
|
|
159
|
+
* realpathed by `resolveDocPath`. The file keeps its inode, so watchers and
|
|
160
|
+
* hard links see an ordinary change.
|
|
161
|
+
*/
|
|
162
|
+
export function writeDocFile(path: string, content: string): void {
|
|
163
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o666);
|
|
164
|
+
try {
|
|
165
|
+
writeSync(fd, content, 0, "utf8");
|
|
166
|
+
} finally {
|
|
167
|
+
closeSync(fd);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
78
171
|
/**
|
|
79
172
|
* A refusal that is true from the READER's side.
|
|
80
173
|
*
|
|
@@ -86,7 +179,10 @@ export function resolveDocPath(doc: string | null | undefined): DocResolution {
|
|
|
86
179
|
* asked for, says what the translation is.
|
|
87
180
|
*/
|
|
88
181
|
function outsideRootsMessage(doc: string, roots: string[]): string {
|
|
89
|
-
const
|
|
182
|
+
const chronicle = chronicleRoot();
|
|
183
|
+
const where =
|
|
184
|
+
(roots.length ? roots.join(", ") : "(no readable root is configured)") +
|
|
185
|
+
(chronicle ? `, nor a chronicle diagram (${chronicle}/<episode>/diagrams/<file>.json, in an existing diagrams/ folder)` : "");
|
|
90
186
|
const hint =
|
|
91
187
|
roots.length === 1 && roots[0] === "/data"
|
|
92
188
|
? " — this server is in a container, and it sees your mounted directory as /data," +
|
|
@@ -95,6 +191,10 @@ function outsideRootsMessage(doc: string, roots: string[]): string {
|
|
|
95
191
|
return `${doc} is not under the permitted document root${roots.length === 1 ? "" : "s"} ${where}${hint}`;
|
|
96
192
|
}
|
|
97
193
|
|
|
194
|
+
/** How many episode folders, newest name first, and how many chronicle files the listing reads. */
|
|
195
|
+
const CHRONICLE_EPISODE_LIMIT = 400;
|
|
196
|
+
const CHRONICLE_FILE_LIMIT = 200;
|
|
197
|
+
|
|
98
198
|
export interface DocEntry {
|
|
99
199
|
/** Absolute path as the SERVER sees it — the value `?doc=` wants. */
|
|
100
200
|
path: string;
|
|
@@ -125,7 +225,13 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
|
|
|
125
225
|
const seen = new Set<string>();
|
|
126
226
|
let truncated = false;
|
|
127
227
|
|
|
128
|
-
|
|
228
|
+
// Nothing under the chronicle root is listed by a doc root's walk: only the
|
|
229
|
+
// chronicle rule admits there, and it is listed on its own below.
|
|
230
|
+
const chronicle = chronicleRoot();
|
|
231
|
+
const underChronicle = (real: string): boolean =>
|
|
232
|
+
!!chronicle && (real === chronicle || real.startsWith(chronicle + sep));
|
|
233
|
+
|
|
234
|
+
const walk = (dir: string, depth: number, root: string): void => {
|
|
129
235
|
if (docs.length >= limit) {
|
|
130
236
|
truncated = true;
|
|
131
237
|
return;
|
|
@@ -139,33 +245,87 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
|
|
|
139
245
|
for (const entry of entries) {
|
|
140
246
|
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
141
247
|
const full = join(dir, entry);
|
|
248
|
+
let real: string;
|
|
142
249
|
let stat;
|
|
143
250
|
try {
|
|
144
|
-
|
|
251
|
+
real = realpathSync(full);
|
|
252
|
+
stat = statSync(real);
|
|
145
253
|
} catch {
|
|
146
254
|
continue; // a broken symlink lists as nothing, not as a crash
|
|
147
255
|
}
|
|
256
|
+
// A link out of the root is neither listed nor descended: the listing
|
|
257
|
+
// never names a path the file API would refuse.
|
|
258
|
+
if (real !== root && !real.startsWith(root + sep)) continue;
|
|
259
|
+
if (underChronicle(real)) continue;
|
|
148
260
|
if (stat.isDirectory()) {
|
|
149
|
-
if (depth < maxDepth) walk(
|
|
261
|
+
if (depth < maxDepth) walk(real, depth + 1, root);
|
|
150
262
|
continue;
|
|
151
263
|
}
|
|
152
|
-
if (!entry.endsWith(".json")) continue;
|
|
264
|
+
if (!entry.endsWith(".json") || !real.endsWith(".json")) continue;
|
|
153
265
|
if (docs.length >= limit) {
|
|
154
266
|
truncated = true;
|
|
155
267
|
return;
|
|
156
268
|
}
|
|
157
|
-
if (seen.has(
|
|
158
|
-
seen.add(
|
|
159
|
-
docs.push({ path:
|
|
269
|
+
if (seen.has(real)) continue;
|
|
270
|
+
seen.add(real);
|
|
271
|
+
docs.push({ path: real, name: basename(real), dir: dirname(real), size: stat.size, mtime: stat.mtimeMs });
|
|
160
272
|
}
|
|
161
273
|
};
|
|
162
274
|
|
|
163
|
-
for (const root of allowedDocRoots()) walk(root, 0);
|
|
275
|
+
for (const root of allowedDocRoots()) walk(root, 0, root);
|
|
276
|
+
|
|
277
|
+
// The chronicle's diagrams: `<episode>/diagrams/*.json` only, as the rule
|
|
278
|
+
// admits them, and bounded on its own so a long chronicle neither crowds the
|
|
279
|
+
// roots out of the list nor turns it into a crawl of every episode.
|
|
280
|
+
if (chronicle) {
|
|
281
|
+
let listed = 0;
|
|
282
|
+
let episodes: string[] = [];
|
|
283
|
+
try {
|
|
284
|
+
episodes = readdirSync(chronicle).filter((e) => !e.startsWith(".")).sort().reverse().slice(0, CHRONICLE_EPISODE_LIMIT);
|
|
285
|
+
} catch {
|
|
286
|
+
episodes = [];
|
|
287
|
+
}
|
|
288
|
+
for (const episode of episodes) {
|
|
289
|
+
if (listed >= CHRONICLE_FILE_LIMIT) {
|
|
290
|
+
truncated = true;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
const dir = join(chronicle, episode, "diagrams");
|
|
294
|
+
let files: string[];
|
|
295
|
+
try {
|
|
296
|
+
files = readdirSync(dir);
|
|
297
|
+
} catch {
|
|
298
|
+
continue; // no diagrams/ folder: nothing of this episode is admitted
|
|
299
|
+
}
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
if (file.startsWith(".") || !file.endsWith(".json")) continue;
|
|
302
|
+
const full = join(dir, file);
|
|
303
|
+
let real: string;
|
|
304
|
+
let stat;
|
|
305
|
+
try {
|
|
306
|
+
real = realpathSync(full);
|
|
307
|
+
stat = statSync(real);
|
|
308
|
+
} catch {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
// Listed only where the file API would serve it: never a path it refuses.
|
|
312
|
+
if (!stat.isFile() || !isChronicleDiagram(real, chronicle) || seen.has(real)) continue;
|
|
313
|
+
if (listed >= CHRONICLE_FILE_LIMIT) {
|
|
314
|
+
truncated = true;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
seen.add(real);
|
|
318
|
+
docs.push({ path: real, name: file, dir: dirname(real), size: stat.size, mtime: stat.mtimeMs });
|
|
319
|
+
listed += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
164
323
|
|
|
165
|
-
// The loom's own documents first (`.smdf.json` machines, `.erdf.json` ERDs
|
|
166
|
-
//
|
|
167
|
-
// looking for — then most
|
|
168
|
-
|
|
324
|
+
// The loom's own documents first (`.smdf.json` machines, `.erdf.json` ERDs,
|
|
325
|
+
// `.sqdf.json` sequences, `.sysdf.json` systems) — the rest are `.json` files
|
|
326
|
+
// the allowlist tolerates but nobody came here looking for — then most
|
|
327
|
+
// recently touched.
|
|
328
|
+
const rank = (name: string): number => (/\.(smdf|erdf|sqdf|sysdf)\.json$/i.test(name) ? 0 : 1);
|
|
169
329
|
docs.sort((a, b) => rank(a.name) - rank(b.name) || b.mtime - a.mtime || a.path.localeCompare(b.path));
|
|
170
330
|
|
|
171
331
|
return { docs, truncated };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a check or a derivation over a document that may be malformed, and fall
|
|
3
|
+
* back instead of throwing: a hand- or agent-written file is not always the
|
|
4
|
+
* shape the types say, and one bad field must not take the page down. The
|
|
5
|
+
* error is kept for whoever asks (`onError`) and logged once.
|
|
6
|
+
*/
|
|
7
|
+
export function safely<T>(run: () => T, fallback: T, onError?: (message: string) => void): T {
|
|
8
|
+
try {
|
|
9
|
+
return run();
|
|
10
|
+
} catch (e) {
|
|
11
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
12
|
+
console.warn("[stateloom] skipped on a malformed document:", message);
|
|
13
|
+
onError?.(message);
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
}
|