@mat3ra/esse 2026.9.5-0 → 2026.9.6-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -139,7 +139,7 @@ function categorizationSchemesFragment(graph) {
|
|
|
139
139
|
const rest = graph.nodes.filter((node) => node.id.startsWith(prefix) && !reachesCategories(node.id));
|
|
140
140
|
const tiered = total - rest.length;
|
|
141
141
|
if (!tiered)
|
|
142
|
-
return [`\`${domain}\``, String(total), "
|
|
142
|
+
return [`\`${domain}\``, String(total), "M-CODE — compositional"];
|
|
143
143
|
// Today the untiered remainder is entirely enum holders; say so only while true.
|
|
144
144
|
const allEnums = rest.every((node) => /\/enum-options$|\/enums$/.test(node.id));
|
|
145
145
|
const remainder = rest.length
|
|
@@ -245,13 +245,15 @@ function renderPage(page, pages, html) {
|
|
|
245
245
|
</head>
|
|
246
246
|
<body>
|
|
247
247
|
<div id="titlebar">
|
|
248
|
-
<span class="app-name">AI4Materials
|
|
248
|
+
<span class="app-name">AI4Materials</span>
|
|
249
249
|
<nav id="surfaces">
|
|
250
250
|
<a href="index.html" class="current">Docs</a>
|
|
251
251
|
<a href="../index.html">Explorer</a>
|
|
252
|
+
<a href="../index.html#/categories">Categories</a>
|
|
253
|
+
<a href="../index.html#/directories">Directories</a>
|
|
252
254
|
<a href="../map/index.html">Ontology</a>
|
|
253
255
|
</nav>
|
|
254
|
-
<span id="titlebar-tagline">ESSE · materials science ontology</span>
|
|
256
|
+
<span id="titlebar-tagline">ESSE · materials science data standards and ontology</span>
|
|
255
257
|
</div>
|
|
256
258
|
<div id="workspace">
|
|
257
259
|
<aside id="docs-nav">
|
|
@@ -308,15 +310,16 @@ body {
|
|
|
308
310
|
z-index: 5;
|
|
309
311
|
}
|
|
310
312
|
#titlebar .app-name { color: var(--text-primary); font-weight: 600; }
|
|
311
|
-
/*
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
#surfaces { display:
|
|
313
|
+
/* Five items do not fit a phone: the nav shrinks and scrolls inside the bar rather than
|
|
314
|
+
being clipped or pushing the page sideways. */
|
|
315
|
+
#surfaces { display: flex; gap: 14px; flex: 0 1 auto; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
|
316
|
+
#surfaces::-webkit-scrollbar { display: none; }
|
|
317
|
+
@media (max-width: 700px) { #surfaces { gap: 10px; } }
|
|
315
318
|
#surfaces a { color: var(--text-muted); text-decoration: none; }
|
|
316
319
|
#surfaces a:hover { color: var(--text-primary); }
|
|
317
320
|
#surfaces a.current { color: var(--text-primary); border-bottom: 2px solid var(--accent); }
|
|
318
|
-
#titlebar-tagline { margin-left: auto; color: var(--text-muted); }
|
|
319
|
-
@media (max-width:
|
|
321
|
+
#titlebar-tagline { margin-left: auto; color: var(--text-muted); white-space: nowrap; }
|
|
322
|
+
@media (max-width: 900px) { #titlebar-tagline { display: none; } }
|
|
320
323
|
#workspace { display: flex; flex: 1; align-items: flex-start; }
|
|
321
324
|
#docs-nav {
|
|
322
325
|
width: 250px;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EntityGraph } from "./buildEntityGraph";
|
|
2
|
+
/**
|
|
3
|
+
* One openable file in a view.
|
|
4
|
+
*
|
|
5
|
+
* `segments` is the folder chain with the leaf label last. An array rather than a joined
|
|
6
|
+
* string because leaf labels themselves contain slashes — a catalogue entry filed under a
|
|
7
|
+
* vocabulary node reads `models_directory/gga.json` — and joining would invent folders.
|
|
8
|
+
*/
|
|
9
|
+
export interface ExplorerViewEntry {
|
|
10
|
+
segments: string[];
|
|
11
|
+
/** Site-relative path of the file to open, e.g. "schema/models_directory/gga.json". */
|
|
12
|
+
path: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ExplorerViews {
|
|
15
|
+
categories: ExplorerViewEntry[];
|
|
16
|
+
directories: ExplorerViewEntry[];
|
|
17
|
+
}
|
|
18
|
+
export declare function buildExplorerViews(graph: EntityGraph): ExplorerViews;
|
|
19
|
+
/**
|
|
20
|
+
* L12 — the views stay in step with the corpus.
|
|
21
|
+
*
|
|
22
|
+
* A view that silently drops a schema is worse than no view: it tells the reader the corpus
|
|
23
|
+
* does not contain something it does contain. Every browsable schema must appear, and every
|
|
24
|
+
* row must open a file that exists.
|
|
25
|
+
*/
|
|
26
|
+
export declare function lintExplorerViews(graph: EntityGraph, views: ExplorerViews): string[];
|
|
27
|
+
/** Writes `views.json` into the given directory, creating it when missing. */
|
|
28
|
+
export declare function writeExplorerViews(views: ExplorerViews, outputDir: string): string;
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildExplorerViews = buildExplorerViews;
|
|
7
|
+
exports.lintExplorerViews = lintExplorerViews;
|
|
8
|
+
exports.writeExplorerViews = writeExplorerViews;
|
|
9
|
+
/**
|
|
10
|
+
* Builds the Explorer's Categories and Directories trees from the entity graph.
|
|
11
|
+
*
|
|
12
|
+
* The Explorer browses by file path, which is the one arrangement that cannot show what the
|
|
13
|
+
* categorization layer is *for*: a CateCom tier ladder is a chain of `allOf`s, and M-CODE's
|
|
14
|
+
* three axes are three different ways through the same 17 recipes. Neither is a directory
|
|
15
|
+
* listing. These views are virtual trees over the same files — same widget, same editor, same
|
|
16
|
+
* tabs — so a coordinate becomes something you can walk.
|
|
17
|
+
*
|
|
18
|
+
* Built here rather than in the browser so the shape is testable and lint-able, and so the
|
|
19
|
+
* client stays a renderer. The output is `views.json`, written beside `graph.json`.
|
|
20
|
+
*/
|
|
21
|
+
const fs_1 = __importDefault(require("fs"));
|
|
22
|
+
const path_1 = __importDefault(require("path"));
|
|
23
|
+
const MODELS_ROOT = "Models · CateCom";
|
|
24
|
+
const METHODS_ROOT = "Methods · CateCom";
|
|
25
|
+
const MATERIALS_ROOT = "Materials · M-CODE";
|
|
26
|
+
/** Display names for the catalogues; the entry count is appended at build time. */
|
|
27
|
+
const CATALOGUE_LABELS = {
|
|
28
|
+
properties: "Properties",
|
|
29
|
+
models: "Models",
|
|
30
|
+
methods: "Methods",
|
|
31
|
+
software: "Software",
|
|
32
|
+
"context-providers": "Context providers",
|
|
33
|
+
};
|
|
34
|
+
/** Titles are written for a schema reader; a tree row wants the noun, not the file kind. */
|
|
35
|
+
function shortTitle(node) {
|
|
36
|
+
return node.title
|
|
37
|
+
.replace(/\s+(category|configuration)?\s*schema$/i, "")
|
|
38
|
+
.replace(/\s+category$/i, "")
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The published file name, not the source one. They differ: a source basename may contain a
|
|
43
|
+
* dash, which round-trips to an underscore through `$id` (`p-norm.json` is published as
|
|
44
|
+
* `p_norm.json`). Labelling a row with the source name names a file the site does not serve
|
|
45
|
+
* and that searching for the published name cannot find.
|
|
46
|
+
*/
|
|
47
|
+
function fileName(node) {
|
|
48
|
+
return node.publishedPath.split("/").pop();
|
|
49
|
+
}
|
|
50
|
+
function slugOf(node) {
|
|
51
|
+
return node.id.split("/").pop();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `pb · physics-based model`. The title is dropped when it adds nothing — either because it
|
|
55
|
+
* is the slug, or because it merely ends with it, which would otherwise print the slug twice
|
|
56
|
+
* (`nstruct · Unstructured meshing category nstruct`).
|
|
57
|
+
*/
|
|
58
|
+
function vocabularyLabel(node) {
|
|
59
|
+
const slug = slugOf(node);
|
|
60
|
+
const title = shortTitle(node)
|
|
61
|
+
.replace(new RegExp(`\\s*\\b${slug}$`, "i"), "")
|
|
62
|
+
.trim();
|
|
63
|
+
return title && title.toLowerCase() !== slug.toLowerCase() ? `${slug} · ${title}` : slug;
|
|
64
|
+
}
|
|
65
|
+
function examplePath(node) {
|
|
66
|
+
return node.hasExample ? node.publishedPath.replace(/^schema\//, "example/") : undefined;
|
|
67
|
+
}
|
|
68
|
+
function buildContext(graph) {
|
|
69
|
+
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
70
|
+
const ladderParents = new Map();
|
|
71
|
+
const filedUnder = new Map();
|
|
72
|
+
const filedEntries = new Map();
|
|
73
|
+
graph.edges.forEach((edge) => {
|
|
74
|
+
var _a, _b;
|
|
75
|
+
const source = byId.get(edge.source);
|
|
76
|
+
const target = byId.get(edge.target);
|
|
77
|
+
if (!source || !target)
|
|
78
|
+
return;
|
|
79
|
+
if (edge.kind === "extends" && source.layer === "category" && target.layer === "category") {
|
|
80
|
+
const parents = (_a = ladderParents.get(edge.source)) !== null && _a !== void 0 ? _a : [];
|
|
81
|
+
parents.push(edge.target);
|
|
82
|
+
ladderParents.set(edge.source, parents);
|
|
83
|
+
}
|
|
84
|
+
if (edge.kind === "contains" &&
|
|
85
|
+
edge.label === "categories" &&
|
|
86
|
+
source.layer === "directory" &&
|
|
87
|
+
target.layer === "category") {
|
|
88
|
+
filedUnder.set(edge.source, edge.target);
|
|
89
|
+
const entries = (_b = filedEntries.get(edge.target)) !== null && _b !== void 0 ? _b : [];
|
|
90
|
+
entries.push(edge.source);
|
|
91
|
+
filedEntries.set(edge.target, entries);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
ladderParents.forEach((parents) => parents.sort());
|
|
95
|
+
filedEntries.forEach((entries) => entries.sort());
|
|
96
|
+
return { byId, ladderParents, filedUnder, filedEntries };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The chain of vocabulary schemas from the top of the ladder down to `nodeId`.
|
|
100
|
+
*
|
|
101
|
+
* This is the `allOf` chain, not the directory path: it is how the corpus itself defines the
|
|
102
|
+
* ladder, and it is right where the path is not. `methods_category/mathematical/regression`
|
|
103
|
+
* narrows a lower field than its depth suggests, so reading position off the path — or off
|
|
104
|
+
* which facet fields are present — misplaces it.
|
|
105
|
+
*/
|
|
106
|
+
function ladderChain(nodeId, context) {
|
|
107
|
+
var _a;
|
|
108
|
+
const chain = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
let current = nodeId;
|
|
111
|
+
while (current && !seen.has(current)) {
|
|
112
|
+
seen.add(current);
|
|
113
|
+
const node = context.byId.get(current);
|
|
114
|
+
if (!node)
|
|
115
|
+
break;
|
|
116
|
+
chain.push(node);
|
|
117
|
+
[current] = (_a = context.ladderParents.get(current)) !== null && _a !== void 0 ? _a : [];
|
|
118
|
+
}
|
|
119
|
+
return chain.reverse();
|
|
120
|
+
}
|
|
121
|
+
function isEnumHolder(node) {
|
|
122
|
+
var _a;
|
|
123
|
+
return ((_a = node.facets) === null || _a === void 0 ? void 0 : _a.role) === "enum-options";
|
|
124
|
+
}
|
|
125
|
+
// ── Categories ────────────────────────────────────────────────────────────────
|
|
126
|
+
function categoriesView(graph, context) {
|
|
127
|
+
const entries = [];
|
|
128
|
+
const push = (segments, node) => {
|
|
129
|
+
entries.push({ segments: [...segments, fileName(node)], path: node.publishedPath });
|
|
130
|
+
const example = examplePath(node);
|
|
131
|
+
if (example) {
|
|
132
|
+
entries.push({ segments: [...segments, `${fileName(node)} · example`], path: example });
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
graph.nodes
|
|
136
|
+
.filter((node) => { var _a; return ((_a = node.facets) === null || _a === void 0 ? void 0 : _a.role) === "vocabulary"; })
|
|
137
|
+
.forEach((node) => {
|
|
138
|
+
var _a, _b;
|
|
139
|
+
const root = node.domain === "models_category" ? MODELS_ROOT : METHODS_ROOT;
|
|
140
|
+
// The branch is the first path segment but not part of the `allOf` chain, so it
|
|
141
|
+
// has to be prepended for a methods coordinate to be complete.
|
|
142
|
+
const branch = (_a = node.facets) === null || _a === void 0 ? void 0 : _a.branch;
|
|
143
|
+
const folders = [
|
|
144
|
+
root,
|
|
145
|
+
...(branch ? [branch] : []),
|
|
146
|
+
...ladderChain(node.id, context).map(vocabularyLabel),
|
|
147
|
+
];
|
|
148
|
+
push(folders, node);
|
|
149
|
+
((_b = context.filedEntries.get(node.id)) !== null && _b !== void 0 ? _b : []).forEach((entryId) => {
|
|
150
|
+
const entry = context.byId.get(entryId);
|
|
151
|
+
if (!entry)
|
|
152
|
+
return;
|
|
153
|
+
// Named with its domain: inside `gga · DFT GGA functional` this sits beside
|
|
154
|
+
// the vocabulary schema's own `gga.json`, and the two must not read alike.
|
|
155
|
+
const label = `${entry.domain}/${fileName(entry)}`;
|
|
156
|
+
entries.push({ segments: [...folders, label], path: entry.publishedPath });
|
|
157
|
+
const example = examplePath(entry);
|
|
158
|
+
if (example) {
|
|
159
|
+
entries.push({
|
|
160
|
+
segments: [...folders, `${label} · example`],
|
|
161
|
+
path: example,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
// M-CODE's axes are orthogonal, so a recipe is filed under each of them rather than
|
|
167
|
+
// once: reaching a slab by dimensionality and by structural class are both legitimate.
|
|
168
|
+
graph.nodes
|
|
169
|
+
.filter((node) => { var _a; return ((_a = node.facets) === null || _a === void 0 ? void 0 : _a.role) === "recipe"; })
|
|
170
|
+
.forEach((node) => {
|
|
171
|
+
var _a;
|
|
172
|
+
const { structuralClass, dimensionality, operation } = (_a = node.facets) !== null && _a !== void 0 ? _a : {};
|
|
173
|
+
if (structuralClass && dimensionality) {
|
|
174
|
+
push([MATERIALS_ROOT, "by structural class", structuralClass, dimensionality], node);
|
|
175
|
+
push([MATERIALS_ROOT, "by dimensionality", dimensionality, structuralClass], node);
|
|
176
|
+
}
|
|
177
|
+
if (operation)
|
|
178
|
+
push([MATERIALS_ROOT, "by operation", operation], node);
|
|
179
|
+
});
|
|
180
|
+
graph.nodes
|
|
181
|
+
.filter((node) => { var _a; return ((_a = node.facets) === null || _a === void 0 ? void 0 : _a.role) === "entity"; })
|
|
182
|
+
.forEach((node) => {
|
|
183
|
+
var _a;
|
|
184
|
+
const { entityRole, dimensionality } = (_a = node.facets) !== null && _a !== void 0 ? _a : {};
|
|
185
|
+
push([
|
|
186
|
+
MATERIALS_ROOT,
|
|
187
|
+
"components",
|
|
188
|
+
"entities",
|
|
189
|
+
...(entityRole ? [entityRole] : []),
|
|
190
|
+
...(dimensionality ? [dimensionality] : []),
|
|
191
|
+
], node);
|
|
192
|
+
});
|
|
193
|
+
graph.nodes
|
|
194
|
+
.filter((node) => { var _a; return ((_a = node.facets) === null || _a === void 0 ? void 0 : _a.role) === "operation"; })
|
|
195
|
+
.forEach((node) => {
|
|
196
|
+
var _a;
|
|
197
|
+
const { operationKind } = (_a = node.facets) !== null && _a !== void 0 ? _a : {};
|
|
198
|
+
push([
|
|
199
|
+
MATERIALS_ROOT,
|
|
200
|
+
"components",
|
|
201
|
+
"operations",
|
|
202
|
+
...(operationKind ? [operationKind] : []),
|
|
203
|
+
], node);
|
|
204
|
+
});
|
|
205
|
+
return entries;
|
|
206
|
+
}
|
|
207
|
+
// ── Directories ───────────────────────────────────────────────────────────────
|
|
208
|
+
/** Where a catalogue entry sits below its catalogue root. */
|
|
209
|
+
function directoryGroup(node, context) {
|
|
210
|
+
var _a, _b, _c;
|
|
211
|
+
const facets = (_a = node.facets) !== null && _a !== void 0 ? _a : {};
|
|
212
|
+
if (facets.legacy === "true")
|
|
213
|
+
return ["legacy"];
|
|
214
|
+
switch (facets.catalogue) {
|
|
215
|
+
case "properties":
|
|
216
|
+
return facets.valueShape ? [facets.valueShape] : ["uncategorized"];
|
|
217
|
+
case "software":
|
|
218
|
+
return facets.softwareKind ? [facets.softwareKind] : ["uncategorized"];
|
|
219
|
+
case "context-providers":
|
|
220
|
+
return [
|
|
221
|
+
...(facets.scope ? [facets.scope] : ["uncategorized"]),
|
|
222
|
+
...(facets.application ? [facets.application] : []),
|
|
223
|
+
];
|
|
224
|
+
case "models":
|
|
225
|
+
case "methods": {
|
|
226
|
+
// The coordinate, slugs only — a position on the ladder, not a label for one.
|
|
227
|
+
const target = context.filedUnder.get(node.id);
|
|
228
|
+
if (!target)
|
|
229
|
+
return ["uncategorized"];
|
|
230
|
+
const branch = (_c = (_b = context.byId.get(target)) === null || _b === void 0 ? void 0 : _b.facets) === null || _c === void 0 ? void 0 : _c.branch;
|
|
231
|
+
return [...(branch ? [branch] : []), ...ladderChain(target, context).map(slugOf)];
|
|
232
|
+
}
|
|
233
|
+
default:
|
|
234
|
+
return ["uncategorized"];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function directoriesView(graph, context) {
|
|
238
|
+
const directories = graph.nodes.filter((node) => node.layer === "directory" && !isEnumHolder(node));
|
|
239
|
+
const counts = new Map();
|
|
240
|
+
directories.forEach((node) => {
|
|
241
|
+
var _a, _b, _c;
|
|
242
|
+
const catalogue = (_b = (_a = node.facets) === null || _a === void 0 ? void 0 : _a.catalogue) !== null && _b !== void 0 ? _b : "other";
|
|
243
|
+
counts.set(catalogue, ((_c = counts.get(catalogue)) !== null && _c !== void 0 ? _c : 0) + 1);
|
|
244
|
+
});
|
|
245
|
+
const entries = [];
|
|
246
|
+
directories.forEach((node) => {
|
|
247
|
+
var _a, _b, _c, _d;
|
|
248
|
+
const catalogue = (_b = (_a = node.facets) === null || _a === void 0 ? void 0 : _a.catalogue) !== null && _b !== void 0 ? _b : "other";
|
|
249
|
+
const label = (_c = CATALOGUE_LABELS[catalogue]) !== null && _c !== void 0 ? _c : catalogue;
|
|
250
|
+
const root = `${label} (${(_d = counts.get(catalogue)) !== null && _d !== void 0 ? _d : 0})`;
|
|
251
|
+
const folders = [root, ...directoryGroup(node, context)];
|
|
252
|
+
entries.push({ segments: [...folders, fileName(node)], path: node.publishedPath });
|
|
253
|
+
const example = examplePath(node);
|
|
254
|
+
if (example) {
|
|
255
|
+
entries.push({ segments: [...folders, `${fileName(node)} · example`], path: example });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
return entries;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Makes every leaf label unique within its folder.
|
|
262
|
+
*
|
|
263
|
+
* File names in this corpus are often generic: all 17 M-CODE recipes are `configuration.json`
|
|
264
|
+
* and the recipe name is the folder above them, so grouping by facet lands four indentical
|
|
265
|
+
* rows side by side. Where that happens, as much of the source path is prefixed as it takes
|
|
266
|
+
* to tell them apart — `adatom/configuration.json`, `island/configuration.json`.
|
|
267
|
+
*/
|
|
268
|
+
function disambiguateLeaves(entries) {
|
|
269
|
+
const groups = new Map();
|
|
270
|
+
entries.forEach((entry) => {
|
|
271
|
+
const key = entry.segments.join("\u0000");
|
|
272
|
+
const group = groups.get(key);
|
|
273
|
+
if (group)
|
|
274
|
+
group.push(entry);
|
|
275
|
+
else
|
|
276
|
+
groups.set(key, [entry]);
|
|
277
|
+
});
|
|
278
|
+
groups.forEach((group) => {
|
|
279
|
+
if (group.length < 2)
|
|
280
|
+
return;
|
|
281
|
+
const directoriesOf = (entry) => entry.path.split("/").slice(0, -1);
|
|
282
|
+
const deepest = Math.max(...group.map((entry) => directoriesOf(entry).length));
|
|
283
|
+
for (let depth = 1; depth <= deepest; depth += 1) {
|
|
284
|
+
const labelled = group.map((entry) => {
|
|
285
|
+
const leaf = entry.segments[entry.segments.length - 1];
|
|
286
|
+
const prefix = directoriesOf(entry).slice(-depth).join("/");
|
|
287
|
+
return prefix ? `${prefix}/${leaf}` : leaf;
|
|
288
|
+
});
|
|
289
|
+
if (new Set(labelled).size === group.length) {
|
|
290
|
+
group.forEach((entry, index) => {
|
|
291
|
+
entry.segments = [...entry.segments.slice(0, -1), labelled[index]];
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
return entries;
|
|
298
|
+
}
|
|
299
|
+
function compareEntries(a, b) {
|
|
300
|
+
const depth = Math.min(a.segments.length, b.segments.length);
|
|
301
|
+
for (let i = 0; i < depth; i += 1) {
|
|
302
|
+
const order = a.segments[i].localeCompare(b.segments[i]);
|
|
303
|
+
if (order !== 0)
|
|
304
|
+
return order;
|
|
305
|
+
}
|
|
306
|
+
return a.segments.length - b.segments.length || a.path.localeCompare(b.path);
|
|
307
|
+
}
|
|
308
|
+
function buildExplorerViews(graph) {
|
|
309
|
+
const context = buildContext(graph);
|
|
310
|
+
return {
|
|
311
|
+
categories: disambiguateLeaves(categoriesView(graph, context)).sort(compareEntries),
|
|
312
|
+
directories: disambiguateLeaves(directoriesView(graph, context)).sort(compareEntries),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* L12 — the views stay in step with the corpus.
|
|
317
|
+
*
|
|
318
|
+
* A view that silently drops a schema is worse than no view: it tells the reader the corpus
|
|
319
|
+
* does not contain something it does contain. Every browsable schema must appear, and every
|
|
320
|
+
* row must open a file that exists.
|
|
321
|
+
*/
|
|
322
|
+
function lintExplorerViews(graph, views) {
|
|
323
|
+
const failures = [];
|
|
324
|
+
const files = new Set();
|
|
325
|
+
graph.nodes.forEach((node) => {
|
|
326
|
+
files.add(node.publishedPath);
|
|
327
|
+
const example = examplePath(node);
|
|
328
|
+
if (example)
|
|
329
|
+
files.add(example);
|
|
330
|
+
});
|
|
331
|
+
const check = (name, entries) => {
|
|
332
|
+
const rows = new Map();
|
|
333
|
+
entries.forEach((entry) => {
|
|
334
|
+
if (!files.has(entry.path)) {
|
|
335
|
+
failures.push(`L12 views.json ${name}: "${entry.path}" is not a published file`);
|
|
336
|
+
}
|
|
337
|
+
if (entry.segments.length < 2) {
|
|
338
|
+
failures.push(`L12 views.json ${name}: "${entry.path}" has no folder above its leaf`);
|
|
339
|
+
}
|
|
340
|
+
// The label must name the file the row opens. Checked because it is the one
|
|
341
|
+
// thing here NOT derivable from the inputs: everything else in this function
|
|
342
|
+
// rebuilds `entry.path` from the same node fields that produced it, and so
|
|
343
|
+
// holds by construction. Reading the source path instead of the published one
|
|
344
|
+
// shipped four rows labelled `p-norm.json` that open `p_norm.json`.
|
|
345
|
+
const leaf = entry.segments[entry.segments.length - 1]
|
|
346
|
+
.replace(/ · example$/, "")
|
|
347
|
+
.split("/")
|
|
348
|
+
.pop();
|
|
349
|
+
if (leaf !== entry.path.split("/").pop()) {
|
|
350
|
+
failures.push(`L12 views.json ${name}: row "${entry.segments.join(" / ")}" is labelled ` +
|
|
351
|
+
`"${leaf}" but opens ${entry.path}`);
|
|
352
|
+
}
|
|
353
|
+
// Two rows reading the same in the same folder are indistinguishable to a
|
|
354
|
+
// reader, whichever files they point at.
|
|
355
|
+
const row = entry.segments.join(" / ");
|
|
356
|
+
const claimed = rows.get(row);
|
|
357
|
+
if (claimed && claimed !== entry.path) {
|
|
358
|
+
failures.push(`L12 views.json ${name}: "${row}" is used by both ${claimed} and ${entry.path}`);
|
|
359
|
+
}
|
|
360
|
+
rows.set(row, entry.path);
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
check("categories", views.categories);
|
|
364
|
+
check("directories", views.directories);
|
|
365
|
+
const countBy = (entries) => {
|
|
366
|
+
const counts = new Map();
|
|
367
|
+
entries.forEach((entry) => { var _a; return counts.set(entry.path, ((_a = counts.get(entry.path)) !== null && _a !== void 0 ? _a : 0) + 1); });
|
|
368
|
+
return counts;
|
|
369
|
+
};
|
|
370
|
+
// Every browsable category schema, not only the vocabulary pass: the recipe, entity and
|
|
371
|
+
// operation passes produce 145 of the 250 rows and were policed by nothing, so a schema
|
|
372
|
+
// one of them silently skipped would vanish from the tree with a green build. "At least
|
|
373
|
+
// once" rather than "exactly once" because M-CODE files a recipe under each axis it has.
|
|
374
|
+
const categoryCounts = countBy(views.categories);
|
|
375
|
+
graph.nodes
|
|
376
|
+
.filter((node) => node.layer === "category" && !isEnumHolder(node))
|
|
377
|
+
.forEach((node) => {
|
|
378
|
+
var _a, _b, _c;
|
|
379
|
+
const seen = (_a = categoryCounts.get(node.publishedPath)) !== null && _a !== void 0 ? _a : 0;
|
|
380
|
+
if (seen < 1) {
|
|
381
|
+
failures.push(`L12 views.json categories: ${node.id} (role ` +
|
|
382
|
+
`${(_c = (_b = node.facets) === null || _b === void 0 ? void 0 : _b.role) !== null && _c !== void 0 ? _c : "none"}) appears nowhere in the tree`);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
const directoryCounts = countBy(views.directories);
|
|
386
|
+
graph.nodes
|
|
387
|
+
.filter((node) => node.layer === "directory" && !isEnumHolder(node))
|
|
388
|
+
.forEach((node) => {
|
|
389
|
+
var _a;
|
|
390
|
+
const seen = (_a = directoryCounts.get(node.publishedPath)) !== null && _a !== void 0 ? _a : 0;
|
|
391
|
+
if (seen !== 1) {
|
|
392
|
+
failures.push(`L12 views.json directories: catalogue entry ${node.id} appears ${seen} ` +
|
|
393
|
+
`time(s), expected exactly 1`);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
const holders = graph.nodes.filter(isEnumHolder).map((node) => node.publishedPath);
|
|
397
|
+
holders.forEach((held) => {
|
|
398
|
+
if (categoryCounts.has(held) || directoryCounts.has(held)) {
|
|
399
|
+
failures.push(`L12 views.json: enum holder ${held} should not be browsable`);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
return failures;
|
|
403
|
+
}
|
|
404
|
+
/** Writes `views.json` into the given directory, creating it when missing. */
|
|
405
|
+
function writeExplorerViews(views, outputDir) {
|
|
406
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
407
|
+
const outputPath = path_1.default.join(outputDir, "views.json");
|
|
408
|
+
fs_1.default.writeFileSync(outputPath, `${JSON.stringify(views, null, 4)}\n`, "utf8");
|
|
409
|
+
return outputPath;
|
|
410
|
+
}
|
package/package.json
CHANGED