Skip to content
Visibility internal Owner _ Approver _ Created _ Updated _

generate_view_pages.ts



FieldValue
TypeTypeScript
SourceForge/Skills/Manage_Pages/generate_view_pages.ts
ParentForge
GitHubForge/Skills/Manage_Pages/generate_view_pages.ts

This page is auto-generated. Edit the source to change the content.



/**
 * generate_folder_pages.ts
 *
 * Generates file-view pages, MEDIA.md pages,
 * and sidebar addition reports for the Uvilo OS documentation site.
 *
 * Run with: npx tsx generate_folder_pages.ts
 */

import * as fs from 'node:fs';
import * as path from 'node:path';

interface FolderConfig {
  view_folder_patterns: string[];
  supported_extensions: string[];
  image_extensions: string[];
  excluded_dirs: string[];
  generated_dir: string;
  max_file_lines: number;
}

interface IndexedFolder {
  absPath: string;
  name: string;
  relPath: string;
}

interface ScannedFile {
  absPath: string;
  relPath: string;
  folderRelPath: string;
  ext: string;
  fileName: string;
}

interface MediaFile {
  absPath: string;
  relPath: string;
  folderRelPath: string;
  fileName: string;
  ext: string;
}


function getFileType(ext: string): string {
  const map: Record<string, string> = {
    '.ts': 'TypeScript', '.tsx': 'TypeScript',
    '.js': 'JavaScript', '.jsx': 'JavaScript',
    '.json': 'JSON', '.yaml': 'YAML', '.yml': 'YAML',
    '.py': 'Python', '.sh': 'Shell', '.sql': 'SQL',
    '.txt': 'Plain Text', '.csv': 'CSV', '.css': 'CSS', '.html': 'HTML',
    '.md': 'Markdown',
  };
  return map[ext] || ext.slice(1).toUpperCase();
}

function getCodeBlockLang(ext: string): string {
  const map: Record<string, string> = {
    '.ts': 'typescript', '.tsx': 'typescript',
    '.js': 'javascript', '.jsx': 'javascript',
    '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml',
    '.py': 'python', '.sh': 'bash', '.sql': 'sql',
    '.txt': 'text', '.csv': 'text', '.css': 'css', '.html': 'html',
    '.md': 'markdown',
  };
  return map[ext] || '';
}

function isExcludedDir(dirName: string, excludedDirs: string[]): boolean {
  return excludedDirs.includes(dirName);
}

function isSupportedExt(ext: string, extensions: string[]): boolean {
  return extensions.includes(ext);
}

function isImageExt(ext: string, extensions: string[]): boolean {
  return extensions.includes(ext);
}

function githubRawUrl(relPath: string): string {
  return `https://github.com/ErikDakoda/uvilo-os/blob/dev/${relPath}`;
}

function loadConfig(repoRoot: string): FolderConfig {
  const configPath = path.join(
    repoRoot, 'Forge', 'Skills', 'Manage_Pages', 'view_folder_config.json'
  );
  const raw = fs.readFileSync(configPath, 'utf-8');
  return JSON.parse(raw) as FolderConfig;
}

function expandPatterns(repoRoot: string, patterns: string[], excludedDirs: string[]): IndexedFolder[] {
  const folders: IndexedFolder[] = [];
  const seen = new Set<string>();

  for (const pattern of patterns) {
    const absPattern = path.join(repoRoot, pattern);
    const dirs = fs.globSync(absPattern.endsWith('/') ? absPattern : absPattern + '/', {
      withFileTypes: true,
      exclude: (entry) => entry.name.startsWith('.') || excludedDirs.includes(entry.name),
    });

    for (const dir of dirs) {
      if (!dir.isDirectory()) continue;
      const relPath = path.relative(repoRoot, dir.parentPath ? path.join(dir.parentPath, dir.name) : dir.path);
      if (seen.has(relPath)) continue;
      seen.add(relPath);
      folders.push({
        absPath: path.join(repoRoot, relPath),
        name: path.basename(relPath),
        relPath,
      });
    }
  }

  return folders;
}

function scanFolders(repoRoot: string, config: FolderConfig): IndexedFolder[] {
  return expandPatterns(repoRoot, config.view_folder_patterns, config.excluded_dirs);
}

function generateFileViewPage(file: ScannedFile, folder: IndexedFolder, config: FolderConfig): string {
  const content = fs.readFileSync(file.absPath, 'utf-8');
  const lines = content.split('\n');
  const fileType = getFileType(file.ext);
  const codeLang = getCodeBlockLang(file.ext);

  const depth = file.folderRelPath.split(path.sep).length;
  const backLink = '../'.repeat(depth) + (file.relPath.includes('Skills') ? 'skill/' : 'readme/');

  let fileContentBlock: string;
  if (lines.length > config.max_file_lines) {
    const truncated = lines.slice(0, config.max_file_lines).join('\n');
    fileContentBlock = `\`\`\`${codeLang}\n${truncated}\n\`\`\`\n\n`;
    fileContentBlock += `> **Note:** This file exceeds ${config.max_file_lines} lines. Showing the first ${config.max_file_lines} lines.\n`;
    fileContentBlock += `> [View the complete file on GitHub](${githubRawUrl(file.relPath)})\n`;
  } else {
    fileContentBlock = `\`\`\`${codeLang}\n${content}\n\`\`\`\n`;
  }

  return `---
title: "${file.fileName}"
generated: true
---

# ${file.fileName}

<br><br>

| Field | Value |
|---|---|
| **Type** | ${fileType} |
| **Source** | \`${file.relPath}\` |
| **Parent** | [${folder.name}](${backLink}) |
| **GitHub** | [${file.relPath}](${githubRawUrl(file.relPath)}) |

This page is auto-generated. Edit the source to change the content.

<br><br>

${fileContentBlock}
`;
}

function scanForNonMdFiles(folder: IndexedFolder, config: FolderConfig): ScannedFile[] {
  const files: ScannedFile[] = [];

  function walk(dirPath: string, relDir: string): void {
    const entries = fs.readdirSync(dirPath, { withFileTypes: true });
    for (const entry of entries) {
      if (entry.name.startsWith('.')) continue;
      if (isExcludedDir(entry.name, config.excluded_dirs)) continue;

      const fullPath = path.join(dirPath, entry.name);
      const fullRelPath = path.join(relDir, entry.name);

      if (entry.isDirectory()) {
        walk(fullPath, fullRelPath);
      } else {
        const ext = path.extname(entry.name).toLowerCase();
        if (ext === '.md') continue;
        if (isSupportedExt(ext, config.supported_extensions)) {
          files.push({
            absPath: fullPath,
            relPath: fullRelPath,
            folderRelPath: path.relative(folder.absPath, fullPath),
            ext,
            fileName: entry.name,
          });
        }
      }
    }
  }

  walk(folder.absPath, folder.relPath);
  return files;
}

function scanDirForMedia(dirPath: string, imageExts: string[], excludedDirs: string[]): boolean {
  if (!fs.existsSync(dirPath)) return false;
  try {
    const entries = fs.readdirSync(dirPath, { withFileTypes: true });
    for (const entry of entries) {
      if (entry.name.startsWith('.') || entry.isDirectory()) continue;
      const ext = path.extname(entry.name).toLowerCase();
      if (isImageExt(ext, imageExts)) return true;
    }
  } catch {
    return false;
  }
  return false;
}

function scanForMediaFolders(folder: IndexedFolder, config: FolderConfig): Map<string, MediaFile[]> {
  const folders = new Map<string, MediaFile[]>();

  function walk(dirPath: string, relDir: string): void {
    let hasMedia = false;
    const mediaFiles: MediaFile[] = [];

    try {
      const entries = fs.readdirSync(dirPath, { withFileTypes: true });
      for (const entry of entries) {
        if (entry.name.startsWith('.')) continue;
        if (isExcludedDir(entry.name, config.excluded_dirs)) continue;

        const fullPath = path.join(dirPath, entry.name);
        const fullRelPath = path.join(relDir, entry.name);

        if (entry.isDirectory()) {
          walk(fullPath, fullRelPath);
        } else {
          const ext = path.extname(entry.name).toLowerCase();
          if (isImageExt(ext, config.image_extensions)) {
            hasMedia = true;
            mediaFiles.push({
              absPath: fullPath,
              relPath: fullRelPath,
              folderRelPath: path.relative(folder.absPath, fullPath),
              fileName: entry.name,
              ext,
            });
          }
        }
      }
    } catch {
      return;
    }

    if (hasMedia && dirPath !== folder.absPath) {
      folders.set(dirPath, mediaFiles);
    }
  }

  walk(folder.absPath, folder.relPath);
  return folders;
}

function generateMediaMd(
  folderPath: string,
  mediaFiles: MediaFile[],
  folder: IndexedFolder
): string {
  const folderRelPath = path.relative(folder.absPath, folderPath);
  const folderName = path.basename(folderPath);
  const displayName = folder.name.replace(/_/g, ' ');

  let md = `---\n`;
  md += `title: "${displayName}${folderName}"\n`;
  md += `visibility: internal\n`;
  md += `generated: true\n`;
  md += `---\n\n`;
  md += `# ${displayName}${folderName}\n\n`;
  md += `> Path: \`${folder.relPath}/${folderRelPath.replace(/\\/g, '/')}\`\n\n`;

  for (const media of mediaFiles) {
    // Relative paths for image display (raw.githubusercontent.com 404s on this private repo).
    const relUrl = `./${media.fileName}`;
    // GitHub blob URL for download link (the relative path breaks in the built site).
    const downloadUrl = `https://github.com/ErikDakoda/uvilo-os/blob/dev/${media.relPath}`;
    md += `## ${media.fileName}\n\n`;
    md += `![${media.fileName}](${relUrl})\n\n`;
    md += `[Download](${downloadUrl})\n\n`;
  }

  return md;
}

function generateSidebarReport(
  folders: IndexedFolder[],
  nonMdFilesMap: Map<string, ScannedFile[]>,
  mediaFoldersMap: Map<string, Map<string, MediaFile[]>>,
  config: FolderConfig
): string {
  let report = `# Sidebar Additions Report\n\n`;
  report += `> Generated by generate_view_pages.ts\n`;
  report += `> Review each section and add the suggested slugs to astro.config.mjs\n\n`;

  for (const folder of folders) {
    const folderSlug = folder.relPath.replace(/\\/g, '/').toLowerCase();
    const nonMdFiles = nonMdFilesMap.get(folder.absPath) || [];
    const mediaFolders = mediaFoldersMap.get(folder.absPath) || new Map();

    report += `## ${folder.name}\n\n`;

    const filesByDir = new Map<string, ScannedFile[]>();
    for (const file of nonMdFiles) {
      const dir = path.dirname(file.folderRelPath);
      const files = filesByDir.get(dir) || [];
      files.push(file);
      filesByDir.set(dir, files);
    }

    if (filesByDir.size > 0) {
      report += `### File-view pages\n\n`;
      for (const [dir, files] of filesByDir) {
        const dirSlug = dir === '.' ? folderSlug : `${folderSlug}/${dir.replace(/\\/g, '/').toLowerCase()}`;
        const dirLabel = dir === '.' ? folder.name : path.basename(dir);
        report += `**${dirLabel}** (${dir === '.' ? 'root' : dir}):\n\`\`\`js\n`;
        for (const file of files) {
          const fileViewSlug = `${dirSlug}/${file.fileName}.view`;
          report += `{ slug: '${fileViewSlug}' },\n`;
        }
        report += `\`\`\`\n\n`;
      }
    }

    if (mediaFolders.size > 0) {
      report += `### MEDIA.md pages\n\n`;
      for (const [folderPath] of mediaFolders) {
        const folderRelPath = path.relative(folder.absPath, folderPath);
        const mediaSlug = `${folderSlug}/${folderRelPath.replace(/\\/g, '/').toLowerCase()}/media`;
        report += `**${folderRelPath}**:\n\`\`\`js\n`;
        report += `{ slug: '${mediaSlug}' },\n`;
        report += `\`\`\`\n\n`;
      }
    }

    report += `---\n\n`;
  }

  return report;
}

function main(): void {
  const scriptDir = __dirname;
  const repoRoot = path.resolve(scriptDir, '..', '..', '..');

  console.log(`Repo root: ${repoRoot}`);

  const config = loadConfig(repoRoot);
  console.log(`Config loaded. View folder patterns: ${config.view_folder_patterns.join(', ')}`);

  const folders = scanFolders(repoRoot, config);
  console.log(`Found ${folders.length} folders:`);
  for (const p of folders) {
    console.log(`  - ${p.name}`);
  }

  const generatedBase = path.join(repoRoot, config.generated_dir);
  if (!fs.existsSync(generatedBase)) {
    fs.mkdirSync(generatedBase, { recursive: true });
  }

  const nonMdFilesMap = new Map<string, ScannedFile[]>();
  const mediaFoldersMap = new Map<string, Map<string, MediaFile[]>>();

  for (const folder of folders) {
    console.log(`\nProcessing: ${folder.name}`);

    const nonMdFiles = scanForNonMdFiles(folder, config);
    nonMdFilesMap.set(folder.absPath, nonMdFiles);

    for (const file of nonMdFiles) {
      const relativeToFolder = path.relative(folder.absPath, file.absPath);
      const generatedRelPath = path.join(
        config.generated_dir,
        folder.relPath,
        relativeToFolder + '.view.md'
      );
      const generatedAbsPath = path.join(repoRoot, generatedRelPath);

      fs.mkdirSync(path.dirname(generatedAbsPath), { recursive: true });

      const viewContent = generateFileViewPage(file, folder, config);
      fs.writeFileSync(generatedAbsPath, viewContent);
    }
    console.log(`  ✓ ${nonMdFiles.length} file-view page(s) generated`);

    const mediaFolders = scanForMediaFolders(folder, config);
    mediaFoldersMap.set(folder.absPath, mediaFolders);

    for (const [folderPath, mediaFiles] of mediaFolders) {
      const mediaMdPath = path.join(folderPath, 'MEDIA.md');
      const mediaContent = generateMediaMd(folderPath, mediaFiles, folder);
      fs.writeFileSync(mediaMdPath, mediaContent);
      console.log(`  ✓ MEDIA.md written to ${path.relative(folder.absPath, folderPath)}`);
    }
  }

  const sidebarReport = generateSidebarReport(folders, nonMdFilesMap, mediaFoldersMap, config);
  const reportPath = path.join(generatedBase, 'sidebar-additions.md');
  fs.writeFileSync(reportPath, sidebarReport);
  console.log(`\n✓ Sidebar additions report: ${reportPath}`);

  console.log('\nDone!');
}

main();