2024-01-05 08:29:34 +00:00
import { slug as slugAnchor } from "github-slugger"
import type { Element as HastElement } from "hast"
import rfdc from "rfdc"
export const clone = rfdc ( )
2023-08-14 00:47:07 +00:00
// this file must be isomorphic so it can't use node libs (e.g. path)
2023-05-30 15:02:20 +00:00
2023-08-13 04:16:34 +00:00
export const QUARTZ = "quartz"
2023-07-13 07:19:35 +00:00
/// Utility type to simulate nominal types in TypeScript
type SlugLike < T > = string & { __brand : T }
2023-08-19 22:52:25 +00:00
/** Cannot be relative and must have a file extension. */
export type FilePath = SlugLike < "filepath" >
export function isFilePath ( s : string ) : s is FilePath {
const validStart = ! s . startsWith ( "." )
return validStart && _hasFileExtension ( s )
2023-07-13 07:19:35 +00:00
}
2023-08-19 22:52:25 +00:00
/** Cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. */
export type FullSlug = SlugLike < "full" >
export function isFullSlug ( s : string ) : s is FullSlug {
2023-07-16 06:02:12 +00:00
const validStart = ! ( s . startsWith ( "." ) || s . startsWith ( "/" ) )
2023-08-19 22:52:25 +00:00
const validEnding = ! s . endsWith ( "/" )
return validStart && validEnding && ! _containsForbiddenCharacters ( s )
}
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
export type SimpleSlug = SlugLike < "simple" >
export function isSimpleSlug ( s : string ) : s is SimpleSlug {
2024-01-05 08:29:34 +00:00
const validStart = ! ( s . startsWith ( "." ) || ( s . length > 1 && s . startsWith ( "/" ) ) )
2023-08-19 22:52:25 +00:00
const validEnding = ! ( s . endsWith ( "/index" ) || s === "index" )
2023-07-16 06:02:12 +00:00
return validStart && ! _containsForbiddenCharacters ( s ) && validEnding && ! _hasFileExtension ( s )
2023-07-13 07:19:35 +00:00
}
2023-08-19 22:52:25 +00:00
/** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */
2023-07-13 07:19:35 +00:00
export type RelativeURL = SlugLike < "relative" >
export function isRelativeURL ( s : string ) : s is RelativeURL {
const validStart = /^\.{1,2}/ . test ( s )
2023-08-17 05:04:15 +00:00
const validEnding = ! ( s . endsWith ( "/index" ) || s === "index" )
2023-08-17 07:55:52 +00:00
return validStart && validEnding && ! [ ".md" , ".html" ] . includes ( _getFileExtension ( s ) ? ? "" )
2023-07-13 07:19:35 +00:00
}
2023-08-19 22:52:25 +00:00
export function getFullSlug ( window : Window ) : FullSlug {
const res = window . document . body . dataset . slug ! as FullSlug
2023-07-16 06:02:12 +00:00
return res
2023-07-13 07:19:35 +00:00
}
2024-01-05 08:29:34 +00:00
function sluggify ( s : string ) : string {
return s
. split ( "/" )
. map ( ( segment ) = > segment . replace ( /\s/g , "-" ) . replace ( /%/g , "-percent" ) . replace ( /\?/g , "-q" ) ) // slugify all segments
. join ( "/" ) // always use / as sep
. replace ( /\/$/ , "" )
}
2023-08-19 22:52:25 +00:00
export function slugifyFilePath ( fp : FilePath , excludeExt? : boolean ) : FullSlug {
2023-07-16 06:02:12 +00:00
fp = _stripSlashes ( fp ) as FilePath
2023-08-17 07:55:28 +00:00
let ext = _getFileExtension ( fp )
const withoutFileExt = fp . replace ( new RegExp ( ext + "$" ) , "" )
if ( excludeExt || [ ".md" , ".html" , undefined ] . includes ( ext ) ) {
ext = ""
}
2024-01-05 08:29:34 +00:00
let slug = sluggify ( withoutFileExt )
2023-07-13 07:19:35 +00:00
2023-07-16 06:33:06 +00:00
// treat _index as index
if ( _endsWith ( slug , "_index" ) ) {
slug = slug . replace ( /_index$/ , "index" )
}
2023-08-19 22:52:25 +00:00
return ( slug + ext ) as FullSlug
}
export function simplifySlug ( fp : FullSlug ) : SimpleSlug {
2024-01-05 08:29:34 +00:00
const res = _stripSlashes ( _trimSuffix ( fp , "index" ) , true )
return ( res . length === 0 ? "/" : res ) as SimpleSlug
2023-07-13 07:19:35 +00:00
}
export function transformInternalLink ( link : string ) : RelativeURL {
2023-07-16 06:02:12 +00:00
let [ fplike , anchor ] = splitAnchor ( decodeURI ( link ) )
2023-08-17 05:04:15 +00:00
2023-08-19 22:52:25 +00:00
const folderPath = _isFolderPath ( fplike )
2023-07-23 00:27:41 +00:00
let segments = fplike . split ( "/" ) . filter ( ( x ) = > x . length > 0 )
2023-07-13 07:19:35 +00:00
let prefix = segments . filter ( _isRelativeSegment ) . join ( "/" )
2023-08-19 22:52:25 +00:00
let fp = segments . filter ( ( seg ) = > ! _isRelativeSegment ( seg ) && seg !== "" ) . join ( "/" )
2023-07-16 06:02:12 +00:00
2023-08-17 07:55:28 +00:00
// manually add ext here as we want to not strip 'index' if it has an extension
2023-08-19 22:52:25 +00:00
const simpleSlug = simplifySlug ( slugifyFilePath ( fp as FilePath ) )
const joined = joinSegments ( _stripSlashes ( prefix ) , _stripSlashes ( simpleSlug ) )
2023-08-17 05:04:15 +00:00
const trail = folderPath ? "/" : ""
2023-08-17 07:55:28 +00:00
const res = ( _addRelativeToStart ( joined ) + trail + anchor ) as RelativeURL
2023-07-16 06:02:12 +00:00
return res
2023-07-13 07:19:35 +00:00
}
2024-01-05 08:29:34 +00:00
// from micromorph/src/utils.ts
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
const _rebaseHtmlElement = ( el : Element , attr : string , newBase : string | URL ) = > {
const rebased = new URL ( el . getAttribute ( attr ) ! , newBase )
el . setAttribute ( attr , rebased . pathname + rebased . hash )
}
export function normalizeRelativeURLs ( el : Element | Document , destination : string | URL ) {
el . querySelectorAll ( '[href^="./"], [href^="../"]' ) . forEach ( ( item ) = >
_rebaseHtmlElement ( item , "href" , destination ) ,
)
el . querySelectorAll ( '[src^="./"], [src^="../"]' ) . forEach ( ( item ) = >
_rebaseHtmlElement ( item , "src" , destination ) ,
)
}
const _rebaseHastElement = (
el : HastElement ,
attr : string ,
curBase : FullSlug ,
newBase : FullSlug ,
) = > {
if ( el . properties ? . [ attr ] ) {
if ( ! isRelativeURL ( String ( el . properties [ attr ] ) ) ) {
return
}
const rel = joinSegments ( resolveRelative ( curBase , newBase ) , ".." , el . properties [ attr ] as string )
el . properties [ attr ] = rel
}
}
export function normalizeHastElement ( rawEl : HastElement , curBase : FullSlug , newBase : FullSlug ) {
const el = clone ( rawEl ) // clone so we dont modify the original page
_rebaseHastElement ( el , "src" , curBase , newBase )
_rebaseHastElement ( el , "href" , curBase , newBase )
if ( el . children ) {
el . children = el . children . map ( ( child ) = >
normalizeHastElement ( child as HastElement , curBase , newBase ) ,
)
}
return el
}
2023-08-19 22:52:25 +00:00
// resolve /a/b/c to ../..
export function pathToRoot ( slug : FullSlug ) : RelativeURL {
2023-07-13 07:19:35 +00:00
let rootPath = slug
2023-07-23 00:27:41 +00:00
. split ( "/" )
. filter ( ( x ) = > x !== "" )
2023-08-19 22:52:25 +00:00
. slice ( 0 , - 1 )
2023-07-23 00:27:41 +00:00
. map ( ( _ ) = > ".." )
. join ( "/" )
2023-07-13 07:19:35 +00:00
2023-08-19 22:52:25 +00:00
if ( rootPath . length === 0 ) {
rootPath = "."
}
return rootPath as RelativeURL
2023-07-16 06:02:12 +00:00
}
2023-08-19 22:52:25 +00:00
export function resolveRelative ( current : FullSlug , target : FullSlug | SimpleSlug ) : RelativeURL {
const res = joinSegments ( pathToRoot ( current ) , simplifySlug ( target as FullSlug ) ) as RelativeURL
2023-07-16 06:02:12 +00:00
return res
}
export function splitAnchor ( link : string ) : [ string , string ] {
let [ fp , anchor ] = link . split ( "#" , 2 )
2023-07-23 00:27:41 +00:00
anchor = anchor === undefined ? "" : "#" + slugAnchor ( anchor )
2023-07-16 06:02:12 +00:00
return [ fp , anchor ]
}
2023-07-26 04:10:37 +00:00
export function slugTag ( tag : string ) {
return tag
. split ( "/" )
2024-01-05 08:29:34 +00:00
. map ( ( tagSegment ) = > sluggify ( tagSegment ) )
2023-07-26 04:10:37 +00:00
. join ( "/" )
}
2023-07-16 06:02:12 +00:00
export function joinSegments ( . . . args : string [ ] ) : string {
2023-09-13 04:29:57 +00:00
return args
. filter ( ( segment ) = > segment !== "" )
. join ( "/" )
. replace ( /\/\/+/g , "/" )
2023-07-13 07:19:35 +00:00
}
2023-07-26 04:10:37 +00:00
export function getAllSegmentPrefixes ( tags : string ) : string [ ] {
const segments = tags . split ( "/" )
const results : string [ ] = [ ]
for ( let i = 0 ; i < segments . length ; i ++ ) {
results . push ( segments . slice ( 0 , i + 1 ) . join ( "/" ) )
}
return results
}
2023-08-13 04:16:34 +00:00
export interface TransformOptions {
strategy : "absolute" | "relative" | "shortest"
2023-08-19 22:52:25 +00:00
allSlugs : FullSlug [ ]
2023-08-13 04:16:34 +00:00
}
2023-08-19 22:52:25 +00:00
export function transformLink ( src : FullSlug , target : string , opts : TransformOptions ) : RelativeURL {
let targetSlug = transformInternalLink ( target )
2023-08-13 04:16:34 +00:00
if ( opts . strategy === "relative" ) {
2023-08-19 22:52:25 +00:00
return targetSlug as RelativeURL
2023-08-13 04:16:34 +00:00
} else {
2023-08-19 22:52:25 +00:00
const folderTail = _isFolderPath ( targetSlug ) ? "/" : ""
2023-08-17 07:55:28 +00:00
const canonicalSlug = _stripSlashes ( targetSlug . slice ( "." . length ) )
let [ targetCanonical , targetAnchor ] = splitAnchor ( canonicalSlug )
2023-08-13 04:16:34 +00:00
if ( opts . strategy === "shortest" ) {
// if the file name is unique, then it's just the filename
const matchingFileNames = opts . allSlugs . filter ( ( slug ) = > {
const parts = slug . split ( "/" )
const fileName = parts . at ( - 1 )
return targetCanonical === fileName
} )
// only match, just use it
if ( matchingFileNames . length === 1 ) {
2023-08-19 22:52:25 +00:00
const targetSlug = matchingFileNames [ 0 ]
2023-08-13 04:16:34 +00:00
return ( resolveRelative ( src , targetSlug ) + targetAnchor ) as RelativeURL
}
}
// if it's not unique, then it's the absolute path from the vault root
2023-08-17 07:55:52 +00:00
return ( joinSegments ( pathToRoot ( src ) , canonicalSlug ) + folderTail ) as RelativeURL
2023-08-13 04:16:34 +00:00
}
}
2023-07-13 07:19:35 +00:00
2023-08-19 22:52:25 +00:00
function _isFolderPath ( fplike : string ) : boolean {
return (
fplike . endsWith ( "/" ) ||
_endsWith ( fplike , "index" ) ||
_endsWith ( fplike , "index.md" ) ||
_endsWith ( fplike , "index.html" )
)
2023-07-02 20:08:29 +00:00
}
2023-07-16 06:33:06 +00:00
function _endsWith ( s : string , suffix : string ) : boolean {
2023-07-23 00:27:41 +00:00
return s === suffix || s . endsWith ( "/" + suffix )
2023-07-16 06:33:06 +00:00
}
function _trimSuffix ( s : string , suffix : string ) : string {
if ( _endsWith ( s , suffix ) ) {
2023-07-23 00:27:41 +00:00
s = s . slice ( 0 , - suffix . length )
2023-07-16 06:33:06 +00:00
}
return s
}
2023-07-13 07:19:35 +00:00
function _containsForbiddenCharacters ( s : string ) : boolean {
return s . includes ( " " ) || s . includes ( "#" ) || s . includes ( "?" )
}
2023-06-17 02:41:59 +00:00
2023-07-13 07:19:35 +00:00
function _hasFileExtension ( s : string ) : boolean {
2023-07-16 06:02:12 +00:00
return _getFileExtension ( s ) !== undefined
}
function _getFileExtension ( s : string ) : string | undefined {
2023-08-09 04:31:36 +00:00
return s . match ( /\.[A-Za-z0-9]+$/ ) ? . [ 0 ]
2023-06-17 02:41:59 +00:00
}
2023-07-13 07:19:35 +00:00
function _isRelativeSegment ( s : string ) : boolean {
return /^\.{0,2}$/ . test ( s )
2023-05-30 15:02:20 +00:00
}
2023-08-19 22:52:25 +00:00
export function _stripSlashes ( s : string , onlyStripPrefix? : boolean ) : string {
2023-07-13 07:19:35 +00:00
if ( s . startsWith ( "/" ) ) {
s = s . substring ( 1 )
}
2023-05-30 15:02:20 +00:00
2023-08-19 22:52:25 +00:00
if ( ! onlyStripPrefix && s . endsWith ( "/" ) ) {
2023-07-13 07:19:35 +00:00
s = s . slice ( 0 , - 1 )
2023-06-03 19:07:19 +00:00
}
2023-07-13 07:19:35 +00:00
return s
2023-05-31 21:01:23 +00:00
}
2023-07-13 07:19:35 +00:00
function _addRelativeToStart ( s : string ) : string {
if ( s === "" ) {
s = "."
}
2023-05-31 21:01:23 +00:00
2023-07-13 07:19:35 +00:00
if ( ! s . startsWith ( "." ) ) {
2023-07-16 06:02:12 +00:00
s = joinSegments ( "." , s )
2023-07-13 07:19:35 +00:00
}
2023-05-31 21:01:23 +00:00
2023-07-13 07:19:35 +00:00
return s
}