/**
* 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
}