jamdesk 1.1.34 → 1.1.35

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": "jamdesk",
3
- "version": "1.1.34",
3
+ "version": "1.1.35",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { ReactNode, Children, cloneElement, isValidElement, memo } from 'react';
4
4
  import { getIconClass } from '@/lib/icon-utils';
5
+ import { generateSlug } from '@/lib/heading-extractor';
5
6
 
6
7
  type TitleSize = 'p' | 'h2' | 'h3';
7
8
  type IconType = 'regular' | 'solid' | 'light' | 'thin' | 'sharp-solid' | 'duotone' | 'brands';
@@ -129,9 +130,24 @@ function StepTitle({ title, titleSize }: { title: string; titleSize: TitleSize }
129
130
 
130
131
  export const Step = memo(function Step({ title, children, stepNumber, isLast, icon, iconType, titleSize = 'p' }: StepProps) {
131
132
  const containerClassName = isLast ? 'relative pb-0' : 'relative pb-8';
133
+ const slug = title ? generateSlug(title) || undefined : undefined;
134
+
135
+ // Emit both attrs together or neither — the DOM scanner keys off
136
+ // `data-step-number`, so a label without a number would be a dangling
137
+ // attribute, and a number without a label would produce a TOC entry with
138
+ // empty text.
139
+ const stepAttrs: { 'data-step-number'?: number; 'data-step-label'?: string } = {};
140
+ if (typeof stepNumber === 'number' && title) {
141
+ stepAttrs['data-step-number'] = stepNumber;
142
+ stepAttrs['data-step-label'] = title;
143
+ }
132
144
 
133
145
  return (
134
- <div className={containerClassName}>
146
+ <div
147
+ className={containerClassName}
148
+ id={slug}
149
+ {...stepAttrs}
150
+ >
135
151
  {/* Vertical line connecting to next step - positioned absolutely */}
136
152
  {!isLast && (
137
153
  <div
@@ -2,12 +2,13 @@
2
2
 
3
3
  import { useEffect, useState, useRef, useCallback } from 'react';
4
4
  import { getIconClass } from '@/lib/icon-utils';
5
- import { generateSlug } from '@/lib/heading-extractor';
5
+ import { extractHeadings } from '@/lib/heading-extractor';
6
6
 
7
7
  interface TocItem {
8
8
  id: string;
9
9
  text: string;
10
10
  level: number;
11
+ stepNumber?: number;
11
12
  }
12
13
 
13
14
  interface TableOfContentsProps {
@@ -196,75 +197,128 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
196
197
  return () => observer.disconnect();
197
198
  }, [updateThumb]);
198
199
 
199
- // Parse headings from content in source order, then scan DOM for dynamic components
200
+ // Source-order parse gives immediate headings; the MutationObserver below
201
+ // keeps the list in sync when dynamic components (Accordion, Tabs) reveal
202
+ // Steps after initial render.
200
203
  useEffect(() => {
201
- // Line-by-line pass to preserve source order and skip fenced code blocks
202
- const items: TocItem[] = [];
203
- const lines = content.split('\n');
204
- let inCodeBlock = false;
205
- let fencePattern = '';
206
-
207
- for (const line of lines) {
208
- const fenceMatch = line.match(/^(`{3,}|~{3,})/);
209
- if (fenceMatch) {
210
- if (!inCodeBlock) {
211
- inCodeBlock = true;
212
- fencePattern = fenceMatch[1];
213
- continue;
214
- } else if (line.startsWith(fencePattern)) {
215
- inCodeBlock = false;
216
- fencePattern = '';
217
- continue;
218
- }
219
- }
220
- if (inCodeBlock) continue;
221
-
222
- const headingMatch = line.match(/^(#{2,3})\s+(.+)$/);
223
- if (headingMatch) {
224
- const level = headingMatch[1].length;
225
- const text = headingMatch[2].trim();
226
- const id = generateSlug(text);
227
- if (id) items.push({ id, text, level });
228
- }
204
+ const sourceItems: TocItem[] = extractHeadings(content)
205
+ .filter(h => h.level === 2 || h.level === 3)
206
+ .map(h => ({
207
+ id: h.id,
208
+ text: h.text,
209
+ level: h.level,
210
+ stepNumber: h.stepNumber,
211
+ }));
229
212
 
230
- const updateMatch = line.match(/<Update\s+label=["']([^"']+)["']/);
231
- if (updateMatch) {
232
- const text = updateMatch[1];
233
- const id = generateSlug(text);
234
- if (id) items.push({ id, text, level: 2 });
235
- }
236
- }
237
-
238
- setHeadings(items);
213
+ setHeadings(sourceItems);
239
214
 
240
- // DOM scan: single query to get all TOC elements in document order
241
215
  const scanDomHeadings = () => {
242
- const tocElements = document.querySelectorAll('main h2[id], main h3[id], main [data-update-label]');
216
+ const tocElements = document.querySelectorAll<HTMLElement>(
217
+ 'main h2[id], main h3[id], main [data-update-label], main [data-step-number]',
218
+ );
243
219
  const newItems: TocItem[] = [];
244
220
  const seenDomIds = new Set<string>();
221
+ const collisions: string[] = [];
245
222
 
246
223
  tocElements.forEach((element) => {
247
224
  const id = element.id;
248
- if (!id || seenDomIds.has(id)) return;
225
+ if (!id) return;
226
+ if (seenDomIds.has(id)) {
227
+ collisions.push(id);
228
+ return;
229
+ }
230
+
231
+ const stepNumberAttr = element.getAttribute('data-step-number');
232
+ const isStep = stepNumberAttr !== null && stepNumberAttr !== '';
233
+ const isUpdate = !isStep && element.hasAttribute('data-update-label');
234
+
235
+ let text = '';
236
+ let level = 2;
237
+ let stepNumber: number | undefined;
238
+
239
+ if (isStep) {
240
+ text = element.getAttribute('data-step-label')?.trim() || '';
241
+ level = 3;
242
+ const parsed = parseInt(stepNumberAttr as string, 10);
243
+ stepNumber = Number.isFinite(parsed) ? parsed : undefined;
244
+ } else if (isUpdate) {
245
+ text = element.getAttribute('data-update-label')?.trim() || '';
246
+ level = 2;
247
+ } else {
248
+ text = element.textContent?.trim() || '';
249
+ level = element.tagName === 'H2' ? 2 : 3;
250
+ }
249
251
 
250
- const isUpdate = element.hasAttribute('data-update-label');
251
- const text = isUpdate
252
- ? (element.getAttribute('data-update-label') || '')
253
- : (element.textContent?.trim() || '');
254
252
  if (!text) return;
255
253
 
256
254
  seenDomIds.add(id);
257
- const level = isUpdate ? 2 : (element.tagName === 'H2' ? 2 : 3);
258
- newItems.push({ id, text, level });
255
+ newItems.push({ id, text, level, stepNumber });
259
256
  });
260
257
 
261
- if (newItems.length > 0) {
262
- setHeadings(newItems);
258
+ // Dev-mode warning for slug collisions. In production builds, stay
259
+ // silent — browsers still render correctly (first match wins).
260
+ if (collisions.length > 0 && process.env.NODE_ENV !== 'production') {
261
+ console.warn(
262
+ `[TableOfContents] Duplicate anchor id(s) detected on this page: ${[...new Set(collisions)].join(', ')}. ` +
263
+ `This usually means a <Step title="X"> collides with a heading of the same text. ` +
264
+ `Only the first element will be targetable via fragment link or TOC click.`,
265
+ );
263
266
  }
267
+
268
+ if (newItems.length === 0) return;
269
+
270
+ // Dedupe re-renders: if the new item list matches the current one
271
+ // position-for-position on (id, stepNumber), skip setState. This keeps
272
+ // the MutationObserver cheap when it fires on unrelated DOM changes.
273
+ setHeadings(prev => {
274
+ if (prev.length !== newItems.length) return newItems;
275
+ for (let i = 0; i < newItems.length; i++) {
276
+ if (
277
+ prev[i].id !== newItems[i].id ||
278
+ prev[i].stepNumber !== newItems[i].stepNumber ||
279
+ prev[i].text !== newItems[i].text
280
+ ) {
281
+ return newItems;
282
+ }
283
+ }
284
+ return prev;
285
+ });
264
286
  };
265
287
 
266
- const timer = setTimeout(scanDomHeadings, 500);
267
- return () => clearTimeout(timer);
288
+ // Coalesce scans to one per animation frame. cancelAnimationFrame in the
289
+ // cleanup below is only effective while scheduledScan is non-null — once
290
+ // the rAF callback starts, the functional setHeadings is safe on an
291
+ // unmounted tree because it returns prev when items are unchanged.
292
+ let scheduledScan: number | null = null;
293
+ const scheduleScan = () => {
294
+ if (scheduledScan !== null) return;
295
+ scheduledScan = requestAnimationFrame(() => {
296
+ scheduledScan = null;
297
+ scanDomHeadings();
298
+ });
299
+ };
300
+
301
+ scheduleScan();
302
+
303
+ const mainEl = document.querySelector('main');
304
+ const observer = mainEl ? new MutationObserver(scheduleScan) : null;
305
+ if (mainEl && observer) {
306
+ observer.observe(mainEl, {
307
+ childList: true,
308
+ subtree: true,
309
+ attributes: true,
310
+ attributeFilter: ['id', 'data-step-number', 'data-step-label', 'data-update-label'],
311
+ });
312
+ } else if (!mainEl && process.env.NODE_ENV !== 'production') {
313
+ console.warn(
314
+ '[TableOfContents] no <main> element found — late-mount TOC updates will not be observed.',
315
+ );
316
+ }
317
+
318
+ return () => {
319
+ if (scheduledScan !== null) cancelAnimationFrame(scheduledScan);
320
+ observer?.disconnect();
321
+ };
268
322
  }, [content]);
269
323
 
270
324
  // For API pages, hide TOC if there are code panels visible
@@ -415,6 +469,7 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
415
469
  <div ref={containerRef} className="relative flex flex-col">
416
470
  {headings.map((heading, index) => {
417
471
  const isActive = activeAnchors.includes(heading.id);
472
+ const hasStepNumber = typeof heading.stepNumber === 'number';
418
473
 
419
474
  const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
420
475
  e.preventDefault();
@@ -425,18 +480,45 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
425
480
  }
426
481
  };
427
482
 
483
+ // 30px leaves room for the absolute-positioned 18px step circle
484
+ // (insetInlineStart: 1) + a gap. Non-step H3s use the same value
485
+ // so mixed step/non-step groups align visually.
486
+ const startOffset = heading.level === 3
487
+ ? 30
488
+ : getItemOffset(heading.level);
489
+
428
490
  return (
429
491
  <a
430
492
  key={`${heading.id}-${index}`}
431
493
  href={`#${heading.id}`}
432
494
  onClick={handleClick}
433
495
  data-active={isActive}
496
+ data-step={hasStepNumber || undefined}
434
497
  className={`
435
498
  relative block py-1.5 text-sm transition-colors duration-200
436
499
  ${getLinkClass(heading.level, isActive)}
437
500
  `}
438
- style={{ paddingInlineStart: getItemOffset(heading.level) }}
501
+ style={{ paddingInlineStart: startOffset }}
439
502
  >
503
+ {hasStepNumber && (
504
+ <span
505
+ aria-hidden="true"
506
+ className="absolute flex items-center justify-center rounded-full text-[11px] font-medium"
507
+ style={{
508
+ insetInlineStart: 1,
509
+ top: '50%',
510
+ transform: 'translateY(-50%)',
511
+ width: 18,
512
+ height: 18,
513
+ backgroundColor: isActive
514
+ ? 'var(--color-primary)'
515
+ : 'var(--color-bg-primary)',
516
+ color: isActive ? '#fff' : 'var(--color-text-muted)',
517
+ }}
518
+ >
519
+ {heading.stepNumber}
520
+ </span>
521
+ )}
440
522
  {heading.text}
441
523
  </a>
442
524
  );
@@ -2,7 +2,8 @@
2
2
  * Heading extractor for link validation.
3
3
  * Extracts heading slugs from MDX content to validate #fragment links.
4
4
  *
5
- * Canonical source for generateSlug — imported by TableOfContents.tsx and Update.tsx.
5
+ * Canonical source for generateSlug — imported by TableOfContents.tsx, Update.tsx,
6
+ * and Steps.tsx.
6
7
  */
7
8
 
8
9
  export interface HeadingInfo {
@@ -10,6 +11,8 @@ export interface HeadingInfo {
10
11
  text: string;
11
12
  level: number;
12
13
  line: number; // 1-indexed
14
+ /** When the entry is a <Step> inside a <Steps> block, this is its 1-based index within the block. */
15
+ stepNumber?: number;
13
16
  }
14
17
 
15
18
  /**
@@ -26,10 +29,20 @@ export function generateSlug(text: string): string {
26
29
  const HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
27
30
  const FENCE_REGEX = /^(`{3,}|~{3,})/;
28
31
  const UPDATE_LABEL_REGEX = /<Update\s+label=["']([^"']+)["']/;
32
+ const STEPS_OPEN_REGEX = /<Steps(\s|>)/;
33
+ const STEPS_CLOSE_REGEX = /<\/Steps>/;
34
+ // Global flag — we iterate with matchAll so authors who inline multiple
35
+ // <Step> tags on one line get every one numbered.
36
+ // Alternation (not a character class) keeps the capture from terminating on
37
+ // apostrophes inside double-quoted attribute values (e.g. `<Step title="You're Live">`).
38
+ // MUST only be used with matchAll — direct `test`/`exec` would share
39
+ // lastIndex across calls and miscount.
40
+ const STEP_TITLE_REGEX = /<Step\s+[^>]*title=(?:"([^"]+)"|'([^']+)')/g;
29
41
 
30
42
  /**
31
43
  * Extract all heading slugs from MDX content.
32
- * Includes markdown headings and <Update label="..."> component anchors.
44
+ * Includes markdown headings, <Update label="..."> component anchors,
45
+ * and <Step title="..."> entries inside <Steps> blocks (with per-block numbering).
33
46
  * Skips content inside fenced code blocks.
34
47
  */
35
48
  export function extractHeadings(content: string): HeadingInfo[] {
@@ -37,6 +50,10 @@ export function extractHeadings(content: string): HeadingInfo[] {
37
50
  const lines = content.split('\n');
38
51
  let inCodeBlock = false;
39
52
  let fencePattern = '';
53
+ // Boolean (not depth counter) — resets on every <Steps> open so an unclosed
54
+ // block can't stick the counter across subsequent blocks.
55
+ let inStepsBlock = false;
56
+ let stepCounter = 0;
40
57
 
41
58
  for (let i = 0; i < lines.length; i++) {
42
59
  const line = lines[i];
@@ -56,6 +73,14 @@ export function extractHeadings(content: string): HeadingInfo[] {
56
73
 
57
74
  if (inCodeBlock) continue;
58
75
 
76
+ // <Steps> open resets the per-block counter unconditionally. Ordering
77
+ // matters: open-reset must run BEFORE step matching on the same line so
78
+ // `<Steps><Step title="A">...</Step>` numbers "A" as 1.
79
+ if (STEPS_OPEN_REGEX.test(line)) {
80
+ inStepsBlock = true;
81
+ stepCounter = 0;
82
+ }
83
+
59
84
  const headingMatch = line.match(HEADING_REGEX);
60
85
  if (headingMatch) {
61
86
  const level = headingMatch[1].length;
@@ -74,6 +99,28 @@ export function extractHeadings(content: string): HeadingInfo[] {
74
99
  headings.push({ id, text, level: 2, line: i + 1 });
75
100
  }
76
101
  }
102
+
103
+ if (inStepsBlock) {
104
+ for (const match of line.matchAll(STEP_TITLE_REGEX)) {
105
+ const text = match[1] ?? match[2];
106
+ const id = generateSlug(text);
107
+ if (!id) continue;
108
+ stepCounter += 1;
109
+ headings.push({
110
+ id,
111
+ text,
112
+ level: 3,
113
+ line: i + 1,
114
+ stepNumber: stepCounter,
115
+ });
116
+ }
117
+ }
118
+
119
+ // </Steps> close runs AFTER step matching so `</Steps>` on the same line
120
+ // as the last <Step> still picks up that step.
121
+ if (STEPS_CLOSE_REGEX.test(line)) {
122
+ inStepsBlock = false;
123
+ }
77
124
  }
78
125
 
79
126
  return headings;
@@ -219,16 +219,26 @@ function generateSlug(text) {
219
219
 
220
220
  /**
221
221
  * Extract heading slugs from MDX content.
222
- * Includes markdown headings and <Update label="..."> component anchors.
222
+ * Includes markdown headings, <Update label="..."> anchors, and
223
+ * <Step title="..."> anchors inside a <Steps> block.
223
224
  * Skips content inside fenced code blocks.
224
225
  *
225
- * Uses single-pass fence tracking (matches heading-extractor.ts pattern).
226
+ * Must stay semantically compatible with extractHeadings() in lib/heading-extractor.ts
227
+ * for slug collection. stepNumber tracking is intentionally omitted here — this
228
+ * function returns a Set<string>, not HeadingInfo[].
226
229
  */
227
230
  function extractHeadingSlugs(content) {
228
231
  const slugs = new Set();
229
232
  const lines = content.split('\n');
230
233
  let inCodeBlock = false;
231
234
  let fencePattern = '';
235
+ let inStepsBlock = false;
236
+
237
+ const STEPS_OPEN_REGEX = /<Steps(\s|>)/;
238
+ const STEPS_CLOSE_REGEX = /<\/Steps>/;
239
+ // Alternation, not a character class — must not terminate at apostrophes
240
+ // inside double-quoted attribute values (e.g. <Step title="You're Live">).
241
+ const STEP_TITLE_REGEX = /<Step\s+[^>]*title=(?:"([^"]+)"|'([^']+)')/g;
232
242
 
233
243
  for (let i = 0; i < lines.length; i++) {
234
244
  const line = lines[i];
@@ -248,6 +258,10 @@ function extractHeadingSlugs(content) {
248
258
 
249
259
  if (inCodeBlock) continue;
250
260
 
261
+ if (STEPS_OPEN_REGEX.test(line)) {
262
+ inStepsBlock = true;
263
+ }
264
+
251
265
  const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
252
266
  if (headingMatch) {
253
267
  const slug = generateSlug(headingMatch[1].trim());
@@ -259,6 +273,17 @@ function extractHeadingSlugs(content) {
259
273
  const slug = generateSlug(updateMatch[1]);
260
274
  if (slug) slugs.add(slug);
261
275
  }
276
+
277
+ if (inStepsBlock) {
278
+ for (const match of line.matchAll(STEP_TITLE_REGEX)) {
279
+ const slug = generateSlug(match[1] ?? match[2]);
280
+ if (slug) slugs.add(slug);
281
+ }
282
+ }
283
+
284
+ if (STEPS_CLOSE_REGEX.test(line)) {
285
+ inStepsBlock = false;
286
+ }
262
287
  }
263
288
 
264
289
  return slugs;