@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), "
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
231
232
|
}
|
|
232
|
-
function
|
|
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
|
|
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 · materials science ontology</span>
|
|
315
|
+
<span id="titlebar-tagline">ESSE · 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-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
/*
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
#surfaces { display:
|
|
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:
|
|
320
|
-
#workspace { display: flex; flex: 1;
|
|
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
|
-
|
|
409
|
+
}
|
|
410
|
+
.docs-nav-inner {
|
|
327
411
|
position: sticky;
|
|
328
412
|
top: 34px;
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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