Product design skills

scripts/discover-routes.mjs

A supporting file of the design-critique skill, shown as source.

#!/usr/bin/env node
/**
 * discover-routes.mjs — find the UI routes in a code package.
 *
 * File-based frameworks are listed directly. Programmatic routing is only
 * scanned when a real router is a dependency, and only inside source dirs
 * (never node_modules / build output / this tool's own scripts). If there are
 * no crawlable routes, it says so plainly instead of inventing one.
 *
 * Usage: node discover-routes.mjs <project-dir>
 * Prints JSON: { framework, routing, routes:[{path,source}], needsConfirmation, hostPage?, notes }
 */
import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'

const root = process.argv[2] || process.cwd()
const notes = []

// Dirs we never walk: deps, build output, VCS, this tool's own scripts, tests.
const IGNORE = /^(node_modules|dist|build|out|coverage|\.next|\.nuxt|\.svelte-kit|\.git|\.cache|scripts|__pycache__|tests?|__tests__|e2e|\.turbo|\.vercel)$/

function findDir(...cands) { return cands.map(c => join(root, c)).find(existsSync) }
function read(p) { try { return readFileSync(p, 'utf8') } catch { return '' } }
function pkg() { try { return JSON.parse(read(join(root, 'package.json')) || '{}') } catch { return {} } }
function hasRouterDep(deps) {
  return !!(deps['react-router'] || deps['react-router-dom'] || deps['vue-router'] ||
    deps['@tanstack/react-router'] || deps['@reach/router'])
}

// Walk a file-based routing dir (Next app/, pages/, Remix/SvelteKit/Nuxt routes/).
function walkFileRoutes(dir, base = '', mode = 'app') {
  const out = []
  for (const name of readdirSync(dir)) {
    if (name.startsWith('.') || IGNORE.test(name)) continue
    const full = join(dir, name)
    if (statSync(full).isDirectory()) {
      const seg = name.replace(/^\((.*)\)$/, '').replace(/^\[\.\.\.(.+)\]$/, '*').replace(/^\[(.+)\]$/, ':$1')
      out.push(...walkFileRoutes(full, seg ? `${base}/${seg}` : base, mode))
    } else {
      const isPage = mode === 'app'
        ? /^page\.(t|j)sx?$/.test(name)
        : /\.(t|j)sx?$/.test(name) && !/^_/.test(name) && !/\.(test|spec)\./.test(name)
      if (isPage) {
        let p = base
        if (mode !== 'app') {
          const f = name.replace(/\.(t|j)sx?$/, '').replace(/^index$/, '')
          p = `${base}/${f}`.replace(/\/index$/, '')
        }
        out.push({ path: p === '' ? '/' : p.replace(/\/+/g, '/'), source: relative(root, full) })
      }
    }
  }
  return out
}

// Programmatic routes: ONLY in files that actually use a router, path must start with "/".
function scanProgrammatic(dir, depth = 0) {
  const routes = []
  if (depth > 5 || !existsSync(dir)) return routes
  for (const n of readdirSync(dir)) {
    if (n.startsWith('.') || IGNORE.test(n)) continue
    const f = join(dir, n)
    const s = statSync(f)
    if (s.isDirectory()) { routes.push(...scanProgrammatic(f, depth + 1)); continue }
    if (!/\.(t|j)sx?$/.test(n)) continue // real source only — skip .mjs tooling
    const src = read(f)
    if (!/react-router|vue-router|createBrowserRouter|@tanstack\/react-router|<Routes\b|<Route\b/.test(src)) continue
    const re = /<Route\b[^>]*\bpath=['"`]([^'"`]+)['"`]|\bpath:\s*['"`](\/[^'"`]*)['"`]/g
    let m
    while ((m = re.exec(src))) {
      const p = m[1] || m[2]
      if (p && p.startsWith('/')) routes.push({ path: p, source: relative(root, f) })
    }
  }
  return routes
}

function detect() {
  const p = pkg()
  const deps = { ...p.dependencies, ...p.devDependencies }

  // KiroCrew app with an embedded UI — no routes of its own.
  const ajRaw = read(join(root, 'app.json'))
  if (ajRaw) {
    try {
      const aj = JSON.parse(ajRaw)
      if (aj.ui || aj.mcpServers || aj.agents) {
        const hostPage = aj.ui?.pages?.[0]?.route || null
        notes.push('This is a KiroCrew app with an embedded UI — it has no routes to crawl by itself. Run the whole KiroCrew dashboard and open its page' + (hostPage ? ` (${hostPage})` : '') + ', or give a screenshot.')
        return { framework: 'KiroCrew app (embedded UI)', routing: 'host-embedded', routes: [], hostPage }
      }
    } catch { /* not valid json — ignore */ }
  }

  // Electron desktop app — not a web server.
  if (deps.electron || /electron/i.test(p.main || '')) {
    notes.push('Electron desktop app — not a web server. Screenshot the running app instead.')
    return { framework: 'Electron app', routing: 'desktop', routes: [] }
  }

  // Next.js app router
  let d = findDir('app', 'src/app')
  if (d && (deps.next || existsSync(join(root, 'next.config.js')) || existsSync(join(root, 'next.config.ts'))))
    return { framework: 'Next.js', routing: 'file-based (app router)', routes: walkFileRoutes(d, '', 'app') }
  // Next.js pages router
  d = findDir('pages', 'src/pages')
  if (d && deps.next) return { framework: 'Next.js', routing: 'file-based (pages router)', routes: walkFileRoutes(d, '', 'pages') }
  // Remix / SvelteKit / Nuxt
  d = findDir('app/routes', 'src/routes')
  if (d) {
    const fw = deps['@remix-run/react'] ? 'Remix' : deps['@sveltejs/kit'] ? 'SvelteKit' : deps.nuxt ? 'Nuxt' : 'file-based framework'
    return { framework: fw, routing: 'file-based (routes/)', routes: walkFileRoutes(d, '', 'pages') }
  }

  // Programmatic SPA — only when a router is actually a dependency.
  if (hasRouterDep(deps)) {
    const routes = scanProgrammatic(findDir('src') || root)
    if (routes.length) {
      notes.push('Programmatic routing — route list is best-effort. Confirm with the user before capturing.')
      return { framework: deps.vue ? 'Vue' : 'React (SPA)', routing: 'programmatic', routes, needsConfirmation: true }
    }
  }

  // Static HTML site
  const htmls = existsSync(root) ? readdirSync(root).filter(n => n.endsWith('.html')) : []
  if (htmls.length) return { framework: 'Static HTML', routing: 'files', routes: htmls.map(h => ({ path: '/' + h, source: h })) }

  notes.push('No crawlable web routes found. If this is a component library or an embedded widget, render individual components (e.g. Storybook) or provide a screenshot.')
  return { framework: 'not a standalone web app', routing: 'none', routes: [] }
}

const res = detect()
res.needsConfirmation = !!res.needsConfirmation
res.notes = notes
const seen = new Set()
res.routes = (res.routes || []).filter(r => !seen.has(r.path) && seen.add(r.path)).sort((a, b) => a.path.localeCompare(b.path))
console.log(JSON.stringify(res, null, 2))
console.error(`discover-routes: ${res.framework} · ${res.routing} · ${res.routes.length} routes${res.needsConfirmation ? ' (confirm with user)' : ''}`)