@arcote.tech/arc-map 0.7.27
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 +38 -0
- package/src/app/arc.tsx +92 -0
- package/src/app/contexts.tsx +72 -0
- package/src/app/detail-panel.tsx +164 -0
- package/src/app/index.tsx +107 -0
- package/src/app/layout.ts +350 -0
- package/src/app/map-view.tsx +225 -0
- package/src/app/mdx-view.tsx +74 -0
- package/src/app/nodes.tsx +478 -0
- package/src/app/ref-context.tsx +49 -0
- package/src/app/structure.tsx +95 -0
- package/src/app/subgraph.ts +127 -0
- package/src/app/theme.ts +67 -0
- package/src/app/usecase-layout.ts +249 -0
- package/src/app/usecase-model.ts +346 -0
- package/src/app/usecase-tree.tsx +104 -0
- package/src/app/usecase-view.tsx +173 -0
- package/src/index.ts +18 -0
- package/src/mapper.ts +392 -0
- package/src/module-index.ts +70 -0
- package/src/refs.ts +121 -0
- package/src/scan.ts +393 -0
- package/src/server.ts +262 -0
- package/src/types.ts +154 -0
- package/src/use-cases/discover.ts +109 -0
- package/tests/discover.test.ts +42 -0
- package/tests/fixtures/pkg/detail-page.tsx +11 -0
- package/tests/fixtures/pkg/direct.tsx +7 -0
- package/tests/fixtures/pkg/list-page.tsx +11 -0
- package/tests/fixtures/pkg/routes.ts +6 -0
- package/tests/fixtures/pkg/scope.ts +4 -0
- package/tests/fixtures/uc/pkgA/use-cases/bar.mdx +3 -0
- package/tests/fixtures/uc/pkgA/use-cases/sub/foo.mdx +9 -0
- package/tests/mapper.test.ts +115 -0
- package/tests/refs.test.ts +56 -0
- package/tests/scan.test.ts +71 -0
- package/tests/subgraph.test.ts +54 -0
- package/tests/usecase-model.test.ts +197 -0
- package/tsconfig.json +8 -0
package/src/mapper.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mapper — turns a live Arc context into the JSON the map UI consumes.
|
|
3
|
+
*
|
|
4
|
+
* Pure and dependency-light: it reads metadata off element instances via the
|
|
5
|
+
* accessors documented in the element classes (`@arcote.tech/arc`). The caller
|
|
6
|
+
* supplies the module index and the React component edges, keeping this unit
|
|
7
|
+
* testable in isolation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ArcAggregateElement,
|
|
12
|
+
ArcCommand,
|
|
13
|
+
ArcEvent,
|
|
14
|
+
ArcListener,
|
|
15
|
+
ArcRoute,
|
|
16
|
+
ArcStaticView,
|
|
17
|
+
ArcView,
|
|
18
|
+
type ArcContextAny,
|
|
19
|
+
type ArcContextElementAny,
|
|
20
|
+
} from "@arcote.tech/arc";
|
|
21
|
+
import {
|
|
22
|
+
UNGROUPED_SPACE_ID,
|
|
23
|
+
type ElementKind,
|
|
24
|
+
type MapComponent,
|
|
25
|
+
type MapComponentEdge,
|
|
26
|
+
type MapEdge,
|
|
27
|
+
type MapElement,
|
|
28
|
+
type MapElementMethod,
|
|
29
|
+
type MapJson,
|
|
30
|
+
type MapSpace,
|
|
31
|
+
} from "./types";
|
|
32
|
+
|
|
33
|
+
/** Per-element module attribution, computed by the caller from the registry. */
|
|
34
|
+
export interface ModuleInfo {
|
|
35
|
+
moduleId: string;
|
|
36
|
+
moduleName: string;
|
|
37
|
+
scope?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BuildMapInput {
|
|
41
|
+
context: ArcContextAny;
|
|
42
|
+
/** element name -> owning module info (best effort). */
|
|
43
|
+
moduleByElement?: Map<string, ModuleInfo>;
|
|
44
|
+
/** React component -> element edges from the ts-morph scan. */
|
|
45
|
+
componentEdges?: MapComponentEdge[];
|
|
46
|
+
components?: MapComponent[];
|
|
47
|
+
/** Page nodes (from arc `page()` registry), appended as kind `page`. */
|
|
48
|
+
pages?: MapElement[];
|
|
49
|
+
/** ISO timestamp; injected so the function stays deterministic for tests. */
|
|
50
|
+
generatedAt?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Kind detection
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function detectKind(el: ArcContextElementAny): ElementKind {
|
|
58
|
+
if (el instanceof ArcAggregateElement) return "aggregate";
|
|
59
|
+
if (el instanceof ArcCommand) return "command";
|
|
60
|
+
if (el instanceof ArcEvent) return "event";
|
|
61
|
+
if (el instanceof ArcStaticView) return "static-view";
|
|
62
|
+
if (el instanceof ArcView) return "view";
|
|
63
|
+
if (el instanceof ArcListener) return "listener";
|
|
64
|
+
if (el instanceof ArcRoute) return "route";
|
|
65
|
+
return "unknown";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Small safe accessors
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function toJsonSchema(schema: unknown): unknown {
|
|
73
|
+
const fn = (schema as { toJsonSchema?: () => unknown })?.toJsonSchema;
|
|
74
|
+
if (typeof fn === "function") {
|
|
75
|
+
try {
|
|
76
|
+
return fn.call(schema);
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read protection token names off any element exposing `.protections`. */
|
|
85
|
+
function protectionNames(el: any): string[] | undefined {
|
|
86
|
+
const list = el?.protections;
|
|
87
|
+
if (!Array.isArray(list) || list.length === 0) return undefined;
|
|
88
|
+
const names = list
|
|
89
|
+
.map((p: any) => p?.token?.name)
|
|
90
|
+
.filter((n: unknown): n is string => typeof n === "string");
|
|
91
|
+
return names.length ? names : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve which space an element belongs to. Prefers the core `__contextId`
|
|
96
|
+
* stamp (true context boundary), then falls back to the owning module — so the
|
|
97
|
+
* map still groups by module on a stock runtime where core hasn't been rebuilt
|
|
98
|
+
* with the stamp. Last resort: a shared "ungrouped" bucket.
|
|
99
|
+
*/
|
|
100
|
+
/** Element names from an array of element instances (dep arrays). */
|
|
101
|
+
function depNames(value: unknown): string[] {
|
|
102
|
+
return Array.isArray(value)
|
|
103
|
+
? value
|
|
104
|
+
.map((t) => (t && typeof (t as any).name === "string" ? (t as any).name : null))
|
|
105
|
+
.filter((n): n is string => !!n)
|
|
106
|
+
: [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function spaceOf(
|
|
110
|
+
el: ArcContextElementAny | undefined,
|
|
111
|
+
module?: ModuleInfo,
|
|
112
|
+
): string {
|
|
113
|
+
// Group by module NAME first: it is the feature-level unit. A single feature
|
|
114
|
+
// is often split across many `context()` calls (distinct __contextId each) and
|
|
115
|
+
// many module() registrations (distinct id, shared name) — keying on the name
|
|
116
|
+
// collapses them into one box. __contextId / __contextName only group elements
|
|
117
|
+
// that have no module attribution.
|
|
118
|
+
if (module?.moduleName) return `mod:${module.moduleName}`;
|
|
119
|
+
const contextId = (el as any)?.__contextId;
|
|
120
|
+
if (contextId) return contextId;
|
|
121
|
+
if (module?.moduleId) return `mod:${module.moduleId}`;
|
|
122
|
+
return UNGROUPED_SPACE_ID;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function spaceLabelOf(
|
|
126
|
+
el: ArcContextElementAny | undefined,
|
|
127
|
+
module?: ModuleInfo,
|
|
128
|
+
): string | undefined {
|
|
129
|
+
return module?.moduleName ?? (el as any)?.__contextName;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Element extraction
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function extractElement(
|
|
137
|
+
el: ArcContextElementAny,
|
|
138
|
+
kind: ElementKind,
|
|
139
|
+
module: ModuleInfo | undefined,
|
|
140
|
+
): MapElement {
|
|
141
|
+
const data = (el as any).data ?? {};
|
|
142
|
+
const base: MapElement = {
|
|
143
|
+
id: el.name,
|
|
144
|
+
name: el.name,
|
|
145
|
+
kind,
|
|
146
|
+
spaceId: spaceOf(el, module),
|
|
147
|
+
moduleId: module?.moduleId,
|
|
148
|
+
moduleName: module?.moduleName,
|
|
149
|
+
scope: module?.scope,
|
|
150
|
+
description: data.description,
|
|
151
|
+
protections: protectionNames(el),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
switch (kind) {
|
|
155
|
+
case "aggregate": {
|
|
156
|
+
const Agg = (el as any).Aggregate;
|
|
157
|
+
const events: string[] = (Agg?.__aggregateEvents ?? [])
|
|
158
|
+
.map((e: any) => e?.event?.name)
|
|
159
|
+
.filter(Boolean);
|
|
160
|
+
const mutate: MapElementMethod[] = (Agg?.__aggregateMutateMethods ?? []).map(
|
|
161
|
+
(m: any) => ({
|
|
162
|
+
name: m.name,
|
|
163
|
+
kind: "mutate" as const,
|
|
164
|
+
queryDeps: depNames(m?.queryElements),
|
|
165
|
+
mutationDeps: depNames(m?.mutationElements),
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
const query: MapElementMethod[] = (Agg?.__aggregateQueryMethods ?? []).map(
|
|
169
|
+
(m: any) => ({ name: m.name, kind: "query" as const }),
|
|
170
|
+
);
|
|
171
|
+
const cron: MapElementMethod[] = (Agg?.__aggregateCronMethods ?? []).map(
|
|
172
|
+
(m: any) => ({ name: m.name, kind: "cron" as const }),
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
...base,
|
|
176
|
+
schema: toJsonSchema(Agg?.__aggregateSchema ?? (el as any).schema),
|
|
177
|
+
events,
|
|
178
|
+
methods: [...mutate, ...query, ...cron],
|
|
179
|
+
protections: protectionNames({ protections: Agg?.__aggregateProtections }),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
case "command":
|
|
183
|
+
return {
|
|
184
|
+
...base,
|
|
185
|
+
params: toJsonSchema(data.params),
|
|
186
|
+
results: Array.isArray(data.results)
|
|
187
|
+
? data.results.map(toJsonSchema).filter(Boolean)
|
|
188
|
+
: undefined,
|
|
189
|
+
queryDeps: depNames(data.queryElements),
|
|
190
|
+
mutationDeps: depNames(data.mutationElements),
|
|
191
|
+
};
|
|
192
|
+
case "listener":
|
|
193
|
+
return {
|
|
194
|
+
...base,
|
|
195
|
+
queryDeps: depNames(data.queryElements),
|
|
196
|
+
mutationDeps: depNames(data.mutationElements),
|
|
197
|
+
};
|
|
198
|
+
case "event":
|
|
199
|
+
return { ...base, payload: toJsonSchema((el as any).payload) };
|
|
200
|
+
case "view":
|
|
201
|
+
case "static-view":
|
|
202
|
+
return { ...base, schema: toJsonSchema((el as any).schema) };
|
|
203
|
+
case "route":
|
|
204
|
+
return {
|
|
205
|
+
...base,
|
|
206
|
+
path: (el as any).routePath,
|
|
207
|
+
queryDeps: depNames(data.queryElements),
|
|
208
|
+
mutationDeps: depNames(data.mutationElements),
|
|
209
|
+
};
|
|
210
|
+
default:
|
|
211
|
+
return base;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Edge extraction
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
interface RawEdge {
|
|
220
|
+
source: string;
|
|
221
|
+
targets: ArcContextElementAny[];
|
|
222
|
+
kind: MapEdge["kind"];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function depTargets(value: unknown): ArcContextElementAny[] {
|
|
226
|
+
return Array.isArray(value)
|
|
227
|
+
? value.filter((t): t is ArcContextElementAny => !!t && typeof (t as any).name === "string")
|
|
228
|
+
: [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function collectRawEdges(el: ArcContextElementAny, kind: ElementKind): RawEdge[] {
|
|
232
|
+
const data = (el as any).data ?? {};
|
|
233
|
+
const edges: RawEdge[] = [];
|
|
234
|
+
const push = (k: MapEdge["kind"], targets: ArcContextElementAny[]) => {
|
|
235
|
+
if (targets.length) edges.push({ source: el.name, targets, kind: k });
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Shared query/mutate dependency arrays (command / route / listener).
|
|
239
|
+
push("queries", depTargets(data.queryElements));
|
|
240
|
+
push("mutates", depTargets(data.mutationElements));
|
|
241
|
+
|
|
242
|
+
if (kind === "listener") {
|
|
243
|
+
push("listens", depTargets((el as any).eventElements));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (kind === "view" || kind === "static-view") {
|
|
247
|
+
const handled = typeof (el as any).getElements === "function" ? (el as any).getElements() : [];
|
|
248
|
+
push("handles", depTargets(handled));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (kind === "aggregate") {
|
|
252
|
+
const Agg = (el as any).Aggregate;
|
|
253
|
+
// Emitted events (inline + public) — targets resolved by name later.
|
|
254
|
+
const emitted = depTargets((Agg?.__aggregateEvents ?? []).map((e: any) => e?.event));
|
|
255
|
+
push("emits", emitted);
|
|
256
|
+
// External events handled by the aggregate.
|
|
257
|
+
const handled = typeof (el as any).getElements === "function" ? (el as any).getElements() : [];
|
|
258
|
+
push("handles", depTargets(handled));
|
|
259
|
+
// Per-method query/mutate dependencies.
|
|
260
|
+
for (const m of Agg?.__aggregateMutateMethods ?? []) {
|
|
261
|
+
push("queries", depTargets(m?.queryElements));
|
|
262
|
+
push("mutates", depTargets(m?.mutationElements));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return edges;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Public API
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
export function buildMapJson(input: BuildMapInput): MapJson {
|
|
274
|
+
const { context, moduleByElement, componentEdges = [], components = [] } = input;
|
|
275
|
+
const elements: MapElement[] = [];
|
|
276
|
+
const nodeIndex = new Map<string, MapElement>();
|
|
277
|
+
const spaceIndex = new Map<string, MapSpace>();
|
|
278
|
+
|
|
279
|
+
const registerSpace = (id: string, name?: string) => {
|
|
280
|
+
const existing = spaceIndex.get(id);
|
|
281
|
+
if (existing) {
|
|
282
|
+
if (name && existing.name === existing.id) existing.name = name;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
spaceIndex.set(id, { id, name: name ?? id });
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// 1) Element nodes from the live context.
|
|
289
|
+
for (const el of context.elements as ArcContextElementAny[]) {
|
|
290
|
+
const module = moduleByElement?.get(el.name);
|
|
291
|
+
let mapped: MapElement;
|
|
292
|
+
try {
|
|
293
|
+
const kind = detectKind(el);
|
|
294
|
+
mapped = extractElement(el, kind, module);
|
|
295
|
+
} catch {
|
|
296
|
+
mapped = {
|
|
297
|
+
id: el.name,
|
|
298
|
+
name: el.name,
|
|
299
|
+
kind: "unknown",
|
|
300
|
+
spaceId: spaceOf(el, module),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (nodeIndex.has(mapped.id)) continue;
|
|
304
|
+
elements.push(mapped);
|
|
305
|
+
nodeIndex.set(mapped.id, mapped);
|
|
306
|
+
registerSpace(mapped.spaceId, spaceLabelOf(el, module));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 1b) Page nodes (arc page() registry) — UI layer, no domain edges.
|
|
310
|
+
for (const page of input.pages ?? []) {
|
|
311
|
+
if (nodeIndex.has(page.id)) continue;
|
|
312
|
+
elements.push(page);
|
|
313
|
+
nodeIndex.set(page.id, page);
|
|
314
|
+
registerSpace(page.spaceId, page.moduleName);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 2) Synthesize event nodes for aggregate-declared events not present as
|
|
318
|
+
// standalone elements, so `emits` edges always have a target.
|
|
319
|
+
for (const el of context.elements as ArcContextElementAny[]) {
|
|
320
|
+
if (!(el instanceof ArcAggregateElement)) continue;
|
|
321
|
+
const Agg = (el as any).Aggregate;
|
|
322
|
+
const aggModule = moduleByElement?.get(el.name);
|
|
323
|
+
for (const entry of Agg?.__aggregateEvents ?? []) {
|
|
324
|
+
const ev = entry?.event;
|
|
325
|
+
const name: string | undefined = ev?.name;
|
|
326
|
+
if (!name || nodeIndex.has(name)) continue;
|
|
327
|
+
const spaceId = spaceOf(el, aggModule);
|
|
328
|
+
const synth: MapElement = {
|
|
329
|
+
id: name,
|
|
330
|
+
name,
|
|
331
|
+
kind: "event",
|
|
332
|
+
spaceId,
|
|
333
|
+
payload: toJsonSchema(ev?.payload),
|
|
334
|
+
};
|
|
335
|
+
elements.push(synth);
|
|
336
|
+
nodeIndex.set(name, synth);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Resolve a referenced name to a node — with a fallback for namespaced event
|
|
341
|
+
// names: aggregate inline events are referenced as `aggregate.eventName` by
|
|
342
|
+
// listeners/views (via getEvent) but registered under the short `eventName`.
|
|
343
|
+
const resolveNode = (name: string): MapElement | undefined => {
|
|
344
|
+
const direct = nodeIndex.get(name);
|
|
345
|
+
if (direct) return direct;
|
|
346
|
+
const dot = name.lastIndexOf(".");
|
|
347
|
+
return dot >= 0 ? nodeIndex.get(name.slice(dot + 1)) : undefined;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// 3) Domain edges (only those whose target resolves to a known node).
|
|
351
|
+
const edges: MapEdge[] = [];
|
|
352
|
+
const seenEdge = new Set<string>();
|
|
353
|
+
for (const el of context.elements as ArcContextElementAny[]) {
|
|
354
|
+
let raw: RawEdge[] = [];
|
|
355
|
+
try {
|
|
356
|
+
raw = collectRawEdges(el, detectKind(el));
|
|
357
|
+
} catch {
|
|
358
|
+
raw = [];
|
|
359
|
+
}
|
|
360
|
+
for (const r of raw) {
|
|
361
|
+
const sourceNode = resolveNode(r.source);
|
|
362
|
+
for (const target of r.targets) {
|
|
363
|
+
const targetNode = resolveNode(target.name);
|
|
364
|
+
if (!sourceNode || !targetNode || sourceNode.id === targetNode.id) continue;
|
|
365
|
+
const id = `${sourceNode.id}->${targetNode.id}:${r.kind}`;
|
|
366
|
+
if (seenEdge.has(id)) continue;
|
|
367
|
+
seenEdge.add(id);
|
|
368
|
+
edges.push({
|
|
369
|
+
id,
|
|
370
|
+
source: sourceNode.id,
|
|
371
|
+
target: targetNode.id,
|
|
372
|
+
kind: r.kind,
|
|
373
|
+
crossContext: sourceNode.spaceId !== targetNode.spaceId,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 4) Keep only component edges that resolve to a known element node.
|
|
380
|
+
const resolvedComponentEdges = componentEdges.filter((e) => nodeIndex.has(e.target));
|
|
381
|
+
const usedComponentIds = new Set(resolvedComponentEdges.map((e) => e.source));
|
|
382
|
+
const resolvedComponents = components.filter((c) => usedComponentIds.has(c.id));
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
spaces: [...spaceIndex.values()],
|
|
386
|
+
elements,
|
|
387
|
+
edges,
|
|
388
|
+
components: resolvedComponents,
|
|
389
|
+
componentEdges: resolvedComponentEdges,
|
|
390
|
+
generatedAt: input.generatedAt,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds an element-name -> module attribution index from the platform
|
|
3
|
+
* registry. Best-effort and defensive: the map is a UI tag, so any registry
|
|
4
|
+
* shape mismatch degrades to an empty index rather than throwing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getContextElementFragments,
|
|
9
|
+
getModule,
|
|
10
|
+
getPageFragments,
|
|
11
|
+
} from "@arcote.tech/platform";
|
|
12
|
+
import type { ModuleInfo } from "./mapper";
|
|
13
|
+
import type { MapElement } from "./types";
|
|
14
|
+
import { normalizePagePath } from "./refs";
|
|
15
|
+
|
|
16
|
+
export function buildModuleIndex(): Map<string, ModuleInfo> {
|
|
17
|
+
const index = new Map<string, ModuleInfo>();
|
|
18
|
+
try {
|
|
19
|
+
for (const frag of getContextElementFragments()) {
|
|
20
|
+
const element = (frag as any).element;
|
|
21
|
+
const moduleId = (frag as any).moduleId;
|
|
22
|
+
const name = element?.name;
|
|
23
|
+
if (!name || !moduleId) continue;
|
|
24
|
+
// First module to own an element wins (mirrors element __contextId origin).
|
|
25
|
+
if (index.has(name)) continue;
|
|
26
|
+
const mod = getModule(moduleId) as any;
|
|
27
|
+
index.set(name, {
|
|
28
|
+
moduleId,
|
|
29
|
+
moduleName: mod?.name ?? moduleId,
|
|
30
|
+
scope: mod?.scope,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// registry shape changed — leave the index empty.
|
|
35
|
+
}
|
|
36
|
+
return index;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Page nodes from the arc `page()` registry (UI layer), flattened over children. */
|
|
40
|
+
export function buildPageNodes(): MapElement[] {
|
|
41
|
+
const out: MapElement[] = [];
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
const walk = (frags: any[]) => {
|
|
44
|
+
for (const p of frags ?? []) {
|
|
45
|
+
// Identify the page by its normalized route PATH (never the module key) so
|
|
46
|
+
// it collapses onto the same node `<Arc page="…">` resolves to.
|
|
47
|
+
const path: string | undefined = p?.path ? normalizePagePath(p.path) : undefined;
|
|
48
|
+
if (path && !seen.has(path)) {
|
|
49
|
+
seen.add(path);
|
|
50
|
+
const mod = p.moduleId ? (getModule(p.moduleId) as any) : undefined;
|
|
51
|
+
out.push({
|
|
52
|
+
id: path,
|
|
53
|
+
name: path,
|
|
54
|
+
kind: "page",
|
|
55
|
+
spaceId: mod?.name ? `mod:${mod.name}` : "pages",
|
|
56
|
+
moduleName: mod?.name,
|
|
57
|
+
description: typeof p.label === "string" ? p.label : undefined,
|
|
58
|
+
path,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (p?.children?.length) walk(p.children);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
walk(getPageFragments());
|
|
66
|
+
} catch {
|
|
67
|
+
// registry shape changed — no pages.
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
package/src/refs.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution of `<Arc>` use-case references to map nodes.
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free (no React) so it is unit-testable and importable by both the
|
|
5
|
+
* `<Arc>` component and the subgraph builder. The `<Arc>` props are a typed
|
|
6
|
+
* union per element kind; `resolveRef` maps them to a target node id (an
|
|
7
|
+
* element name present in `MapJson.elements`) plus an optional aggregate method.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Props accepted by the `<Arc>` component (one kind per element). */
|
|
11
|
+
export interface ArcRefProps {
|
|
12
|
+
command?: string;
|
|
13
|
+
event?: string;
|
|
14
|
+
view?: string;
|
|
15
|
+
staticView?: string;
|
|
16
|
+
listener?: string;
|
|
17
|
+
route?: string;
|
|
18
|
+
aggregate?: string;
|
|
19
|
+
/** With `aggregate`: target a mutation method. */
|
|
20
|
+
mutationMethod?: string;
|
|
21
|
+
/** With `aggregate`: target a query method. */
|
|
22
|
+
queryMethod?: string;
|
|
23
|
+
/** A registered page (arc `page()`), referenced by its route path. */
|
|
24
|
+
page?: string;
|
|
25
|
+
/** A React component / UI file (matched against the ts-morph scan). */
|
|
26
|
+
component?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ArcRefKind =
|
|
30
|
+
| "command"
|
|
31
|
+
| "event"
|
|
32
|
+
| "view"
|
|
33
|
+
| "static-view"
|
|
34
|
+
| "listener"
|
|
35
|
+
| "route"
|
|
36
|
+
| "aggregate"
|
|
37
|
+
| "page";
|
|
38
|
+
|
|
39
|
+
export interface ResolvedRef {
|
|
40
|
+
/** Target node id — an element name in MapJson. */
|
|
41
|
+
nodeId: string;
|
|
42
|
+
kind: ArcRefKind;
|
|
43
|
+
/** Aggregate method, when the ref targets one. */
|
|
44
|
+
method?: { name: string; kind: "mutation" | "query" };
|
|
45
|
+
/** Canonical key for dedup / chip<->node linking. */
|
|
46
|
+
key: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Canonical page identity = its route PATH, stripped of query/hash and any
|
|
51
|
+
* trailing slash (root "/" kept). Pages are identified by path, never by the
|
|
52
|
+
* module's page key — the key (`{ checkout: page("/checkout", …) }`) is a
|
|
53
|
+
* module-local name absent from the fragment, while the path is the stable
|
|
54
|
+
* identity `buildPageNodes` registers nodes under. Normalizing here AND in
|
|
55
|
+
* buildPageNodes guarantees `<Arc page="/checkout">` and a node registered as
|
|
56
|
+
* `/checkout` collapse to the same node regardless of incidental `?query`.
|
|
57
|
+
*/
|
|
58
|
+
export function normalizePagePath(path: string): string {
|
|
59
|
+
let p = path.split("?")[0].split("#")[0].trim();
|
|
60
|
+
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map `<Arc>` props to a resolved reference, or null if no recognised kind is
|
|
66
|
+
* present. Resolution is purely structural — whether the node actually exists
|
|
67
|
+
* is validated separately against MapJson (front-side red-border).
|
|
68
|
+
*/
|
|
69
|
+
export function resolveRef(props: ArcRefProps): ResolvedRef | null {
|
|
70
|
+
if (props.command) {
|
|
71
|
+
return { nodeId: props.command, kind: "command", key: `command:${props.command}` };
|
|
72
|
+
}
|
|
73
|
+
if (props.view) {
|
|
74
|
+
return { nodeId: props.view, kind: "view", key: `view:${props.view}` };
|
|
75
|
+
}
|
|
76
|
+
if (props.staticView) {
|
|
77
|
+
return { nodeId: props.staticView, kind: "static-view", key: `static-view:${props.staticView}` };
|
|
78
|
+
}
|
|
79
|
+
if (props.listener) {
|
|
80
|
+
return { nodeId: props.listener, kind: "listener", key: `listener:${props.listener}` };
|
|
81
|
+
}
|
|
82
|
+
if (props.route) {
|
|
83
|
+
return { nodeId: props.route, kind: "route", key: `route:${props.route}` };
|
|
84
|
+
}
|
|
85
|
+
if (props.page) {
|
|
86
|
+
const path = normalizePagePath(props.page);
|
|
87
|
+
return { nodeId: path, kind: "page", key: `page:${path}` };
|
|
88
|
+
}
|
|
89
|
+
if (props.component) {
|
|
90
|
+
return { nodeId: props.component, kind: "page", key: `component:${props.component}` };
|
|
91
|
+
}
|
|
92
|
+
if (props.aggregate) {
|
|
93
|
+
if (props.mutationMethod) {
|
|
94
|
+
return {
|
|
95
|
+
nodeId: props.aggregate,
|
|
96
|
+
kind: "aggregate",
|
|
97
|
+
method: { name: props.mutationMethod, kind: "mutation" },
|
|
98
|
+
key: `aggregate:${props.aggregate}#mutate:${props.mutationMethod}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (props.queryMethod) {
|
|
102
|
+
return {
|
|
103
|
+
nodeId: props.aggregate,
|
|
104
|
+
kind: "aggregate",
|
|
105
|
+
method: { name: props.queryMethod, kind: "query" },
|
|
106
|
+
key: `aggregate:${props.aggregate}#query:${props.queryMethod}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// `<Arc aggregate="x" event="e">` — the inline event is a node of its own
|
|
110
|
+
// (the mapper synthesises event nodes for aggregate events).
|
|
111
|
+
if (props.event) {
|
|
112
|
+
return { nodeId: props.event, kind: "event", key: `event:${props.event}` };
|
|
113
|
+
}
|
|
114
|
+
return { nodeId: props.aggregate, kind: "aggregate", key: `aggregate:${props.aggregate}` };
|
|
115
|
+
}
|
|
116
|
+
// Standalone event (no aggregate).
|
|
117
|
+
if (props.event) {
|
|
118
|
+
return { nodeId: props.event, kind: "event", key: `event:${props.event}` };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|