wikirefs
Version:
wikiref utilities (including [[wikilinks]]).
1,041 lines (1,011 loc) • 41.4 kB
JavaScript
// wikirefs v0.0.11 - https://github.com/wikibonsai/wikirefs.git
;
Object.defineProperty(exports, '__esModule', { value: true });
var escapeMkdn = require('escape-mkdn');
/* eslint indent: 0 */
/* eslint @typescript-eslint/no-namespace: 0 */
////
// note:
//
// at the time of writing, constants are mainly a means of defining a
// controlled vocabulary that is referenced across the entire WikiBonsai
// project (https://github.com/wikibonsai/wikibonsai/blob/main/docs/TERMS.md).
exports.CONST = void 0;
(function (_CONST) {
_CONST.DIR = {
FORE: 'fore',
BACK: 'back'
};
_CONST.REL = {
// tree
FAM: {
FAM: 'fam',
ANCESTOR: 'ancestor',
// all up
PARENT: 'parent',
// one up
SIBLING: 'sibling',
// all sides
CHILD: 'child',
// one down
DESCENDANT: 'descendant',
// all down
LINEAGE: 'lineage' // all up + all down - self (excludes self)
},
// web
REF: {
REF: 'ref',
ATTR: 'attr',
LINK: 'link',
EMBED: 'embed',
// direction
DIR: {
FORE: 'fore',
BACK: 'back'
}
}
};
_CONST.WIKI = {
REF: 'wikiref',
ATTR: 'wikiattr',
LINK: 'wikilink',
EMBED: 'wikiembed'
};
_CONST.TYPE = {
REF: 'reftype',
ATTR: 'attrtype',
LINK: 'linktype'
};
_CONST.MEDIA = {
// individual
MD: 'markdown',
PDF: 'pdf',
// categorical
AUD: 'audio',
IMG: 'image',
VID: 'video'
};
_CONST.URI = {
FNAME: 'filename',
ABSPATH: 'absolute',
RELPATH: 'relative'
};
_CONST.MARKER = {
EMBED: '!',
PREFIX: ':',
TYPE: '::',
OPEN: '[[',
HEADER: '#',
// BLOCK : '#^',
LABEL: '|',
CLOSE: ']]'
};
_CONST.EXTS = {
MD: '.md',
PDF: '.pdf',
AUD: new Set(['.mp3', '.webm', '.wav', '.m4a', '.ogg', '.3gp', '.flac']),
// DOC : new Set([
// '.html',
// '.doc',
// '.docx',
// '.rtf',
// '.txt',
// '.odt',
// '.xls',
// '.xlsx',
// '.ppt',
// '.pptm',
// '.pptx',
// '.pages',
// '.tldr',
// '.excalidraw',
// ]),
IMG: new Set(['.png', '.jpg', '.jpeg', '.gif', '.psd', '.svg',
// from: https://github.com/blacksmithgu/obsidian-dataview/blob/0f1ecc771bba6561fbb1767bc96f221dc2978bd8/src/util/media.ts#L3
'.tif', '.tiff', '.apng', '.avif', '.jfif', '.pjepg', '.pjp', '.webp', '.bmp', '.ico', '.cur']),
VID: new Set(['.mp4', '.mov', '.wmv', '.flv', '.avi', '.mkv', '.ogv'])
};
_CONST.GLOB_MEDIA = '[' + [].concat([exports.CONST.EXTS.PDF]).concat(Array.from(exports.CONST.EXTS.AUD)).concat(Array.from(exports.CONST.EXTS.IMG)).concat(Array.from(exports.CONST.EXTS.VID)).join('|') + ']';
})(exports.CONST || (exports.CONST = {}));
/* eslint-disable indent*/
exports.RGX = void 0;
(function (_RGX) {
const _MKDN = _RGX._MKDN = {
// markdown (originally from kramdown)
// atx header: https://github.com/gettalong/kramdown/blob/master/lib/kramdown/parser/kramdown/header.rbL29
ATX_HEADER: /^#{1,6}[\t ]+([^ \t].*)\n/im,
// setext header: https://github.com/gettalong/kramdown/blob/master/lib/kramdown/parser/kramdown/header.rbL17
SETEXT_HEADER: /^ {0,3}([^ \t].*)\n[-=][-=]*[ \t\r\f\v]*\n/im,
// list item: https://github.com/gettalong/kramdown/blob/master/lib/kramdown/parser/kramdown/list.rbL49
BULLET: /[^\n\r\S]{0,4}([+*-]) /i,
// markdown link: [label](uri) — excludes images (negative lookbehind for !)
LINK: /(?<!!)\[(^$|.*?)\]\((.*?)\)/gi,
// markdown image: 
IMAGE: /!\[(.*?)]\((.*?)\)/gi
// markdown-style block-reference
// BLOCK : /".* \^" + BLOCK_ID$/i,
};
const MARKER = _RGX.MARKER = {
// wikilink (by order of syntactic appearance)
EMBED: new RegExp(escapeMkdn.esc(exports.CONST.MARKER.EMBED)),
NOT_EMBED: new RegExp('(?<!' + escapeMkdn.esc(exports.CONST.MARKER.EMBED) + ')', 'i'),
PREFIX: new RegExp('(?:' + exports.CONST.MARKER.PREFIX + ' ?)'),
TYPE: new RegExp('(?: *' + exports.CONST.MARKER.TYPE + ' ?)'),
OPEN: new RegExp(escapeMkdn.esc(exports.CONST.MARKER.OPEN)),
HEADER: new RegExp(exports.CONST.MARKER.HEADER),
// BLOCK : new RegExp(esc(CONST.MARKER.BLOCK)),
LABEL: new RegExp(escapeMkdn.esc(exports.CONST.MARKER.LABEL)),
CLOSE: new RegExp(escapeMkdn.esc(exports.CONST.MARKER.CLOSE))
};
const VALID_CHARS = _RGX.VALID_CHARS = {
TYPE: /[^\n\r!:^|[\]]+/i,
FILENAME: /[^\n\r!#:^|[\]]+/i,
HEADER: /[^\n\r!^|[\]]*/i,
// BLOCK_ID : /[^\n\r!:^|[\]\/]+/i,
LABEL: /.+?(?=\]{2})/i
};
const CAP_GRP = _RGX.CAP_GRP = {
TYPE: new RegExp('(' + VALID_CHARS.TYPE.source + ')', 'i'),
FILENAME: new RegExp('(' + VALID_CHARS.FILENAME.source + ')', 'i'),
HEADER: new RegExp('(' + VALID_CHARS.HEADER.source + ')', 'i'),
// BLOCK_ID : new RegExp('(' + VALID_CHARS.BLOCK_ID.source + ')', 'i'),
LABEL: new RegExp('(' + VALID_CHARS.LABEL.source + ')', 'i')
};
// <------------------------------------------------------------------------>
// used to build wikiattr regexes; will not return complete wikilinks
// <------------------------------------------------------------------------>
// this is a local-only variable -- see WIKILINK.BASE for exported version
const _CAP_GRP_BASE = new RegExp(MARKER.OPEN.source + CAP_GRP.FILENAME.source + MARKER.CLOSE.source, 'i');
const _BASE = new RegExp(MARKER.OPEN.source + VALID_CHARS.FILENAME.source + MARKER.CLOSE.source, 'i');
const ATTR_UTIL = _RGX.ATTR_UTIL = {
// list-comma is responsible for catching the single case too
LIST_COMMA: new RegExp(_CAP_GRP_BASE.source + ' ?' + '(?:' + ', *' + _CAP_GRP_BASE.source + ' *' + ')*', 'i'),
PREFIX: new RegExp('^' + MARKER.PREFIX.source + '?' + CAP_GRP.TYPE.source + MARKER.TYPE.source, 'im')
};
const ATTR_LINE = _RGX.ATTR_LINE = {
// todo: TYPE -> SINGLE_OR_TYPE
TYPE: new RegExp(ATTR_UTIL.PREFIX.source + '(?:' + ATTR_UTIL.LIST_COMMA.source + ')?' + '$', 'im'),
LIST_ITEM: new RegExp('^' + '(?: *)' + _MKDN.BULLET.source + '(' + _CAP_GRP_BASE.source + ')', 'im')
};
const WIKI = _RGX.WIKI = {
// base [[wikilink]] for wikiattrs, since they only support file level links
BASE: new RegExp(MARKER.OPEN.source + CAP_GRP.FILENAME.source + MARKER.CLOSE.source, 'i'),
// javascript/typescript regex does not support + or * in lookarounds (from: https://stackoverflow.com/questions/9030305/regular-expression-lookbehind-doesnt-work-with-quantifiers-or)
// link / inline / text
LINK: new RegExp(
// capture indices (0 is full match)
'(?:' + '(?:' + MARKER.PREFIX.source + CAP_GRP.TYPE.source // 1
+ MARKER.TYPE.source + ')?'
// skip embed
+ MARKER.NOT_EMBED.source + MARKER.OPEN.source + CAP_GRP.FILENAME.source // 2
+ '(?:' + MARKER.HEADER.source + CAP_GRP.HEADER.source + ')?' // 3
// + '(?:' + MARKER.BLOCK.source + CAP_GRP.BLOCK_ID.source + ')?'//
+ '(?:' + MARKER.LABEL.source + CAP_GRP.LABEL.source + ')?' // 4
+ MARKER.CLOSE.source + ')', 'i'),
EMBED: new RegExp(
// capture indices (0 is full match)
'(?:'
// skip embed
+ MARKER.EMBED.source + MARKER.OPEN.source + CAP_GRP.FILENAME.source // 1
+ '(?:' + MARKER.HEADER.source + CAP_GRP.HEADER.source // 2
+ ')?' + MARKER.CLOSE.source + ')', 'i'),
// raw attr block regex — use WIKI.ATTR() function for structured results
_ATTR: new RegExp(ATTR_UTIL.PREFIX.source // 1
+ '(?:'
// comma-separated
+ '(?:' + '(?<!\n)' + ATTR_UTIL.LIST_COMMA.source // only captures first and last item
+ '(?:\n|$)' + ')' + '|'
// mkdwn-list-separated
+ '\n' + '(?:' + ATTR_LINE.LIST_ITEM.source // only captures last item
+ '(?:\n|$)' + ')+' + ')', 'im'),
/**
* Match all wikiattr blocks in content and extract filenames in one call.
* Returns structured results with type, filenames, offsets, and list format.
*
* Uses sticky (y-flag) regexes internally — JS equivalent of Ruby's \G anchor.
* The raw block regex is available as `WIKI._ATTR` for find-and-replace uses.
*/
ATTR(content) {
const results = [];
const blockRegex = new RegExp(WIKI._ATTR, 'gim');
let attrMatch;
while ((attrMatch = blockRegex.exec(content)) !== null) {
const matchText = attrMatch[0];
const attrtypeText = attrMatch[1];
const attrtypeOffset = attrMatch.index + matchText.indexOf(attrtypeText);
// detect list format
const isMkdnList = /\n *[-+*] /.test(matchText);
let listFormat = 'none';
if (isMkdnList) {
listFormat = 'mkdn';
} else if (/ *, */.test(matchText)) {
listFormat = 'comma';
}
// extract filenames via sticky walk
const filenames = [];
const stickyItemRegex = isMkdnList ? new RegExp(_STICKY.ATTR_ITEM_MKDN, 'iy') : new RegExp(_STICKY.ATTR_ITEM_COMMA, 'iy');
const prefixRegex = new RegExp(_STICKY.ATTR_PREFIX, 'im');
const prefixMatch = prefixRegex.exec(matchText);
if (prefixMatch) {
const valueStart = attrMatch.index + prefixMatch.index + prefixMatch[0].length;
stickyItemRegex.lastIndex = valueStart;
let fnameMatch;
while ((fnameMatch = stickyItemRegex.exec(content)) !== null) {
const filenameText = isMkdnList ? fnameMatch[2].replace(/^\[\[/, '').replace(/\]\]$/, '') : fnameMatch[1];
const twoLeftBrackets = 2;
const bracketOffset = fnameMatch[0].indexOf('[[');
const filenameOffset = fnameMatch.index + bracketOffset + twoLeftBrackets;
filenames.push([filenameText, filenameOffset]);
}
}
results.push({
text: matchText,
start: attrMatch.index,
type: [attrtypeText.trim(), attrtypeOffset],
filenames,
listFormat
});
}
return results;
}
};
_RGX.GET = {
// capture indices (0 is full match)
// link / inline / text
LINKTYPE: new RegExp(MARKER.PREFIX.source + CAP_GRP.TYPE.source // 1
+ MARKER.TYPE.source + MARKER.OPEN.source + VALID_CHARS.FILENAME.source
// + '(?:'
// + '(?:' + CAP_GRP.HEADER.source + CAP_GRP.HEADER.source + ')?' //
// + '|'
// + '(?:' + CAP_GRP.BLOCK.source + CAP_GRP.BLOCK_ID.source + ')?'//
// + '|'
// + ')?'
+ '(?:' + MARKER.LABEL.source + VALID_CHARS.LABEL.source + ')?' + MARKER.CLOSE.source + '(?!\n)', 'i'),
// attr / block / flow
ATTRTYPE: new RegExp('^' + '(?:' + MARKER.PREFIX.source + ')?' + CAP_GRP.TYPE.source // 1
+ MARKER.TYPE.source + '(?:'
// single / comma
+ '(?:' + _BASE.source + '(' + ', *' + _BASE.source + ' *' + ')*' + '\n' + ')' + '|'
// mkdn-list-separated
+ '(?:' + '\n' + '(?:' + '^ *' + _MKDN.BULLET.source + _BASE.source + '\n' + ')+' + ')' + ')', 'im'),
REFTYPE: new RegExp(
// attr
'(?:' + '^' + '(?:' + MARKER.PREFIX.source + ')?' + CAP_GRP.TYPE.source + MARKER.TYPE.source // 1 (attr)
+ '(?:'
// single / comma
+ '(?:' + _BASE.source + '$' + '|' + ',' + ')' + '|'
// mkdn list
+ '$' + ')' + ')' + '|'
// link
+ '(?:' + MARKER.PREFIX.source + CAP_GRP.TYPE.source + MARKER.TYPE.source // 2 (link)
+ MARKER.OPEN.source + ')', 'im'),
FILENAME: new RegExp(MARKER.OPEN.source + CAP_GRP.FILENAME.source // 1
+ '(?:' + MARKER.HEADER.source // includes BLOCK
+ '|' + MARKER.LABEL.source + '|' + MARKER.CLOSE.source + ')', 'i'),
HEADER: new RegExp(MARKER.HEADER.source + CAP_GRP.HEADER.source // 1
+ '(?:' + MARKER.LABEL.source + '|' + MARKER.CLOSE.source + ')', 'i')
// BLOCK_ID : new RegExp(MARKER.LINK_LEFT.source
// + '(?:' + VALID_CHARS.FILENAME.source + ')'
// + MARKER.BLOCK.source
// + CAP_GRP.BLOCK_ID.source
// + '(?:'
// + MARKER.LINK_LABEL.source
// + '|' + MARKER.LINK_RIGHT.source
// + ')'
// , 'i'),
};
// <------------------------------------------------------------------------>
// sticky (y-flag) regexes — JS equivalent of Ruby's \G anchor.
//
// The 'y' flag anchors each exec() to lastIndex and returns null if the
// pattern doesn't match at that exact position. Usage:
//
// const re = new RegExp(RGX._STICKY.ATTR_ITEM_COMMA, 'iy');
// re.lastIndex = startOffset;
// let m;
// while ((m = re.exec(content)) !== null) {
// // m[1] = filename; re.lastIndex is advanced automatically
// }
//
// Create a fresh RegExp per use — sticky regexes are stateful via lastIndex.
// <------------------------------------------------------------------------>
const _STICKY = {
/** Locates where `:type::` prefix ends so we know where to anchor the item regex. */
ATTR_PREFIX: new RegExp(ATTR_UTIL.PREFIX.source, 'im'),
/** Matches one [[filename]] in a comma-separated attr value. Capture [1] = filename. */
ATTR_ITEM_COMMA: new RegExp(' *,? *' + MARKER.OPEN.source + CAP_GRP.FILENAME.source // 1
+ MARKER.CLOSE.source + ' *', 'iy'),
/** Matches one `- [[filename]]` line in a mkdn-list attr value. Capture [1] = bullet, [2] = full `[[filename]]`. */
ATTR_ITEM_MKDN: new RegExp('\\n' + '(?: *)' + _MKDN.BULLET.source // 1
+ '(' + _CAP_GRP_BASE.source + ')' // 2
, 'iy')
};
})(exports.RGX || (exports.RGX = {}));
function extname$1(fname) {
const idx = fname.lastIndexOf('.');
return idx >= 0 ? fname.slice(idx) : '';
}
function getMediaKind(filename) {
const mediaExt = extname$1(filename).toLowerCase();
let media;
// individual
if (mediaExt === exports.CONST.EXTS.PDF) {
media = exports.CONST.MEDIA.PDF;
// categorical
} else if (exports.CONST.EXTS.AUD.has(mediaExt)) {
media = exports.CONST.MEDIA.AUD;
} else if (exports.CONST.EXTS.IMG.has(mediaExt)) {
media = exports.CONST.MEDIA.IMG;
} else if (exports.CONST.EXTS.VID.has(mediaExt)) {
media = exports.CONST.MEDIA.VID;
} else {
// presume markdown -- might actually include unsupported media extension though...
media = exports.CONST.MEDIA.MD;
}
return media;
}
function isMedia(filename) {
const mediaExt = extname$1(filename).toLowerCase();
return exports.CONST.EXTS.PDF === mediaExt || exports.CONST.EXTS.AUD.has(mediaExt) || exports.CONST.EXTS.IMG.has(mediaExt) || exports.CONST.EXTS.VID.has(mediaExt);
}
// types
// scan
function scan(content, opts) {
const wikirefs = [];
// opts
const kind = opts ? opts.kind : undefined;
const filename = opts ? opts.filename : undefined;
const skipEsc = opts ? opts.skipEsc : true;
const escdIndices = escapeMkdn.getEscIndices(content);
// go
// attr //
const attrMatches = exports.RGX.WIKI.ATTR(content);
for (const am of attrMatches) {
// filter filenames by opts
const filenames = [];
for (const [fnameText, fnameOffset] of am.filenames) {
if (!filename || filename === fnameText) {
const isMkdnList = am.listFormat === 'mkdn';
const escaped = escapeMkdn.isStrEscaped(fnameText, content, fnameOffset, escdIndices) && !isMkdnList;
if (!kind || kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.ATTR && (skipEsc || !escaped)) {
filenames.push({
text: fnameText,
start: fnameOffset
});
}
}
}
const [trimmedAttrTypeText, attrtypeOffset] = am.type;
const escaped = escapeMkdn.isStrEscaped(trimmedAttrTypeText, content, attrtypeOffset, escdIndices);
if ((!kind || kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.ATTR) && filenames.length !== 0 && (skipEsc || !escaped)) {
wikirefs.push({
kind: 'wikiattr',
match: am.text,
start: am.start,
type: {
text: trimmedAttrTypeText,
start: attrtypeOffset
},
filenames: filenames,
listFormat: am.listFormat
});
}
}
// note: consume processed wikiattrs so they are
// not mistaken for inlines in next section
for (const am of attrMatches) {
content = content.replace(am.text, match => {
// pad with whitespace so positions don't get screwed up
return ' '.repeat(match.length);
});
}
// embed //
if (!kind || kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.EMBED) {
const embedsGottaCatchEmAll = new RegExp(exports.RGX.WIKI.EMBED, 'g');
let embedMatch;
do {
embedMatch = embedsGottaCatchEmAll.exec(content);
if (embedMatch) {
const matchText = embedMatch[0];
const fileNameText = embedMatch[1];
const headerText = embedMatch[2];
const wikilinkOffset = embedMatch.index;
const filenameOffset = matchText.indexOf(fileNameText);
if (!filename || filename === fileNameText) {
const escaped = escapeMkdn.isStrEscaped(matchText, content, wikilinkOffset, escdIndices);
if (skipEsc || !escaped) {
const headerStart = matchText.indexOf('#') + 1;
const embed = {
kind: 'wikiembed',
match: matchText,
start: embedMatch.index,
filename: {
text: fileNameText,
start: wikilinkOffset + filenameOffset
},
media: getMediaKind(fileNameText)
};
if (headerText !== undefined) {
embed.header = {
text: headerText,
start: embedMatch.index + headerStart
};
}
wikirefs.push(embed);
}
}
}
} while (embedMatch);
}
// link //
if (!kind || kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.LINK) {
const linksGottaCatchEmAll = new RegExp(exports.RGX.WIKI.LINK, 'g');
let linkMatch;
do {
linkMatch = linksGottaCatchEmAll.exec(content);
if (linkMatch) {
const matchText = linkMatch[0];
const linkTypeText = linkMatch[1];
const fileNameText = linkMatch[2];
const headerText = linkMatch[3];
const labelText = linkMatch[4];
const wikilinkOffset = linkMatch.index;
const linkTypeOffset = matchText.indexOf(linkTypeText);
const filenameOffset = matchText.indexOf(fileNameText);
const headerOffset = headerText !== undefined ? headerText === '' ? matchText.indexOf('#') + 1 : matchText.indexOf(headerText) : -1;
const labelOffset = matchText.indexOf(labelText);
if (!filename || filename === fileNameText) {
const escaped = escapeMkdn.isStrEscaped(matchText, content, wikilinkOffset, escdIndices);
if (skipEsc || !escaped) {
const link = {
kind: 'wikilink',
match: matchText,
start: linkMatch.index,
filename: {
text: fileNameText,
start: wikilinkOffset + filenameOffset
}
};
if (linkTypeText) {
link.type = {
text: linkTypeText.trim(),
start: linkMatch.index + linkTypeOffset
};
}
if (headerText !== undefined) {
link.header = {
text: headerText,
start: linkMatch.index + headerOffset
};
}
if (labelText) {
link.label = {
text: labelText,
start: linkMatch.index + labelOffset
};
}
wikirefs.push(link);
}
}
}
} while (linkMatch);
}
// sort matches by start position
wikirefs.sort((a, b) => a.start - b.start);
// build flat filenames list from wikirefs
const filenames = [];
for (const ref of wikirefs) {
if (ref.kind === 'wikiattr') {
for (const fname of ref.filenames) {
const entry = {
filename: fname,
kind: 'wikiattr',
type: ref.type,
listFormat: ref.listFormat
};
filenames.push(entry);
}
} else if (ref.kind === 'wikilink') {
const entry = {
filename: ref.filename,
kind: 'wikilink'
};
if (ref.type) entry.type = ref.type;
if (ref.header) entry.header = ref.header;
if (ref.label) entry.label = ref.label;
filenames.push(entry);
} else if (ref.kind === 'wikiembed') {
const entry = {
filename: ref.filename,
kind: 'wikiembed',
media: ref.media
};
if (ref.header) entry.header = ref.header;
filenames.push(entry);
}
}
return {
wikirefs,
filenames
};
}
// note: (atm, attrs are implicitly included in 'link' option)
function isValidWikiKind(kind) {
const isValid = kind === exports.CONST.WIKI.REF
// || (kind === CONST.WIKI.ATTR) todo
|| kind === exports.CONST.WIKI.LINK || kind === exports.CONST.WIKI.EMBED;
if (!isValid) {
console.warn('invalid kind: ' + '"' + kind + '"' + '; ' + 'using default instead: "wikiref"');
}
return isValid;
}
function isValidUriFormat(format) {
const isValid = format === exports.CONST.URI.FNAME || format === exports.CONST.URI.RELPATH || format === exports.CONST.URI.ABSPATH;
if (!isValid) {
console.warn('invalid uri format: ' + '"' + format + '"' + '; ' + 'using default instead: "filename"');
}
return isValid;
}
// browser-safe path helpers
function extname(fname) {
const idx = fname.lastIndexOf('.');
return idx >= 0 ? fname.slice(idx) : '';
}
function basename(fname, ext) {
let base = fname;
const slashIdx = base.lastIndexOf('/');
if (slashIdx >= 0) base = base.slice(slashIdx + 1);
const backslashIdx = base.lastIndexOf('\\');
if (backslashIdx >= 0) base = base.slice(backslashIdx + 1);
if (ext && base.endsWith(ext)) base = base.slice(0, -ext.length);
return base;
}
function dirname(fname) {
const idx = Math.max(fname.lastIndexOf('/'), fname.lastIndexOf('\\'));
return idx >= 0 ? fname.slice(0, idx) : '.';
}
function joinPath(a, b) {
if (a.endsWith('/') || a.endsWith('\\')) return a + b;
return a + '/' + b;
}
function buildURI(fileUri) {
let uriFormat = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'filename';
let uriExt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
let uri;
if (!isValidUriFormat(uriFormat)) {
console.warn('invalid uri format: ' + uriFormat);
return;
}
if (uriFormat === exports.CONST.URI.FNAME) {
uri = '/' + basename(fileUri, exports.CONST.EXTS.MD);
}
if (uriFormat === exports.CONST.URI.RELPATH) {
uri = fileUri.replace(process.cwd(), '');
}
if (uriFormat === exports.CONST.URI.ABSPATH) {
uri = fileUri;
}
// w/ file extension
if (uriExt) {
const slugifyUri = fileUri.trim().toLowerCase().replace(/ /g, '-');
return slugifyUri;
// w/o file extension
} else {
// @ts-expect-error: if-checks should prevent error
const dir = dirname(uri);
const fname = basename(fileUri, extname(fileUri));
const fullUri = joinPath(dir, fname);
const slugifyUri = fullUri.trim().toLowerCase().replace(/ /g, '-');
return slugifyUri;
}
}
function extractFileName(uri) {
let uriFormat = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'filename';
if (!isValidUriFormat(uriFormat)) {
console.warn('invalid uri format: ' + uriFormat);
return;
}
return basename(uri, exports.CONST.EXTS.MD);
}
// todo:
// - handle 'wikiattr' individually
/**
* Remove pointless wikilink labels: empty `|`, or `|target` when label duplicates the target
* (including `[[file#header|file#header]]`). Applies to `[[...]]` and `![[...]]`.
*/
function stripEmptyWikiLinkLabels(content) {
const stripInner = (target, label) => {
if (label === '' || label === target) {
return exports.CONST.MARKER.OPEN + target + exports.CONST.MARKER.CLOSE;
}
return null;
};
let out = content;
out = out.replace(/!\[\[([^\|\]]+)\|([^\]]*)\]\]/g, (m, target, label) => {
const inner = stripInner(target, label);
return inner !== null ? exports.CONST.MARKER.EMBED + inner : m;
});
out = out.replace(/(?<!!)\[\[([^\|\]]+)\|([^\]]*)\]\]/g, (m, target, label) => {
const inner = stripInner(target, label);
return inner !== null ? inner : m;
});
return out;
}
function mkdnToWiki(content, opts) {
var _opts$uriToFnameHash;
// opts
const kind = opts !== null && opts !== void 0 && opts.kind && isValidWikiKind(opts.kind) ? opts.kind : exports.CONST.WIKI.REF;
const format = opts !== null && opts !== void 0 && opts.format && isValidUriFormat(opts.format) ? opts.format : exports.CONST.URI.FNAME;
const uriToFnameHash = (_opts$uriToFnameHash = opts === null || opts === void 0 ? void 0 : opts.uriToFnameHash) !== null && _opts$uriToFnameHash !== void 0 ? _opts$uriToFnameHash : {};
const escdIndices = escapeMkdn.getEscIndices(content);
// links (includes attrs)
if (kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.LINK) {
content = content.replace(exports.RGX._MKDN.LINK, (match, label, uri, offset) => {
if (escapeMkdn.isStrEscaped(match, content, offset, escdIndices)) {
return match;
}
// extract header if present — decode %20 etc. back to readable text
let header = '';
let cleanUri = uri;
const hashIndex = uri.indexOf('#');
if (hashIndex !== -1) {
try {
header = decodeURIComponent(uri.substring(hashIndex + 1));
} catch {
header = uri.substring(hashIndex + 1);
}
cleanUri = uri.substring(0, hashIndex);
}
/* eslint-disable indent */
const filename = Object.keys(uriToFnameHash).includes(cleanUri) ? uriToFnameHash[cleanUri] : extractFileName(cleanUri, format);
/* eslint-disable indent */
if (filename !== undefined) {
const wikiBase = exports.CONST.MARKER.OPEN + filename + (header ? exports.CONST.MARKER.HEADER + header : '');
const wikiTarget = filename + (header ? exports.CONST.MARKER.HEADER + header : '');
if (label === '' || label === filename || label === wikiTarget) {
return wikiBase + exports.CONST.MARKER.CLOSE;
}
return wikiBase + exports.CONST.MARKER.LABEL + label + exports.CONST.MARKER.CLOSE;
}
// simply put back if unable to determine wiki-equivalent
return `[${label}](${uri})`;
});
}
// embeds
if (kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.EMBED) {
content = content.replace(exports.RGX._MKDN.IMAGE, (match, label, uri, offset) => {
if (escapeMkdn.isStrEscaped(match, content, offset, escdIndices)) {
return match;
}
// decode %20 etc. back to readable text
let header = '';
let cleanUri = uri;
const hashIndex = uri.indexOf('#');
if (hashIndex !== -1) {
try {
header = decodeURIComponent(uri.substring(hashIndex + 1));
} catch {
header = uri.substring(hashIndex + 1);
}
cleanUri = uri.substring(0, hashIndex);
}
const filename = extractFileName(cleanUri, format);
if (filename) {
const wikiBase = exports.CONST.MARKER.EMBED + exports.CONST.MARKER.OPEN + filename + (header ? exports.CONST.MARKER.HEADER + header : '');
return wikiBase + exports.CONST.MARKER.CLOSE;
}
// simply put back if unable to determine wiki-equivalent
return ``;
});
}
return content;
}
function wikiToMkdn(content, opts) {
var _opts$fnameToUriHash;
// opts
const kind = opts !== null && opts !== void 0 && opts.kind && isValidWikiKind(opts.kind) ? opts.kind : exports.CONST.WIKI.REF;
const format = opts !== null && opts !== void 0 && opts.format && isValidUriFormat(opts.format) ? opts.format : exports.CONST.URI.FNAME;
const ext = opts !== null && opts !== void 0 && opts.ext ? opts.ext : false;
const fnameToUriHash = (_opts$fnameToUriHash = opts === null || opts === void 0 ? void 0 : opts.fnameToUriHash) !== null && _opts$fnameToUriHash !== void 0 ? _opts$fnameToUriHash : {};
// vars
let mkdnContent = '';
let curPos = 0;
const {
wikirefs
} = scan(content);
for (const m of wikirefs) {
let linkedFileUri;
// process non-wikiref text
mkdnContent += content.substring(curPos, m.start);
curPos = m.start;
////
// attr
if (m.kind === 'wikiattr' && (kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.ATTR || kind === exports.CONST.WIKI.LINK) // todo: remove
) {
for (let fi = 0; fi < m.filenames.length; fi++) {
const fname = m.filenames[fi];
// wikiattrs encode header fragments inside the "filename" capture (e.g. `[[file#Header Text]]`)
// but markdown links expect the label to be just the filename, with header in the URI fragment.
const rawTarget = fname.text;
const hashIndex = rawTarget.indexOf('#');
const baseName = hashIndex === -1 ? rawTarget : rawTarget.substring(0, hashIndex);
const headerText = hashIndex === -1 ? '' : rawTarget.substring(hashIndex + 1);
/* eslint-disable indent */
linkedFileUri = Object.keys(fnameToUriHash).includes(baseName) ? fnameToUriHash[baseName] : '/' + baseName + exports.CONST.EXTS.MD;
/* eslint-enable indent */
const uri = buildURI(linkedFileUri, format, ext);
if (uri === undefined) {
console.warn('invalid uri from file uri: ', linkedFileUri);
continue;
}
const fullUri = headerText ? `${uri}#${encodeURIComponent(headerText)}` : uri;
const isFirstItem = fi === 0;
const isNotLastItem = fi < m.filenames.length - 1;
let suffix = '';
if (isNotLastItem) {
if (m.listFormat === 'comma') {
// todo: extract precise number of whitespaces
suffix = ', ';
}
if (m.listFormat === 'mkdn') {
suffix = '\n- ';
}
} else {
suffix = '\n';
}
if (isFirstItem) {
mkdnContent += content.substring(curPos, fname.start - exports.CONST.MARKER.OPEN.length);
}
mkdnContent += `[${baseName}](${fullUri})` + suffix;
}
curPos = m.start + m.match.length;
////
// link
} else if (m.kind === 'wikilink' && (kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.LINK)) {
/* eslint-disable indent */
linkedFileUri = Object.keys(fnameToUriHash).includes(m.filename.text) ? fnameToUriHash[m.filename.text] : '/' + m.filename.text + exports.CONST.EXTS.MD;
/* eslint-enable indent */
const uri = buildURI(linkedFileUri, format, ext);
if (uri === undefined) {
console.warn('invalid uri from file uri: ', linkedFileUri);
continue;
}
// append header if present — percent-encode for valid URI
const headerFrag = m.header ? m.header.text : null;
const fullUri = headerFrag ? `${uri}#${encodeURIComponent(headerFrag)}` : uri;
const linktype = m.type
// todo: read from trug to see if colon prefix is default format
? exports.CONST.MARKER.PREFIX + m.type.text + exports.CONST.MARKER.TYPE + ' ' : '';
// unlabelled
const hasExplicitLabelPipe = m.match.includes(exports.CONST.MARKER.LABEL);
if (!m.label && !hasExplicitLabelPipe) {
mkdnContent += linktype + `[${m.filename.text}](${fullUri})`;
curPos = m.start + m.match.length;
// labelled
} else {
const labelText = m.label ? m.label.text : '';
mkdnContent += linktype + `[${labelText}](${fullUri})`;
curPos = m.start + m.match.length;
}
////
// embed
// convert to markdown img link -- img
} else if (m.kind === 'wikiembed' && (kind === exports.CONST.WIKI.REF || kind === exports.CONST.WIKI.EMBED)) {
var _m$header;
const headerTxt = (_m$header = m.header) === null || _m$header === void 0 ? void 0 : _m$header.text;
// image embed
if (m.media === exports.CONST.MEDIA.IMG) {
/* eslint-disable indent */
linkedFileUri = Object.keys(fnameToUriHash).includes(m.filename.text) ? fnameToUriHash[m.filename.text] : '/' + m.filename.text;
/* eslint-enable indent */
const INCLUDE_IMG_EXT = true;
const uri = buildURI(linkedFileUri, format, INCLUDE_IMG_EXT);
if (uri === undefined) {
console.warn('invalid uri from file uri: ', linkedFileUri);
continue;
}
const fullUri = headerTxt ? `${uri}#${encodeURIComponent(headerTxt)}` : uri;
mkdnContent += ``;
curPos = m.start + m.match.length;
// convert to markdown link -- markdown, audio, video
} else if (m.media === exports.CONST.MEDIA.MD || m.media === exports.CONST.MEDIA.AUD || m.media === exports.CONST.MEDIA.VID) {
/* eslint-disable indent */
linkedFileUri = Object.keys(fnameToUriHash).includes(m.filename.text) ? fnameToUriHash[m.filename.text] : '/' + m.filename.text;
/* eslint-enable indent */
const INCLUDE_MEDIA_EXT = m.media === exports.CONST.MEDIA.AUD || m.media === exports.CONST.MEDIA.VID;
const uri = buildURI(linkedFileUri, format, INCLUDE_MEDIA_EXT || ext);
if (uri === undefined) {
console.warn('invalid uri from file uri: ', linkedFileUri);
continue;
}
const fullUri = headerTxt ? `${uri}#${encodeURIComponent(headerTxt)}` : uri;
mkdnContent += `[${m.filename.text}](${fullUri})`;
curPos = m.start + m.match.length;
}
// just add the match back since we are not processing it
} else {
mkdnContent += m.match;
curPos = m.start + m.match.length;
}
}
// add remaining content
mkdnContent += content.substring(curPos);
// if no links were found, return original content
return mkdnContent.length === 0 ? content : mkdnContent;
}
function replace(regex, oldStr, newStr, content, opts) {
/* eslint-disable indent */
const defaults = {
escape: true,
pad: false
};
/* eslint-enable indent */
opts = {
...defaults,
...opts
};
// 🦨 do-while: https://stackoverflow.com/a/6323598
let match;
let lastOffset = 0;
let updatedContent = '';
const escdIndices = opts.escape ? escapeMkdn.getEscIndices(content) : [];
do {
match = regex.exec(content);
if (match) {
const matchFull = match[0];
const matchOldStr = opts.pad ? match[1].trim() : match[1];
if (matchOldStr === oldStr) {
const fnameOffset = matchFull.indexOf(oldStr);
// filename range
const start = match.index + fnameOffset;
const end = match.index + fnameOffset + oldStr.length;
// check for escapes
const escaped = opts.escape ? escapeMkdn.isStrEscaped(oldStr, content, start, escdIndices) : false;
if (!escaped) {
updatedContent += content.substring(lastOffset, start) + newStr;
lastOffset = end;
}
}
}
} while (match);
updatedContent += content.substring(lastOffset);
return updatedContent;
}
// todo:
// - should filename validity be checked here, or at a higher level? (duplicate filenames + valid filename chars)
// - add ability to update only certain kinds of wikirefs (see 'wikiToMkdn' for logic)
function renameFileName(oldFileName, newFileName, content, opts) {
const wikiTextFilename = new RegExp(exports.RGX.GET.FILENAME, 'g');
const skip = (opts === null || opts === void 0 ? void 0 : opts.escape) !== undefined ? opts.escape : true;
return replace(wikiTextFilename, oldFileName, newFileName, content, {
escape: skip
});
}
// alias
function rename(oldFileName, newFileName, content, opts) {
return renameFileName(oldFileName, newFileName, content, opts);
}
function renameHeader(oldHeader, newHeader, content, opts) {
// Only match #header inside [[...]] or ![[...]] (group 1 = header)
const linkOrEmbed = '(?:' + exports.RGX.MARKER.EMBED.source + ')?' + exports.RGX.MARKER.OPEN.source + '(?:' + exports.RGX.VALID_CHARS.FILENAME.source + ')';
let headerRegex;
if (opts !== null && opts !== void 0 && opts.filename) {
const escapedFname = opts.filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
headerRegex = new RegExp('(?:' + exports.RGX.MARKER.EMBED.source + ')?' + exports.RGX.MARKER.OPEN.source + '(?:' + escapedFname + ')' + exports.RGX.GET.HEADER.source, 'gi');
} else {
headerRegex = new RegExp(linkOrEmbed + exports.RGX.GET.HEADER.source, 'gi');
}
const skip = (opts === null || opts === void 0 ? void 0 : opts.escape) !== undefined ? opts.escape : true;
return replace(headerRegex, oldHeader, newHeader, content, {
escape: skip
});
}
// alias
function rehead(oldHeader, newHeader, content, opts) {
return renameHeader(oldHeader, newHeader, content, opts);
}
function retypeRefType(oldRefType, newRefType, content, opts) {
content = retypeAttrType(oldRefType, newRefType, content, opts);
content = retypeLinkType(oldRefType, newRefType, content, opts);
return content;
}
function retypeAttrType(oldAttrType, newAttrType, content, opts) {
const wikiattr = new RegExp(exports.RGX.WIKI._ATTR, 'gm');
const skip = (opts === null || opts === void 0 ? void 0 : opts.escape) !== undefined ? opts.escape : true;
return replace(wikiattr, oldAttrType, newAttrType, content, {
pad: true,
escape: skip
});
}
function retypeLinkType(oldLinkType, newLinkType, content, opts) {
const wikilink = new RegExp(exports.RGX.WIKI.LINK, 'g');
const skip = (opts === null || opts === void 0 ? void 0 : opts.escape) !== undefined ? opts.escape : true;
return replace(wikilink, oldLinkType, newLinkType, content, {
escape: skip
});
}
function slugify(text) {
return text.toLowerCase().trim().replace(/[\s]+/g, '-') // Replace spaces with -
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
.replace(/\-\-+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, ''); // Trim - from end of text
}
function getHeaders(content) {
const headers = [];
const lines = content.split(/\r?\n/);
let offset = 0;
let prevLineStart = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineStart = offset;
const hasNewline = i < lines.length - 1;
const lineEnd = offset + line.length + (hasNewline ? 1 : 0);
const atx = exports.RGX._MKDN.ATX_HEADER.exec(line + '\n');
if (atx) {
const text = atx[1].replace(/\s*#+\s*$/, '').trim(); // trim trailing #
headers.push({
level: atx[0].match(/^#+/)[0].length,
text,
start: lineStart,
end: lineEnd
});
} else if (i > 0 && /^\s{0,3}[-=][-=]*\s*$/.test(line)) {
// setext: previous line is header text; = is h1, - is h2 (CommonMark allows 0–3 leading spaces on underline)
const prev = lines[i - 1].trim();
if (prev) {
const underline = line.trimStart();
headers.push({
level: underline.startsWith('=') ? 1 : 2,
text: prev,
start: prevLineStart,
end: lineEnd
});
}
}
prevLineStart = lineStart;
offset = lineEnd;
}
return headers;
}
/**
* Extract the markdown section for a given header, for embed rendering.
* The section runs from the end of the matching header line until the next
* header of the same or higher level, or end of content.
*
* @param content - Full markdown document
* @param headerRef - Header identifier: id/slug (e.g. "header-text") or raw text (e.g. "Header Text")
* @returns The section markdown (after the header line), or undefined if no matching header
*/
function getHeaderSection(content, headerRef) {
if (!headerRef || typeof headerRef !== 'string') {
return undefined;
}
const refSlug = slugify(headerRef);
const refTrim = headerRef.trim();
const headers = getHeaders(content);
const idx = headers.findIndex(h => slugify(h.text) === refSlug || h.text === refTrim);
if (idx === -1) {
return undefined;
}
const sectionStart = headers[idx].end;
const next = headers.slice(idx + 1).find(h => h.level <= headers[idx].level);
const sectionEnd = next !== undefined ? next.start : content.length;
return content.slice(sectionStart, sectionEnd).trim();
}
exports.getHeaderSection = getHeaderSection;
exports.getMediaKind = getMediaKind;
exports.isMedia = isMedia;
exports.mkdnToWiki = mkdnToWiki;
exports.rehead = rehead;
exports.rename = rename;
exports.renameFileName = renameFileName;
exports.renameHeader = renameHeader;
exports.retypeAttrType = retypeAttrType;
exports.retypeLinkType = retypeLinkType;
exports.retypeRefType = retypeRefType;
exports.scan = scan;
exports.slugify = slugify;
exports.stripEmptyWikiLinkLabels = stripEmptyWikiLinkLabels;
exports.wikiToMkdn = wikiToMkdn;
//# sourceMappingURL=index.cjs.js.map