All files / packages/design-system-2-site/src/lib/zeroheight match.js

97.61% Statements 41/42
90.9% Branches 30/33
92.85% Functions 13/14
96.96% Lines 32/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137                                    51x                                 1x 1x 13x 13x   1x                 10x 9x 9x 9x 10x 10x   1x               2x                         14x 2x 14x 14x                 7x                               7x 7x 7x 14x         7x     7x               7x 7x 9x   6x   6x     7x 7x    
/**
 * Map Zeroheight pages and tabs to local content files.
 *
 * Order: manual mapping in the manifest (handled by the caller), then
 * `navigation.md` (page and tab names -> URLs -> files), then a filename
 * search (`<slug>.md`, `<slug>--<tab>.md`) anywhere under `content/`. Tabs
 * don't always live in the page's folder (`develop/badge--develop.md`), so
 * the filename search ignores folders unless it needs a tie-breaker.
 */
 
import path from 'node:path';
import { normName } from './pages.js';
 
/**
 * Name to the slug style used by content filenames: `Styling API` -> `styling-api`.
 * @param {string} name
 */
export function slugify(name) {
  return normName(name)
    .replace(/[^a-z0-9\s-]/g, '')
    .trim()
    .replace(/\s+/g, '-');
}
 
/**
 * @typedef {Object} LocalIndex
 * @property {Set<string>} files               package-relative, e.g. `content/components/badge.md`
 * @property {Map<string, string[]>} byBase    `badge--usage` -> [paths]
 */
 
/**
 * @param {string[]} files package-relative content paths
 * @returns {LocalIndex}
 */
export function buildLocalIndex(files) {
  const byBase = new Map();
  for (const file of [...files].sort()) {
    const base = path.posix.basename(file).replace(/\.mdx?$/, '');
    byBase.set(base, [...(byBase.get(base) ?? []), file]);
  }
  return { files: new Set(files), byBase };
}
 
/**
 * @param {string} url nav URL, e.g. `/components/badge--usage`
 * @param {LocalIndex} index
 * @returns {string | null}
 */
export function navUrlToFile(url, index) {
  if (/^https?:\/\//.test(url)) return null;
  let clean = url;
  while (clean.endsWith('/')) clean = clean.slice(0, -1);
  for (const ext of ['.md', '.mdx']) {
    const file = `content${clean}${ext}`;
    if (index.files.has(file)) return file;
  }
  return null;
}
 
/**
 * Flatten parsed navigation into pages tagged with their category name.
 * @param {import('../nav-md-parser.js').NavCategory[]} categories
 */
export function flattenNav(categories) {
  return categories.flatMap((cat) => cat.children.map((page) => ({ ...page, categoryName: cat.name })));
}
 
/**
 * Pick the single candidate, using the Zeroheight top-level category as a
 * tie-breaker. Returns null when still ambiguous.
 * @template T
 * @param {T[]} candidates
 * @param {(c: T) => string} categoryOf
 * @param {string | undefined} zhCategory
 * @returns {T | null}
 */
function pickOne(candidates, categoryOf, zhCategory) {
  if (candidates.length <= 1) return candidates[0] ?? null;
  const want = zhCategory ? slugify(zhCategory) : '';
  const narrowed = candidates.filter((c) => slugify(categoryOf(c)) === want);
  return narrowed.length === 1 ? narrowed[0] : null;
}
 
/**
 * @param {string} base
 * @param {LocalIndex} index
 * @param {string | undefined} zhCategory
 */
function findByBase(base, index, zhCategory) {
  return pickOne(index.byBase.get(base) ?? [], (file) => file.split('/')[1] ?? '', zhCategory);
}
 
/**
 * @typedef {Object} PageMatch
 * @property {'nav' | 'filename' | null} matchedBy
 * @property {string | null} localFile                 page landing file
 * @property {Map<string, string | null>} tabs         `normName(tab)` -> file
 */
 
/**
 * @param {{ name: string, categoryPath: string[], tabNames: string[] }} zh
 * @param {{ nav: ReturnType<typeof flattenNav>, index: LocalIndex }} ctx
 * @returns {PageMatch}
 */
export function matchPage(zh, { nav, index }) {
  const topCategory = zh.categoryPath[0];
  const slug = slugify(zh.name);
  const navPage = pickOne(
    nav.filter((p) => slugify(p.name) === slug),
    (p) => p.categoryName,
    topCategory,
  );
 
  const localFile = navPage ? navUrlToFile(navPage.url, index) : findByBase(slug, index, topCategory);
  // Some pages point at their first tab (`conversation-design--overview`),
  // so drop any `--tab` suffix before building tab filenames.
  const pageBase = localFile
    ? path.posix
        .basename(localFile)
        .replace(/\.mdx?$/, '')
        .split('--')[0]
    : slug;
 
  /** @type {Map<string, string | null>} */
  const tabs = new Map();
  for (const tabName of zh.tabNames) {
    const navTab = navPage?.tabs?.find((t) => slugify(t.name) === slugify(tabName));
    const file =
      (navTab && navUrlToFile(navTab.url, index)) ??
      findByBase(`${pageBase}--${slugify(tabName)}`, index, topCategory);
    tabs.set(normName(tabName), file);
  }
 
  const found = localFile !== null || [...tabs.values()].some(Boolean);
  return { matchedBy: navPage && found ? 'nav' : found ? 'filename' : null, localFile, tabs };
}