path-master
is a TypeScript library that simplifies working with dynamic file structures.
You can think of path-master
as providing structure to files in the same way that JSON provides structure to data.
Path Master provides a whole host of benefits:
- π‘ Centralised: a single source of truth for defining paths
- π Parameterised: paths are as dynamic as you need
- β¨ Type-safe: parameters are typed, and path types are inferred
- π Incremental: model a complete file structure or just parts of it
- 𧬠Consistent: standardises your path generation
This library does not modify any filesystem directly, it's solely for modelling and getting paths.
It can be used anywhere paths are used, such as:
- βοΈ S3 Buckets: for remote storage in the cloud
- πΎ Local Filesystem: for local storage (e.g. via Node.js
fs
module) - π§ Origin Private File System: for local storage in web apps
- π Relative URLs: between resources on the web
- Model your file structure with
dir
andfile
- Get paths, either:
- to a file/dir with
getPath
- between files/dirs with
getRelativeFsPath
orgetRelativeUrlPath
- to a file/dir with
Here is a model to represent a HLS video package file structure and how to get paths with getPath
and getRelativeUrlPath
.
import { dir, file, getPath, getRelativeUrlPath } from '@midzdotdev/path-master'
/* This is the file structure we're modelling:
.
βββ videos
βββ [videoId]
βββ master.m3u8
βββ stream_[quality]
βββ playlist.m3u8
βββ segment_[segmentId].ts
*/
const hlsPackageModel = dir(
({ videoId }: { videoId: number }) => `videos/${videoId}`,
{
manifest: file(`master.m3u8`),
variantStream: dir(
({ quality }: { quality: 720 | 1080 }) => `stream_${quality}`,
{
playlist: file(`playlist.m3u8`),
segment: file(
({ segmentId }: { segmentId: number }) => `segment_${segmentId}.ts`
),
}
),
}
)
const hlsPackagePath = getPath(hlsPackageModel, '', { videoId: 42 })
// result: "videos/42/"
const streamPlaylist = getPath(hlsPackageModel, 'variantStream.playlist', {
videoId: 42,
quality: 720,
})
// result: "videos/42/stream_720/playlist.m3u8"
const masterPlaylistToVariantPlaylist = getRelativeUrlPath(
hlsPackageModel,
['manifest', { videoId: 42 }],
['variantStream.playlist', { videoId: 42, quality: 720 }]
)
// result: "stream_720/playlist.m3u8"
As you can see in above (
videos/${videoId}
), the path of a node in the model can span across multiple directories. This can help to produce a more concise model when dealing with deeply nested structures.
If the Quickstart Guide doesn't make sense, don't worry! The rest of this README will cover everything you need to know.
- Introduction
- Installation
- Conceptualising Models
- Create a Model
- Get Paths
- Get Relative Paths
- Contributing
Making storage paths in any language is a painful process.
Any application that persists data requires specifying paths for how it should be structured. Previously this involved working with lots of template strings, one for each path. These would probably be copied around your codebase to be used multiple times.
If you're smart, then you'd make a helper function for each path, typing the function parameters with the parameterised parts of the path. Each of these functions has to assume a root for the path if it's relative, or might give absolute paths.
If one of these helper functions gives relative paths, you've got to join the result by prepending a parent path before you can use it.
It's a lot to think about, and if you've ever dealt with complex file structures, you'll know it can get very messy very quickly.
path-master
aims to solve all these problems. It provides you with the ability to model a file structure as a tree of directories and files, so you can easily and safely get the paths you need.
Make sure to install the @midzdotdev/path-master
package from JSR.
In order to get paths, we need to model the shape of our file structure. Before diving straight into the code, let's make sure we understand this kind of modelling as a concept.
As an example, we're going to be modelling a collection of HTTP Live Streaming (HLS) video packages.
In our HLS package, there are playlists (.m3u8
) and video segments (.ts
). We have a master playlist defining the available variants, and each of those variants has a number of video segments and a playlist to index them.
The following is our psuedo-model to understand what we're aiming for.
.
βββ videos
βββ [videoId]
βββ master.m3u8
βββ stream_[quality]
βββ playlist.m3u8
βββ segment_[segmentId].ts
As you're probably already aware, a file structure is a tree of file and directory nodes. Later on you'll notice that our model definition in code follows the exact same shape.
Notice the square brackets above ([]
), where we've parameterised the parts of the paths that are variable.
The name of the videos
directory is static so there will only be one, but there could be any number of directories within videos
because of the parameterised [videoId]
part. The same idea applies to the master.m3u8
and segment_[segmentId].ts
files.
In path-master
, these parameters are referred to as dependencies and are always named so we know which values to use when forming a path.
The following table gives us some examples of concrete paths for different nodes in the tree, each with the required dependencies.
Target Node | Dependencies | Path |
---|---|---|
[videoId] |
{ videoId: 42 } |
videos/42/ |
master.m3u8 |
{ videoId: 42 } |
videos/42/master.m3u8 |
stream_[quality] |
{ videoId: 42, quality: 720 } |
videos/42/stream_720/ |
playlist.m3u8 |
{ videoId: 42, quality: 720 } |
videos/42/stream_720/playlist.m3u8 |
segment_[segmentId].ts |
{ videoId: 42, quality: 720, segmentId: 11 } |
videos/42/stream_720/segment_11.ts |
Note that the path is relative to the root of the model (represented by
.
in the psuedo-model).
The library exposes two helper functions file
and dir
for us to build our model.
Both of these start with a path parameter, which behaves identically between the two.
The path can be either:
- static as a plain string, or
- dynamic as a callback with a dependencies param
const staticFile = file('foo.ext')
const dynamicFile = file(
({ myParam }: { myParam: string }) => `foo_${myParam}.ext`
)
You have to type dependencies manually and they must be assignable to
Record<string, any>
.Keep in mind that values interpolated in a template string must be serialisable, and non-primitives can serialise in strange ways. For example, interpolating an object like
`${someObject}`
gives'[object Object]'
as the resultant string.
The dir
modelling function has an additional parameter children
so you can recursively nest directories and files inside it.
const myDir = dir('my_dir', {
staticFile,
})
const deepDir = dir('deep_dir', {
surfaceFile: file('foo.ext'),
level1: dir('level_1', {
level2: dir('level_2', {
deepFile: file('bar.ext'),
}),
}),
})
Ideally a model should fully describe the structure of a storage destination. This way your model's root aligns with the storage's root and the paths given by
path-master
can be used directly as absolute paths.
If you're working with a very large model, it might be clearer to define parts of a model separately, then join them together into one main model.
If all the parts are together in a single file, then you can export
the main model to remove any ambiguity about which model should be used in other parts of your application.
import { dir, file } from '@midzdotdev/path-master'
const variantStream = dir(
({ quality }: { quality: 720 | 1080 }) => `stream_${quality}`,
{
playlist: file(`playlist.m3u8`),
segment: file(
({ segmentId }: { segmentId: number }) => `segment_${segmentId}.ts`
),
}
)
export const hlsPackageModel = dir(
({ videoId }: { videoId: number }) => `videos/${videoId}`,
{
manifest: file(`master.m3u8`),
variantStream,
}
)
Now that we have a model, let's address the reason that we're here in the first place! Let's get some paths.
We can get the path to a node by calling the getPath
function.
declare const getPath: (
model: FileNode | DirNode,
keypath: string,
dependencies: {}
) => string
The parameters are:
- model: the model
- keypath: a string to define which node we're getting the path for
- dependencies: an object with all the dependencies from the node and it's ancestors
import { dir, file, getPath } from '@midzdotdev/path-master'
const hlsPackageModel = dir(
({ videoId }: { videoId: number }) => `videos/${videoId}`,
{
manifest: file(`master.m3u8`),
variantStream: dir(
({ quality }: { quality: 720 | 1080 }) => `stream_${quality}`,
{
playlist: file(`playlist.m3u8`),
segment: file(
({ segmentId }: { segmentId: number }) => `segment_${segmentId}.ts`
),
}
),
}
)
const hlsPackagePath = getPath(hlsPackageModel, '', { videoId: 42 })
// result: "videos/42/"
// type: `videos/${number}`
const streamPlaylist = getPath(hlsPackageModel, 'variantStream.playlist', {
videoId: 42,
quality: 720,
})
// result: "videos/42/stream_720/playlist.m3u8"
// type: `videos/${number}/stream_${number}/playlist.m3u8`
const segment11 = getPath(hlsPackageModel, 'variantStream.segment', {
videoId: 42,
quality: 720,
segmentId: 11,
})
// result: "videos/42/stream_720/segment_11.ts"
// type: `videos/${number}/stream_${number}/segment_${number}.ts`
Notice that the type of the path is properly inferred from the model definition.
You can get relative paths between nodes, so long as they're in the same model.
It's important to recognise that relative paths behave differently in URLs as opposed to filesystem-like paths.
The relative path from a/b
to a/c/d
:
- in a filesystem is
../c/d
- in a URL is
c/d
You can have a play around with this yourself to better understand the difference.
- Use the Node REPL with
path.relative(from, to)
for filesystem paths- Use the browser console with
new URL(relativePath, fromUrl)
for URL paths
As a result we have two separate functions for each use-case:
- filesystem paths:
getRelativeFsPath
- URL paths:
getRelativeUrlPath
Both of these functions have an identical signature that looks like this.
declare const x: (
model: DirNode,
from: string | [keypath: string, dependencies: {}],
to: string | [keypath: string, dependencies: {}]
) => string
When the node specified for from or to has no dependencies, then you can just pass the keypath string.
Since our example model being a HLS package only concerns itself with URLs, we'll demonstrate with getRelativeUrlPath
.
import { dir, file, getRelativeUrlPath } from '@midzdotdev/path-master'
const hlsPackageModel = dir(
({ videoId }: { videoId: number }) => `videos/${videoId}`,
{
manifest: file(`master.m3u8`),
variantStream: dir(
({ quality }: { quality: 720 | 1080 }) => `stream_${quality}`,
{
playlist: file(`playlist.m3u8`),
segment: file(
({ segmentId }: { segmentId: number }) => `segment_${segmentId}.ts`
),
}
),
}
)
const masterPlaylistToVariantPlaylist = getRelativeUrlPath(
hlsPackageModel,
['manifest', { videoId: 42 }],
['variantStream.playlist', { videoId: 42, quality: 720 }]
)
// result: "stream_720/playlist.m3u8"
const variantPlaylistToSegment = getRelativeUrlPath(
hlsPackageModel,
['variantStream.playlist', { videoId: 42, quality: 720 }],
['variantStream.segment', { videoId: 42, quality: 720, segmentId: 11 }]
)
// result: "segment_11.ts"
If you have any ideas for improvements or new features, please file an issue or open a pull request.
This project is licensed under the MIT License - see the LICENSE file for details.