@kenjura/ursa 0.72.0 → 0.75.0

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.
@@ -0,0 +1,42 @@
1
+ import { toTitleCase } from "./build/titleCase.js";
2
+
3
+ /**
4
+ * Generate breadcrumb navigation HTML from a document's path.
5
+ *
6
+ * @param {string} dir - Directory relative to source root, e.g. "settings/eberron/" or "/"
7
+ * @param {string} base - Filename without extension, e.g. "index" or "places"
8
+ * @param {object} [fileMeta] - Parsed frontmatter (used for current-page label override)
9
+ * @returns {string} Breadcrumb HTML string, or empty string if not applicable
10
+ */
11
+ export function generateBreadcrumbs(dir, base, fileMeta) {
12
+ const segments = dir.split('/').filter(Boolean);
13
+ const isIndexFile = (base === 'index' || base === 'home');
14
+
15
+ // All path segments leading to the current page
16
+ const allSegments = isIndexFile ? segments : [...segments, base];
17
+
18
+ // No breadcrumbs for root-level pages (need at least 1 segment beyond root)
19
+ if (allSegments.length < 1) return '';
20
+
21
+ const parts = [`<a class="breadcrumb-link" href="/">Home</a>`];
22
+ let href = '/';
23
+
24
+ for (let i = 0; i < allSegments.length; i++) {
25
+ const seg = allSegments[i];
26
+ const isLast = i === allSegments.length - 1;
27
+
28
+ // Current page can use frontmatter title; others use title-cased folder name
29
+ const label = isLast && fileMeta?.title
30
+ ? fileMeta.title
31
+ : toTitleCase(seg);
32
+
33
+ if (isLast) {
34
+ parts.push(`<span class="breadcrumb-current" aria-current="page">${label}</span>`);
35
+ } else {
36
+ href += seg + '/';
37
+ parts.push(`<a class="breadcrumb-link" href="${href}">${label}</a>`);
38
+ }
39
+ }
40
+
41
+ return `<nav class="breadcrumbs" aria-label="Breadcrumbs">${parts.join('<span class="breadcrumb-sep" aria-hidden="true">/</span>')}</nav>\n`;
42
+ }
@@ -9,6 +9,35 @@ import { toTitleCase } from "./titleCase.js";
9
9
  import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
10
10
  import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
11
11
  import { getCustomMenuForFile } from "./menu.js";
12
+ import { generateBreadcrumbs } from "../breadcrumbs.js";
13
+
14
+ // Document extensions for checking if a folder has content
15
+ const SOURCE_DOC_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.html'];
16
+ const OUTPUT_DOC_EXTENSIONS = ['.html'];
17
+
18
+ /**
19
+ * Recursively check if a directory contains any document files.
20
+ * @param {string} dir - Directory path to check
21
+ * @param {string[]} extensions - File extensions that count as documents
22
+ * @returns {Promise<boolean>} True if the directory (or any subdirectory) contains at least one document
23
+ */
24
+ async function directoryHasDocuments(dir, extensions) {
25
+ try {
26
+ const children = await readdir(dir, { withFileTypes: true });
27
+ for (const child of children) {
28
+ if (child.name.startsWith('.')) continue;
29
+ const fullPath = join(dir, child.name);
30
+ if (child.isDirectory()) {
31
+ if (child.name === 'img') continue;
32
+ if (await directoryHasDocuments(fullPath, extensions)) return true;
33
+ } else {
34
+ const ext = extname(child.name).toLowerCase();
35
+ if (extensions.includes(ext)) return true;
36
+ }
37
+ }
38
+ } catch (e) { /* ignore */ }
39
+ return false;
40
+ }
12
41
 
13
42
  /**
14
43
  * Generate auto-index HTML content for a directory from the OUTPUT folder
@@ -50,6 +79,11 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
50
79
 
51
80
  for (const child of filteredChildren) {
52
81
  const isDir = child.isDirectory();
82
+ // Skip directories that contain no documents
83
+ if (isDir) {
84
+ const childDir = join(dir, child.name);
85
+ if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
86
+ }
53
87
  const name = isDir ? child.name : child.name.replace('.html', '');
54
88
  // Use pathPrefix to ensure hrefs are correct relative to the document root
55
89
  const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
@@ -99,11 +133,11 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
99
133
  // Skip hidden files
100
134
  if (child.name.startsWith('.')) return false;
101
135
  // Skip index files (we're generating into the index)
102
- if (child.name.match(/^index\.(md|txt|yml|html)$/i)) return false;
136
+ if (child.name.match(/^index\.(md|mdx|txt|yml|html)$/i)) return false;
103
137
  // Skip img folders (contain images, not content)
104
138
  if (child.isDirectory() && child.name === 'img') return false;
105
- // Include directories and article files (md, txt, yml, html)
106
- return child.isDirectory() || child.name.match(/\.(md|txt|yml|html)$/i);
139
+ // Include directories and article files (md, mdx, txt, yml, html)
140
+ return child.isDirectory() || child.name.match(/\.(md|mdx|txt|yml|html)$/i);
107
141
  })
108
142
  .sort((a, b) => {
109
143
  // Directories first, then files, alphabetically within each group
@@ -120,6 +154,11 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
120
154
 
121
155
  for (const child of filteredChildren) {
122
156
  const isDir = child.isDirectory();
157
+ // Skip directories that contain no documents
158
+ if (isDir) {
159
+ const childDir = join(sourceDir, child.name);
160
+ if (!await directoryHasDocuments(childDir, SOURCE_DOC_EXTENSIONS)) continue;
161
+ }
123
162
  // Get name without extension for display
124
163
  const ext = isDir ? '' : extname(child.name);
125
164
  const nameWithoutExt = isDir ? child.name : basename(child.name, ext);
@@ -262,23 +301,29 @@ export async function generateAutoIndices(output, directories, source, templates
262
301
  const children = await readdir(dir, { withFileTypes: true });
263
302
 
264
303
  // Filter to only include relevant files and folders
265
- const items = children
266
- .filter(child => {
267
- // Skip hidden files and index alternates we just checked
268
- if (child.name.startsWith('.')) return false;
269
- if (child.name === 'index.html') return false;
270
- // Include directories and html files
271
- return child.isDirectory() || child.name.endsWith('.html');
272
- })
273
- .map(child => {
274
- const isDir = child.isDirectory();
275
- const name = isDir ? child.name : child.name.replace('.html', '');
276
- // For directories, link to /folder/index.html; for files, use the filename directly
277
- const href = isDir ? `${child.name}/index.html` : child.name;
278
- const displayName = toTitleCase(name);
279
- const icon = isDir ? '📁' : '📄';
280
- return `<li>${icon} <a href="${href}">${displayName}</a></li>`;
281
- });
304
+ const filteredItems = children.filter(child => {
305
+ // Skip hidden files and index alternates we just checked
306
+ if (child.name.startsWith('.')) return false;
307
+ if (child.name === 'index.html') return false;
308
+ // Include directories and html files
309
+ return child.isDirectory() || child.name.endsWith('.html');
310
+ });
311
+
312
+ // Build items, skipping directories with no documents
313
+ const items = [];
314
+ for (const child of filteredItems) {
315
+ const isDir = child.isDirectory();
316
+ if (isDir) {
317
+ const childDir = join(dir, child.name);
318
+ if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
319
+ }
320
+ const name = isDir ? child.name : child.name.replace('.html', '');
321
+ // For directories, link to /folder/index.html; for files, use the filename directly
322
+ const href = isDir ? `${child.name}/index.html` : child.name;
323
+ const displayName = toTitleCase(name);
324
+ const icon = isDir ? '📁' : '📄';
325
+ items.push(`<li>${icon} <a href="${href}">${displayName}</a></li>`);
326
+ }
282
327
 
283
328
  if (items.length === 0) {
284
329
  // Empty folder, skip generating index
@@ -286,7 +331,13 @@ export async function generateAutoIndices(output, directories, source, templates
286
331
  }
287
332
 
288
333
  const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
289
- const indexHtml = `<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
334
+
335
+ // Generate breadcrumbs for auto-index pages
336
+ const relDir = dir.replace(outputNorm, '').replace(/^\//, '');
337
+ const breadcrumbDir = relDir ? relDir + '/' : '/';
338
+ const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null);
339
+
340
+ const indexHtml = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
290
341
 
291
342
  const template = templates["default-template"];
292
343
  if (!template) {
@@ -357,13 +408,19 @@ export async function generateAutoIndices(output, directories, source, templates
357
408
  finalHtml = finalHtml.replace(key, value);
358
409
  }
359
410
 
360
- // Add custom menu data attributes if applicable
411
+ // Add menu data attributes to body
361
412
  if (customMenuInfo) {
362
- const menuPosition = customMenuInfo.menuPosition || 'side';
413
+ const menuPosition = customMenuInfo.menuPosition || 'top';
363
414
  finalHtml = finalHtml.replace(
364
415
  /<body([^>]*)>/,
365
416
  `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
366
417
  );
418
+ } else {
419
+ // No custom menu — default to top menu
420
+ finalHtml = finalHtml.replace(
421
+ /<body([^>]*)>/,
422
+ `<body$1 data-menu-position="top">`
423
+ );
367
424
  }
368
425
 
369
426
  // Add cache-busting timestamps to static file references
@@ -48,7 +48,7 @@ export function findAllCustomMenus(allSourceFilenames, source) {
48
48
  menuPath: menuInfo.path,
49
49
  menuDir: menuInfo.menuDir,
50
50
  menuData: parsedMenu.menuData,
51
- menuPosition: parsedMenu.menuPosition || 'side',
51
+ menuPosition: parsedMenu.menuPosition || 'top',
52
52
  // The URL path for the menu JSON file
53
53
  menuJsonPath: '/public/custom-menu-' + getMenuId(menuInfo.menuDir, source) + '.json',
54
54
  });
@@ -60,7 +60,7 @@ export function findAllCustomMenus(allSourceFilenames, source) {
60
60
  let menuData;
61
61
  if (autoGenerate) {
62
62
  // Auto-generate menu and combine with manual content
63
- const depth = parseInt(frontmatter['menu-depth'], 10) || 2;
63
+ const depth = parseInt(frontmatter['menu-depth'], 10) || 10;
64
64
  menuData = combineAutoAndManualMenu(body, menuInfo.menuDir, source, depth);
65
65
  } else {
66
66
  menuData = parseCustomMenu(body, menuInfo.menuDir, source);
@@ -70,7 +70,7 @@ export function findAllCustomMenus(allSourceFilenames, source) {
70
70
  menuPath: menuInfo.path,
71
71
  menuDir: menuInfo.menuDir,
72
72
  menuData,
73
- menuPosition: frontmatter['menu-position'] || 'side',
73
+ menuPosition: frontmatter['menu-position'] || 'top',
74
74
  // The URL path for the menu JSON file
75
75
  menuJsonPath: '/public/custom-menu-' + getMenuId(menuInfo.menuDir, source) + '.json',
76
76
  });
@@ -112,7 +112,7 @@ export function getCustomMenuForFile(filePath, source, customMenus) {
112
112
  return {
113
113
  menuJsonPath: menuInfo.menuJsonPath,
114
114
  menuDir: relative(source, menuInfo.menuDir) || '',
115
- menuPosition: menuInfo.menuPosition || 'side',
115
+ menuPosition: menuInfo.menuPosition || 'top',
116
116
  };
117
117
  }
118
118
  const parentDir = dirname(currentDir);
@@ -18,6 +18,64 @@ const INDEX_NAMES = ['index', 'home'];
18
18
  // Default icons
19
19
  const FOLDER_ICON = '📁';
20
20
  const DOCUMENT_ICON = '📄';
21
+ const HOME_ICON = '🏠';
22
+
23
+ /**
24
+ * Collapse single-document folders into direct links.
25
+ * If a folder's only children are index-like files (no sub-folders),
26
+ * collapse it to a direct link using the folder's label and the first child's href.
27
+ * @param {Array} items - Menu items
28
+ * @returns {Array} - Processed menu items
29
+ */
30
+ function collapseSingleDocFolders(items) {
31
+ return items.map(item => {
32
+ if (!item.hasChildren || !item.children || item.children.length === 0) return item;
33
+
34
+ // Recurse first so nested single-doc folders are collapsed bottom-up
35
+ item.children = collapseSingleDocFolders(item.children);
36
+
37
+ // Check if all children are non-folder items (i.e., no child has children)
38
+ const hasSubfolders = item.children.some(c => c.hasChildren);
39
+ if (!hasSubfolders && item.children.length > 0) {
40
+ // All children are leaf files — if there's only one, collapse to a direct link
41
+ // For index-like content (single child), collapse the folder entirely
42
+ if (item.children.length === 1) {
43
+ const onlyChild = item.children[0];
44
+ return {
45
+ ...item,
46
+ hasChildren: false,
47
+ children: [],
48
+ href: onlyChild.href || item.href,
49
+ };
50
+ }
51
+ }
52
+
53
+ return item;
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Recursively check if a folder contains any document files.
59
+ * @param {string} dirPath - Directory to check
60
+ * @returns {boolean} True if the folder (or any subfolder) contains at least one document
61
+ */
62
+ function folderHasDocuments(dirPath) {
63
+ try {
64
+ const entries = readdirSync(dirPath, { withFileTypes: true });
65
+ for (const entry of entries) {
66
+ if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
67
+ const fullPath = join(dirPath, entry.name);
68
+ if (entry.isDirectory()) {
69
+ if (entry.name === 'img') continue;
70
+ if (folderHasDocuments(fullPath)) return true;
71
+ } else {
72
+ const ext = extname(entry.name);
73
+ if (SOURCE_EXTENSIONS.includes(ext)) return true;
74
+ }
75
+ }
76
+ } catch (e) { /* ignore */ }
77
+ return false;
78
+ }
21
79
 
22
80
  /**
23
81
  * Extract frontmatter from menu file content
@@ -113,30 +171,18 @@ function getMenuLabelFromFile(filePath) {
113
171
  * Similar to the main automenu but for custom menu contexts
114
172
  * @param {string} folderPath - Absolute path to folder
115
173
  * @param {string} sourceRoot - Root source directory
116
- * @param {number} depth - How deep to recurse (default: 2)
174
+ * @param {number} depth - How deep to recurse (default: 10)
117
175
  * @param {boolean} isRoot - Whether this is the root level (adds Home item)
118
176
  * @returns {Array} - Menu items array
119
177
  */
120
- export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, isRoot = true) {
178
+ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 10, isRoot = true) {
121
179
  const items = [];
122
180
 
123
181
  if (depth <= 0 || !existsSync(folderPath)) {
124
182
  return items;
125
183
  }
126
184
 
127
- // Home item to be added at the start (after sorting other items)
128
- let homeItem = null;
129
- if (isRoot) {
130
- const relativePath = '/' + relative(sourceRoot, folderPath).replace(/\\/g, '/');
131
- homeItem = {
132
- label: 'Home',
133
- path: 'home',
134
- href: relativePath + '/index.html',
135
- hasChildren: false,
136
- icon: `<span class="menu-icon">${DOCUMENT_ICON}</span>`,
137
- children: [],
138
- };
139
- }
185
+ // (Home item is built at the end for root level, after partitioning)
140
186
 
141
187
  try {
142
188
  const entries = readdirSync(folderPath, { withFileTypes: true });
@@ -155,6 +201,9 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, is
155
201
  const relativePath = '/' + relative(sourceRoot, fullPath).replace(/\\/g, '/');
156
202
 
157
203
  if (entry.isDirectory()) {
204
+ // Skip folders that contain no documents (recursively)
205
+ if (!folderHasDocuments(fullPath)) continue;
206
+
158
207
  // Check for index file to get label
159
208
  let label = null;
160
209
  for (const ext of SOURCE_EXTENSIONS) {
@@ -182,6 +231,8 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, is
182
231
  const baseName = basename(entry.name, ext);
183
232
  // Skip index files (they're represented by the folder)
184
233
  if (INDEX_NAMES.includes(baseName.toLowerCase())) continue;
234
+ // Skip foldername-matching files (e.g. Arcanist/Arcanist.md — represented by the folder)
235
+ if (baseName.toLowerCase() === basename(folderPath).toLowerCase()) continue;
185
236
 
186
237
  // Get label from frontmatter or filename
187
238
  const label = getMenuLabelFromFile(fullPath) || toDisplayName(baseName);
@@ -197,23 +248,39 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, is
197
248
  }
198
249
  }
199
250
 
200
- // Sort: folders first, then alphabetically
251
+ // Sort: folders first (a-z), then files (a-z), case-insensitive
201
252
  items.sort((a, b) => {
202
253
  if (a.hasChildren && !b.hasChildren) return -1;
203
254
  if (!a.hasChildren && b.hasChildren) return 1;
204
- return a.label.localeCompare(b.label);
255
+ return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
205
256
  });
206
257
 
207
258
  } catch (e) {
208
259
  console.error(`Error reading folder ${folderPath}:`, e);
209
260
  }
210
261
 
211
- // Add Home item at the very start (after sorting)
212
- if (homeItem) {
213
- items.unshift(homeItem);
262
+ // Post-process: collapse single-doc folders into direct links
263
+ const processedItems = collapseSingleDocFolders(items);
264
+
265
+ if (isRoot) {
266
+ // Partition top-level items: files go under Home, folders stay at top level
267
+ const topLevelFolders = processedItems.filter(item => item.hasChildren);
268
+ const topLevelFiles = processedItems.filter(item => !item.hasChildren);
269
+
270
+ const relativePath = '/' + relative(sourceRoot, folderPath).replace(/\\/g, '/');
271
+ const homeItem = {
272
+ label: 'Home',
273
+ path: 'home',
274
+ href: relativePath + '/index.html',
275
+ hasChildren: topLevelFiles.length > 0,
276
+ icon: `<span class="menu-icon">${HOME_ICON}</span>`,
277
+ children: topLevelFiles,
278
+ };
279
+
280
+ return [homeItem, ...topLevelFolders];
214
281
  }
215
282
 
216
- return items;
283
+ return processedItems;
217
284
  }
218
285
 
219
286
  /**
@@ -241,7 +308,7 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, is
241
308
  * @param {number} depth - How many levels deep to auto-generate
242
309
  * @returns {Array} - Combined menu data array
243
310
  */
244
- export function combineAutoAndManualMenu(manualContent, menuDir, sourceRoot, depth = 2) {
311
+ export function combineAutoAndManualMenu(manualContent, menuDir, sourceRoot, depth = 10) {
245
312
  // Generate the auto menu
246
313
  const autoMenuItems = autoGenerateMenuFromFolder(menuDir, sourceRoot, depth, true);
247
314
 
@@ -422,15 +489,31 @@ export function parseCustomMenu(content, menuDir, sourceRoot) {
422
489
  // It's a relative path - resolve it relative to the menu directory
423
490
  absoluteSourcePath = resolve(menuDir, href);
424
491
 
425
- // Check if the path already has a source extension (.md, .txt)
492
+ // Check if the path already has a source extension (.md, .txt, .mdx)
426
493
  const hrefExt = extname(href).toLowerCase();
427
494
  const hasSourceExt = SOURCE_EXTENSIONS.includes(hrefExt);
495
+ const hasOutputExt = hrefExt === '.html';
428
496
 
429
497
  // If it has a source extension, check if that exact file exists
498
+ // If it has .html extension, strip it and look for corresponding source file
430
499
  // Otherwise use sourceFileExists which tries multiple extensions
431
500
  let fileExists = false;
432
501
  if (hasSourceExt) {
433
502
  fileExists = existsSync(absoluteSourcePath);
503
+ } else if (hasOutputExt) {
504
+ // Strip .html and look for source files (the user linked to the output path)
505
+ const withoutHtml = absoluteSourcePath.replace(/\.html$/, '');
506
+ for (const ext of SOURCE_EXTENSIONS) {
507
+ if (existsSync(withoutHtml + ext)) {
508
+ absoluteSourcePath = withoutHtml + ext;
509
+ fileExists = true;
510
+ break;
511
+ }
512
+ }
513
+ // Also check if it's a directory with an index file (e.g., index.html → index.md)
514
+ if (!fileExists) {
515
+ fileExists = sourceFileExists(withoutHtml);
516
+ }
434
517
  } else {
435
518
  fileExists = sourceFileExists(absoluteSourcePath);
436
519
  }
@@ -441,8 +524,8 @@ export function parseCustomMenu(content, menuDir, sourceRoot) {
441
524
  // Normalize path separators for web
442
525
  href = href.replace(/\\/g, '/');
443
526
  // Convert source extensions to .html
444
- if (hasSourceExt) {
445
- href = href.replace(/\.(md|txt)$/i, '.html');
527
+ if (hasSourceExt || existsSync(absoluteSourcePath) && SOURCE_EXTENSIONS.includes(extname(absoluteSourcePath).toLowerCase())) {
528
+ href = href.replace(/\.(md|mdx|txt)$/i, '.html');
446
529
  } else if (!href.match(/\.[a-z]+$/i)) {
447
530
  // Ensure it ends with .html if it doesn't have an extension
448
531
  // Check if it's likely a folder (ends with /) or file
@@ -454,8 +537,14 @@ export function parseCustomMenu(content, menuDir, sourceRoot) {
454
537
  }
455
538
  }
456
539
  } else {
457
- // Source file doesn't exist - this is a non-navigable menu item
458
- href = null;
540
+ // Source file doesn't exist - preserve href as-is so it still renders
541
+ // as a clickable (though possibly broken) link rather than plain text.
542
+ // If the href was relative, resolve it to an absolute web path.
543
+ const resolvedAbsolute = resolve(menuDir, href.startsWith('./') || href.startsWith('../') ? href : './' + href);
544
+ const resolvedRelative = relative(sourceRoot, resolvedAbsolute);
545
+ href = '/' + resolvedRelative.replace(/\\/g, '/');
546
+ // Convert source extensions to .html for web paths
547
+ href = href.replace(/\.(md|mdx|txt)$/i, '.html');
459
548
  }
460
549
  }
461
550
 
@@ -507,8 +596,8 @@ export function getCustomMenuForFile(filePath, sourceRoot) {
507
596
  // Extract frontmatter options
508
597
  const { frontmatter, body } = extractMenuFrontmatter(customMenuInfo.content);
509
598
  const autoGenerate = frontmatter['auto-generate-menu'] === true || frontmatter['auto-generate-menu'] === 'true';
510
- const menuPosition = frontmatter['menu-position'] || 'side';
511
- const depth = parseInt(frontmatter['menu-depth'], 10) || 2;
599
+ const menuPosition = frontmatter['menu-position'] || 'top';
600
+ const depth = parseInt(frontmatter['menu-depth'], 10) || 10;
512
601
 
513
602
  let menuData;
514
603
 
@@ -464,8 +464,32 @@ export function transformImageTags(html, imageMap, docUrlPath = '/') {
464
464
  const lastAClose = precedingHtml.lastIndexOf('</a>');
465
465
  const isInsideAnchor = lastAOpen > lastAClose;
466
466
 
467
+ // Detect per-image no-preview flag:
468
+ // Markdown: ![alt](image.jpg?no-preview) → ?no-preview in src
469
+ // HTML: <img data-no-preview src="..."> → data-no-preview attribute
470
+ const noPreviewQuery = /[?&]no-preview(?:&|$)/.test(src);
471
+ const noPreviewAttr = /data-no-preview/.test(before + after);
472
+ const noPreview = noPreviewQuery || noPreviewAttr;
473
+
474
+ // Strip ?no-preview from src so it doesn't appear in output
475
+ let cleanSrc = src;
476
+ if (noPreviewQuery) {
477
+ cleanSrc = cleanSrc
478
+ .replace(/[?&]no-preview(?=&|$)/, (m) => m[0] === '?' ? '?' : '')
479
+ .replace(/\?&/, '?')
480
+ .replace(/\?$/, '');
481
+ }
482
+
483
+ // Strip data-no-preview attribute from output
484
+ let cleanBefore = before;
485
+ let cleanAfter = after;
486
+ if (noPreviewAttr) {
487
+ cleanBefore = cleanBefore.replace(/\s*data-no-preview(?:="[^"]*")?\s*/g, ' ');
488
+ cleanAfter = cleanAfter.replace(/\s*data-no-preview(?:="[^"]*")?\s*/g, ' ');
489
+ }
490
+
467
491
  // Normalize src path for lookup
468
- let lookupPath = src;
492
+ let lookupPath = cleanSrc;
469
493
  // Remove query strings for lookup
470
494
  const queryIndex = lookupPath.indexOf('?');
471
495
  if (queryIndex !== -1) {
@@ -490,25 +514,31 @@ export function transformImageTags(html, imageMap, docUrlPath = '/') {
490
514
 
491
515
  const imageInfo = imageMap.get(lookupPath);
492
516
 
493
- // Determine full-size URL (use original from imageInfo, or fallback to src)
494
- const fullSizeUrl = imageInfo ? imageInfo.original : src;
517
+ // Determine full-size URL (use original from imageInfo, or fallback to cleaned src)
518
+ const fullSizeUrl = imageInfo ? imageInfo.original : cleanSrc;
495
519
 
496
- // Determine the src to use (preview if available, otherwise original)
497
- let newSrc = src;
498
- if (imageInfo && imageInfo.preview !== imageInfo.original) {
520
+ // Determine the src to use (preview if available and not suppressed)
521
+ let newSrc = cleanSrc;
522
+ if (!noPreview && imageInfo && imageInfo.preview !== imageInfo.original) {
499
523
  // Preserve any existing query string (like cache busting) on preview
500
- const querySuffix = queryIndex !== -1 ? src.substring(queryIndex) : '';
524
+ const querySuffix = queryIndex !== -1 ? cleanSrc.substring(queryIndex) : '';
501
525
  newSrc = imageInfo.preview + querySuffix;
502
526
  }
503
527
 
504
528
  // Build the new img tag
505
- const imgTag = `<img${before}src="${newSrc}"${after}>`;
529
+ const imgTag = `<img${cleanBefore}src="${newSrc}"${cleanAfter}>`;
506
530
 
507
531
  // If already inside an anchor tag, just return the updated img tag
508
532
  if (isInsideAnchor) {
509
533
  return imgTag;
510
534
  }
511
535
 
536
+ // If no-preview is set, the image is intentionally full-size — skip the
537
+ // click-to-view-original wrapper since it would be redundant.
538
+ if (noPreview) {
539
+ return imgTag;
540
+ }
541
+
512
542
  // Wrap in anchor tag linking to full-size image
513
543
  return `<a href="${fullSizeUrl}" target="_blank" class="image-link">${imgTag}</a>`;
514
544
  }