@mat3ra/esse 2026.9.6-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.
@@ -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>`)
@@ -257,16 +316,24 @@ function renderPage(page, pages, html) {
257
316
  </div>
258
317
  <div id="workspace">
259
318
  <aside id="docs-nav">
260
- <div class="nav-title">Concepts</div>
261
- <nav>
262
- ${nav}
263
- </nav>
319
+ <div class="docs-nav-inner">
320
+ <div class="nav-title">Concepts</div>
321
+ <nav>
322
+ ${nav}
323
+ </nav>
324
+ </div>
264
325
  </aside>
265
326
  <main id="docs-content">
266
327
  <article>
267
328
  ${html}
268
329
  </article>
330
+ ${renderPageNav(page, pages)}
269
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>
270
337
  </div>
271
338
  </body>
272
339
  </html>
@@ -283,6 +350,8 @@ const DOCS_STYLESHEET = `/* Concept documentation — shares the explorer's pale
283
350
  --text-muted: #858585;
284
351
  --border: #3e3e3e;
285
352
  --accent: #4ea1ff;
353
+ /* The Explorer and the Ontology map both paint their status bar in this blue. */
354
+ --accent-bar: #0078d4;
286
355
  --code: #9cdcfe;
287
356
  }
288
357
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -320,22 +389,29 @@ body {
320
389
  #surfaces a.current { color: var(--text-primary); border-bottom: 2px solid var(--accent); }
321
390
  #titlebar-tagline { margin-left: auto; color: var(--text-muted); white-space: nowrap; }
322
391
  @media (max-width: 900px) { #titlebar-tagline { display: none; } }
323
- #workspace { display: flex; flex: 1; align-items: flex-start; }
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
+ */
324
404
  #docs-nav {
325
405
  width: 250px;
326
406
  flex-shrink: 0;
327
407
  background: var(--bg-sidebar);
328
408
  border-right: 1px solid var(--border);
329
- padding: 16px 0;
409
+ }
410
+ .docs-nav-inner {
330
411
  position: sticky;
331
412
  top: 34px;
332
- /* Not "stretch": that makes this box exactly as tall as #docs-content (the long
333
- sibling), leaving sticky no slack to hold its position in -- an element can only
334
- stick while it is shorter than the room it is sticking within. flex-start sizes
335
- this to its own (short) nav-list content, so it actually stays pinned. The height
336
- cap is a second guard: if the nav list ever grows past the viewport, it scrolls
337
- internally instead of being clipped or pushing off-screen. */
338
- align-self: flex-start;
413
+ padding: 16px 0;
414
+ /* If the list ever outgrows the viewport it scrolls itself rather than being clipped. */
339
415
  max-height: calc(100vh - 34px);
340
416
  overflow-y: auto;
341
417
  }
@@ -362,7 +438,8 @@ body {
362
438
  border-left-color: var(--accent);
363
439
  }
364
440
  #docs-content { flex: 1; min-width: 0; padding: 32px 40px 80px; }
365
- 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; }
366
443
  article h1 { font-size: 27px; font-weight: 600; margin-bottom: 18px; text-wrap: balance; }
367
444
  article h2 {
368
445
  font-size: 19px;
@@ -413,17 +490,88 @@ article th { background: var(--bg-sidebar); font-weight: 600; }
413
490
  article td:not(:first-child) { font-variant-numeric: tabular-nums; }
414
491
  article hr { border: none; border-top: 1px solid var(--border); margin: 26px 0; }
415
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; } }
416
560
  @media (max-width: 760px) {
417
561
  #workspace { flex-direction: column; }
418
562
  #docs-nav {
419
563
  width: 100%;
420
- position: static;
421
564
  border-right: none;
422
565
  border-bottom: 1px solid var(--border);
566
+ }
567
+ .docs-nav-inner {
568
+ position: static;
423
569
  max-height: none;
424
570
  overflow-y: visible;
425
571
  }
426
572
  #docs-content { padding: 20px; }
573
+ .page-nav { flex-direction: column; }
574
+ .page-nav-next { text-align: left; }
427
575
  }
428
576
  `;
429
577
  // ── Entry point ───────────────────────────────────────────────────────────────
@@ -451,11 +599,12 @@ function buildDocsPages(outputDir) {
451
599
  // should fail the build outright, not leave a half-written site behind.
452
600
  const rendered = pages.map((page) => {
453
601
  const expanded = expandFragments(page.body, graph);
454
- const html = marked_1.marked.parse(expanded, { async: false })
602
+ const parsed = marked_1.marked.parse(expanded, { async: false })
455
603
  // Wide tables scroll inside their own container rather than the page body.
456
604
  .replace(/<table>/g, '<div class="table-wrap"><table>')
457
605
  .replace(/<\/table>/g, "</table></div>");
458
- return { page, html: renderPage(page, pages, html) };
606
+ const { html, headings } = extractHeadings(parsed);
607
+ return { page, html: renderPage(page, pages, html, headings) };
459
608
  });
460
609
  const targetDir = path_1.default.join(outputDir, "docs");
461
610
  fs_1.default.mkdirSync(targetDir, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mat3ra/esse",
3
- "version": "2026.9.6-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",