OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / floor / location.js

168 lines · javascript ref:
/**
 * Where you are, as one object, shared by every surface.
 *
 * There were two location models: the SPA's `graphStack` serialized to PATH
 * segments, and the floor's { module, path, symbolId } serialized to QUERY
 * params. Two schemes for the same idea meant opening a file in one view told
 * the other nothing.
 *
 * The SPA's path scheme is canonical, because it is the one with existing deep
 * links and history entries. The floor adopts it under a /floor prefix, and the
 * symbol rung extends it:
 *
 *   /                                        system
 *   /module/:name                            a module
 *   /module/:name/file/:rel/:path            a file in it
 *   /module/:name/file/:rel/symbol/:symbol   a symbol in that file
 *   /path/:a/:b                              a directory in the scan
 *   /validation  /docs/*  /suggestions/*     the SPA's other views
 *
 *   /floor/...                               the same places, drawn as the floor
 *
 * Pure: strings in, strings out, no tree and no DOM, so both surfaces and the
 * tests share one implementation.
 */

const SURFACE = 'floor'

/**
 * @returns {object} { surface, kind, module, file, symbol, path, extra }
 *   surface: 'spa' | 'floor'
 *   kind:    'system' | 'module' | 'file' | 'symbol' | 'path' |
 *            'validation' | 'docs' | 'suggestions'
 *   path:    array of directory segments, for kind 'path'
 */
export function parseLocation (pathname, basePrefix = '') {
  const loc = {
    surface: 'spa',
    kind: 'system',
    module: null,
    file: null,
    symbol: null,
    path: [],
    extra: {}
  }

  let p = pathname || '/'
  if (basePrefix && p.startsWith(basePrefix)) p = p.slice(basePrefix.length) || '/'
  if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)

  let segs = p.split('/').filter(Boolean).map(decodeURIComponent)

  if (segs[0] === SURFACE) {
    loc.surface = SURFACE
    segs = segs.slice(1)
  }
  if (segs.length === 0 || segs[0] === 'system') return loc

  if (segs[0] === 'validation') {
    loc.kind = 'validation'
    if (segs[1] === 'module' && segs[2]) loc.extra.moduleName = segs[2]
    return loc
  }
  if (segs[0] === 'docs') {
    loc.kind = 'docs'
    if (segs.length > 1) loc.extra.docPath = segs.slice(1).join('/')
    return loc
  }
  if (segs[0] === 'suggestions') {
    loc.kind = 'suggestions'
    if (segs.length > 1) loc.extra.suggestPath = segs.slice(1).join('/')
    return loc
  }

  if (segs[0] === 'path') {
    loc.kind = 'path'
    loc.path = segs.slice(1)
    return loc
  }

  if (segs[0] === 'module' && segs[1]) {
    loc.kind = 'module'
    loc.module = segs[1]

    const fileAt = segs.indexOf('file', 2)
    if (fileAt === -1) return loc

    // A file path has slashes of its own, so it runs until 'symbol' or the end.
    const symbolAt = segs.indexOf('symbol', fileAt + 1)
    const fileEnd = symbolAt === -1 ? segs.length : symbolAt
    const fileParts = segs.slice(fileAt + 1, fileEnd)
    if (fileParts.length > 0) {
      loc.kind = 'file'
      loc.file = fileParts.join('/')
    }
    if (symbolAt !== -1 && segs[symbolAt + 1]) {
      loc.kind = 'symbol'
      loc.symbol = segs[symbolAt + 1]
    }
    return loc
  }

  return loc
}

export function locationToUrl (loc, basePrefix = '') {
  const base = basePrefix + (loc.surface === SURFACE ? '/' + SURFACE : '')
  const enc = encodeURIComponent

  switch (loc.kind) {
    case 'module':
    case 'file':
    case 'symbol': {
      if (!loc.module) return base + '/'
      let url = base + '/module/' + enc(loc.module)
      if (loc.file) url += '/file/' + loc.file.split('/').map(enc).join('/')
      if (loc.symbol && loc.kind === 'symbol') url += '/symbol/' + enc(loc.symbol)
      return url
    }
    case 'path':
      return loc.path.length > 0 ? base + '/path/' + loc.path.map(enc).join('/') : base + '/'
    case 'validation':
      return base + '/validation' +
        (loc.extra && loc.extra.moduleName ? '/module/' + enc(loc.extra.moduleName) : '')
    case 'docs':
      return base + '/docs' +
        (loc.extra && loc.extra.docPath ? '/' + loc.extra.docPath.split('/').map(enc).join('/') : '')
    case 'suggestions':
      return base + '/suggestions' +
        (loc.extra && loc.extra.suggestPath ? '/' + loc.extra.suggestPath.split('/').map(enc).join('/') : '')
    default:
      return base + '/'
  }
}

/** The same place, on the other surface — which is what makes the views agree. */
export function otherSurface (loc, basePrefix = '') {
  return locationToUrl({ ...loc, surface: loc.surface === SURFACE ? 'spa' : SURFACE }, basePrefix)
}

/**
 * A root-relative file path split into the module that owns it and the path
 * within that module, because the canonical scheme addresses a file as
 * module + relative path while the floor loads it by root-relative path.
 */
export function splitFilePath (tree, rootRelPath) {
  if (!rootRelPath) return { module: null, file: null }
  let best = null
  for (const [name, mod] of Object.entries(tree.modules || {})) {
    const prefix = mod.path && mod.path !== '.' ? mod.path + '/' : ''
    if (prefix && !rootRelPath.startsWith(prefix)) continue
    const rel = prefix ? rootRelPath.slice(prefix.length) : rootRelPath
    if (!mod.files || !mod.files[rel]) continue
    // The deepest module path wins: a nested package inside another one owns
    // its own files.
    if (!best || prefix.length > best.prefixLength) {
      best = { module: name, file: rel, prefixLength: prefix.length }
    }
  }
  return best ? { module: best.module, file: best.file } : { module: null, file: null }
}

/** The inverse: module + relative path back to the root-relative path. */
export function joinFilePath (tree, moduleName, file) {
  const mod = (tree.modules || {})[moduleName]
  if (!mod || !file) return file || null
  return mod.path && mod.path !== '.' ? mod.path + '/' + file : file
}