All files / packages/design-system-2-site/src/lib content.ts

98.14% Statements 53/54
89.18% Branches 33/37
100% Functions 11/11
100% Lines 45/45

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            2x   2x 2x                                                 48x 48x             6x   5x 5x 5x 5x 47x   33x 28x 28x   5x               2x 48x 48x 46x   38x 38x 38x   38x         38x                             2x   21x 21x 21x 4x               3x 3x 18x   18x       1x 1x 6x 1x       1x 1x 1x 6x 1x   5x 5x     1x    
import fs from 'node:fs';
import path from 'node:path';
import { cache } from 'react';
import matter from 'gray-matter';
import { renderMarkdownString } from './markdown';
 
const NAV_FILENAME = 'navigation.md';
 
const contentDirectory = process.env.CONTENT_DIR || path.resolve(process.cwd(), 'content');
const contentDirectoryResolved = path.resolve(contentDirectory);
 
export interface ContentMetadata {
  title: string;
  description?: string;
  tagline?: string;
  category?: string;
  order?: number;
  slug: string;
  [key: string]: unknown;
}
 
export interface ContentFile {
  metadata: ContentMetadata;
  content: string;
  html: string;
  rawMarkdown: string;
}
 
/**
 * Reject paths that escape the content directory (e.g. `../../secret.md`).
 * In production static export this can't be reached at runtime, but `next dev`
 * accepts any params, so we defend at the resolver layer.
 */
function isInsideContentDir(fullPath: string): boolean {
  const resolved = path.resolve(fullPath);
  return resolved === contentDirectoryResolved || resolved.startsWith(contentDirectoryResolved + path.sep);
}
 
/**
 * Get all `.md` / `.mdx` content files recursively under `dir`.
 */
export function getAllContentFiles(dir: string = contentDirectory): string[] {
  if (!fs.existsSync(dir)) return [];
 
  const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: true });
  const navFile = path.join(dir, NAV_FILENAME);
  const files: string[] = [];
  for (const entry of entries) {
    if (!entry.isFile()) continue;
    // Only the root nav file is skipped; `patterns/navigation.md` is a page.
    if (path.join(entry.parentPath, entry.name) === navFile) continue;
    Iif (!entry.name.endsWith('.md') && !entry.name.endsWith('.mdx')) continue;
    files.push(path.join(entry.parentPath, entry.name));
  }
  return files;
}
 
/**
 * Resolve a content file by its path relative to the content root.
 * Deduped per render pass via `react.cache()` so `generateMetadata` and the
 * page component don't re-parse the same markdown at build time.
 */
export const getContentByPath = cache(async (relativePath: string): Promise<ContentFile | null> => {
  const fullPath = path.join(contentDirectory, relativePath);
  if (!isInsideContentDir(fullPath)) return null;
  if (!fs.existsSync(fullPath)) return null;
 
  const rawMarkdown = fs.readFileSync(fullPath, 'utf8');
  const { data, content } = matter(rawMarkdown);
  const html = await renderMarkdownString(content);
 
  const slug = path
    .relative(contentDirectory, fullPath)
    .replace(/\.mdx?$/, '')
    .replace(/\\/g, '/');
 
  return {
    metadata: {
      ...data,
      slug,
      title: (data.title as string) || 'Untitled',
    } as ContentMetadata,
    content,
    html,
    rawMarkdown,
  };
});
 
/**
 * Resolve a content file by category + optional slug segments.
 */
export const getContentBySlug = cache(
  async (category: string, slug?: string[]): Promise<ContentFile | null> => {
    const slugPath = slug && slug.length > 0 ? slug.join('/') : 'index';
    const result = await getContentByPath(`${category}/${slugPath}.md`);
    if (result) return result;
    return getContentByPath(`${category}/${slugPath}.mdx`);
  },
);
 
/**
 * Load every content file with metadata + rendered HTML.
 */
export async function getAllContent(): Promise<ContentFile[]> {
  const files = getAllContentFiles();
  const results = await Promise.all(
    files.map((filePath) => getContentByPath(path.relative(contentDirectory, filePath))),
  );
  return results.filter((item): item is ContentFile => item !== null);
}
 
export async function getContentByCategory(category: string): Promise<ContentFile[]> {
  const all = await getAllContent();
  return all
    .filter((item) => item.metadata.category === category || item.metadata.slug.startsWith(`${category}/`))
    .sort((a, b) => ((a.metadata.order as number) ?? 999) - ((b.metadata.order as number) ?? 999));
}
 
export async function getAllCategories(): Promise<string[]> {
  const all = await getAllContent();
  const categories = new Set<string>();
  for (const item of all) {
    if (item.metadata.category) {
      categories.add(item.metadata.category);
    } else {
      const parts = item.metadata.slug.split('/');
      if (parts.length > 1) categories.add(parts[0]);
    }
  }
  return Array.from(categories).sort();
}