seemore 1.12.3 → 1.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seemore",
3
- "version": "1.12.3",
3
+ "version": "1.12.4",
4
4
  "description": "Let AI write the Markdown. Let seemore show it better — zero config documentation framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,8 @@
1
1
  import { Writable } from 'node:stream';
2
- import { StrictMode, type ReactNode } from 'react';
2
+ import { StrictMode, type ComponentProps, type ReactNode } from 'react';
3
3
  import { renderToPipeableStream } from 'react-dom/server';
4
4
  import { MemoryRouter, RouterProvider, createMemoryRouter } from 'react-router';
5
+ import { TabsContent } from 'fumadocs-ui/components/tabs';
5
6
  import { config } from 'virtual:seemore/config';
6
7
  import { toBasename, withBase } from '../shared/base.js';
7
8
  import { ogImagePath } from '../shared/og.js';
@@ -121,6 +122,11 @@ export interface ExportedArticle {
121
122
  * single-page export. Rendering the component directly, rather than extracting the
122
123
  * article from a full-page render, means no HTML parsing anywhere in the export path.
123
124
  *
125
+ * Tabs render force-mounted: Radix keeps only the active panel's content in the tree, and
126
+ * a file with three of its four tabs empty is no export at all. Force-mounted panels hide
127
+ * through fumadocs' own `data-[state=inactive]:hidden` rule, which the stylesheet the
128
+ * export inlines already carries, and the file's runtime does the switching.
129
+ *
124
130
  * Diagrams are absent here, as in every prerendered page (see `Mermaid.tsx`); the CLI
125
131
  * export inlines a runtime that renders them when the file is opened.
126
132
  */
@@ -132,12 +138,16 @@ export async function renderArticle(url: string): Promise<ExportedArticle> {
132
138
  if (page === undefined) throw new Error(`No page at ${url}.`);
133
139
 
134
140
  const Content = page.default;
141
+ const exportComponents = {
142
+ ...mdxComponents,
143
+ CodeBlockTab: (props: ComponentProps<typeof TabsContent>) => <TabsContent {...props} forceMount />,
144
+ };
135
145
  // A router is still required: content links go through react-router's `Link`, which
136
146
  // reads the routing context. A memory router with just this page is the smallest one.
137
147
  const { html, failures } = await renderToHtml(
138
148
  <StrictMode>
139
149
  <MemoryRouter initialEntries={[withBase(config.base, url)]} basename={toBasename(config.base)}>
140
- <Content components={mdxComponents} />
150
+ <Content components={exportComponents} />
141
151
  </MemoryRouter>
142
152
  </StrictMode>,
143
153
  );
@@ -216,6 +216,42 @@ function buildExportToc(article: Element): Element | undefined {
216
216
  return nav;
217
217
  }
218
218
 
219
+ /**
220
+ * Radix Tabs keeps only the active panel's content mounted, so the article's DOM alone
221
+ * misses every inactive tab. Walk each tablist, select each trigger in turn — which also
222
+ * gives any diagram inside the panel its scroll-into-view — and record the panel's HTML
223
+ * while it is up. The first trigger is re-selected so the page is left as it was found.
224
+ */
225
+ async function captureTabPanels(): Promise<Map<string, string>> {
226
+ const captured = new Map<string, string>();
227
+ const scrollX = window.scrollX;
228
+ const scrollY = window.scrollY;
229
+
230
+ for (const tablist of document.querySelectorAll('[role="tablist"]')) {
231
+ const triggers = Array.from(tablist.querySelectorAll<HTMLButtonElement>('button[role="tab"]'));
232
+ for (const trigger of triggers) {
233
+ // Radix activates a tab on `mousedown`, not `click` — and a React re-render means
234
+ // the panel's content appears a tick later, so both sides are handled by hand.
235
+ trigger.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window, button: 0 }));
236
+ const panel = document.getElementById(trigger.getAttribute('aria-controls') ?? '');
237
+ if (panel === null) continue;
238
+ await waitFor(() => panel.childElementCount > 0, 15_000);
239
+ panel.scrollIntoView({ behavior: 'instant', block: 'center' });
240
+ await waitFor(
241
+ () =>
242
+ panel.querySelector('.seemore-mermaid, .seemore-d2') === null ||
243
+ panel.querySelector('svg, .seemore-mermaid-error, .seemore-d2-error') !== null,
244
+ 15_000,
245
+ );
246
+ captured.set(panel.id, panel.innerHTML);
247
+ }
248
+ triggers[0]?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window, button: 0 }));
249
+ }
250
+
251
+ window.scrollTo(scrollX, scrollY);
252
+ return captured;
253
+ }
254
+
219
255
  /** The article is the only thing taken, but dev leaves editor affordances inside it. */
220
256
  function cleanArticleForExport(article: Element): Element {
221
257
  const clone = article.cloneNode(true) as Element;
@@ -237,8 +273,9 @@ function escapeHtml(text: string): string {
237
273
  }
238
274
 
239
275
  /**
240
- * The exported file's runtime: theme toggle, code copy, heading anchor copy, click-to-zoom —
241
- * the behaviors kept, at roughly a kilobyte instead of the site bundle. Handed to React in
276
+ * The exported file's runtime: theme toggle, code copy, heading anchor copy, tabs,
277
+ * click-to-zoom — the behaviors kept, at roughly a kilobyte instead of the site bundle.
278
+ * Handed to React in
242
279
  * hydration on the live page; here each is a few lines against the static DOM.
243
280
  *
244
281
  * Kept free of `</script>`-shaped sequences by construction: it is inlined verbatim.
@@ -280,6 +317,27 @@ const RUNTIME = `(function () {
280
317
  });
281
318
  });
282
319
 
320
+ // Tabs are exported as the static Radix markup the live page hydrated; the file wires
321
+ // them back up by hand — a click selects within its tablist and swaps the panels.
322
+ document.querySelectorAll('[role="tablist"]').forEach(function (tablist) {
323
+ var triggers = [].slice.call(tablist.querySelectorAll('button[role="tab"]'));
324
+ triggers.forEach(function (trigger) {
325
+ trigger.addEventListener('click', function () {
326
+ triggers.forEach(function (other) {
327
+ var active = other === trigger;
328
+ other.setAttribute('aria-selected', active ? 'true' : 'false');
329
+ other.setAttribute('data-state', active ? 'active' : 'inactive');
330
+ other.setAttribute('tabindex', active ? '0' : '-1');
331
+ var panel = document.getElementById(other.getAttribute('aria-controls') || '');
332
+ if (panel) {
333
+ panel.hidden = !active;
334
+ panel.setAttribute('data-state', active ? 'active' : 'inactive');
335
+ }
336
+ });
337
+ });
338
+ });
339
+ });
340
+
283
341
  // The live site's TOC follows the reader with fumadocs' own scroll tracking; in the file,
284
342
  // the plainest version of the same behaviour — last heading above the fold wins.
285
343
  var tocLinks = [].slice.call(document.querySelectorAll('.seemore-export-toc a'));
@@ -398,7 +456,12 @@ export async function exportPageAsHtml(): Promise<void> {
398
456
 
399
457
  await prepareDiagrams();
400
458
 
459
+ const tabs = await captureTabPanels();
401
460
  const clone = cleanArticleForExport(article);
461
+ for (const [id, html] of tabs) {
462
+ const panel = clone.querySelector(`[id="${CSS.escape(id)}"]`);
463
+ if (panel !== null) panel.innerHTML = html;
464
+ }
402
465
  await inlineImages(clone);
403
466
 
404
467
  const toc = buildExportToc(clone);
@@ -6,7 +6,7 @@
6
6
  * bundled to an IIFE and inlined into the file, finishes the job when the page is opened:
7
7
  * each diagram is rendered from the source `pre` the site's own components leave in
8
8
  * prerendered output. It also binds the behaviors the export keeps — theme toggle, code
9
- * copy, click-to-zoom — so the file behaves like the browser-exported one.
9
+ * copy, tabs, click-to-zoom — so the file behaves like the browser-exported one.
10
10
  *
11
11
  * The browser export ships a hand-written twin of the behavior half (the `RUNTIME` string
12
12
  * in `exportPage.ts`): it needs no diagram half, because its diagrams are already SVG when
@@ -96,6 +96,27 @@ function bindBehaviors(): void {
96
96
  });
97
97
  }
98
98
 
99
+ // Tabs are exported as the static Radix markup the live page hydrated; the file wires
100
+ // them back up by hand — a click selects within its tablist and swaps the panels.
101
+ for (const tablist of document.querySelectorAll('[role="tablist"]')) {
102
+ const triggers = Array.from(tablist.querySelectorAll('button[role="tab"]'));
103
+ for (const trigger of triggers) {
104
+ trigger.addEventListener('click', () => {
105
+ for (const other of triggers) {
106
+ const active = other === trigger;
107
+ other.setAttribute('aria-selected', active ? 'true' : 'false');
108
+ other.setAttribute('data-state', active ? 'active' : 'inactive');
109
+ other.setAttribute('tabindex', active ? '0' : '-1');
110
+ const panel = document.getElementById(other.getAttribute('aria-controls') ?? '');
111
+ if (panel !== null) {
112
+ panel.hidden = !active;
113
+ panel.setAttribute('data-state', active ? 'active' : 'inactive');
114
+ }
115
+ }
116
+ });
117
+ }
118
+ }
119
+
99
120
  // The live site's TOC follows the reader with fumadocs' own scroll tracking; in the file,
100
121
  // the plainest version of the same behaviour — last heading above the fold wins.
101
122
  const tocLinks = Array.from(document.querySelectorAll<HTMLAnchorElement>('.seemore-export-toc a'));