@mat3ra/esse 2026.9.5-0 → 2026.9.6-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.
@@ -15,5 +15,22 @@ export declare function parseFrontMatter(source: string): {
15
15
  body: string;
16
16
  };
17
17
  export declare function expandFragments(body: string, graph: EntityGraph): string;
18
+ /** A heading the in-page table of contents can link to. */
19
+ export interface DocsHeading {
20
+ id: string;
21
+ text: string;
22
+ level: number;
23
+ }
24
+ /**
25
+ * Gives every `h2`/`h3` an id and returns them in document order.
26
+ *
27
+ * `marked` emits bare heading tags, so without this there is nothing for a table of
28
+ * contents — or an external deep link — to point at. Ids are de-duplicated because two
29
+ * sections may legitimately share a title.
30
+ */
31
+ export declare function extractHeadings(html: string): {
32
+ html: string;
33
+ headings: DocsHeading[];
34
+ };
18
35
  export declare function buildDocsPages(outputDir: string): DocsPage[];
19
36
  export {};
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parseFrontMatter = parseFrontMatter;
7
7
  exports.expandFragments = expandFragments;
8
+ exports.extractHeadings = extractHeadings;
8
9
  exports.buildDocsPages = buildDocsPages;
9
10
  /**
10
11
  * Renders the concept documentation in `docs/` to static HTML for the site.
@@ -139,7 +140,7 @@ function categorizationSchemesFragment(graph) {
139
140
  const rest = graph.nodes.filter((node) => node.id.startsWith(prefix) && !reachesCategories(node.id));
140
141
  const tiered = total - rest.length;
141
142
  if (!tiered)
142
- return [`\`${domain}\``, String(total), "none — compositional"];
143
+ return [`\`${domain}\``, String(total), "M-CODE — compositional"];
143
144
  // Today the untiered remainder is entirely enum holders; say so only while true.
144
145
  const allEnums = rest.every((node) => /\/enum-options$|\/enums$/.test(node.id));
145
146
  const remainder = rest.length
@@ -229,7 +230,65 @@ function expandFragments(body, graph) {
229
230
  function escapeHtml(value) {
230
231
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
231
232
  }
232
- function renderPage(page, pages, html) {
233
+ function slugifyHeading(text) {
234
+ return text
235
+ .toLowerCase()
236
+ .replace(/[^a-z0-9]+/g, "-")
237
+ .replace(/^-+|-+$/g, "");
238
+ }
239
+ /**
240
+ * Gives every `h2`/`h3` an id and returns them in document order.
241
+ *
242
+ * `marked` emits bare heading tags, so without this there is nothing for a table of
243
+ * contents — or an external deep link — to point at. Ids are de-duplicated because two
244
+ * sections may legitimately share a title.
245
+ */
246
+ function extractHeadings(html) {
247
+ const headings = [];
248
+ const seen = new Map();
249
+ const withIds = html.replace(/<h([23])>([\s\S]*?)<\/h\1>/g, (_match, level, inner) => {
250
+ var _a;
251
+ const text = inner.replace(/<[^>]+>/g, "").trim();
252
+ const base = slugifyHeading(text) || `section-${headings.length + 1}`;
253
+ const count = (_a = seen.get(base)) !== null && _a !== void 0 ? _a : 0;
254
+ seen.set(base, count + 1);
255
+ const id = count === 0 ? base : `${base}-${count}`;
256
+ headings.push({ id, text, level: Number(level) });
257
+ return `<h${level} id="${id}">${inner}</h${level}>`;
258
+ });
259
+ return { html: withIds, headings };
260
+ }
261
+ function renderTableOfContents(headings) {
262
+ // One heading is the page title's own section; a list of one is noise.
263
+ if (headings.length < 2)
264
+ return "";
265
+ const items = headings
266
+ .map((heading) => `<a href="#${heading.id}" class="toc-h${heading.level}">` +
267
+ `${escapeHtml(heading.text)}</a>`)
268
+ .join("\n ");
269
+ return `<aside id="docs-toc">
270
+ <div class="nav-title">On this page</div>
271
+ <nav>
272
+ ${items}
273
+ </nav>
274
+ </aside>`;
275
+ }
276
+ /** Sequential links, so the pages can be read in the order they are written in. */
277
+ function renderPageNav(page, pages) {
278
+ const index = pages.findIndex((item) => item.slug === page.slug);
279
+ const previous = index > 0 ? pages[index - 1] : undefined;
280
+ const next = index < pages.length - 1 ? pages[index + 1] : undefined;
281
+ const link = (target, direction, label) => target
282
+ ? `<a class="page-nav-${direction}" href="${target.slug}.html">` +
283
+ `<span class="page-nav-label">${label}</span>` +
284
+ `<span class="page-nav-title">${escapeHtml(target.title)}</span></a>`
285
+ : `<span class="page-nav-${direction}"></span>`;
286
+ return `<nav class="page-nav">
287
+ ${link(previous, "prev", "Previous")}
288
+ ${link(next, "next", "Next")}
289
+ </nav>`;
290
+ }
291
+ function renderPage(page, pages, html, headings) {
233
292
  const nav = pages
234
293
  .map((item) => `<a href="${item.slug}.html"${item.slug === page.slug ? ' class="current"' : ""}>` +
235
294
  `${escapeHtml(item.title)}</a>`)
@@ -245,26 +304,36 @@ function renderPage(page, pages, html) {
245
304
  </head>
246
305
  <body>
247
306
  <div id="titlebar">
248
- <span class="app-name">AI4Materials<span class="app-name-suffix">: data standards</span></span>
307
+ <span class="app-name">AI4Materials</span>
249
308
  <nav id="surfaces">
250
309
  <a href="index.html" class="current">Docs</a>
251
310
  <a href="../index.html">Explorer</a>
311
+ <a href="../index.html#/categories">Categories</a>
312
+ <a href="../index.html#/directories">Directories</a>
252
313
  <a href="../map/index.html">Ontology</a>
253
314
  </nav>
254
- <span id="titlebar-tagline">ESSE &middot; materials science ontology</span>
315
+ <span id="titlebar-tagline">ESSE &middot; materials science data standards and ontology</span>
255
316
  </div>
256
317
  <div id="workspace">
257
318
  <aside id="docs-nav">
258
- <div class="nav-title">Concepts</div>
259
- <nav>
260
- ${nav}
261
- </nav>
319
+ <div class="docs-nav-inner">
320
+ <div class="nav-title">Concepts</div>
321
+ <nav>
322
+ ${nav}
323
+ </nav>
324
+ </div>
262
325
  </aside>
263
326
  <main id="docs-content">
264
327
  <article>
265
328
  ${html}
266
329
  </article>
330
+ ${renderPageNav(page, pages)}
267
331
  </main>
332
+ ${renderTableOfContents(headings)}
333
+ </div>
334
+ <div id="statusbar">
335
+ <span id="status-page">${escapeHtml(page.summary || page.title)}</span>
336
+ <span id="status-position">Page ${pages.findIndex((item) => item.slug === page.slug) + 1} of ${pages.length}</span>
268
337
  </div>
269
338
  </body>
270
339
  </html>
@@ -281,6 +350,8 @@ const DOCS_STYLESHEET = `/* Concept documentation — shares the explorer's pale
281
350
  --text-muted: #858585;
282
351
  --border: #3e3e3e;
283
352
  --accent: #4ea1ff;
353
+ /* The Explorer and the Ontology map both paint their status bar in this blue. */
354
+ --accent-bar: #0078d4;
284
355
  --code: #9cdcfe;
285
356
  }
286
357
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -308,31 +379,39 @@ body {
308
379
  z-index: 5;
309
380
  }
310
381
  #titlebar .app-name { color: var(--text-primary); font-weight: 600; }
311
- /* Completes the wordmark so the name never reads as a dangling fragment. */
312
- #titlebar .app-name-suffix { color: var(--text-muted); font-weight: 400; }
313
- @media (max-width: 700px) { #titlebar .app-name-suffix { display: none; } }
314
- #surfaces { display: flex; gap: 14px; }
382
+ /* Five items do not fit a phone: the nav shrinks and scrolls inside the bar rather than
383
+ being clipped or pushing the page sideways. */
384
+ #surfaces { display: flex; gap: 14px; flex: 0 1 auto; min-width: 0; overflow-x: auto; scrollbar-width: none; }
385
+ #surfaces::-webkit-scrollbar { display: none; }
386
+ @media (max-width: 700px) { #surfaces { gap: 10px; } }
315
387
  #surfaces a { color: var(--text-muted); text-decoration: none; }
316
388
  #surfaces a:hover { color: var(--text-primary); }
317
389
  #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: 640px) { #titlebar-tagline { display: none; } }
320
- #workspace { display: flex; flex: 1; align-items: flex-start; }
390
+ #titlebar-tagline { margin-left: auto; color: var(--text-muted); white-space: nowrap; }
391
+ @media (max-width: 900px) { #titlebar-tagline { display: none; } }
392
+ #workspace { display: flex; flex: 1; }
393
+ /*
394
+ * Two boxes, two jobs. The panel (#docs-nav) stretches for the full height of the
395
+ * workspace, so its background and border run the length of the page instead of stopping
396
+ * where the link list ends. The inner box is what sticks.
397
+ *
398
+ * Sticking the panel itself does not work: an element can only stick while it is shorter
399
+ * than the space it sticks within, so a full-height panel has no slack to hold a position
400
+ * in. Sizing the panel to its own content instead (align-self: flex-start) restores the
401
+ * sticking but leaves the panel visibly cut off part-way down the page. Separating the
402
+ * two concerns gets both.
403
+ */
321
404
  #docs-nav {
322
405
  width: 250px;
323
406
  flex-shrink: 0;
324
407
  background: var(--bg-sidebar);
325
408
  border-right: 1px solid var(--border);
326
- padding: 16px 0;
409
+ }
410
+ .docs-nav-inner {
327
411
  position: sticky;
328
412
  top: 34px;
329
- /* Not "stretch": that makes this box exactly as tall as #docs-content (the long
330
- sibling), leaving sticky no slack to hold its position in -- an element can only
331
- stick while it is shorter than the room it is sticking within. flex-start sizes
332
- this to its own (short) nav-list content, so it actually stays pinned. The height
333
- cap is a second guard: if the nav list ever grows past the viewport, it scrolls
334
- internally instead of being clipped or pushing off-screen. */
335
- align-self: flex-start;
413
+ padding: 16px 0;
414
+ /* If the list ever outgrows the viewport it scrolls itself rather than being clipped. */
336
415
  max-height: calc(100vh - 34px);
337
416
  overflow-y: auto;
338
417
  }
@@ -359,7 +438,8 @@ body {
359
438
  border-left-color: var(--accent);
360
439
  }
361
440
  #docs-content { flex: 1; min-width: 0; padding: 32px 40px 80px; }
362
- article { max-width: 68ch; }
441
+ /* A fixed measure, centred: on a wide screen the text should not hug the sidebar. */
442
+ article { max-width: 68ch; margin: 0 auto; }
363
443
  article h1 { font-size: 27px; font-weight: 600; margin-bottom: 18px; text-wrap: balance; }
364
444
  article h2 {
365
445
  font-size: 19px;
@@ -410,17 +490,88 @@ article th { background: var(--bg-sidebar); font-weight: 600; }
410
490
  article td:not(:first-child) { font-variant-numeric: tabular-nums; }
411
491
  article hr { border: none; border-top: 1px solid var(--border); margin: 26px 0; }
412
492
  :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
493
+
494
+ /* In-page contents. Mirrors the concept nav opposite it, one weight lighter. */
495
+ #docs-toc {
496
+ width: 220px;
497
+ flex-shrink: 0;
498
+ padding: 16px 0;
499
+ position: sticky;
500
+ top: 34px;
501
+ align-self: flex-start;
502
+ max-height: calc(100vh - 34px);
503
+ overflow-y: auto;
504
+ }
505
+ #docs-toc nav { display: flex; flex-direction: column; }
506
+ #docs-toc a {
507
+ padding: 4px 16px;
508
+ color: var(--text-muted);
509
+ text-decoration: none;
510
+ font-size: 12px;
511
+ line-height: 1.4;
512
+ border-left: 2px solid transparent;
513
+ }
514
+ #docs-toc a:hover { color: var(--text-primary); border-left-color: var(--border); }
515
+ #docs-toc a.toc-h3 { padding-left: 28px; font-size: 11.5px; }
516
+
517
+ /* The pages are written as a sequence; give the reader a way to follow it. */
518
+ .page-nav {
519
+ display: flex;
520
+ gap: 12px;
521
+ max-width: 68ch;
522
+ margin: 40px auto 0;
523
+ padding-top: 20px;
524
+ border-top: 1px solid var(--border);
525
+ }
526
+ .page-nav > * { flex: 1 1 0; min-width: 0; }
527
+ .page-nav a {
528
+ display: flex;
529
+ flex-direction: column;
530
+ gap: 3px;
531
+ padding: 10px 14px;
532
+ border: 1px solid var(--border);
533
+ border-radius: 3px;
534
+ text-decoration: none;
535
+ color: var(--text-primary);
536
+ }
537
+ .page-nav a:hover { background: var(--bg-hover); border-color: var(--accent); }
538
+ .page-nav-next { text-align: right; }
539
+ .page-nav-label { font-size: 11px; color: var(--text-muted); }
540
+ .page-nav-title { font-size: 13px; }
541
+
542
+ /* Matches the Explorer's and the map's, so the three surfaces end the same way. */
543
+ #statusbar {
544
+ height: 22px;
545
+ background: var(--accent-bar);
546
+ color: #fff;
547
+ display: flex;
548
+ align-items: center;
549
+ padding: 0 14px;
550
+ font-size: 12px;
551
+ flex-shrink: 0;
552
+ gap: 16px;
553
+ position: sticky;
554
+ bottom: 0;
555
+ }
556
+ #status-page { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
557
+
558
+ /* The contents column is the first thing to go when it stops earning its width. */
559
+ @media (max-width: 1100px) { #docs-toc { display: none; } }
413
560
  @media (max-width: 760px) {
414
561
  #workspace { flex-direction: column; }
415
562
  #docs-nav {
416
563
  width: 100%;
417
- position: static;
418
564
  border-right: none;
419
565
  border-bottom: 1px solid var(--border);
566
+ }
567
+ .docs-nav-inner {
568
+ position: static;
420
569
  max-height: none;
421
570
  overflow-y: visible;
422
571
  }
423
572
  #docs-content { padding: 20px; }
573
+ .page-nav { flex-direction: column; }
574
+ .page-nav-next { text-align: left; }
424
575
  }
425
576
  `;
426
577
  // ── Entry point ───────────────────────────────────────────────────────────────
@@ -448,11 +599,12 @@ function buildDocsPages(outputDir) {
448
599
  // should fail the build outright, not leave a half-written site behind.
449
600
  const rendered = pages.map((page) => {
450
601
  const expanded = expandFragments(page.body, graph);
451
- const html = marked_1.marked.parse(expanded, { async: false })
602
+ const parsed = marked_1.marked.parse(expanded, { async: false })
452
603
  // Wide tables scroll inside their own container rather than the page body.
453
604
  .replace(/<table>/g, '<div class="table-wrap"><table>')
454
605
  .replace(/<\/table>/g, "</table></div>");
455
- return { page, html: renderPage(page, pages, html) };
606
+ const { html, headings } = extractHeadings(parsed);
607
+ return { page, html: renderPage(page, pages, html, headings) };
456
608
  });
457
609
  const targetDir = path_1.default.join(outputDir, "docs");
458
610
  fs_1.default.mkdirSync(targetDir, { recursive: true });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mat3ra/esse",
3
- "version": "2026.9.5-0",
3
+ "version": "2026.9.6-1",
4
4
  "description": "Mat3ra's Excellent Source of Schemas and Examples (ESSE) for Digital Materials R&D",
5
5
  "scripts": {
6
6
  "transpile": "tsc -p tsconfig-transpile.json",